在上一期的超酷演算法中,我們聊到了BK樹,這是一種非常聰明的索引結構,能夠在搜尋過程中進行模糊匹配,它基於編輯距離(Levenshtein distance),或者任何其它服從三角不等式的度量標準。今天,我將繼續介紹另一種方法,它能夠在常規索引中進行模糊匹配搜尋,我們將它稱之為Levenshtein自動機。
簡介
Levenshtein自動機背後的基本理念是:能夠構建一個有限狀態自動機,準確識別出和某個目標單詞相距在給定編輯距離內的所有字串集合。之後就好辦了,我們可以輸入任意單詞,自動機能夠判斷這個單詞到目標單詞的距離是否大於我們在構建時指定的距離,並選擇接收或拒絕。更進一步說,根據FSA的自然特性,這項工作可以在O(n)時間內完成,取決於測試字串的長度。與此相比,標準動態程式設計距離向量演算法需要消耗O(mn)時間,m和n分別是兩個輸入單詞的長度。因此很顯然,起碼Levenshtein向量機提供了一種更快的方式,供我們針對一個目標單詞和最大距離,檢查所有的單詞,這是一個不錯的改進的開端。
當然,如果Levenshtein向量機只有優點,那這篇文章將會很短。我們將會談到很多,不過我們先來看一下Levenshtein向量機究竟是何物,以及我們如何建立一個Levenshtein自動機。
構建與評價
上圖展示了針對單詞food的Levenshtein自動機的NFA(譯者注:非確定性有限自動機),其最大編輯距離為2。你可以看到,它很普通,構建過程也非常直觀。初始狀態在左下部分,我們使用ne記法對狀態進行命名,n是指到目前為止被處理過的特性的數量,e是指錯誤的個數。水平線表示沒有被修改的特性,垂直線表示插入的值,而兩條對角線則分別表示交換(標記a*)和刪除。
我們來看一下如何通過一個給定的輸入單詞和最大編輯距離構建一個NFA,由於整個NFA類是非常標準化的,因此我就不贅述其原始碼了,如果你需要更多細節,請看Gist。以下是基於Python的相關方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
def levenshtein_automata(term, k): nfa = NFA((0, 0)) for i, c in enumerate(term): for e in range(k + 1): # Correct character nfa.add_transition((i, e), c, (i + 1, e)) if e < k: # Deletion nfa.add_transition((i, e), NFA.ANY, (i, e + 1)) # Insertion nfa.add_transition((i, e), NFA.EPSILON, (i + 1, e + 1)) # Substitution nfa.add_transition((i, e), NFA.ANY, (i + 1, e + 1)) for e in range(k + 1): if e < k: nfa.add_transition((len(term), e), NFA.ANY, (len(term), e + 1)) nfa.add_final_state((len(term), e)) return nfa |
這應該很容易實現,基本上我們用了一種最直接了當的方式構建前圖中表示的變換,同時也指出了最終正確的狀態集。狀態標籤是元組,而不是字串,這與我們前面的描述是一致的。
由於這是一個NFA,可以有多個活躍狀態,它們表示目前被處理過的字串的可能解釋。舉個例子,考慮一下,在處理字元f和x之後的活躍狀態:
這表明,在前兩個字元f和x一致的情況下,會存在若干可能的變化:一次替換,如fxod;一次插入,如fxood;兩次插入,如fxfood;或者一次交換和一次刪除,如fxd。同時,這也會引入了一些冗餘的情況,如一次刪除和一次插入,結果也是fxod。隨著越來越多的字元被處理,其中一些可能性會慢慢消失,而另一些可能性會逐漸產生。如果,在處理完整個單詞的所有字元後,在當前狀態集中存在一個接收狀態(bolded state),那麼就表明存在一種方式,能夠將通過兩次或更少次的變換,將輸入單詞轉化為目標單詞,那麼我們就可以將該單詞視為是有效的。
實際上,要直接評價一個NFA,從計算的角度來講是極其昂貴的,因為會存在多個活躍狀態和epsilon變換(不需要輸入符號的變換),所以通常的做法是首先使用powerset構建法將NFA轉換為DFA(譯者注:確定性有限自動機)。使用這個演算法能夠構建出一個DFA,使每一個狀態都對應原來NFA中的一個活躍狀態集。在這裡我們不會涉及powerset的細節,因為這有點扯遠了。以下是一個例子,展示了在一個容差下,單詞food的NFA所對應的DFA:
記住,我們是在一個容差下描述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,準確地在字典中找到符合標準的單詞集,過程非常簡單,如下:
1 2 3 4 5 6 7 8 9 10 11 12 |
def intersect(dfa1, dfa2): stack = [("", dfa1.start_state, dfa2.start_state)] while stack: s, state1, state2 = stack.pop() for edge in set(dfa1.edges(state1)).intersect(dfa2.edges(state2)): state1 = dfa1.next(state1, edge) state2 = dfa2.next(state2, edge) if state1 and state2: s = s + edge stack.append((s, state1, state2)) if dfa1.is_final(state1) and dfa2.is_final(state2): yield s |
好了,我們按照兩個DFA共有的邊界同時進行遍歷,並記錄遍歷的路徑軌跡。只要兩個DFA處於最終狀態,單詞在輸出集內,我們就將其輸出。
如果你的索引是以DFA(或字典樹,或有向非迴圈字圖)的形式儲存的話,這非常完美,但遺憾的是許多索引並不是:如果在記憶體中,它們很可能位於一個排序列表中;如果在磁碟上,它們很可能位於BTree或類似結構中。有沒有辦法可以讓我們修改方案適應這些排序索引,繼而繼續提供一種速度極快的方法?事實證明是有的。
這裡的關鍵點在於,根據我們目前以DFA表示的標準,我們可以,對於一個不匹配的輸入字串,找到下一個(按字母排序)匹配的字串。憑直覺來說,這相當容易:我們基於DFA去評估輸入字串,直到我們無法進一步處理為止,比如說沒有針對下一個字元的有效變換,之後,我們可以反覆遵照字母排序的最小標籤的邊界,直到到達終態。在這裡我們應用了兩個特殊事件:首先,在第一次變換中,我們需要遵照按字母排序的最小標籤,同時這些標籤要大於在準備步驟中沒有有效變換的特性。第二,如果我們達到了一個狀態而其沒有有效的外邊界,那麼我們要回溯到之前的狀態,並重試。這差不多是解決迷宮問題的一種“循牆”演算法,應用在DFA上。
以此舉例,參照food(1)的DFA,我們來思考一下輸入單詞foogle。我們可以有效處理前4個單詞,留下狀態3141,這裡唯一的外邊界是d,下一個字元是l,因此我們可以向前回溯一步,到21303141,現在下一個字元是g,有一個外邊界f,所以我們接收這個邊界,留下接收狀態(事實上,和之前的狀態是一樣的,只不過路徑不同),輸出單詞為fooh,這是在DFA中按字母排序在foogle之後的單詞。
以下是python程式碼,展示了在DFA類上的一個方法。和前面一樣,我不會寫出整個DFA的樣板程式碼,它們都在這裡。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
def next_valid_string(self, input): state = self.start_state stack = [] # Evaluate the DFA as far as possible for i, x in enumerate(input): stack.append((input[:i], state, x)) state = self.next_state(state, x) if not state: break else: stack.append((input[:i+1], state, None)) if self.is_final(state): # Input word is already valid return input # Perform a 'wall following' search for the lexicographically smallest # accepting state. while stack: path, state, x = stack.pop() x = self.find_next_edge(state, x) if x: path += x state = self.next_state(state, x) if self.is_final(state): return path stack.append((path, state, None)) return None |
在這個方法的第一部分,我們以常見的方式評價DFA,記錄下訪問過的狀態,這些狀態包括它們的路徑以及我們嘗試尋找遵循它們的邊界。之後,假設沒有找到一個準確的匹配項,那麼就進行一次回溯,嘗試去尋找一個可以到達接收狀態的最小變換集。關於這個方法的一般性說明,請繼續閱讀……
同時我們還需要一個工具函式find_next_edge,找出一個狀態中按字母排序比指定輸入大的最小外邊界:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
def find_next_edge(self, s, x): if x is None: x = u'' else: x = unichr(ord(x) + 1) state_transitions = self.transitions.get(s, {}) if x in state_transitions or s in self.defaults: return x labels = sorted(state_transitions.keys()) pos = bisect.bisect_left(labels, x) if pos < len(labels): return labels[pos] return None |
經過一些預處理,這可以更高效,打個比方,我們可以對每個字元和第一個大於它的外邊界建立一個對映關係,而不是在茫茫大海中進行二進位制檢索。再強調一次,我會把這些優化工作作為練習題留給讀者。
既然我們已經找到了這一過程,那麼我們就可以最終描述如何使用這一過程進行索引搜尋,演算法出人意料的簡單:
1. 取得索引中的第一個元素,或者,比索引任意有效字串更小的一個字串,將其稱之為“當前”字串。
2. 將“當前”字串傳入我們之前談到的DFA演算法,得到“下一個”字串。
3. 如果“下一個”字串和“當前”字串相等,那麼你已經找到了一個匹配,將其輸出,再從索引中獲取下一個元素作為“當前”元素,重複步驟2。
4. 如果“下一個”字串和“當前”字串不相等,那麼在你的索引中搜尋大於等於“下一個”字串的第一個字串,將其作為“當前”元素,重複步驟2。
以下是用Python實現這一過程的程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
def find_all_matches(word, k, lookup_func): """Uses lookup_func to find all words within levenshtein distance k of word. Args: word: The word to look up k: Maximum edit distance lookup_func: A single argument function that returns the first word in the database that is greater than or equal to the input argument. Yields: Every matching word within levenshtein distance k from the database. """ lev = levenshtein_automata(word, k).to_dfa() match = lev.next_valid_string(u'') while match: next = lookup_func(match) if not next: return if match == next: yield match next = next + u'' match = lev.next_valid_string(next) |
理解這一演算法的一種方式是將Levenshtein DFA和索引都視為排序列表,那麼以上過程就類似於App引擎中的“拉鍊合併連線”策略。我們重複地在一側查詢字串,再跳轉到另一側的合適位置,等等。結果是,我們省去了大量不匹配的索引實體,以及大量不匹配的Levenshtein字串,節省了列舉它們的工作量。這些描述表明,這一過程有潛力避免去評估所有的索引實體,或所有的候選Levenshtein字串。
補充說明一下,所有的DFA針對任意字串都可以找到按字母排序的最小後繼,這句話是錯誤的。比如說,考慮一下DFA中字串a的後繼,識別模式為a+b,答案是沒有這樣的後繼,它必須由無限多的a字元跟隨單個b字元構成!不過我們可以基於以上過程做一些簡單的修改,比如返回一個字串,確保它是DFA可以識別的下一個字串的一個字首,這能滿足我們的需求。由於Levenshtein DFA總是有限的,因此我們總是會得到一個有限長度的後繼(當然,除了最後一個字串),我們把這樣的擴充套件留給讀者作為練習題。使用這種方法,會產生一些很有意思的應用程式,比如索引化正規表示式搜尋。
測試
首先,我們理論聯絡實際,定義一個簡單的Matcher類,其中實現了一個lookup_func方法,它會被find_all_matches方法呼叫:
1 2 3 4 5 6 7 8 9 10 11 12 |
class Matcher(object): def __init__(self, l): self.l = l self.probes = 0 def __call__(self, w): self.probes += 1 pos = bisect.bisect_left(self.l, w) if pos < len(self.l): return self.l[pos] else: return None |
記住,在此我們實現一個可呼叫的類的唯一理由是:我們想要從程式中提取一些資訊,比如探針的個數。通常來說,一個常規或巢狀函式已經足夠完美,現在,我們需要一個簡單的資料集,讓我們載入web2字典:
1 2 3 4 |
>>> words = [x.strip().lower().decode('utf-8') for x in open('/usr/share/dict/web2')] >>> words.sort() >>> len(words) 234936 |
我們也可以使用幾個子集測試隨著資料規模的變化,會發生什麼:
1 2 |
>>> words10 = [x for x in words if random.random() <= 0.1] >>> words100 = [x for x in words if random.random() <= 0.01] |
這裡,我們看到了實踐結果:
1 2 3 4 5 6 7 |
>>> m = Matcher(words) >>> list(automata.find_all_matches('nice', 1, m)) [u'anice', u'bice', u'dice', u'fice', u'ice', u'mice', u'nace', u'nice', u'niche', u'nick', u'nide', u'niece', u'nife', u'nile', u'nine', u'niue', u'pice', u'rice', u'sice', u'tice', u'unice', u'vice', u'wice'] >>> len(_) 23 >>> m.probes 142 |
大讚啊!在擁有235000個單詞的字典中找到了針對nice的23個模擬匹配,需要142個探針。注意,如果我們假設一個字母表包含26個字母,那麼會有4+26*4+26*5=238個字串在一個Levenshtein距離內是有效的,因此與詳盡的測試相比,我們做出了合理的節省。考慮到有更大的字母表,更長的字串,或更大的編輯距離,這種節省的效果應該會更明顯。如果我們使用不同種類的輸入去測試,看一下探針的個數隨著單詞長度和字典大小的變化情況,可能會更受啟發:
String length |
Max strings |
Small dict |
Med dict |
Full dict |
1 |
79 |
47 (59%) |
54 (68%) |
81 (100%) |
2 |
132 |
81 (61%) |
103 (78%) |
129 (97%) |
3 |
185 |
94 (50%) |
120 (64%) |
147 (79%) |
4 |
238 |
94 (39%) |
123 (51%) |
155 (65%) |
5 |
291 |
94 (32%) |
124 (43%) |
161 (55%) |
在這個表中,”max strings”表示與輸入字串在編輯距離內的字串總數;small,med,full dict表示所有三種字典(包含web2字典的1%,10%和100%)所需要的探針個數。所有對應的行,至少在10個字元以內,都需要與第五行差不多的探針個數。我們採用的輸入字串的例子是由單詞’abracadabra’的字首構成的。
我們可以立即看出一些端倪:
1. 對於很短的字串和很大的字典,探針的個數並沒有低很多,即使低一些,和有效字串的最大個數相比也是小巫見大巫,所以這並沒有節省什麼。
2. 隨著字串越來越長,探針的個數的增長出人意料的比預期結果慢,結果就是對於10個字元,我們僅僅需要探測821中的161個(大約20%)可能結果。對於一般的單詞長度(在web2字典中,97%的單詞至少有5個字元長),與樸素的檢查每個字串變化相比,我們已經節省了可觀的代價。
3. 雖然樣本字典的大小以不同的數量級區分,但是探針的個數增長卻不太明顯,這是一項令人鼓舞的證據,它表明該方法可以很好的擴充套件到非常大的索引數量級上。
我們再來看一下根據不同的編輯距離閾值,情況會有何變化,你同樣能得到一些啟發。下面是相同的表格,最大編輯距離為2:
String length |
Max strings |
Small dict |
Med dict |
Full dict |
1 |
2054 |
413 (20%) |
843 (41%) |
1531 (75%) |
2 |
10428 |
486 (5%) |
1226 (12%) |
2600 (25%) |
3 |
24420 |
644 (3%) |
1643 (7%) |
3229 (13%) |
4 |
44030 |
646 (1.5%) |
1676 (4%) |
3366 (8%) |
5 |
69258 |
648 (0.9%) |
1676 (2%) |
3377 (5%) |
前途一片光明:在編輯距離為2的情況下,雖然我們被迫需要加入很多探針,但是與候選字串的數量相比,仍然是很小的代價。對於一個長度為5、編輯距離為2的單詞,需要使用3377個探針,但是比起做69258次(對每一個匹配字串)或做234936次(對字典裡的每個單詞),這顯然少得多了!
我們來做一個快速比較,對於一個長度為5的字串,編輯距離為1(與上面的例子一樣),一個標準的BK樹實現,基於相同的字典,需要檢查5858個節點,同時,相同的情況下,我們把編輯距離改為2,則需要檢查58928個節點!應當承認,如果結構合理的話,這些節點中很多都應處於相同的磁碟頁,但是依然存在驚人的查詢數量級的差異。
最後一點:我們在這篇文章中參考的第二篇論文,[MIHOV2004FAST]描述了一個非常棒的結構:一個廣義的Levenshtein自動機。這是一種DFA,它能線上性時間內判斷,任意一組單詞對互相之間的距離是否小於給定的編輯距離。改造一下我們前面的方案,使其能適應這種自動機,這也是我們留給讀者的練習。
這篇文章是涉及的完整的原始碼都可以在這裡找到。