超酷算法:Levenshtein自动机

798 查看

在上一期的超酷算法中,我们聊到了BK树,这是一种非常聪明的索引结构,能够在搜索过程中进行模糊匹配,它基于编辑距离(Levenshtein distance),或者任何其它服从三角不等式的度量标准。今天,我将继续介绍另一种方法,它能够在常规索引中进行模糊匹配搜索,我们将它称之为Levenshtein自动机。

简介

Levenshtein自动机背后的基本理念是:能够构建一个有限状态自动机,准确识别出和某个目标单词相距在给定编辑距离内的所有字符串集合。之后就好办了,我们可以输入任意单词,自动机能够判断这个单词到目标单词的距离是否大于我们在构建时指定的距离,并选择接收或拒绝。更进一步说,根据FSA的自然特性,这项工作可以在O(n)时间内完成,取决于测试字符串的长度。与此相比,标准动态编程距离向量算法需要消耗O(mn)时间,m和n分别是两个输入单词的长度。因此很显然,起码Levenshtein向量机提供了一种更快的方式,供我们针对一个目标单词和最大距离,检查所有的单词,这是一个不错的改进的开端。
当然,如果Levenshtein向量机只有优点,那这篇文章将会很短。我们将会谈到很多,不过我们先来看一下Levenshtein向量机究竟是何物,以及我们如何建立一个Levenshtein自动机。

构建与评价

levenstein-nfa-food

上图展示了针对单词food的Levenshtein自动机的NFA(译者注:非确定性有限自动机),其最大编辑距离为2。你可以看到,它很普通,构建过程也非常直观。初始状态在左下部分,我们使用ne记法对状态进行命名,n是指到目前为止被处理过的特性的数量,e是指错误的个数。水平线表示没有被修改的特性,垂直线表示插入的值,而两条对角线则分别表示交换(标记a*)和删除。

我们来看一下如何通过一个给定的输入单词和最大编辑距离构建一个NFA,由于整个NFA类是非常标准化的,因此我就不赘述其源码了,如果你需要更多细节,请看Gist。以下是基于Python的相关方法:

这应该很容易实现,基本上我们用了一种最直接了当的方式构建前图中表示的变换,同时也指出了最终正确的状态集。状态标签是元组,而不是字符串,这与我们前面的描述是一致的。

由于这是一个NFA,可以有多个活跃状态,它们表示目前被处理过的字符串的可能解释。举个例子,考虑一下,在处理字符f和x之后的活跃状态:
levenstein-nfa-food-fx

这表明,在前两个字符f和x一致的情况下,会存在若干可能的变化:一次替换,如fxod;一次插入,如fxood;两次插入,如fxfood;或者一次交换和一次删除,如fxd。同时,这也会引入了一些冗余的情况,如一次删除和一次插入,结果也是fxod。随着越来越多的字符被处理,其中一些可能性会慢慢消失,而另一些可能性会逐渐产生。如果,在处理完整个单词的所有字符后,在当前状态集中存在一个接收状态(bolded state),那么就表明存在一种方式,能够将通过两次或更少次的变换,将输入单词转化为目标单词,那么我们就可以将该单词视为是有效的。

实际上,要直接评价一个NFA,从计算的角度来讲是极其昂贵的,因为会存在多个活跃状态和epsilon变换(不需要输入符号的变换),所以通常的做法是首先使用powerset构建法将NFA转换为DFA(译者注:确定性有限自动机)。使用这个算法能够构建出一个DFA,使每一个状态都对应原来NFA中的一个活跃状态集。在这里我们不会涉及powerset的细节,因为这有点扯远了。以下是一个例子,展示了在一个容差下,单词food的NFA所对应的DFA:
levenstein-dfa-food

记住,我们是在一个容差下描述DFA的,因为要找出完全匹配我们提到的NFA所对应的DFA实在是太复杂了!以上DFA能准确接收与单词food相距一个或更少编辑距离的单词集。试试看,选择任意一个单词,通过DFA跟踪它的路径,如果你最终能到达一个接收状态,则这个单词是有效的。

我不会把power构建的源码贴在这里,同样的,如果你感兴趣,可以在GIST里找到。

我们暂时回到执行效率的问题上来,你可能想知道Levenshtein DFA构建的效率怎么样。我们可以在O(kn)时间内构建NFA,k是指编辑距离,n是指目标单词的长度。将其变换为DFA的最坏情况需要O(2^n)时间,所以极端情况下会需要O(2^kn)运行时间!不过情况并没有那么糟糕,有两个原因:首先,Levenshtein自动机并不会充斥着2^n这种最坏情况的DFA构建;其次,一些智慧的计算机科学家已经提出了一些算法,能够在O(n)时间内直接构建出DFA,甚至还有人[SCHULZ2002FAST]完全避开了DFA构建,使用了一种基于表格的评价方法!

索引

既然我们已经证实可以构建一个Levenshtein自动机,并演示了其工作原理,下面我们来看一看如何使用这项技术高效地模糊匹配搜索索引。第一个观点,同时也是很多论文[SCHULZ2002FAST] [MIHOV2004FAST]所采用的方法,就是去观测一本字典,即你所要搜索的记录集,它自身可以被视为是一个DFA。事实上,他们经常被存储为一种字典树或有向非循环字图,这两种结构都可以被视为是DFA的特例。假设字典和标准(Levenshtein自动机)都表示为DFA,之后我们就可以高效地通过这两个DFA,准确地在字典中找到符合标准的单词集,过程非常简单,如下:

好了,我们按照两个DFA共有的边界同时进行遍历,并记录遍历的路径轨迹。只要两个DFA处于最终状态,单词在输出集内,我们就将其输出。

如果你的索引是以DFA(或字典树,或有向非循环字图)的形式存储的话,这非常完美,但遗憾的是许多索引并不是:如果在内存中,它们很可能位于一个排序列表中;如果在磁盘上,它们很可能位于BTree或类似结构中。有没有办法可以让我们修改方案适应这些排序索引,继而继续提供一种速度极快的方法?事实证明是有的。

这里的关键点在于,根据我们目前以DFA表示的标准,我们可以,对于一个不匹配的输入字符串,找到下一个(按字母排序)匹配的字符串。凭直觉来说,这相当容易:我们基于DFA去评估输入字符串,直到我们无法进一步处理为止,比如说没有针对下一个字符的有效变换,之后,我们可以反复遵照字母排序的最小标签的边界,直到到达终态。在这里我们应用了两个特殊事件:首先,在第一次变换中,我们需要遵照按字母排序的最小标签,同时这些标签要大于在准备步骤中没有有效变换的特性。第二,如果我们达到了一个状态而其没有有效的外边界,那么我们要回溯到之前的状态,并重试。这差不多是解决迷宫问题的一种“循墙”算法,应用在DFA上。

以此举例,参照food(1)的DFA,我们来思考一下输入单词foogle。我们可以有效处理前4个单词,留下状态3141,这里唯一的外边界是d,下一个字符是l,因此我们可以向前回溯一步,到21303141,现在下一个字符是g,有一个外边界f,所以我们接收这个边界,留下接收状态(事实上,和之前的状态是一样的,只不过路径不同),输出单词为fooh,这是在DFA中按字母排序在foogle之后的单词。

以下是python代码,展示了在DFA类上的一个方法。和前面一样,我不会写出整个DFA的样板代码,它们都在这里