雙數組Trie樹(DoubleArrayTrie)Java實現


http://www.hankcs.com/program/java/%E5%8F%8C%E6%95%B0%E7%BB%84trie%E6%A0%91doublearraytriejava%E5%AE%9E%E7%8E%B0.html

 

雙數組Trie樹(DoubleArrayTrie)是一種空間復雜度低的Trie樹,應用於字符區間大的語言(如中文、日文等)分詞領域。

雙數組Trie (Double-Array Trie)結構由日本人JUN-ICHI AOE於1989年提出的,是Trie結構的壓縮形式,僅用兩個線性數組來表示Trie樹,該結構有效結合了數字搜索樹(Digital Search Tree)檢索時間高效的特點和鏈式表示的Trie空間結構緊湊的特點。雙數組Trie的本質是一個確定有限狀態自動機(DFA),每個節點代表自動機的一個狀態,根據變量不同,進行狀態轉移,當到達結束狀態或無法轉移時,完成一次查詢操作。在雙數組所有鍵中包含的字符之間的聯系都是通過簡單的數學加法運算表示,不僅提高了檢索速度,而且省去了鏈式結構中使用的大量指針,節省了存儲空間。

——《基於雙數組Trie樹算法的字典改進和實現》

我看了幾篇論文,發現中文里也就上面這篇質量最好,英文當屬這篇雙數組Trie的一種實現。不過我並不打算按論文的腔調摘抄理論,而是准備借助開源的 darts-java 寫點代碼分析與筆記,如果能幫到你,實屬意外。

darts-java 是對 Taku Kudo 桑的 C++ 版 Double Array Trie 的 Java 移植,代碼精簡,只有一個Java文件,十分優美。

寫一段測試代碼

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
package  com.hankcs;
 
import  darts.DoubleArrayTrie;
 
import  java.io.*;
import  java.util.*;
 
/**
  * @author hankcs
  */
public  class  Main
{
     public  static  void  main(String[] args)  throws  IOException
     {
         BufferedReader reader =  new  BufferedReader( new  FileReader( "./data/small.dic" ));
         String line;
         List<String> words =  new  ArrayList<String>();
         Set<Character> charset =  new  HashSet<Character>();
         while  ((line = reader.readLine()) !=  null )
         {
             words.add(line);
             // 制作一份碼表debug
             for  ( char  c : line.toCharArray())
             {
                 charset.add(c);
             }
         }
         reader.close();
         // 這個字典如果要加入新詞必須按字典序,參考下面的代碼
//        Collections.sort(words);
//        BufferedWriter writer = new BufferedWriter(new FileWriter("./data/sorted.dic", false));
//        for (String w : words)
//        {
//            writer.write(w);
//            writer.newLine();
//        }
         System.out.println( "字典詞條:"  + words.size());
 
         {
             String infoCharsetValue =  "" ;
             String infoCharsetCode =  "" ;
             for  (Character c : charset)
             {
                 infoCharsetValue += c.charValue() +  "    " ;
                 infoCharsetCode += ( int )c.charValue() +  " " ;
             }
             infoCharsetValue +=  '\n' ;
             infoCharsetCode +=  '\n' ;
             System.out.print(infoCharsetValue);
             System.out.print(infoCharsetCode);
         }
 
         DoubleArrayTrie dat =  new  DoubleArrayTrie();
         System.out.println( "是否錯誤: "  + dat.build(words));
         System.out.println(dat);
         List<Integer> integerList = dat.commonPrefixSearch( "一舉成名天下知" );
         for  ( int  index : integerList)
         {
             System.out.println(words.get(index));
         }
     }
}

其中small.dic是一個微型的詞典:

1
2
3
4
5
6
一舉
一舉一動
一舉成名
一舉成名天下知
萬能
萬能膠

輸出:

1
2
3
4
5
6
7
字典詞條:6
膠    名    動    知    下    成    舉    一    能    天    萬    
33014 21517 21160 30693 19979 25104 20030 19968 33021 22825 19975 
是否錯誤: 0
一舉
一舉成名
一舉成名天下知

Trie樹的構造與雙數組的構造

雙數組Trie樹歸根結底還是屬於Trie樹,所以免不了有一顆樹的構造過程。不過這棵樹並沒有保存下來,而是邊構造樹邊維護雙數組,雙數組的信息足以表示整棵樹。比如對於上面的例子,首先建立一個空的root節點:

Node{code=0, depth=0, left=0, right=6}

其中code指的是字符的編碼,在Java中是雙字節,depth是深度,left及right表示這個節點在字典中的索引范圍。

比如:

然后按照字典序插入所有的字串節點:

其中綠色節點為空字符,代表從根節點到此節點的路徑上的所有節點構成一個詞,整個的構建順序是:

 

在darts-java中,使用了兩個數組base和check來維護Trie樹,它們的下標以及值都代表着一個確定的狀態。base儲存當前的狀態以供狀態轉移使用,check驗證字串是否由同一個狀態轉移而來並且當check為負的時候代表字串結束。(PS 雙數組Tire樹的具體實現有多種,有的實現將base為負作為狀態的結束,大同小異。)

假定有字符串狀態s,當前字符串狀態為t,假定t加了一個字符c就等於狀態tc,加了一個字符x等於狀態tx,那么有

base[t] + c = base[tc]

base[t] + x = base[tx]

check[tc] = check[tx]

可見,在單詞“一舉一動”中,雖然有兩個“一”,但它們的前一個狀態不同,所以對應的狀態分別為“一”和“一舉一”,在base數組中的下標不一樣。

