基於海量詞庫的單詞拼寫檢查、推薦到底是咋做的?

weixin_34162629發表於2018-09-05

前言

在我們日常應用中,應該遇到不少類似的狀況:

  • 寫文件時,單詞拼寫錯誤後,工具自動推薦一個相似且正確的拼寫形式;
  • 使用搜狗輸入法時,敲錯某個字的拼音照樣能夠打出我們想要的漢字;
  • 利用搜尋引擎進行搜尋時,下拉框中自動列出與輸入相近的詞語。
  • 等等,不一一列舉。

這種功能是如何實現的呢?裡面用到了哪些演算法呢?本文就來介紹一個能夠完成這個任務的演算法。

問題描述

其實,這幾個問題都能夠轉換成同一個問題:即對於給定的輸入字串T,在預先準備好的模式串集合Q中找到與輸入串相似的模式串子集。

那麼如何得到準備好的這些模式串集合呢?我們可以通過資料探勘等一些機制來得到。

那麼接下來的問題就是如何快速的從這個集合中找到與輸入串相似的字串?通常我們用最小編輯距離來表示兩個字串的相似程度。

例如,對於輸入串T,我們限制錯誤數小於等於2,即在預先準備好的模式集合中找所有與輸入串編輯距離小於等於2的字串。

有什麼演算法能夠快速完成這個任務呢?

暴力演算法

遍歷集合Q中的每個模式串P,分別計算其與輸入串T的最小編輯距離,如果編輯距離小於指定的錯誤容忍度x,則輸出這個模式串。

  • 時間複雜度:O(|Q| * n * m),當|Q|很大時,速度將會很慢。

那麼這個演算法可以優化麼?可以!

比如,第一個字很少有人輸入錯,所以我們可以在模式串集合Q中只對第一個字與輸入串第一個字相同的那些字串進行相似度計算,這樣就能夠減少相當多的算量,是一個可行方法。

但是這也有問題,假若少部分人確實第一個字輸入錯了,那麼這個演算法找到的所有串也是錯的,不能達到糾錯的效果。

所以,針對首字元過濾的優化演算法有一定的侷限性。

步步優化

我們仔細思考這個問題,由於模式串Q是一個集合,那麼其中必定有大量的模式串有共同的字首。能否利用這個字首進行優化呢?

優化1: 利用兩個詞的相同字首進行優化

比如:字串 explore和explain,他們有公共的字首,這就意味著他們與字串explode的編輯矩陣的前幾列值是相同的,不用重複計算,如下圖紅色部分所示。

261319-20180904170159158-647536914.png

explore與explain無論與任何字串計算編輯距離,編輯矩陣的前4列肯定一模一樣。所以,如果我們已經計算過explore與某個串的編輯距離後,那麼當計算該串與explain的編輯距離時,前4列可以複用,直接從第五列開始計算。

到此,我們得到一個新的演算法計算多模式的編輯距離:把模式串集合建立成一棵字典樹,深度優先遍歷這棵樹,在遍歷的過程中,不斷更新編輯矩陣的某一個列,如果到達的節點是一個終結符,並且T與P(路徑上的字元形成的字串)的編輯距離小於指定的容忍度,則找到一個符合條件的串。

優化2:剪枝

雖然我們利用詞字首優化了演算法,能夠避免擁有相同字首模式串的編輯矩陣的重複計算,但是必須要遍歷所有節點。有沒有什麼辦法能夠在計算到某一深度後,根據一些限制條件能夠剪去該子樹其它剩餘節點的計算呢?在搜尋演算法中,這種優化叫做剪枝。接下來我們討論一下該如何設計一個剪枝函式。

重新審視我們的編輯距離定義,其實可以看成是把字串P和T分別拆分成兩段,然後計算對應的段的編輯距離之和,如下圖所示。

261319-20180904170515081-1798370986.png

字串P和T分別拆分成兩段,紅色和綠色。紅色部分之間的編輯距離與綠色部分之間的編輯距離之和即為字串P和T的編輯距離。

舉個例子,更形象:

  • 例子1
    261319-20180904170540244-1765598287.png
ed("explore", "express") = ed("explo", "exp") + ed("re", "ress")
  • 例子2
    261319-20180904170605047-1285887656.png
ed("explore", "express") = ed("exp", "exp") + ed("lore", "ress")
  • 例子3
    但是,並不是每種劃分都是正確的,比如下面圖所示:
    261319-20180904170627505-1799381568.png
ed("ex","exp") + ed("plore", "ress") = 1 + 4 = 5

所以,最小編輯距離問題又相當於一個最優拆分,即對於字串P中位置為i的字元,找到在T中的一個最優位置j,使得

ed(P.prefix(i), T.prefix(j)) + ed(P.suffix(i+1), T.suffix(j+1))

最小。

回到我們這個問題中來,如果我們限制P和T的最小編輯距離小於等於x,

261319-20180904170719441-949982376.png

我們讓 p[i]分別匹配t[i-x],t[i-x+1],......,t[i],t[i+1],......t[i+x],並找到其中前半段匹配的最小的編輯距離ed1=ed(p[1~i],t[1~j]),如果ed1大於x,我們則能推斷出ed(p,t)也終將大於x(ed=ed1+ed2>x)。

為什麼p[i]不匹配t[i-x-1]以及之前的位置呢?那是因為ed(p.prefix(i), t.prefix(i-x-1)) > x,因為必須至少在t.prefix(i-x-1)中插入x+1個字元才能保證字串長度相等;同理p[i]也不能匹配t[i+x+1]及其之後的位置。所以,根據分段原則,最優匹配肯定出現在t[i-x] ~ t[i+x]之間,如果這個區間的最小編輯距離都大於x,那麼我們無需對p[i+1]及其之後的字元進行匹配計算。

261319-20180904170809251-2063208240.png

例如:當遍歷到藍色節點l時,路徑形成的字串expl與T=exist滿足剪枝條件,則後序節點不需要遍歷,因為後面不可能有任何一個字串滿足與T的編輯距離小於2。

至此,我們得到了剪枝優化:深度遍歷到達字典樹的某個節點,其路徑上的字元組成字串P,計算其與T.prefix(i-x), T.prefix(i-x+1),......T.prefix(i+x)的最小編輯距離,如果其中的最小值大於x,則停止遍歷這棵子樹上的後面的節點。

其實,這個最終版本的優化演算法出自論文:《Error-tolerant finite-state recognition with applications to morphological analysis and spelling correction》.K Oflazer:1996

程式碼實現與效果對比

程式碼實現需需要很強的技巧性,因為無論是剪枝函式還是進行最終確認函式都可以複用同一個編輯矩陣,貼一個很醜陋的程式碼:https://github.com/haolujun/Algorithm/tree/master/muti-edit-distance

這個演算法在錯誤容忍度非常小的情況下效率非常高,我隨機生成了10萬個長度5~10的模式串,再隨機生成100個輸入串T(長度5 ~ 10),字符集大小為10,x最小編輯距離限制,計算多模式編輯距離,處理總時間如下,單位ms:

演算法 x = 1 x = 2 x = 3 x = 4 x = 5 x = 6
暴力演算法 21990 21990 21990 21990 21990 21990
優化演算法 97 922 4248 11361 20097 28000

當容忍度很小時,優化演算法完勝暴力演算法,並且實際應用中x一般取值都非常小,正好適合優化演算法。

當x值增大,優化演算法效率逐漸下降,並且最後慢於暴力演算法,這是因為優化演算法實現複雜導致(遞迴+更復雜的判斷)。

所以,最終應用時,我們根據x值選擇不同的演算法。

相關文章