1. 程式人生 > >[轉]Trie樹優化演算法:Double Array Trie 雙陣列Trie

[轉]Trie樹優化演算法:Double Array Trie 雙陣列Trie

Trie邏輯結構

      Trie是一種常見的資料結夠,可以實現字首匹配(hash是不行的),而且對於詞典搜尋來說也是O(1)的時間複雜度,雖然比不上Hash,但是空間會省不少。

       比如下圖表示了包含“pool, prize, preview, prepare, product, progress"的一個Trie

       

       Trie的邏輯結構:每個圓圈都表示一個狀態,比如狀態1,狀態之間的邊表示狀態1遇到字元p就變成狀態2。用兩個圈畫的狀態表示終止狀態,也就是表示匹配了一個單詞。

       這是DFA的表示方法,當然按照正規的定義,還得有個“吸收”所以非法字元的狀態,比如狀態1碰到p之外的任何字元都會匹配失敗,也就是會進入這個“吸收”狀態,這個狀態就像

       黑洞,進去之後就永遠沒有出頭之日了------永遠在那個狀態跳轉。

Trie的實現

       從上面可以知道,要表示一個Trie,關鍵就是一個跳轉矩陣(DFA裡的正式名字是狀態轉移表),比如上圖可以這樣表示    

1234..
p2XXX
rXXXX
oX3XX
eXX4X
..

        X就表示那個“吸收”狀態。

        從上表看出,如果有N個狀態,並且字母表的大小是M,那麼邏輯上就是一個N*M的陣列。M一般很容易知道,比如對於英文單詞,M可能是26或者52,對於漢字,可能有好幾千。

        而N很顯然和詞典的大小有關係,詞典越大,那麼N一般也越大。另外也與詞典資料有關,比如詞典的詞共有的字首很多,那麼N就越少;反之N就越大。

        可以看出,一般這個二維陣列會比較稀疏,所以可以壓縮空間。

        最容易想到的壓縮方法當然是連結串列。比如把狀態1可以接受的字元組成一個連結串列,但是連結串列的缺點是無法實現隨機訪問,這樣效率會有問題。

        我們也可以把連結串列換成樹的結構,比如紅黑樹,這樣可以log(n)的速度。但是還是比不上陣列的o(1)的速度。

        這時我們肯定想到了Hash,沒錯,使用Hash比不壓縮的陣列省空間(陣列也可以理解為Hash),而且速度也慢不了很多。

        但是Hash總會是有衝突的(當然可以構造Perfect Hash,但是如果資料經常變化,那麼就不好處理),能不能既有陣列般的隨機訪問效能,又能節省大量空間的方法呢?

        這就是我們要講到的Double Array Trie。不過先別急,我們先討論Triple Array Trie。瞭解這種壓縮的思路。

