小白詳解 Trie 樹

1、引言

最近學習天然語言處理(NLP)相關的知識,認識了 Trie 這種樹形數據結構,在 NLP 中通常會用其存儲大量的字典字符以用於文本的快速分詞;除此以外,典型應用場景還包括大批量文本的:詞頻統計、字符串查詢和模糊匹配(好比關鍵詞的模糊匹配)、字符串排序等任務;因爲 Trie 大幅下降了無謂的字符串比較,所以在執行上述任務時,其效率很是的高。html

然而...它卻有些複雜,特別是工程實踐中常見的雙數組 Trie 樹,對於新手來講若是沒有好的講解,真的很難徹底弄懂;關於這點,做爲小白的我深有感觸:咱搜索了度娘和谷哥可以搜到的一大波一大波解讀,結果仍是以爲本身實在太笨....最終只能笨辦法整起,從頭梳理這種結構的應用場景和實際構建中的各類問題纔算有所得。這篇文章也是寫給與我有一樣情況的童鞋,所以行文相對繁瑣,講解也更注重原理,所以對於想快速得到 Trie 構建方法的童鞋能夠繞過。git

2、Trie 樹的簡介

Trie 樹中文名叫字典樹、前綴樹(我的比較喜歡這個名字,看完下文就會明白)等等。這些名字暗示其與字符的處理有關,事實也確實如此,它主要用途就是將字符串(固然也能夠不限於字符串)整合成樹形。咱們先來看一下由「清華」、「清華大學」、「清新」、「中華」、「華人」五個中文詞構成的 Trie 樹形(爲了便於敘述,下文提到該實例,以「例樹」簡稱):github

clipboard.png

這個樹裏面每個方塊表明一個節點,其中 」Root」 表示根節點,不表明任何字符;紫色表明分支節點;綠色表明葉子節點。除根節點外每個節點都只包含一個字符。從根節點到葉子節點,路徑上通過的字符鏈接起來,構成一個詞。而葉子節點內的數字表明該詞在字典樹中所處的鏈路(字典中有多少個詞就有多少條鏈路),具備共同前綴的鏈路稱爲串。除此以外,還需特別強調 Trie 樹的如下幾個特色:算法

  1. 具備相同前綴的詞必須位於同一個串內;例如「清華」、「清新」兩個詞都有「清」這個前綴,那麼在 Trie 樹上只需構建一個「清」節點,「華」和「新」節點共用一個父節點便可,如此兩個詞便只需三個節點即可存儲,這在必定程度上減小了字典的存儲空間。數組

  2. Trie 樹中的詞只可共用前綴,不可共用詞的其餘部分;例如「中華」、「華人」這兩個詞雖然前一個詞的後綴是後一個詞的前綴,但在樹形上必須是獨立的兩條鏈路,而不能夠經過首尾交接構建這兩個詞,這也說明 Trie 樹僅能依靠公共前綴壓縮字典的存儲空間,並不能共享詞中的全部相同的字符;固然,這一點也有「例外」,對於複合詞,可能會出現兩詞首尾交接的假象,好比「清華大學」這個詞在上例 Trie 樹中看起來彷佛是由「清華」、「大學」兩詞首尾交接而成,可是葉子節點的標識已經明確說明 Trie 樹裏面只有」清華「和」清華大學「兩個詞,它們之間共用了前綴,而非由「清華」和」大學「兩詞首尾交接所得,所以上例 Trie 樹中若須要「大學」這個詞則必須從根節點開始從新構建該詞。數據結構

  3. Trie 樹中任何一個完整的詞,都必須是從根節點開始至葉子節點結束,這意味着對一個詞進行檢索也必須從根節點開始,至葉子節點纔算結束。函數

3、搜索 Trie 樹的時間複雜度

