平時使用雙數組字典樹的場景蠻多的,但是一直沒有明白它的構建過程,所以通過各位大佬的文章,總結出自己可以理解的雙數組字典樹的構建過程,結合一些實際的例子,體會一下具體的用法。
整個文章的思路都是以Trie爲基礎,然後根據下面幾種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),但是它的速度優於二分查找,畢竟隨着路徑的深入,前綴匹配是遞進的過程,算法不必在比較字符串的前綴。
字典樹的特性
- 以空間換時間
- 根節點不包含字符,除根節點外每一個節點都只包含一個字符。
- 從根節點到某一節點,路徑上經過的字符連接起來,爲該節點對應的字符串。
- 每個節點的所有子節點包含的字符都不相同。
再簡單的理解
比如現在有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其,在模式串中root的三個子節點'A', 'C', 'E'
的check值都是1, 假設root經過A C Z
的作用分別到達三個狀態,可以得到下面矩陣。
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
所以就有 = check[null] = p4 + 2,因爲position爲70,71有佔位。所以後移。
所以就有 = 72
同時出現空的時候,有check[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
所以就可以有 = check[null] =73,因爲position爲70,71有佔位。所以後移,給一個空的值就行。
所以就有 = 73
同時出現空的時候,有check[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