在每個節點插入的過程中會修改這兩個數組,具體說來:

1、初始化root節點base[0] = 1; check[0] = 0;

2、對於每一群兄弟節點,尋找一個begin值使得check[begin + a1...an]  == 0,也就是找到了n個空閑空間,a1…an是siblings中的n個節點對應的code。

1
2
3
4
5
6
7
         int  pos = siblings.get( 0 ).code;
         while  ( true )
         {
             pos++;
             begin = pos - siblings.get( 0 ).code;  // 當前位置離第一個兄弟節點的距離
             ……
         }

3、然后將這群兄弟節點的check設為check[begin + a1...an] = begin;很顯然,葉子節點i的check[i]的值一定等於i,因為它是兄弟節點中的第一個,並且它的code為0。

1
check[begin + siblings.get(i).code] = begin;

4、接着對每個兄弟節點,如果它沒有孩子,也就是上圖除root外的綠色節點(葉子節點),令其base為負值;否則為該節點的子節點的插入位置(也就是begin值),同時插入子節點(迭代跳轉到步驟2)。

1
2
3
4
5
6
7
8
9
10
             if  (fetch(siblings.get(i), new_siblings) ==  0 )   // 無子節點,也就是葉子節點,代表一個詞的終止且不為其他詞的前綴
             {
                 base[begin + siblings.get(i).code] = -siblings.get(i).left -  1 ;
                 ……
             }
             else
             {
                 int  h = insert(new_siblings);    // dfs
                 base[begin + siblings.get(i).code] = h;
             }

 

 

 

這里給出這個例子的base check值以及碼表,下表中×代表空

1
2
3
4
5
6
7
8
9
10
碼表:
    膠    名    動    知    下    成    舉    一    能    天    萬    
33014 21517 21160 30693 19979 25104 20030 19968 33021 22825 19975 
 
DoubleArrayTrie{
char =      ×    一    萬     ×    舉     ×    動     ×     下    名    ×    知      ×     ×    能    一    天    成    膠
i    =      0 19970 19977 20032 20033 21162 21164 21519 21520 21522 30695 30699 33023 33024 33028 40001 44345 45137 66038
base =      1     2     6    -1 20032    -2 21162    -3     5 21519    -4 30695    -5    -6 33023     3  1540     4 33024
check=      0     1     1 20032     2 21162     3 21519  1540     4 30695     5 33023 33024     6 20032 21519 20032 33023
size=66039, allocSize=2097152, key=[一舉, 一舉一動, 一舉成名, 一舉成名天下知, 萬能, 萬能膠], keySize=6, progress=6, nextCheckPos=33024, error_=0}

前綴查詢

定義當前狀態p = base[0] = 1。按照字符串char的順序walk:

如果base[p] == check[p] && base[p] < 0 則查到一個詞;

然后狀態轉移,增加一個字符  p = base[char[i-1]] + char[i] + 1 。加1是為了與null節點區分開。

如果轉移后base[char[i-1]] == check[base[char[i-1]] + char[i] + 1],那么下次p就從base[base[char[i-1]] + char[i] + 1]開始。

 

結合例子看可能更清楚:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
字典詞條:6
膠    名    動    知    下    成    舉    一    能    天    萬    
33014 21517 21160 30693 19979 25104 20030 19968 33021 22825 19975 
是否錯誤: 0
DoubleArrayTrie{
char =     ×    一    萬    ×    舉    ×    動    ×    下    名    ×    知    ×    ×    能    一    天    成    膠
i    =      0 19970 19977 20032 20033 21162 21164 21519 21520 21522 30695 30699 33023 33024 33028 40001 44345 45137 66038
base =      1     2     6    -1 20032    -2 21162    -3     5 21519    -4 30695    -5    -6 33023     3  1540     4 33024
check=      0     1     1 20032     2 21162     3 21519  1540     4 30695     5 33023 33024     6 20032 21519 20032 33023
size=66039, allocSize=2097152, key=null, keySize=6, progress=6, nextCheckPos=33024, error_=0}
 
i       =      0     0     1     1     2     2     3     3     4     4     5     5     6     6
b       =      1     1     2     2 20032 20032     4     4 21519 21519  1540  1540     5     5
 
p       =      1 19970     2 20033 20032 45137     4 21522 21519 44345  1540 21520     5 30699
base[p] =      1     2     0 20032    -1     4     0 21519    -3  1540     0     5     0 30695
check[p]=      0     1     0     2 20032 20032     0     4 21519 21519     0  1540     0     5
一舉
一舉成名
一舉成名天下知

稍微解釋下

初始空 base[0] = 1, p = 1;

轉移 p = base[0] + {char[一] = 19968} + 1 = 1 + 19968 + 1 = 19970,                檢查base[19970]!=0說明有“一”這個字符。

 而  base[base[19970]] = base[2] = 0 說明沒遇到詞尾

轉移 p = base[19970] + {char[舉] = 20030} + 1 = 2 + 20030 + 1 = 20033,            檢查base[20033]!=0說明有“舉”這個字符。

 而  base[base[20033]] = base[20032] = -1 && base[20032] == check[20032] 說明遇到一個詞尾,即查出“一舉”

轉移 p = base[20033] + {char[成] = 25104} + 1 = 20032 + 25104+ 1 = 45137,         檢查base[45137]!=0說明有“成”這個字符。

……

目錄

 

轉載請注明:碼農場 » 雙數組Trie樹(DoubleArrayTrie)Java實現


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM