1 雙數組Tire樹簡介
雙數組Tire樹是Tire樹的升級版,Tire取自英文Retrieval中的一部分,即檢索樹,又稱作字典樹或者鍵樹。下面簡單介紹一下Tire樹。
1.1 Tire樹
Trie是一種高效的索引方法,它實際上是一種確定有限自動機(DFA),在樹的結構中,每一個結點對應一個DFA狀態,每一個從父結點指向子結點(有向)標記的邊對應一個DFA轉換。遍歷從根結點開始,然后從head到tail,由關鍵詞(本想譯成鍵字符串,感太別扭)的每個字符來決定下一個狀態,標記有相同字符的邊被選中做移動。注意每次這種移動會從關鍵詞中消耗一個字符並走向樹的下一層,如果這個關鍵字符串空了,並且走到了葉子結點,那么我們達到了這個關鍵詞的出口。如果我們被困在了一點結點,比如因為沒有分枝被標記為當前我們有的字符,或是因為關鍵字符串在中間結點就空了,這表示關鍵字符串沒有被trie認出來。圖1.1.1即是一顆Tire樹,其由字典{AC,ACE,ACFF,AD,CD,CF,ZQ}構成。
圖1.1.1
圖中R表示根節點,並不代表字符,除根節點外每一個節點都只包含一個字符。從根節點到圖中綠色節點,路徑上經過的字符連接起來,為該節點對應的字符串。綠色葉節點的數字代表其在字典中的位置
1.2 Tire樹的用途
Tire樹核心思想是空間換取時間,利用字符串的公共前綴來節省查詢時間,常用於統計與排序大量字符串。其查詢的時間復雜度是O(L),只與待查詢串的長度相關。所以其有廣泛的應用,下邊簡單介紹下Tire樹的用途
Tire用於統計:
題目:給你100000個長度不超過10的單詞。對於每一個單詞,我們要判斷他出沒出現過,如果出現了,求第一次出現在第幾個位置。
解法 :從第一個單詞開始構造Tire樹,Tire樹包含兩字段,字符與位置,對於非結尾字符,其位置標0,結尾字符,標注在100000個單詞詞表中的位置。對詞表建造Tire樹,對每個詞檢索到詞尾,若詞尾的數字字段>0,表示單詞已經在之前出現過,否則建立結尾字符標志,下次出現即可得知其首次出現位置,便利詞表即可依次計算出每個單詞首次出現位置復雜度為O(N×L)L為最長單詞長度,N為詞表大小
Tire用於排序
題目:對10000個英文名按字典順序排序
解法:建造Tire樹,先序便利即可得到結果。
1.3 針對Tire樹的改進
Tire樹雖然很完美,但缺點是空間的利用率很低,比如建立一顆ASCII的Tire樹,每個節點的指針域為256,這樣每個節點既有256個指針域,即使子節點置空,仍會有空間占用問題,解決辦法是動態數組Tire樹,即對子節點分配動態數組,生成子節點則動態擴大數組容量,這樣便能有效的利用空間。
出於對Tire樹占用空間的更有效利用,便引入了今天的主題:雙數組Tire樹,顧名思義,即把Tire樹壓縮到兩個數組中。
雙數組Tire樹擁有Tire樹的所有優點,而且刻服了Tire樹浪費空間的不足,使其應用范圍更加廣泛,例如詞法分析器,圖書搜索,拼寫檢查,常用單詞過濾器,自然語言處理 中的字典構建等等。在基於字典的分詞方法中,許多開源
的實現都采用了雙數組Tire樹。
2 構造雙數組Tire樹
下面不如本文的主題雙數組Tire樹,其基本觀念是壓縮trie樹,使用兩個一維數組BASE和CHECK來表示整個樹。雙數組缺點在於:構造調整過程中,每個狀態都依賴於其他狀態,所以當在詞典中插入或刪除詞語的時候,往往需要對雙數組結構進行全局調整,靈活性能較差。 但對與,這個缺點是可以忽略的,因為核心詞典已經預先建立好並且有序的,並且不會添加或刪除新詞,所以插入時不會產生沖突。所以常用雙數組Tire樹來載入整個核心分詞詞典。
2.1 雙數組的構造
Tire樹終究是一顆樹形結構,樹形結構的兩個重要要素便是前驅和后繼,把Tire樹壓縮到雙數組中,只需要保持能查詢到每個節點的前驅和后繼即可。Tire樹中幾個重要的概念
STATE:狀態,實際為在數組中的下標
CODE : 狀態轉移值,實際為轉移字符的 ASCII碼
BASE :表示后繼節點的基地址的數組,葉子節點沒有后繼,標識為字符序列的結尾標志
CHECK:標識前驅節點的地址
在DAT的構造過程當中,一般有兩種構造方法:
1 動態輸入詞語,動態構造雙數組。
定義2. 對於一個接收字符c從狀態s移動到t的轉移,在雙數組中保存的條件是:
check[base[s] + c] = s
base[s] + c = t
以上為雙數組中的核心轉移公式,公式中s 和 t 均為狀態state
對於新插入字符串c1c2...cn
樹的構造過程如下,由根節點開始,加入到樹中,加入方法即如上所述的狀態轉移方法。
base[root] + c1 .code= t1 ,check[t1] = root
base[t1] + c2.code = t2 , check[t2] = t1
....
base[tn-1] + cn .code = tn , check[tn] = tn-1
公式中的root ti 即為 狀態state,也就是在數組中的下標,如圖2.1.2所示
圖2.1.1
root的base值一般是給定的,假定root在在位置0,base[root] = base[0] = 1
假設插入字符串AB, base[root]+ 'A'.code = 1+65 = 66,check[66] = 1,即 t1 = 66,然后base[t1] +'B'.code = t2 ,因為t2 可能被占用,所以要確保t2 的check值為空,即沒有父節點,即插入 'B' 的就要在CHECK數組中找到一個空位置,即找到使check[base[t1]+'B'.code] = 0的值begin,另base[t1]=begin , base[t1]+'B'.code即為狀態t2,另check[t2] = t1即可。
接下來依次插入其他字符序列,不同於靜態構造,動態構造的插入過程中注意產生沖突的問題,比如現在Tire樹由{AB,AC}構成,當插入AD時
首先要要找一個狀態t為B C的基地址,若check[base[t]+'D'.code] != 0,即base[t]可以作為BC基地址,而作為BCD的基地址卻產生了沖突,因為base[t]+'D'.code已經被占用,解決辦法是重新選擇base[A],重新尋找base[t],使得check[base[t]+'B'.code] = check[base[t]+'C'.code] = check[base[t]+'D'.code] = 0 即找到三個空位置重新放置三個子節點,可完成動態構造Tire樹。
2 已知所有詞語,靜態構造雙數組;
這就是本文的重點了,靜態構造Tire樹,一般雙數組的實現都會對算法做一個改進,下面的算法講解主要參考開源實現dart-clone,dart-clone 也對雙數組算法做了一個改進,即
base[s] +c = t
check[t] = base[s]
不是原來的check[t] = s , 構造過程是 對於一組待插入的序列c1...cn,找到一個begin值,使得 begin+c1.code...begin+cn.code = t1 ... tn ,check[t1] ... check[tn] = base[s] = begin 即為c1...cn的基地址,而不是原來的 check[t =]s ,所以指向父節點的指針不是指向上一個狀態,而是上一個狀態s的base值 base[s],那么問題來了一個base[s]值只能作為一組children的基地址,若現在有第二組children也可以用base[s]作為基地址,如何防止這種沖突呢,解決方法就是做一個boolean數組 used[],一旦base[s]作為某組children的基地址,used[base[s]] = true,若產生沖突發現used[base[s]] = true,則說明已經作為父節點,則第二組children再重新尋找新的begin值。
dart-clone的另一個改進是另字符的code = ASCII+1,下面就是靜態構造過程了,構造中首先要有一個字典,包含所有字符序列,並且一般情況下不會對構造完成的Tire樹插入新字符序列
對於由Dic = { AC,ACE,ACFF,AD,CD,CF,ZQ }構成的Tire樹,其雙數組如圖2.1.1所示:由 dart-clone 生成的結果:
圖2.1.1
其中i是下標,即為state,這里根據下標i可以看出BASE與CHECK數組的長度均達到了144,本圖中只顯示了BASE與CHECK中不為0的信息。
構造過程
1 建立根節點root,令base[root] =1
2 找出root的子節點 集{root.childreni }(i = 1...n) , 使得 check[root.childreni ] = base[root] = 1
3 對 each element in root.children :
1)找到{elemenet.childreni }(i = 1...n) ,注意若一個字符位於字符序列的結尾,則其孩子節點包括一個空節點,其code值設置為0找到一個值begin使得每一個check[ begini + element.childreni .code] = 0
2)設置base[element.childreni] = begini
3)對element.childreni 遞歸執行步驟3,若遍歷到某個element,其沒有children,即葉節點,則設置base[element]為負值(一般為在字典中的index取負)
下面舉一個實例,對字典Dic = { AC,ACE,ACFF,AD,CD,CF,ZQ }建立Tire樹,圖1.1.1展示了其樹形結構
1 遍歷字典,找到root的所有children,在Dic中為{A C Z},因為首次插入,直接設置其三個子節點的check值=1 root經過A C Z 的作用分別到達三個狀態 t1 t2 t3
狀態t1由條件 ‘A’ 觸發,找到‘A’的子節點值{C D},找一個begin值,使得check[begin + 'C'.code] = check[begin +'D',code] = 0,這里 base[t1] = begin = 2,狀態t1 =67,t1下一狀態為t4 = base[t1]+ 'C'.code ,t5 = base[t2] +'D'.code
繼續向下便利,不斷添加字符,狀態轉移,遞歸的對BASE與CHECK賦值,注意葉節點沒有兒子,則設置其base值為-index , 最終便得到由兩個數組表示的Tire樹。
圖2.1.2表示不同於圖1.1.1,圖2.12.使用DFA的形式來描繪,節點表示state,字符作為轉移條件,不同字符觸發不同的state,由圖可見,一個轉移狀態對應一個state,在實現過程中,可以把2者結合起來,圖2.1.2就會變成圖1.1.1的形式了
圖2.1.2
注意:對於葉節點leaf.code = 0,標識為詞尾
1 對於有相同父節點的children,其有相同的基地址值,如狀態 67 69 92的check值= base[root] = 1
2 每個節點表示一個狀態,子節點的check值即為父節點的base值
3 葉子節點的code值為0,葉子節點代表一個字符序列的結尾 且base[leaf] = -index
4 對於depth = 2 的節點 其基地址為1 ,1+'A'.code 1+'B'.code 1+'C'.code 為三個狀態在數組中的位置 分別為 67 69 92
5 對葉節點tleaf , check[tleaf] = tleaf, 因為到葉節點的轉移字符leaf.code = 0,尋找begin值時,begin + leaf.code =tleaf ,check[begin+ leaf.code] = begin , 由於leaf.code = 0 , 則有begin = tleaf,即check[tleaf] = tleaf
2.2Tire樹的查詢
有了如上的構建過程,查詢就會變的很easy
只需牢記:
base[s] + c = t
check[t] = base[s]
當有 base[s] == t 時說明 c=0 ,即遇到了葉子節點,這時,記錄下其位置index,然后輸出Dic[index]即為匹配出來的dic中的詞