Triple Array Trie(TAT for short)

         TAT的思想很簡單,由於每個狀態接受的字元很有限,大家可以共享一個數組。比如字母表是a-z這26個英文字母,我們可以用0-25這26個數組表示它們。

         比如狀態1接受“a,c,e",那麼我們可以把找一個“base”。可以把這個“base”理解成這個狀態的Hash值。然後base,base+2,base+4就分配給狀態1了。

         又假設狀態2接受“b,d",那麼狀態2也可能Hash到和狀態1相同的base,然後把base+1,base+3分配給狀態2。這樣它們能夠相安無事的共存。

         不過我怎麼能知道base是屬於狀態1,而base+1是屬於狀態2呢?這就需要一個check的陣列來標識了。

           
          比如上圖:狀態s碰到字元c就變成狀態t,那麼首先從base裡找到s的“hash地址”,這個地址指向base=base[s],然後base+c我們分配給c的地址,通過check[base+c]==s我們知道
          這個地址確實是分配給了s,所以我們讀取next[base],它的值就是t。這樣你給我s和c,我通過上面的過程就能告訴下一個狀態就是t。
          我們來比較一下TAT和二維陣列的時間和空間開銷。

        時間

          二維陣列:你給我s和c,我立馬就能告訴你t,array[s*字母表大小+c],當然需要一次乘法和加法算下標。記憶體讀取一次。
          TAT:給我s,首先讀取base[s],然後計算base[s]+c,然後讀取chk=check[base[s]+c],然後一次判斷,如果chk==s,那麼再讀取一次next[base[s]+c]得到t。3次訪問記憶體,一次加法

        空間

          二維陣列:M*N*4(有一個32bit的int表示)

          TAT:狀態個數+雙陣列的長度,這個值比較難估計,與詞典的資料分佈有關。我使用了一個隨機生成的例子:字母表大小26,詞典大小20,000,N=154825,使用DAT後next和check的大小是

          168505(因為沒有實現TAT,所以我這裡只能用DAT來估計,但TAT應該和DAT是差不多的。而且我目前使用的DAT使用了check壓縮,這樣導致雙陣列的大小會稍微大一些,check數

           組的壓縮參考下面)。

           我們簡單的比較一下:二維陣列 26*150k*4=15M; TAT 150K*4+170K*8=2M,可以看出空間節省了多少!!如果像漢字這樣字母表更大的詞典,那麼會節省的更多。

        問題

          從上面的分析我們看出,實現TAT的關鍵就是給每個狀態一個合適的base,比如上面的例子,如果狀態1的base是0,那麼它就會佔用next[0],next[2],next[4],如果我們不小心把狀態1的

          base弄成了1,那麼它會佔用next[2],next[4],這樣就“衝突”了,所以要避免這種情況。如果出現了,我們就必須給某個狀態,比如狀態2分配一個新的base。

           

          上圖就展示了由於衝突,我們需要修改base[s]的例子。我們需要找到原來的base,然後遍歷next[oldBase+0...字母表大小-1],如果next值為s,說明這個next是屬於s的,那麼需要
          把它“搬”到合適的地方,然後原來的check從s變成none,新地址的check從none變成s。

Double Array Trie(DAT for short)

        還能壓縮嗎?

          看起來TAT已經很不錯了,但是還是有冗餘的資訊。

          不過之前需要說明這樣一個前提:Trie是一顆樹,構造Trie時,只會增加狀態;刪除單詞時,首先刪除孩子,然後才能刪除父親。

          形式化一點:假設狀態s遇到c變成狀態t,那麼就不會有另一個狀態r遇到c變成狀態t(否則一個節點有兩個父親,那就不是樹了)。

          這有什麼用呢?如果s遇到c變成t,s是t的父親,t是s的孩子,那麼t只能從s過來,那麼就沒有必要在next數組裡指向base裡,而可以直接讓t=base[s]+c

          如果看上圖,那麼就是所有的next[i]=i,也就是不需要next陣列了。

          這個可能有的繞,需要這樣理解:狀態只是一個數字,叫1還是2並不重要,反正是個唯一的標識就行了,比如原來狀態0遇到c變成狀態1,狀態1遇到d變成狀態2,那麼我把狀態1改成狀

          態100完全是沒有區別的:狀態0遇到c變成狀態100,狀態100遇到d變成狀態2。

          狀態本身並不重要,重要的是它的base(可以理解為hash地址)

          它的搜尋過程如下:給定s和c,直接檢查chk=check[base[s]+c],如果chk==s,則t=base[s]+c,也就是把原來的base和next數組合併成為一個。

          也許你會有這樣的擔心(細心的讀者),萬一base[s]+c被別人用了呢?當然可以調整base[s],這時t也跟著s變化。有沒有怎麼調整也衝突的情況呢?

          考慮一下s遇到c變成t,已經r也遇到c變成t,這會怎麼樣?不論你怎麼調整,因為base[s]=t-c=base[r],也就是s和r的base相同,這沒什麼,關鍵是check陣列

          只能一個,要麼s,要麼r,這種情況沒法處理。不過想想前面,Trie是一顆樹,所以t只能有一個父親節點,所以上面的例子是不可能出現的。

            
          同樣的,如果給s增加一個孩子t(通過字元c),那麼萬一base[s]+c已經被別人使用了check[base[s]+c]=other,那麼就必須給s的base換個地方,參考下圖:
                      
          除了要修改t和t‘的check外,還需要把t’的base改成原來t的base。

