好久沒更新,這是一篇長文。也是一篇比較硬的文章,比較燒腦。在硬長文里,可能很難找到這么有趣的。在有趣的文章里,可能很難找到這么硬的。這就是大半夜在寫文章的價值,不是么?嗯,大概就是這樣吧。
在自然語言處理領域,分詞是最基本的任務。不管是傳統的基于詞典的分詞算法還是現代的基于統計語言模型的分詞算法,都需要詞典作為輸入。本文介紹 Trie 算法,用來存儲詞典,并提供高效的搜索功能。
詞典的格式
這里的詞典比你書架上的現代漢語詞典要簡單很多,因為沒有釋義,只有光溜溜的一個詞在那。這也好理解,釋義是給人看的,而計算機根本就看不懂釋義。計算機頂多只會“算”,但它算的真的很快很快,快到讓人感覺它真的有智能。比如下面就是一個分詞時用到的詞典:
阿
阿巴丹
阿爸
...
...
...
做賊心虛
做主
做作
當然,基于現代的統計語言模型的分詞算法,還需要存儲詞的常用程度。所以需要對詞典里的詞做一些標注,比如:
反 1205
作戰股 1
先人后己 1
傳媒者 1
由此可見,詞典的數據其實是很簡單的,就是一個詞加上與這個詞對應的一個整形數值。在實際應用中,需要非常頻繁地查找詞典,比如在分詞算法里,我們找到兩個字后,需要判斷這兩個字是否在詞典里,如果在,就說明這是一個整體的詞,如果不在,那么就不能組成一個詞。所以詞典的格式應該要滿足快速查找的要求。那么怎么樣保存詞典以便查找速度最快呢?直接把詞典按照線性放在數組里顯然是不行的。
Trie 數據結構
Trie 的讀音和 Tree 相同,也有人讀作 Try ,是為了和 Tree 區分開。因為在 Tree 也是數據結構的一種,容易讓人誤解。
話說,怎么樣存儲詞典呢?科學家們發明了一種叫做叫 Trie 的數據結構來保存詞典:

圖一:圖片來自 wikipedia
上圖展示了 "A", "t", "to", "tea", "ted", "ten", "i", "in", "inn"。在這樣的數據結構里,要查找某個詞時,基本上和詞典的大小無關,而只與要查找的詞的大小有關。查找的速度基本上達到 O(1)。比如,我們要找 "tea" 這個詞,從開始狀態起步,我們的第一個字母是 "t" 則沿著根節點最左邊的子樹上前進到達其對應的子節點,接著是字母 "e" ,找到對應的子節點,再接著字母 "a" 找到 "tea" 這個單詞。再如,我們要找 "too" 這個單詞,在到達 "to" 這個節點時,還剩下一個 "o" 沒有消化掉。所以 "too" 這個單詞就不在上圖表示的詞典里。
從另外一個角度看這個圖,實際上這也是個確定有限狀態機(DFA - Deterministic Finite Automaton)。實際上針對 Trie 算法的實現,就是基于 DFA 的的原理進行的,所以要理解 Trie 算法,本質上需要先理解 DFA。
確定有限狀態機
有限狀態機的定義是非常嚴謹的,它包含一個五元組 (Q, Σ, δ, q0, F),其中:
- Q 是一個有限的狀態集合
- Σ 是一個有限的輸入事件集合
- δ 是一個狀態轉移函數,當某個狀態遇到某個事件時,會引起狀態轉換,跳到另外一個狀態 (δ : Q × Σ → Q)
- q0 是一個起始狀態 (q0 ∈ Q)
- F 是一組可接受的終止狀態 (F ? Q)
不得不說,數學家這個群體還是很令人佩服的。他們把很好理解的概念抽象抽象再抽象,抽象到我等智商平平的人看不懂的程度。當然,數學家的本意并非讓我等看不懂,而是為了計算方便。另外一些人和數學家干的事正好相反,他們把很復雜的數學原理和算法,解釋得通俗易懂,讓大部分資質平平,沒經過專業訓練的人也能感受到數學之美。比如吳軍老師的《數學之美》就是這類的典范。
跑題結束,我們說回 DFA。上文講過,我們可以把 Trie 數據結構看成是一個 DFA 。那么詞典和數學家定義的 DFA 有什么關系呢?
還是以圖一為例,我們看一下對應關系:
- Q 是有限狀態集合。上圖所有的圓圈構成了一個有限狀態集合
- Σ 是有限的輸入事件集合。上圖中,引起狀態轉移,標注在狀態轉移線段上的字母就是輸入事件集合,t, o, e, a, d, n, A, i, n 構成輸入事件集合
- q0 是一個起始狀態。上圖中,根節點就是起始狀態。
- F 是一組可接受的終止狀態。上圖中,有標注數字的圓圈就代表一個可接受的終止狀態,to, tea, ted, ten, A, i, in, inn。從詞典的角度考慮,所有構成合法單詞的狀態就是可接受的終止狀態。
上述對應關系里,我們漏了狀態轉移函數。確定有限狀態機的關鍵點在狀態轉移上,如果當前狀態遇到一個確定的事件時,最多只能轉移到一個確定的狀態上,那么這就是一個確定有限狀態機,簡稱 DFA。為什么有最多一個確定狀態這一說法?因為可能沒有下一個狀態,對詞典的例子來說,如果沒有下一個狀態,就說明要查找的詞不在詞典里。比如我們要查找 "too" 這個單詞,當消耗完 "t", "o" 兩個字母后,還剩下一個 "o" ,這個時候應該還要有下一個由 "o" 觸發的確定的狀態才對,但在上圖中找不到,說明 "too" 這個單詞不在上圖表示的詞典里。
聰明的你可能會問,如果一個輸入事件,導致可能轉移到不同的狀態去,每種狀態有不同的概率,這是什么?答案是不確定有限狀態機。感興趣的搜索一下馬爾可夫鏈。等你研究完馬爾可夫鏈會發現,下一步要看隱馬爾可夫模型了。咳咳,這段文字純屬裝逼,我們還是就此打住,繼續今天的課題吧。
狀態轉移表
我們終于把 Trie 和 DFA 聯系起來了。在 DFA 里,狀態轉移函數一般使用狀態轉移表來描述。這是個二維的表格,行用來表示所有的狀態集合,列用來表示輸入事件集合。
我們來看一個最簡單的使用 Trie 描述的詞典:
t o
begin(0) ---> t(1) ---> to(2:F)
|
| e a
----> te(3) ---> tea(4:F)
|
| d
---> ted(5:F)
|
| n
---> ten(6:F)
這是本文示例圖片的一小部分,這個詞典只包含四個單詞,分別是 to, tea, ted, ten。從 DFA 的角度來看,它總共有 7 個狀態,包含一個狀態為 0 的起始狀態。輸入事件集合是 [t, o, e, a, d, n]。可接受的終止狀態集合就是上圖中標注著 ":F" 字樣的狀態,就是四個有效的單詞。那么上述詞典所代表的 DFA 的的狀態轉移表長什么樣呢?
state | t | o | e | a | d | n |
---|---|---|---|---|---|---|
0 | 1 | x | x | x | x | x |
1 | x | 2 | 3 | x | x | x |
2 | x | x | x | x | x | x |
3 | x | x | x | 4 | 5 | 6 |
4 | x | x | x | x | x | x |
5 | x | x | x | x | x | x |
6 | x | x | x | x | x | x |
表一:狀態轉移表
這就是上述 DFA 的狀態轉移表。從表中,我們可以清晰地看到。[0, 1, 2, 3, 4, 5, 6] 表示我們的 DFA 中的 7 個狀態。而 [t, o, e, a, d, n] 表示我們的輸入事件。表格中的數字表示當前狀態遇到輸入事件后能正確轉移的下一個狀態,其中 x 表示出錯,即無法轉移到下一個有效狀態。比如當狀態為 0 時,遇到 t 即可轉移到狀態 1,而遇到其他的輸入事件,則無法轉移到有效狀態。聰明如你,試著畫一下圖一的狀態轉移表吧。
雙數組 Trie
狀態轉移表很好很強大,可以實現 O(1) 的搜索速度。但其不足非常明顯,如表一所示,表中 x 非常多。即針對詞典來說,其 DFA 的狀態轉移表中,有效的狀態轉移只占少數,大部分都是無效的狀態轉移。聰明的科學家們哪能放過這個揚名立萬的機會?特別是在計算機的早期,內存非常寶貴,往往以字節計算價格,哪像現在,各位的手機動不動就有幾個 G 的內存。
在 1985 年科學家發明了一個壓縮算法,可以用三個數組即可表達狀態轉移表。1989 年更進一步壓縮到只用兩個數組即可表達狀態轉移表。詳細信息可以閱讀 An Implementation of Double-Array Trie。這個頁面里包含兩個實現,一個是早期的使用 C++ 模板類的實現,稱為 midatrie ,網上比較著名的開源分詞算法庫 LibMMSeg 就使用這一實現。另外一個實現是使用 C 語言重寫的,稱為 libdatrie,它更通用,可讀性也比較強。
要使用兩個數組實現 Tire 并非簡單的事情,要理解這一過程沒有燒些腦細胞估計很難做到,除非你是個天才。除此之外,還有一些細節需要了解,對理解代碼不無益處:
- 上文討論中,我們都使用英文作為詞典保存對象。輸入事件集合就是一個個字母。實際上,中文的處理方法類似,如果是 utf-8 編碼,中文會把一個字拆成三個字節,然后以字節為單位,作為輸入事件集合中的一個元素。比如“中文”這兩個字的 utf-8 編碼是 E4 B8 AD E6 96 87,其實際上被解讀為 6 個輸入事件。由此可見,對任何語言,輸入事件總數不會超過 255 個。可以使用一個字節來表示。
- 輸入事件并非連續的,所以在實現 Tire 時,一般會有個映射關系,比如把 a 映射到 1,把 b 映射到 2 等。這是為了更進一步節省空調。
應用場景
Trie 構造的詞典除了在分詞算法里使用外,在自動完成 Auto Complete,拼寫糾正等領域都有非常廣泛的應用。