DoubleArrayTrie 的原理理解和實現

平時使用雙數組字典樹的場景蠻多的,但是一直沒有明白它的構建過程,所以通過各位大佬的文章,總結出自己可以理解的雙數組字典樹的構建過程,結合一些實際的例子,體會一下具體的用法。
整個文章的思路都是以Trie爲基礎,然後根據下面幾種Trie依次簡單梳理一下。

Array Trie
List Trie
Hash Trie
Double Array Trie

在看雙數組字典數之前我們先看看什麼是字典樹。

字典樹(Trie)

字典樹的定義

字典樹:又稱爲Trie樹,前綴樹,這是一種字符串上的樹形數據結構。
也就是將一個字符串構建成一個樹的形狀,如下圖。
對於有限集合 { AC,ACE,ACFF,AD,CD,CF,ZQ }。
R表示根節點。
在這裏插入圖片描述對於字符串的處理,我們通常有應用就是在字符串集合中判斷字符串是否存在,這個也是匹配算法的一個瓶頸,那麼對於普通匹配算法,如果遍歷查找時間複雜度是O(n^2),用二分查找法時間複雜度是O(logn),如果用TreeMap去匹配,時間複雜度是O(logn),這裏的n指的是詞典的大小,如果用HashMap的話,時間複雜度是O(1),但是空間複雜度又上去了,所以,想要找到一種速度又快,同時內存又省的數據結構,來完成這個匹配操作。字典樹就符合這些特徵。
先簡單瞭解一下字典樹的基本原理

字典樹的原理

字典樹的每一個邊都對應一個字,從根節點往下的路徑構成一個個字符串。字典樹並不直接在節點存儲字符串,而是將詞語視作根節點到某一節點之間的一條路徑
並且在終點節點上做個標記(該節點對應詞語的結尾),字符串就是一條路徑,要查詢某一個單詞,就需要順着這條路徑從根節點往下走,如果能走到特殊標記的節點(藍色結點),那麼說明當前字符串在集合中,否則當前字符串不在集合中。
下圖中是以下詞{“abc”、“abcd”、“adb”、“b”、“bcd”、“efg”、“hik”},構成的前綴樹。
原圖出自
在這裏插入圖片描述
橙色標記該節點是一個詞的結尾(詞的結尾不一定是到葉子節點),數字只是一個編號,這些詞和對應的路徑如下表所示。

詞語 路徑
abc 0-1-2-3
abcd 0-1-2-3-4
adb 0-1-2-5
b 0-6
bcd 0-6-7-8
efg 0-9-10-11
hik 0-12-13-14

備註:橙色=色節點不一定是葉子節點,也就是詞的結尾不一定是葉子節點。
字典樹的時間複雜度最壞的情況是O(logn),但是它的速度優於二分查找,畢竟隨着路徑的深入,前綴匹配是遞進的過程,算法不必在比較字符串的前綴。

字典樹的特性

  1. 以空間換時間
  2. 根節點不包含字符,除根節點外每一個節點都只包含一個字符。
  3. 從根節點到某一節點,路徑上經過的字符連接起來,爲該節點對應的字符串。
  4. 每個節點的所有子節點包含的字符都不相同。

再簡單的理解

比如現在有10000個單詞列表,我們要判斷student這個單詞有沒有出現過,遍歷查找時間複雜度是O(n^2),用二分查找法時間複雜度是O(logn),用字典樹也是O(logn),但是上面說了爲什麼字典樹更加優秀,那麼用字典樹的查找規則就是先找到s,再去s的子樹中找t,依次類推,看看能不能找到student這條路徑。

字典樹的實現

具體需要實現方法有以下幾個

  • void insert(String word):添加word;
  • void delete(String word):刪除word;
  • boolean search(String word):查詢word是否在字典樹中;
/**
 * 前綴樹
 */
public class TrieTree {
    //字典樹節點
    class TrieNode {
        public int path;
        public int end;
        public HashMap<Character, TrieNode> map;

        public TrieNode() {
            path = 0;
            end = 0;
            map = new HashMap<>();
        }
    }

    private TrieNode root;

    public TrieTree() {
        root = new TrieNode();
    }

    /**
     * 插入一個新的單詞
     * @param word
     */
    public void insert(String word) {
        if (word == null)
            return;
        TrieNode node = root;
        node.path++;
        char[] words = word.toCharArray();
        for (int i = 0; i < words.length; i++) {
            if (node.map.get(words[i]) == null) {
                node.map.put(words[i], new TrieNode());
            }
            node = node.map.get(words[i]);
            node.path++;
        }
        node.end++;
    }

    public boolean search(String word) {
        if (word == null)
            return false;
        TrieNode node = root;
        char[] words = word.toCharArray();
        for (int i = 0; i < words.length; i++) {
            if (node.map.get(words[i]) == null)
                return false;
            node = node.map.get(words[i]);
        }
        return node.end > 0;
    }

