資料結構與演算法(十一)Trie字典樹
本文主要包括以下內容:
- Trie字典樹的基本概念
- Trie字典樹的基本操作
- 插入
- 查詢
- 字首查詢
- 刪除
- 基於連結串列的Trie字典樹
- 基於Trie的Set效能對比
- LeetCode相關線段樹的問題
- LeetCode第208號問題
- LeetCode第211號問題
- LeetCode第677號問題
Trie字典樹的基本概念
通過前面的介紹我們知道一個線性表的順序查詢的時間複雜度為O(n);二分搜尋樹的查詢為O(log n),它們都和資料結構中的元素個數相關。關於線性表和二分搜尋樹的時間複雜度分析有需要的可以檢視 Set集合和BinarySearchTree的時間複雜度分析
本文介紹的Trie字典樹(主要用於儲存字串)查詢速度主要和它的元素(字串)的長度相關[O(w)]。
Trie字典樹主要用於儲存字串,Trie 的每個 Node 儲存一個字元。用連結串列來描述的話,就是一個字串就是一個連結串列。每個Node都儲存了它的所有子節點。
例如我們往字典樹中插入see、pain、paint三個單詞,Trie字典樹如下所示:
也就是說如果只考慮小寫的26個字母,那麼Trie字典樹的每個節點都可能有26個子節點。
Trie字典樹的基本操作
插入
本文是使用連結串列來實現Trie字典樹,字串的每個字元作為一個Node節點,Node主要有兩部分組成:
- 是否是單詞 (boolean isWord)
- 節點所有的子節點,用map來儲存 (Map next)
例如插入一個paint單詞,如果使用者查詢pain,儘管 paint 包含了 pain,但是Trie中仍然不包含 pain 這個單詞,所以如果往Trie中插入一個單詞,需要把該單詞的最後一個字元的節點的 isWord 設定為 true。所以為什麼Node需要儲存 是否是單詞 這個屬性。
節點的所有子節點,通過一個Map來儲存,key是當前子節點對應的字元,value是子節點。
實現的虛擬碼如下:
public void add(String word) {
Node current = root;
char[] cs = word.toCharArray();
for (char c : cs) {
Node next = current.next.get(c);
if (next == null) {
//一個字元對應一個Node節點
current.next.put(c, new Node());
}
current = current.next.get(c);
}
//current就是word的最後一個字元的Node
//如果當前的node已經是一個word,則不需要新增
if (!current.isWord) {
size++;
current.isWord = true;
}
}
查詢
Trie查詢操作就比較簡單了,遍歷帶查詢的字串的字元,如果每個節點都存在,並且待查詢字串的最後一個字元對應的Node的 isWord 屬性為 true ,則表示該單詞存在,虛擬碼如下:
public boolean contains(String word) {
Node current = root;
for (int i = 0; i < word.length(); i++) {
char c = word.charAt(i);
Node node = current.next.get(c);
if (node == null) {
return false;
}
current = node;
}
//current就是word的最後一個字元的Node
return current.isWord;
}
字首查詢
字首查詢和上面的查詢操作基本類似,就是不需要判斷 isWord 了
public boolean containsPrefix(String prefix) {
Node current = root;
for (int i = 0; i < prefix.length(); i++) {
char c = prefix.charAt(i);
Node node = current.next.get(c);
if (node == null) {
return false;
}
current = node;
}
return true;
}
刪除
Trie的刪除操作就稍微複雜一些,主要分為以下3種情況:
如果單詞是另一個單詞的字首
如果待刪除的單詞是另一個單詞的字首,只需要把該單詞的最後一個節點的 isWord 的改成false
比如Trie中存在 panda 和 pan 這兩個單詞,刪除 pan ,只需要把字元 n 對應的節點的 isWord 改成 false 即可
如下圖所示
如果單詞的所有字母的都沒有多個分支,刪除整個單詞
如果單詞的所有字母的都沒有多個分支(也就是說該單詞所有的字元對應的Node都只有一個子節點),則刪除整個單詞
例如要刪除如下圖的see單詞,如下圖所示:
如果單詞的除了最後一個字母,其他的字母有多個分支
基於連結串列的Trie字典樹
public class Trie {
private Node root;
private int size;
private static class Node {
public boolean isWord;
public Map<Character, Node> next;
public Node() {
next = new TreeMap<>();
}
public Node(boolean isWord) {
this();
this.isWord = isWord;
}
}
public Trie() {
root = new Node();
}
public int size() {
return size;
}
public boolean isEmpty() {
return size == 0;
}
/**
* 插入操作
*
* @param word 單詞
*/
public void add(String word) {
Node current = root;
char[] cs = word.toCharArray();
for (char c : cs) {
Node next = current.next.get(c);
if (next == null) {
current.next.put(c, new Node());
}
current = current.next.get(c);
}
//如果當前的node已經是一個word,則不需要新增
if (!current.isWord) {
size++;
current.isWord = true;
}
}
/**
* 是否包含某個單詞
*
* @param word 單詞
* @return 存在返回true,反之false
*/
public boolean contains(String word) {
Node current = root;
for (int i = 0; i < word.length(); i++) {
char c = word.charAt(i);
Node node = current.next.get(c);
if (node == null) {
return false;
}
current = node;
}
//如果只存在 panda這個詞,查詢 pan,雖然有這3個字母,但是並不存在該單詞
return current.isWord;
}
/**
* Trie是否包含某個字首
*
* @param prefix 字首
* @return
*/
public boolean containsPrefix(String prefix) {
Node current = root;
for (int i = 0; i < prefix.length(); i++) {
char c = prefix.charAt(i);
Node node = current.next.get(c);
if (node == null) {
return false;
}
current = node;
}
return true;
}
/*
* 1,如果單詞是另一個單詞的字首,只需要把該word的最後一個節點的isWord的改成false
* 2,如果單詞的所有字母的都沒有多個分支,刪除整個單詞
* 3,如果單詞的除了最後一個字母,其他的字母有多個分支,
*/
/**
* 刪除操作
*
* @param word
* @return
*/
public boolean remove(String word) {
Node multiChildNode = null;
int multiChildNodeIndex = -1;
Node current = root;
for (int i = 0; i < word.length(); i++) {
Node child = current.next.get(word.charAt(i));
//如果Trie中沒有這個單詞
if (child == null) {
return false;
}
//當前節點的子節點大於1個
if (child.next.size() > 1) {
multiChildNodeIndex = i;
multiChildNode = child;
}
current = child;
}
//如果單詞後面還有子節點
if (current.next.size() > 0) {
if (current.isWord) {
current.isWord = false;
size--;
return true;
}
//不存在該單詞,該單詞只是字首
return false;
}
//如果單詞的所有字母的都沒有多個分支,刪除整個單詞
if (multiChildNodeIndex == -1) {
root.next.remove(word.charAt(0));
size--;
return true;
}
//如果單詞的除了最後一個字母,其他的字母有分支
if (multiChildNodeIndex != word.length() - 1) {
multiChildNode.next.remove(word.charAt(multiChildNodeIndex + 1));
size--;
return true;
}
return false;
}
}
基於Trie的Set效能對比
在前面的Set集合和BinarySearchTree的時間複雜度分析中我們分別使用了基於連結串列和基於二分搜尋樹實現的Set,對兩本英文原著進行簡單的詞頻統計。
現在使用Trie實現下Set集合,然後三者效能做一個比較,還是以傲慢與偏見、雙城記、戰爭與和平三本原著作為資料來源。
傲慢與偏見(Pride and Prejudice)的效能對比
Pride and Prejudice
Total words: 125901
Total different words: 6530
TrieSet Time: 0.099788784
BSTSet Time: 0.339963625
LinkedListSet Time: 3.554973381
從中可以看出傲慢與偏見不同的單詞只有6000左右,閱讀難度不是很大。
雙城記(A Tale of Two Cities)的效能對比
A Tale of Two Cities
Total words: 141489
Total different words: 9944
TrieSet Time: 0.119505174
BSTSet Time: 0.331334495
LinkedListSet Time: 5.26063235
戰爭與和平(War and peace)的效能對比
War and Peace
Total words: 602359
Total different words: 16725
TrieSet Time: 0.09750872
BSTSet Time: 0.233328074
以上關於原著詞彙的統計只是簡單的對比單詞是否一致,並沒有考慮一個單詞的過去式、進行時等時態,只要字串不一致都把它當作不同的單詞。
更多關於Trie的話題
上面實現的Trie中,我們是使用TreeMap來儲存節點的所有的子節點,也可以使用HashMap來儲存所有的子節點,效率更高:
public Node() {
next = new HashMap<>();
}
當然我們也可以使用一個定長的陣列來儲存所有的子節點,效率比HashMap更高,因為不需要使用hash函式:
public Node(boolean isWord){
this.isWord = isWord;
next = new Node[26];//只能儲存26個小寫字母
}
Trie查詢效率非常高,但是對空間的消耗還是挺大的,這也是典型的空間換時間。
可以使用 壓縮字典樹(Compressed Trie) ,但是維護相對來說複雜一些。
如果我們不止儲存英文單詞,還有其他特殊字元,那麼維護子節點的集合可能會更多。
可以對Trie字典樹做些限制,比如每個節點只能有3個子節點,左邊的節點是小於父節點的,中間的節點是等於父節點的,右邊的子節點是大於父節點的,這就是三分搜尋Trie字典樹(Ternary Search Trie)。
LeetCode相關線段樹的問題
LeetCode第208號問題
問題描述:
實現一個 Trie (字首樹),包含 insert, search, 和 startsWith 這三個操作。
示例:
Trie trie = new Trie();
trie.insert("apple");
trie.search("apple"); // 返回 true
trie.search("app"); // 返回 false
trie.startsWith("app"); // 返回 true
trie.insert("app");
trie.search("app"); // 返回 true
問題說明:
你可以假設所有的輸入都是由小寫字母 a-z 構成的。
保證所有輸入均為非空字串。
這個問題在我們實現的 Trie字典樹 中已經實現了這個功能了,add()就是對應的insert(),contains()就是對應的search(),starcontainsPrefix()就是對應的startsWith(),這裡就不貼程式碼了。
LeetCode第211號問題
問題描述:
設計一個支援以下兩種操作的資料結構:
void addWord(word)
bool search(word)
search(word)
可以搜尋文字或正則表示式字串,字串只包含字母 . 或 a-z 。 . 可以表示任何一個字母。
示例:
addWord("bad")
addWord("dad")
addWord("mad")
search("pad") -> false
search("bad") -> true
search(".ad") -> true
search("b..") -> true
問題說明:
你可以假設所有單詞都是由小寫字母 a-z 組成的。
這個問題就是上一個問題的基礎上加上 . 的處理,稍微複雜點。
如果下一個字元是 . ,那麼需要遍歷該節點的所有子節點,對所有子節點的處理就是一個遞迴程式:
public boolean searchByWildCard(String express) {
return search(root, express, 0);
}
private boolean search(Node node, String express, int index) {
//如果已經到了待查詢字串的尾端了
if (index == express.length()) {
return node.isWord;
}
char c = express.charAt(index);
if (c != '.') {
Node nextChar = node.next.get(c);
if (nextChar == null) {
return false;
}
return search(nextChar, express, index + 1);
} else {//如果是萬用字元
Map<Character, Node> nextNodes = node.next;
//遍歷所有的子節點
for (Map.Entry<Character, Node> entry : nextNodes.entrySet()) {
if (search(entry.getValue(), express, index + 1)) {
return true;
}
}
return false;
}
}
LeetCode第677號問題
問題描述:
實現一個 MapSum 類裡的兩個方法,insert 和 sum。
對於方法 insert,你將得到一對(字串,整數)的鍵值對。字串表示鍵,整數表示值。如果鍵已經存在,那麼原來的鍵值對將被替代成新的鍵值對。
對於方法 sum,你將得到一個表示字首的字串,你需要返回所有以該字首開頭的鍵的值的總和。
示例 1:
輸入: insert("apple", 3), 輸出: Null
輸入: sum("ap"), 輸出: 3
輸入: insert("app", 2), 輸出: Null
輸入: sum("ap"), 輸出: 5
總結一句話就是,求出所有符合該字首的字串的鍵值的總和。
節點需要儲存一個鍵值,用於求和。節點Node不需要維護 isWord 這個屬性了,因為不關注是不是一個單詞。
class Node {
public int value;
public Map<Character, Node> next;
}
public int sum(String prefix) {
Node cur = root;
for (int i = 0; i < prefix.length(); i++) {
char c = prefix.charAt(i);
Node node = cur.next.get(c);
if (node == null) {
return 0;
}
cur = node;
}
//cur指向prefix的最後一個字元的Node
//對每個以prefix為字首的node進行累加
return countValue(cur);
}
private int countValue(Node node) {
int result = node.value;
for (char c : node.next.keySet()) {
result += countValue(node.next.get(c));
}
return result;
}
上面三個LeetCode的問題答案,都可以在我的github上檢視