超酷算法:Levenshtein自動機

http://blog.jobbole.com/80659/

在上一期的超酷算法中,我們聊到了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的相關方法:

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之後的活躍狀態:
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,準確地在字典中找到符合標準的單詞集,過程非常簡單,如下:

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,它能在線性時間內判斷,任意一組單詞對互相之間的距離是否小於給定的編輯距離。改造一下我們前面的方案,使其能適應這種自動機,這也是我們留給讀者的練習。

這篇文章是涉及的完整的源代碼都可以在這裏找到。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章