    public void delete(String word) {
        if (search(word)) {
            char[] words = word.toCharArray();
            TrieNode node = root;
            node.path--;
            for (int i = 0; i < words.length; i++) {
                if (--node.map.get(words[i]).path == 0) {
                    node.map.remove(words[i]);
                    return;
                }
                node = node.map.get(words[i]);
            }//for
            node.end--;
        }//if
    }

    public int prefixNumber(String pre) {
        if (pre == null)
            return 0;
        TrieNode node = root;
        char[] pres = pre.toCharArray();
        for (int i = 0; i < pres.length; i++) {
            if (node.map.get(pres[i]) == null)
                return 0;
            node = node.map.get(pres[i]);
        }
        return node.path;
    }

    public static void main(String[] args) {
        TrieTree trie = new TrieTree();
        System.out.println(trie.search("程龍穎"));//f
        trie.insert("自然人");
        trie.insert("自然");
        trie.insert("自然語言");
        trie.insert("自語");
        trie.insert("入門");
        System.out.println(trie.search("自然"));//t
        trie.delete("自然語言");
        System.out.println(trie.search("自然語言"));//f
        trie.insert("自然語言");
        System.out.println(trie.search("自然語言"));//t
        System.out.println(trie.prefixNumber("自然"));//3
    }
}

DFA簡單理解

TrieTree本質上是一個確定有限自動機(DFA)。
DFA的特徵:有一個有限狀態集合和一些從一個狀態通向另一個狀態的邊,每條邊上標記有一個符號,其中一個狀態是初態,某些狀態是終態。但不同於不確定的有限自動機,DFA中不會有從同一狀態出發的兩條邊標誌有相同的符號。
對於DFA來說,每個節點代表一個“狀態”,每條邊代表一個“變量”。

雙數組字典樹

雙數組字典樹(DoubleArrayTrie, DAT)是由三個日本人提出的一種字典樹的高效實現,兼顧了查詢效率與空間存儲。DAT極大地節省了內存佔用。

優點

在Trie數實現過程中,我們發現了每個節點均需要 一個數組來存儲next節點,非常佔用存儲空間,空間複雜度大,雙數組Trie樹正是解決這個問題的。雙數組字典樹(DoubleArrayTrie)是一種空間複雜度低的Trie樹,應用於字典樹壓縮、分詞、敏感詞等領域。所以,DAT是前綴樹的一個變形,同樣也是一個DFA。

缺點

每個狀態都依賴於其他狀態,所以當在詞典中插入或刪除詞語的時候,往往需要對雙數組結構進行全局調整,從而靈活性能較差。

定義

將原來需要多個數組才能表示的Trie樹,使用兩個數組就可以存儲下來,可以極大的減小空間複雜度。由於用base和check兩個數組構成,又稱爲雙數組字典樹。
具體來說就是使用兩個數組base[]和check[]來維護Trie樹,base[]負責記錄狀態,check[]用於檢驗狀態轉移的正確性,當check[i]爲負值時,表示此狀態爲字符串的結束。
具體來說,當狀態b接受字符c然後轉移到狀態p的時候,滿足的狀態轉移公式如下:

p = base[b] + c
check[p] = base[c]	

構建雙數組的過程

對於詞典 { AC,ACE,ACFF,AD,CD,CF,ZQ },構建雙數組具體過程如下。
在這裏插入圖片描述在構造之前,先梳理幾個概念

  • STATE:狀態,也就是數組的下標
  • CODE: 狀態轉移值,實際爲字符的 ASCII碼
  • BASE: 表示後繼節點的基地址的數組,葉子節點沒有後繼,標識爲字符序列的結尾標誌

主要是基於 dart-java,此版本對雙數組算法做了一個改進,即darts雙數組中有以下的改進。

	base[0] = 1 
	check[0] = 0

第二個改進就是令字符的code = ascii+1

結合兩個數組的狀態轉移公式有以下條件

base[0] = 1 
check[0] = 0 
p = base[b] + c
check[p] = base[c]	

基於base和check兩個數據構建雙數組的流程整體如下

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取負)

備註:構建的時候,從廣度搜索,從深度構建詞典
1、根據上面的那個例子{ AC,ACE,ACFF,AD,CD,CF,ZQ }來說,最開始有

base[0] = 1 
check[0] = 0 

備註:ascii表格

	65 	A
	66 	B
	...

此外,結合darts雙數組的改進code= ascii+1, 以及i = base[0] + code可以得到下面每個字符的position(i)和對應字符的code值。base[0] = 1

root A C D E F Q Z
i 0 67 69 92
code 0 66 68 69 70 71 82 91

2、根據構造過程中的第二步,距離root節點深度爲1的所有children其check[root.childreni]=base[root]=1check[root.children_i ] = base[root] = 1,在模式串中root的三個子節點'A', 'C', 'E'的check值都是1, 假設root經過A C Z 的作用分別到達p1,p2,p3p_1 , p_2, p_3三個狀態,可以得到下面矩陣。

root A C Z
i 0 67 69 92
base 1
check 0 1 1 1
state p0 p1 p2 p3

3、根據構建的第三步,狀態p1是由條件 'A’觸發的,那麼’A’的base值的計算方式需要滿足以下的規則:
我們知道,對於每一個字符, 需要確定一個base值,使得對於所有以該字開頭的詞,在雙數組中都能放下。
已知A的子節點值爲{C D}, 需要找一個begin值,使得check[begin +'C'.code] = check[begin +'D'.code] = 0滿足, 即check[begin + 68] = check[begin + 69] = 0,換句話說,需要找到一個begin,從而找到之前沒有使用過的空間。

a、當begin=1的時候,有check[1+ 68] 和check[1+ 69] 都必須爲0,
但是check[1 + 68] 存在字符‘C’,
所以check[begin +’C’.code] = check[begin +’D’.code] = 0不成立。
b、當begin=2的時候
需要有check[2+ 68]check[2 + 69] 的值都必須爲0
check[begin + 68] = check[begin + 69] = 0
所以有base[p1] = begin = 2, 狀態p1= 67。

p4 = base[p1] + ‘C’.code = 2 + 68 = 70 ,
p5 = base[p1] + ‘D’.code = 2 + 69 = 71,
check[p5] = check[p4] = base[p1] = 2,
那麼有以下矩陣
備註:AC指的就是A左子樹C,AD指的就是A的右子樹D。

root A C Z AC AD
i 0 67 69 92 70 71
base 1 2
check 0 1 1 1 2 2
state p0 p1 p2 p3 p4 p5

4、根據上一步,繼續深度遍歷,走A的左子樹C,繼續推導。已知C的子節點是{null、E、F},需要找一個begin值,使得check[begin +null.code] = check[begin +'E'.code] = check[begin +'F'.code] = 0滿足, 在子節點有空的情況下,需要設置base[null] = -1(取負整數,從-1開始,下一次出現就是-2)。
所以有base[null] = -1
所以就有pnullp_{null} = check[null] = p4 + 2,因爲position爲70,71有佔位。所以後移。
所以就有pnullp_{null} = 72
同時出現空的時候,有check[null] = pnullp_{null} = 72
又因爲check[null] = base[p4]
所以base[p4] = 72
null由*表示

root A C Z AC AD AC* ACE ACF
i 0 67 69 92 70 71 72
base 1 2 72 -1
check 0 1 1 1 2 2 72
state p0 p1 p2 p3 p4 p5 p6=null p7 p8

5、然後繼續求ACE和ACF這兩個條鏈路,先求base[p7]base[p8]
有公式:check[begin + 'E'.code] = 0
有公式:check[begin + 'F'.code] = 0
現在當begin從3開始,當爲3的時候,
check[3 + 70] = 0成立
check[3 + 71] = 0成立
所以
p7 = base[p4] + E.code = 72 +70 = 142
p8 = base[p4] + F.code = 72 +71 = 143
所以
check[p7] = base[p4] = 72
check[p8] = base[p4] = 72

root A C Z AC AD AC* ACE ACF
i 0 67 69 92 70 71 72 142 143
base 1 2 72 -1
check 0 1 1 1 2 2 72 72 72
state p0 p1 p2 p3 p4 p5 p6=null p7 p8

6、然後開始算ACE*這個鏈路,由於自己誒單包含爲null節點,所以有
base[null] = -2
所以就可以有pnullp_{null} = check[null] =73,因爲position爲70,71有佔位。所以後移,給一個空的值就行。
所以就有pnullp_{null} = 73
同時出現空的時候,有check[null] = pnullp_{null} = 73
又因爲check[null] = base[p7]
所以base[p7] = 73

root A C Z AC AD AC* ACE ACF ACE*
i 0 67 69 92 70 71 72 142 143 73
base 1 2 72 -1 73 -2
check 0 1 1 1 2 2 72 72 72 73
state p0 p1 p2 p3 p4 p5 p6=null p7 p8 p9=null

7、然後走ACFF,ACFF*。依次類推。

最終的不含非空節點矩陣如下

root A C Z C D D F Q E F F
i 0 67 69 92 70 71 77 79 86 142 143 74
base 1 2 8 4 72 76 78 80 83 73 3 75
check 0 1 1 1 1 2 2 8 8 4 72 72
state p0 p1 p2 p3 p4 p5 p6 p7 p8 p9 p10 p11

使用DFA的形式來描繪,節點表示state,字符作爲轉移條件,不同字符觸發不同的state,可得到到樹如下圖,其中紅色部分正好是第5步驟的矩陣;綠色部分是按照模式集合得到的ouput表。
在這裏插入圖片描述

參考

https://blog.csdn.net/u013300579/article/details/78869742
https://blog.csdn.net/zhoubl668/article/details/6957830
https://github.com/komiya-atsushi/darts-java
https://linux.thai.net/~thep/datrie/datrie.html
https://www.cnblogs.com/ooon/p/4883159.html
https://blog.csdn.net/xlxxcc/article/details/67631988

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章