在 Trie 樹中搜索一個字符串,會從根節點出發,沿着某條鏈路向下逐字比對字符串的每一個字符,直到抵達底部的葉子節點才能確認字符串爲該詞,這種檢索方式具備如下兩個優勢:性能

  1. 公共前綴的詞都位於同一個串內,查詞範圍所以被大幅縮小(好比首字不一樣的字符串,都會被排除)。學習

  2. Trie 樹實質是一個有限狀態自動機((Definite Automata, DFA),這就意味着從 Trie 樹的一個節點(狀態)轉移到另外一個節點(狀態)的行爲徹底由狀態轉移函數控制,而狀態轉移函數本質上是一種映射,這意味着:逐字搜索 Trie 樹時,從一個字符到下一個字符比對是不須要遍歷該節點的全部子節點的。對於肯定性有限自動機感興趣的同窗,能夠看看如下引用[1]測試

肯定的有限自動機 M 是一個五元組:

M = (Σ, Q, δ, q0, F)

其中,

Σ 是輸入符號的有窮集合;

Q 是狀態的有限集合;

δ 是 Q 與 Σ 的直積 Q × Σ 到Q (下一個狀態) 的映射。它支配着有限狀態控制的行爲,有時也稱爲狀態>轉移函數。

q0 ∈ Q 是初始狀態;

F 是終止狀態集合,F ⊆ Q;

能夠把DFA想象成一個單放機,插入一盤磁帶,隨着磁帶的轉動,DFA讀取一個符號,依靠狀態轉移函數>改變本身的狀態,同時磁帶轉到下一個字符。

這兩個優勢相結合能夠最大限度地減小無謂的字符比較,使得搜索的時間複雜度理論上僅與檢索詞的長度有關:O(m),其中 m 爲檢索詞的長度。

4、Trie 樹的缺點

綜上可知, Trie 樹主要是利用詞的公共前綴縮小查詞範圍、經過狀態間的映射關係避免了字符的遍歷,從而達到高效檢索的目的。這一思想有賴於字符在詞中的先後位置可以獲得表達,所以其設計哲學是典型的「以信息換時間」,固然,這種優點一樣是須要付出代價的:

  1. 因爲結構須要記錄更多的信息,所以 Trie 樹的實現稍顯複雜。好在這點在大多數狀況下並不是不可接受。

  2. Trie 型詞典不只須要記錄詞,還須要記錄字符之間、詞之間的相關信息,所以字典構建時必須對每一個詞和字逐一進行處理,而這無疑會減慢詞典的構建速度。對於強調實時更新的詞典而言,這點多是致命的,尤爲是採用雙數組實現的 Trie 樹,更新詞典很大機率會形成詞典的所有重構,詞典構建過程當中還需處理各類衝突,所以重構的時間很是長,這致使其大多用於離線;不過也有一些 Trie 能夠實現實時更新,但也需付出必定的代價,所以這個缺點必定程度上影響了 Trie 樹的應用範圍。

  3. 公共前綴雖然能夠減小必定的存儲空間,但 Trie 樹相比普通字典還需表達詞、字之間的各類關係,其實現也更加複雜,所以實際空間消耗相對更大(大多少,得根據具體實現而定)。尤爲是早期的「Array Trie」,屬於典型的以空間換時間的實現,(其實 Trie 自己的實現思想是是以信息換時間,而非以空間換時間,這就給 Trie 樹的改進提供了可能),然而 Trie 樹現今已經獲得了很好的改進,整體來講,對於相似詞典這樣的應用,Trie 是一個優秀的數據結構。

5、Trie 樹的幾種實現

5.一、Array Trie 樹

不少文章裏將這種實現稱爲「標準 Trie 樹」,但其實它只是 Trie 衆多實現中的一種而已,因爲這種實現結構簡單,檢索效率很好,做爲講解示例很不錯,所以特意改稱其爲「經典 Trie 樹」,這裏引用一下別人家的示例圖[2]

abc、d、da、dda 四個字符串構成的 Trie 樹,若是是字符串會在節點的尾部進行標記。沒有後續字符的 branch 分支指向NULL
http://www.ahathinking.com/archives/14.html

如上圖,這種實現的特色是:每一個節點都由指針數組存儲,每一個節點的全部子節點都位於一個數組之中,每一個數組都是徹底同樣的。對於英文而言,每一個數組有27個指針,其中一個做爲詞的終結符,另外 26 個依次表明字母表中的一個字母,對應指針指向下一個狀態,若沒有後續字符則指向NULL。因爲數組取詞的複雜度爲O(1),所以這種實現的 Trie 樹效率很是的高,好比要在一個節點中寫入字符「c」,則直接在相應數組的第三個位置標入狀態便可,而要肯定字母「b」是否在現有節點的子節點之中,檢查子節點所在數組第二個元素是否爲空便可,這種實現巧妙的利用了等長數組中元素位置和值的一一對應關係,完美的實現了了尋址、存值、取值的統一。
但其缺點也很明顯,它強制要求鏈路每一層都要有一個數組,每一個數組都必須等長,這在實際應用中會形成大多數的數組指針空置(從上圖就能夠看出),事實上,對於真實的詞典而言,公共前綴相對於節點數量而言仍是太少,這致使絕大多數節點下並無太多子節點。而對於中文這樣具備大量單字的語言,若採起這樣的實現,空置指針的數量簡直不可想象。所以,經典 Trie 樹是一種典型的以「空間換時間」的實現方式。通常只是拿來用於課程設計和新手練習,不多實際應用。

5.二、List Trie 樹

因爲數組的長度是不可變,所以經典 Trie 樹存在着明顯的空間浪費。可是若是將每一層都換成可變數組(不一樣語言對這種數據結構稱呼不一樣,好比在 Python 中爲List,C# 稱爲 LinkedList)來存儲節點(見下圖[3]),每層能夠根據節點的數量動態調整數組的長度,就能夠避免大量的空間浪費。下圖就是這種實現的圖例[3]

clipboard.png

可是可變長數組的取詞複雜度是O(d),其中 d 爲數組的長度,這意味着狀態轉移函數沒法經過映射轉移到下一節點,必須先遍歷數組,找到節點後再作轉移,所以Trie 樹實際時間複雜度變爲O(m*n)(其中n爲每層數組中節點的數量)。這顯然下降了查詢效率,所以還算不上完善。

5.三、Hash Trie 樹

可變數組取詞速度太慢,因而就有人想起用一組鍵值對(Java中可用HashMap類型,Python 中爲 dict 類型,C#爲Dictionary類型)代替可變數組:其中每一個節點包含一組 Key-Value,每一個 Key 對應該節點下的一個子節點字符,value 則指向相應的後一個狀態。這種方式能夠有效的減小空間浪費,同時因爲鍵值對本質上就是一個哈希實現,所以理論上其查詞效率也很高(理想狀態下取詞複雜度爲O(1))。
可是哈希有的缺點,這種實現的 Trie 樹也會有:

  1. 爲了儘量的避免鍵值衝突,哈希表須要額外的空間避開碰撞,所以仍有一部分的空間會被浪費;

  2. 哈希表很難作到完美,尤爲是數據體量增大以後,其查詞複雜度經常難以維持在O(1),同時,對哈希值的計算也須要額外的時間,所以實際查詢效率要比經典實現低,其具體複雜度由相應的哈希實現來定。

與數組和可變數組實現相比,這種實現作到了空間和時間上的一種平衡,這個結果並不意外,由於哈希表自己就是平衡數組(查尋迅速、增刪悲劇)和可變數組(增刪迅速,查詢悲劇)相應優勢和缺點的一種數據結構。
整體而言,Hash Trie 結構簡單,性能堪用,並且因爲哈希實現能夠爲每一個節點分配惟一的id,所以能夠作到節點的實時動態添加(這點是很是大的優點)所以對於中小規模的詞典或者對詞典的實時更新有需求的應用,該實現很是適合。

5.四、Double-array Trie 樹

雙數組 Trie 樹是目前 Trie 樹各類實現中性能和存儲空間均達到很好效果的實現。但其完整的實現比較複雜,對於新手而言入手相對較難,所以本節將花費較多的篇幅對其解讀。

5.4.1 Base Array 的做用

雙數組 Trie 樹和經典 Trie 樹同樣,也是用數組實現 Trie 樹。只不過它是將全部節點的狀態都記錄到一個數組之中(Base Array),以此避免數組的大量空置。以行文開頭的示例爲例,每一個字符在 Base Array 中的狀態能夠是這樣子的:

clipboard.png

好吧,我撒了個慌,事實上,爲了能使單個數組承載更多的信息,Base Array 僅僅會經過數組的位置記錄下字符的狀態(節點),好比用數組中的位置 2 指代「清」節點、 位置 7 指代 「中」節點;而數組中真正存儲的值實際上是一個整數,這個整數咱們稱之爲「轉移基數」,好比位置2的轉移基數爲 base[2]=3位置7的轉移基數爲base[7]=2所以在不考慮葉子節點的狀況下, Base Array 是這樣子的:

clipboard.png

轉移基數是爲了在一維數組中實現 Trie 樹中字符的鏈路關係而設計的,舉例而言,若是咱們知道一個詞中某個字符節點的轉移基數,那麼就能夠據此推斷出該詞下一個節點在 Base Array 中的位置:好比知道 「清華」首字的轉移基數爲base[2]=3,那麼「華」在數組中的位置就爲base[2]+code("華"),這裏的code("華")爲字符表中「華」的編碼,假設例樹的字符編碼表爲:

清-1,華-2,大-3,學-4,新-5,中-6,人-7

那麼「華」的位置應該在Base Array 中的的第 5 位(base[2]+code("華")=3+2=5):

clipboard.png

而全部詞的首字,則是經過根節點的轉移基數推算而來。所以,對於字典中已有的詞,只要咱們每次從根節點出發,根據詞典中各個字符的編碼值,結合每一個節點的轉移基數,經過簡單的加法,就能夠在Base Array 中實現詞的鏈路關係。如下是「清華」、「清華大學」、「清新」、「中華」、「華人」五個詞在 Base Array 中的鏈路關係:

clipboard.png

5.4.2 Base Array 的構造

可見 Base Array 不只可以表達詞典中每一個字符的狀態,並且還能實現高效的狀態轉移。那麼,Base Array 又是如何構造的呢?

事實上,一樣一組詞和字符編碼,以不一樣的順序將字符寫入 Trie 樹中,得到的 Base Array 也是不一樣的,以「清華」、「清華大學」、「清新」、「中華」、「華人」五個詞,以及字符編碼:[清-1,華-2,大-3,學-4,新-5,中-6,人-7] 爲例,在不考慮葉子節點的狀況下,兩種處理方式得到的 base array 爲:

  1. 首先依次處理「清華」、「清華大學」、「清新」、「中華」、「華人」五個詞的首字,而後依次處理全部詞的第二個字...直到依次處理完全部詞的最後一個字,獲得的 Base Array 爲:
    clipboard.png

  2. 依次處理「清華」、「清華大學」、「清新」、「中華」、「華人」五個詞中的每一個字,獲得的 Base Array 爲:
    clipboard.png

能夠發現,不一樣的字符處理順序,獲得的 Base Array 存在極大的差異:二者各狀態的轉移基數不只徹底不一樣,並且 Base Array 的長度也有差異。然而,二者得到的方法倒是一致的,下面以第一種字符處理順序講解一下無葉子節點的 Base Array 構建:

  1. 首先人爲賦予根節點的轉移基數爲1(可自定義,詳見下文),而後依次將五個詞中的首字"清"、「中」、「華」寫入數組之中,寫入的位置由base[1]+code(字符)肯定,每一個位置的轉移基數(base[i])等於上一個狀態的轉移基數(此例也即base[1]),這個過程未遇到衝突,最終結果見下圖:
    clipboard.png

  2. 而後依次處理每一個詞的第二個字,首先須要處理的是「清華」這個詞的「華」字,程序先從根節點出發,經過base[1]+code(「清」)=2找到「清」節點,而後以此計算「華」節點應寫入的位置,經過計算base[2]+code(「華」)=3 尋找到位置 3,卻發現位置3已有值,因而後挪一位,在位置4寫入「華」節點,因爲「華」節點未能寫入由前驅節點「清」預測的位置,所以爲了保證經過「清」可以找到「華」,須要從新計算「清」節點的轉移基數,計算公式爲4-code(「華」)=2,得到新的轉移基數後,改寫「清」節點的轉移基數爲2,而後將「華」節點的轉移基數與「清」節點保持一致,最終結果爲:
    clipboard.png

  3. 重複上面的步驟,最終得到整個 Base Array:
    clipboard.png

經過以上步驟,能夠發現 base array 的構造重點在於狀態衝突的處理,對於雙數組 Trie 而言,詞典構造過程當中的衝突是不可避免的,衝突的產生來源於多詞共字的狀況,好比「中華」、「清華」、「華人」三個詞中都有「華」,雖然詞在 Trie 樹中能夠共用前綴,可是對於後綴同字或者後綴與前綴同字的狀況卻只能從新構造新的節點,這勢必會致使衝突。一旦產生衝突,那麼父節點的轉移基數必須改變,以保證基於前驅節點得到的位置可以容納下全部子節點(也即保證 base[i]+code(n1)base[i]+code(n2)base[i]+code(n3)....都爲空,其中n一、n二、n3...爲父節點的全部子節點字符,base[i]爲父節點新的轉移基數,i爲父節在數組中的位置)這意味着其餘已經構造好的子節點必須一併重構。

所以,雙數組 Trie 樹的構建時間比較長,有新詞加入,運氣很差的話,還可能能致使全樹的重構:好比要給詞典添加一個新詞,新詞的首字以前不曾寫入過,如今寫入時若出現衝突,就須要改寫根節點的轉移基數,那麼以前構建好的詞都須要重構(由於全部詞的鏈路都是從根節點開始)。上例中,第二種字符寫入順序就遇到了這個問題,致使在詞典構造過程當中,根節點轉移基數被改寫了兩次,全樹也就被重構了三次:

clipboard.png

可見不一樣的節點構建順序,對 Base Aarry 的構建速度、空間利用率都有影響。建議實際應用中應首先構建全部詞的首字,而後逐一構建各個節點的子節點,這樣一旦產生衝突,能夠將衝突的處理侷限在單個父節點和子節點之間,而不至於致使大範圍的節點重構。

5.4.3 葉子節點的處理

上面關於 Base Array 的敘述,只涉及到了根節點、分支節點的處理,事實上,Base Array 一樣也須要負責葉子節點的表達,可是因爲葉子節點的處理,具體的實現各不一致,所以特意單列一節予以論述。

通常詞的最後一個字都不須要再作狀態轉移,所以有人建議將詞的最後一個節點的轉移基數統一改成某個負數(好比統一設置爲-2),以表示葉子節點,按照這種處理,對於示例而言,base array 是這樣的:

clipboard.png

但細心的童鞋可能會發現,「清華」 和 「清華大學」 這兩個詞中,只有「清華大學」有葉子節點,既是公共前綴又是單個詞的「清華」實際上沒法用這種方法表示出葉子節點。

也有人建議爲詞典中全部的詞增長一個特殊詞尾(好比將「清華」這個詞改寫爲「清華\0」),再將這些詞構建爲樹,特殊字符詞尾節點的轉移基數統一設置設爲-2,以此做爲每一個詞的葉子節點[4]。這種方法的好處是不用對現有邏輯作任何改動,壞處是增長了總節點的數量,相應的會增長詞典構建的時長和空間的消耗。

最後,我的給出一個新的處理方式:直接將現有 base array 中詞尾節點的轉移基數取負,而數組中的其餘信息不用改變。

以樹例爲例,處理葉子節點前,Base Array 是這樣子的:

clipboard.png

處理葉子節點以後,Base Array 會是這樣子的:

clipboard.png

每一個位置的轉移基數絕對值與以前是徹底相同的,只是葉子節點的轉移基數變成了負數,這樣作的好處是:不只標明瞭全部的葉子節點,並且程序只需對狀態轉移公式稍加改變,即可對包括「清華」、「清華大學」這種狀況在內的全部狀態轉移作一致的處理,這樣作的代價就是須要將狀態轉移函數base[s]+code(字符)改成|base[s]|+code(字符),意味着每次轉移須要多作一次取絕對值運算,不過好在這種處理對性能的影響微乎其微。

對此,其餘童鞋如有更好的想法, 歡迎在底部留言!

5.4.4 Check Array 的構造

「雙數組 Trie 樹」,一定是兩個數組,所以單靠 Base Array 是玩不起來的....上面介紹的 Base Array 雖然解決了節點存儲和狀態轉移兩個核心問題,可是單獨的 Base Array 仍然有個問題沒法解決:

Base Array 僅僅記錄了字符的狀態,而非字符自己,雖然在 Base Array,字典中已有的任意一個詞,其鏈路都是肯定的、惟一的,所以並不存在歧義;可是對於一個新的字符串(無論是檢索字符串仍是準備爲字典新增的詞),Base Array 是不能肯定該詞是否位於詞典之中的。對於這點,咱們舉個例子就知道了:

clipboard.png

若是咱們要在例樹中確認外部的一個字符串「清中」是不是一個詞,按照 Trie 樹的查找規則,首先要查找「清」這個字,咱們從根節點出發,得到|base[1]|+code(「清」)=3,而後轉移到「清」節點,確認清在數組中存在,咱們繼續查找「中」,經過|base[3]|+code(「中」)=9得到位置9,字符串此時查詢完畢,根據位置9的轉移基數base[9]=-2肯定該詞在此終結,從而認爲字符串「清中」是一個詞。而這顯然是錯誤的!事實上咱們知道 「清中」這個詞在 base array 中壓根不存在,可是此時的 base array 卻不能爲此提供更多的信息。

爲了解決這些問題,雙數組 Trie 樹專門設計了一個 check 數組:

check array 與 base array 等長,它的做用是標識出 base array 中每一個狀態的前一個狀態,以檢驗狀態轉移的正確性。

所以, 例樹的 check array 應爲:

clipboard.png

如圖,check array 元素與 base array 一一對應,每一個 check array 元素標明瞭base array 中相應節點的父節點位置,好比「清」節點對應的check[2]=0,說明「清」節點的父節點在 base array 的0 位(也即根節點)。對於上例,程序在找到位置9以後,會檢驗 check[9]==2,以檢驗該節點是否與「清」節點處於同一鏈路,因爲check[9]!=2,那麼就能夠斷定字符串「清中」並不在詞典之中。

綜上,check array 巧妙的利用了父子節點間雙向關係的惟一性(公式化的表達就是base[s]+c=t & check[t]=s是惟一的,其中 s爲父節點位置,t爲子節點位置),避免了 base array 之中單向的狀態轉移關係所形成的歧義(公式化的表達就是base[s]+c=t)。

5.4.5 Trie 樹的壓縮

雙數組 Trie 樹雖然大幅改善了經典 Trie 樹的空間浪費,可是因爲衝突發生時,程序老是向後尋找空地址,致使數組不可避免的出現空置,所以空間上仍是會有些浪費。另外, 隨着節點的增長,衝突的產生概率也會愈來愈大,字典構建的時間所以愈來愈長,爲了改善這些問題,有人想到對雙數組 Trie 進行尾綴壓縮,具體作法是:將非公共前綴的詞尾合併爲一個節點(tail 節點),以此大幅減小節點總數,從而改善樹的構建速度;同時將合併的詞尾單獨存儲在另外一個數組之中(Tail array), 並經過 tail 節點的 base 值指向該數組的相應位置,以 {baby#, bachelor#, badge#, jar#}四詞爲例,其實現示意圖以下[3]

clipboard.png

對於這種改進的效果,看一下別人家的測試就知道了[4]

速度
減小了base, check的狀態數,以及衝突的機率,提升了插入的速度。在本地作了一個簡單測試,隨機插入長度1-100的隨機串10w條,no tail的算法需120秒,而tail的算法只需19秒。
查詢速度沒有太大差異。

內存
狀態數的減小的開銷大於存儲tail的開銷,節省了內存。對於10w條線上URL,匹配12456條前綴,內存消耗9M,而no tail的大約16M

刪除
能很方便的實現刪除,只需將tail刪除便可。

對於本文的例樹,若採用tail 改進,其最終效果是這一子的:

clipboard.png

6、總結

  1. Trie 樹是一種以信息換時間的數據結構,其查詢的複雜度爲O(m)

  2. Trie 的單數組實現可以達到最佳的性能,可是其空間利用率極低,是典型的以空間換時間的實現

  3. Trie 樹的哈希實現能夠很好的平衡性能需求和空間開銷,同時可以實現詞典的實時更新

  4. Trie 樹的雙數組實現基本能夠達到單數組實現的性能,同時可以大幅下降空間開銷;可是其難以作到詞典的實時更新

  5. 對雙數組 Trie 進行 tail 改進能夠明顯改善詞典的構建速度,同時進一步減小空間開銷

參考文獻:

(1) Trie 樹詳解
(2) 《統計天然語言處理》,第三章 形式語言與自動機
(3) Theppitak Karoonboonyanan, An Implementation of Double-Array Trie.
(4) 前綴樹匹配(Double Array Trie)

相關文章
相關標籤/搜索