字尾壓縮

          比如前面的例子,pool,狀態3的後代最多隻有一個孩子,也就是一個鏈(沒有更多分支),所以可以把狀態4和5去掉,然後狀態3做為葉子節點,用一個指標指向字串“ol”。

DATrie的插入

           注意:這裡的DATrie是指有後綴壓縮的DATrie。如果沒有後綴壓縮,其實也類似。

           根據插入點的位置,可以分為兩種情況。

           首先我們找到插入點,也就是在Trie樹上不停的走,直到在非葉子節點遇到不能接受的字元或者遇到葉子或者所有的字元都走完了。

           第一和第三種情況可以合併成一種,它們唯一的不同時,前者的字尾不空,字尾的字尾為空(#)表示。 

                         
            比如現在的trie樹如上,
            我們要插入“pooch”,那麼就是第二種情況,我們需要在狀態3增加一個狀態t, 3經過o變成t,然後t分成兩個分支,一個是l,一個是c。
            如果要插入“poa“,那麼是第一種情況,如果要插入”po“,那麼是第三中情況。這兩種情況都需要從3增加狀態,但是原來的孩子不需要改變。
                    插入po,只需要給3增加一個孩子t,邊上的字元是#,然後t是葉子,
                    插入poa,需要給3增加孩子t,邊上的字元是a,a是葉子節點,指向#
            也就是說,第二種情況需要修改原來的tail(字尾壓縮部分)

DATrie的刪除

             刪除一個詞首先需要找到這個詞的路徑,然後反向一個一個刪除狀態,直到遇到某個狀態------這個狀態至少有兩個分支(也就是刪除當前分支後還有分支)。

             如果有後綴壓縮的話,那麼可以再壓縮字尾(當然也可以不壓縮)。比如上面的例子,刪除“produce”,那麼首先刪除狀態14,然後可以壓縮狀態15,13,12,11,讓

             狀態10直接指向ucer#

雙陣列的Pool分配

             我們這裡討論的DAT是一種動態資料結構,會不停的往裡面插入刪除單詞。

             這個時候就需要動態管理雙陣列了。因為如果base和check被使用的話,那麼它們的值會大於等於0,所以可以讓沒有使用的base和check的值為-1,比如需要找

             空閒的base時,我們可以從頭開始掃描base,碰到-1就找到一個空閒的空間。

             這種辦法簡單容易實現,但缺點是時間複雜度比較高。如果對插入刪除要求不高的話,那麼這種方法就比較簡單可行,比如後面我們講到的Static的DAT【構建一次,永不修改】

             就可以使用這種方法。

             改進的辦法就是把空閒的空間組織成連結串列。我們可以用負數代表空閒,然後它的絕對值代表下一個空閒單元的地址(下標)。

check[0] = -r1

     check[ri] = -ri+1 ; 1 <= i <= cm-1

     check[rcm] = -1

            這裡只使用了check來表示空閒單元,其實check空閒,那麼對應的base也是空閒的。那麼其實可以也利用上,來組織成一個迴圈連結串列:

     check[0] = -r1
     check[ri] = -ri+1 ; 1 <= i <= cm-1
     check[rcm] = 0
     base[0] = -rcm
     base[r1] = 0
     base[ri+1] = -ri ; 1 <= i <= cm-1

字母表的問題

             對於英語來說,一般只有26個字母(或者52個,如果考慮大小寫)+一些數字等,一般一個位元組就可以表示下來。然後可以使用比較簡單的演算法把它們對映成0開始的連續整數。

             比如只有字母和數字可以使用如下演算法:

      int getIndex(char c){
        if(ch >='a' && ch <='z')
            return ch-'a';
        else if(ch >='0' && ch <='9')
            return ch-'0';
        else
            return -1;
      }

             如果字母表很大,比如漢字,那麼可能需要一個HashMap<Character,Integer>來儲存了。不過這樣的速度可能有問題,由於一般字元編碼都會是連續的區域,所以也可以參考上面的方

             法來實現,這樣既省空間,又速度更快。

             對於漢字這種“寬”字元,還有一種辦法,那就是先把它轉成多個單位元組的陣列。比如“北京”的unicode是“\u5317\u4eac“,那麼可以把它看出4個位元組。這樣字母表最多256,正好可以

             用一個位元組表示。

libdatrie的用法

             下面介紹一下做為獨立程式使用的方法。

          安裝

             從網站下載,解壓,標準的tar包,./configure && sudo make install安裝。

             預設程式安裝在/usr/local/bin/trietool-0.2,so安裝在/usr/lib/libdatrie.so.1,可以使用man trietool-0.2 檢視用法。

          示例
 要構造一個trie 名字叫test,首先需要告訴它我們的字母表,建立一個test.abm,比如我們的詞典只有大小寫的英文字母
  1. lili@lili-desktop:~/datrie/libdatrie-0.2.4$ cat test.abm  
  2. [0x0041,0x005a]  
  3. [0x0061,0x007a]  
  4. lili@lili-desktop:~/datrie/libdatrie-0.2.4$ trietool-0.2 test add abcd 0
  5. lili@lili-desktop:~/datrie/libdatrie-0.2.4$ trietool-0.2 test add abce  
  6. lili@lili-desktop:~/datrie/libdatrie-0.2.4$ trietool-0.2 test add abcf  
  7. lili@lili-desktop:~/datrie/libdatrie-0.2.4$ trietool-0.2 test add abcg 1
  8. lili@lili-desktop:~/datrie/libdatrie-0.2.4$ trietool-0.2 test query ab  
  9. query: Key 'ab' not found.  
  10. lili@lili-desktop:~/datrie/libdatrie-0.2.4$ trietool-0.2 test query abce  
  11. -1
  12. lili@lili-desktop:~/datrie/libdatrie-0.2.4$ trietool-0.2 test query abcg  
  13. 1
  14. lili@lili-desktop:~/datrie/libdatrie-0.2.4$ trietool-0.2 test delete abcg  
  15. lili@lili-desktop:~/datrie/libdatrie-0.2.4$ trietool-0.2 test query abcg  
  16. query: Key 'abcg' not found.  
  17. lili@lili-desktop:~/datrie/libdatrie-0.2.4$ trietool-0.2 test delete abcg  
  18. No entry 'abcg'. Not deleted.  
  19. 當然一個一個新增詞典很麻煩,可以指定一個詞典檔案,這個檔案的格式是一行一個詞。  
  20. 比如  
  21. lili@lili-desktop:~/datrie/libdatrie-0.2.4$ trietool-0.2 test add-list /usr/share/dict/words  
  22. lili@lili-desktop:~/datrie/libdatrie-0.2.4$ trietool-0.2 test query AOL  
  23. -1
check陣列的壓縮

     在DAT裡,如果s遇到c變成t,那麼就是base[s]+c=t,check[t]=s,如果我們能保證任意兩個狀態的base都不相同,那麼我們可以不用在check陣列存s,而只需要存c。

     原來check數組裡儲存的是s,說明這個位置留給了s,base[s]+c=t,如果還有一個狀態r,比如base[r]=base[s],那麼根據check[t]=s可以判斷是從s->t而不是r->t。

     如果我們做一個限制,讓所有的狀態的base都不同,那麼我們就可以在check[t]裡儲存c而不是s,因為t-c就是s。

     這樣做有什麼好處呢?一般的應用中,字元數遠遠小於狀態數。比如英語,字母數可能不到100,8位足以表示。比如漢語,字母數可能小於4k,12位就可以表示了。

     這樣由於base的限制,雖然會導致base和check陣列增大一些(我的隨機實驗這兩個陣列會稍微大一些,但是不會超過5%),但是這兩個陣列的大小會從8個位元組變成

     5個位元組(英文為例),那麼節約的空間還是非常可觀的。

     這種方法一般用作靜態的(構造一次不再修改)DAT裡,因為如果總是插入刪除的話要保證base不重複代價更大。

     此外DAT除了用來判斷字首匹配之外,可能把它用作Map這樣的資料結構,所以還可以用check節省下來的位數來儲存一個下標(指標)。

參考資料


=====================================================================

雙陣列Trie(Double-Array Trie)

























\

來源:http://dwz.cn/zPAES