一:概念
下面我們有and,as,at,cn,com這些關鍵詞,那麼如何構建trie樹呢?
從上面的圖中,我們或多或少的可以發現一些好玩的特性。
第一:根節點不包含字符,除根節點外的每一個子節點都包含一個字符。
第二:從根節點到某一節點,路徑上經過的字符連接起來,就是該節點對應的字符串。
第三:每個單詞的公共前綴作爲一個字符節點保存。
二:使用範圍
既然學Trie樹,我們肯定要知道這玩意是用來幹嘛的。
第一:詞頻統計。
可能有人要說了,詞頻統計簡單啊,一個hash或者一個堆就可以打完收工,但問題來了,如果內存有限呢?還能這麼
玩嗎?所以這裏我們就可以用trie樹來壓縮下空間,因爲公共前綴都是用一個節點保存的。
第二: 前綴匹配
就拿上面的圖來說吧,如果我想獲取所有以"a"開頭的字符串,從圖中可以很明顯的看到是:and,as,at,如果不用trie樹,
你該怎麼做呢?很顯然樸素的做法時間複雜度爲O(N2) ,那麼用Trie樹就不一樣了,它可以做到h,h爲你檢索單詞的長度,
可以說這是秒殺的效果。
舉個例子:現有一個編號爲1的字符串”and“,我們要插入到trie樹中,採用動態規劃的思想,將編號”1“計入到每個途徑的節點中,
那麼以後我們要找”a“,”an“,”and"爲前綴的字符串的編號將會輕而易舉。
三:實際操作
到現在爲止,我想大家已經對trie樹有了大概的掌握,下面我們看看如何來實現。
1:定義trie樹節點
爲了方便,我也採用純英文字母,我們知道字母有26個,那麼我們構建的trie樹就是一個26叉樹,每個節點包含26個子節點。
1 #region Trie樹節點 2 /// <summary> 3 /// Trie樹節點 4 /// </summary> 5 public class TrieNode 6 { 7 /// <summary> 8 /// 26個字符,也就是26叉樹 9 /// </summary> 10 public TrieNode[] childNodes; 11 12 /// <summary> 13 /// 詞頻統計 14 /// </summary> 15 public int freq; 16 17 /// <summary> 18 /// 記錄該節點的字符 19 /// </summary> 20 public char nodeChar; 21 22 /// <summary> 23 /// 插入記錄時的編碼id 24 /// </summary> 25 public HashSet<int> hashSet = new HashSet<int>(); 26 27 /// <summary> 28 /// 初始化 29 /// </summary> 30 public TrieNode() 31 { 32 childNodes = new TrieNode[26]; 33 freq = 0; 34 } 35 } 36 #endregion
2: 添加操作
既然是26叉樹,那麼當前節點的後續子節點是放在當前節點的哪一叉中,也就是放在childNodes中哪一個位置,這裏我們採用
int k = word[0] - 'a'來計算位置。
1 /// <summary> 2 /// 插入操作 3 /// </summary> 4 /// <param name="root"></param> 5 /// <param name="s"></param> 6 public void AddTrieNode(ref TrieNode root, string word, int id) 7 { 8 if (word.Length == 0) 9 return; 10 11 //求字符地址,方便將該字符放入到26叉樹中的哪一叉中 12 int k = word[0] - 'a'; 13 14 //如果該叉樹爲空,則初始化 15 if (root.childNodes[k] == null) 16 { 17 root.childNodes[k] = new TrieNode(); 18 19 //記錄下字符 20 root.childNodes[k].nodeChar = word[0]; 21 } 22 23 //該id途徑的節點 24 root.childNodes[k].hashSet.Add(id); 25 26 var nextWord = word.Substring(1); 27 28 //說明是最後一個字符,統計該詞出現的次數 29 if (nextWord.Length == 0) 30 root.childNodes[k].freq++; 31 32 AddTrieNode(ref root.childNodes[k], nextWord, id); 33 } 34 #endregion
3:刪除操作
刪除操作中,我們不僅要刪除該節點的字符串編號,還要對詞頻減一操作。
/// <summary> /// 刪除操作 /// </summary> /// <param name="root"></param> /// <param name="newWord"></param> /// <param name="oldWord"></param> /// <param name="id"></param> public void DeleteTrieNode(ref TrieNode root, string word, int id) { if (word.Length == 0) return; //求字符地址,方便將該字符放入到26叉樹種的哪一顆樹中 int k = word[0] - 'a'; //如果該叉樹爲空,則說明沒有找到要刪除的點 if (root.childNodes[k] == null) return; var nextWord = word.Substring(1); //如果是最後一個單詞,則減去詞頻 if (word.Length == 0 && root.childNodes[k].freq > 0) root.childNodes[k].freq--; //刪除途經節點 root.childNodes[k].hashSet.Remove(id); DeleteTrieNode(ref root.childNodes[k], nextWord, id); }
4:測試
這裏我從網上下載了一套的詞彙表,共2279條詞彙,現在我們要做的就是檢索“go”開頭的詞彙,並統計go出現的頻率。
1 public static void Main() 2 { 3 Trie trie = new Trie(); 4 5 var file = File.ReadAllLines(Environment.CurrentDirectory + "//1.txt"); 6 7 foreach (var item in file) 8 { 9 var sp = item.Split(new char[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); 10 11 trie.AddTrieNode(sp.LastOrDefault().ToLower(), Convert.ToInt32(sp[0])); 12 } 13 14 Stopwatch watch = Stopwatch.StartNew(); 15 16 //檢索go開頭的字符串 17 var hashSet = trie.SearchTrie("go"); 18 19 foreach (var item in hashSet) 20 { 21 Console.WriteLine("當前字符串的編號ID爲:{0}", item); 22 } 23 24 watch.Stop(); 25 26 Console.WriteLine("耗費時間:{0}", watch.ElapsedMilliseconds); 27 28 Console.WriteLine("\n\ngo 出現的次數爲:{0}\n\n", trie.WordCount("go")); 29 }
下面我們拿着ID到txt中去找一找,嘿嘿,是不是很有意思。
測試文件:1.txt
完整代碼:
1 using System; 2 using System.Collections.Generic; 3 using System.Linq; 4 using System.Text; 5 using System.Diagnostics; 6 using System.Threading; 7 using System.IO; 8 9 namespace ConsoleApplication2 10 { 11 public class Program 12 { 13 public static void Main() 14 { 15 Trie trie = new Trie(); 16 17 var file = File.ReadAllLines(Environment.CurrentDirectory + "//1.txt"); 18 19 foreach (var item in file) 20 { 21 var sp = item.Split(new char[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); 22 23 trie.AddTrieNode(sp.LastOrDefault().ToLower(), Convert.ToInt32(sp[0])); 24 } 25 26 Stopwatch watch = Stopwatch.StartNew(); 27 28 //檢索go開頭的字符串 29 var hashSet = trie.SearchTrie("go"); 30 31 foreach (var item in hashSet) 32 { 33 Console.WriteLine("當前字符串的編號ID爲:{0}", item); 34 } 35 36 watch.Stop(); 37 38 Console.WriteLine("耗費時間:{0}", watch.ElapsedMilliseconds); 39 40 Console.WriteLine("\n\ngo 出現的次數爲:{0}\n\n", trie.WordCount("go")); 41 } 42 } 43 44 public class Trie 45 { 46 public TrieNode trieNode = new TrieNode(); 47 48 #region Trie樹節點 49 /// <summary> 50 /// Trie樹節點 51 /// </summary> 52 public class TrieNode 53 { 54 /// <summary> 55 /// 26個字符,也就是26叉樹 56 /// </summary> 57 public TrieNode[] childNodes; 58 59 /// <summary> 60 /// 詞頻統計 61 /// </summary> 62 public int freq; 63 64 /// <summary> 65 /// 記錄該節點的字符 66 /// </summary> 67 public char nodeChar; 68 69 /// <summary> 70 /// 插入記錄時的編號id 71 /// </summary> 72 public HashSet<int> hashSet = new HashSet<int>(); 73 74 /// <summary> 75 /// 初始化 76 /// </summary> 77 public TrieNode() 78 { 79 childNodes = new TrieNode[26]; 80 freq = 0; 81 } 82 } 83 #endregion 84 85 #region 插入操作 86 /// <summary> 87 /// 插入操作 88 /// </summary> 89 /// <param name="word"></param> 90 /// <param name="id"></param> 91 public void AddTrieNode(string word, int id) 92 { 93 AddTrieNode(ref trieNode, word, id); 94 } 95 96 /// <summary> 97 /// 插入操作 98 /// </summary> 99 /// <param name="root"></param> 100 /// <param name="s"></param> 101 public void AddTrieNode(ref TrieNode root, string word, int id) 102 { 103 if (word.Length == 0) 104 return; 105 106 //求字符地址,方便將該字符放入到26叉樹中的哪一叉中 107 int k = word[0] - 'a'; 108 109 //如果該叉樹爲空,則初始化 110 if (root.childNodes[k] == null) 111 { 112 root.childNodes[k] = new TrieNode(); 113 114 //記錄下字符 115 root.childNodes[k].nodeChar = word[0]; 116 } 117 118 //該id途徑的節點 119 root.childNodes[k].hashSet.Add(id); 120 121 var nextWord = word.Substring(1); 122 123 //說明是最後一個字符,統計該詞出現的次數 124 if (nextWord.Length == 0) 125 root.childNodes[k].freq++; 126 127 AddTrieNode(ref root.childNodes[k], nextWord, id); 128 } 129 #endregion 130 131 #region 檢索操作 132 /// <summary> 133 /// 檢索單詞的前綴,返回改前綴的Hash集合 134 /// </summary> 135 /// <param name="s"></param> 136 /// <returns></returns> 137 public HashSet<int> SearchTrie(string s) 138 { 139 HashSet<int> hashSet = new HashSet<int>(); 140 141 return SearchTrie(ref trieNode, s, ref hashSet); 142 } 143 144 /// <summary> 145 /// 檢索單詞的前綴,返回改前綴的Hash集合 146 /// </summary> 147 /// <param name="root"></param> 148 /// <param name="s"></param> 149 /// <returns></returns> 150 public HashSet<int> SearchTrie(ref TrieNode root, string word, ref HashSet<int> hashSet) 151 { 152 if (word.Length == 0) 153 return hashSet; 154 155 int k = word[0] - 'a'; 156 157 var nextWord = word.Substring(1); 158 159 if (nextWord.Length == 0) 160 { 161 //採用動態規劃的思想,word最後節點記錄這途經的id 162 hashSet = root.childNodes[k].hashSet; 163 } 164 165 SearchTrie(ref root.childNodes[k], nextWord, ref hashSet); 166 167 return hashSet; 168 } 169 #endregion 170 171 #region 統計指定單詞出現的次數 172 173 /// <summary> 174 /// 統計指定單詞出現的次數 175 /// </summary> 176 /// <param name="root"></param> 177 /// <param name="word"></param> 178 /// <returns></returns> 179 public int WordCount(string word) 180 { 181 int count = 0; 182 183 WordCount(ref trieNode, word, ref count); 184 185 return count; 186 } 187 188 /// <summary> 189 /// 統計指定單詞出現的次數 190 /// </summary> 191 /// <param name="root"></param> 192 /// <param name="word"></param> 193 /// <param name="hashSet"></param> 194 /// <returns></returns> 195 public void WordCount(ref TrieNode root, string word, ref int count) 196 { 197 if (word.Length == 0) 198 return; 199 200 int k = word[0] - 'a'; 201 202 var nextWord = word.Substring(1); 203 204 if (nextWord.Length == 0) 205 { 206 //採用動態規劃的思想,word最後節點記錄這途經的id 207 count = root.childNodes[k].freq; 208 } 209 210 WordCount(ref root.childNodes[k], nextWord, ref count); 211 } 212 213 #endregion 214 215 #region 修改操作 216 /// <summary> 217 /// 修改操作 218 /// </summary> 219 /// <param name="newWord"></param> 220 /// <param name="oldWord"></param> 221 /// <param name="id"></param> 222 public void UpdateTrieNode(string newWord, string oldWord, int id) 223 { 224 UpdateTrieNode(ref trieNode, newWord, oldWord, id); 225 } 226 227 /// <summary> 228 /// 修改操作 229 /// </summary> 230 /// <param name="root"></param> 231 /// <param name="newWord"></param> 232 /// <param name="oldWord"></param> 233 /// <param name="id"></param> 234 public void UpdateTrieNode(ref TrieNode root, string newWord, string oldWord, int id) 235 { 236 //先刪除 237 DeleteTrieNode(oldWord, id); 238 239 //再添加 240 AddTrieNode(newWord, id); 241 } 242 #endregion 243 244 #region 刪除操作 245 /// <summary> 246 /// 刪除操作 247 /// </summary> 248 /// <param name="root"></param> 249 /// <param name="newWord"></param> 250 /// <param name="oldWord"></param> 251 /// <param name="id"></param> 252 public void DeleteTrieNode(string word, int id) 253 { 254 DeleteTrieNode(ref trieNode, word, id); 255 } 256 257 /// <summary> 258 /// 刪除操作 259 /// </summary> 260 /// <param name="root"></param> 261 /// <param name="newWord"></param> 262 /// <param name="oldWord"></param> 263 /// <param name="id"></param> 264 public void DeleteTrieNode(ref TrieNode root, string word, int id) 265 { 266 if (word.Length == 0) 267 return; 268 269 //求字符地址,方便將該字符放入到26叉樹種的哪一顆樹中 270 int k = word[0] - 'a'; 271 272 //如果該叉樹爲空,則說明沒有找到要刪除的點 273 if (root.childNodes[k] == null) 274 return; 275 276 var nextWord = word.Substring(1); 277 278 //如果是最後一個單詞,則減去詞頻 279 if (word.Length == 0 && root.childNodes[k].freq > 0) 280 root.childNodes[k].freq--; 281 282 //刪除途經節點 283 root.childNodes[k].hashSet.Remove(id); 284 285 DeleteTrieNode(ref root.childNodes[k], nextWord, id); 286 } 287 #endregion 288 } 289 }1. 什麼是trie樹
1.Trie樹 (特例結構樹)
Trie樹,又稱單詞查找樹、字典樹,是一種樹形結構,是一種哈希樹的變種,是一種用於快速檢索的多叉樹結構。典型應用是用於統計和排序大量的字符串(但不僅限於字符串),所以經常被搜索引擎系統用於文本詞頻統計。它的優點是:最大限度地減少無謂的字符串比較,查詢效率比哈希表高。Trie的核心思想是空間換時間。利用字符串的公共前綴來降低查詢時間的開銷以達到提高效率的目的。Trie樹也有它的缺點,Trie樹的內存消耗非常大.當然,或許用左兒子右兄弟的方法建樹的話,可能會好點.2. 三個基本特性:
1)根節點不包含字符,除根節點外每一個節點都只包含一個字符。2)從根節點到某一節點,路徑上經過的字符連接起來,爲該節點對應的字符串。3)每個節點的所有子節點包含的字符都不相同。3 .例子
和二叉查找樹不同,在trie樹中,每個結點上並非存儲一個元素。 trie樹把要查找的關鍵詞看作一個字符序列。並根據構成關鍵詞字符的先後順序構造用於檢索的樹結構。 在trie樹上進行檢索類似於查閱英語詞典。一棵m度的trie樹或者爲空,或者由m棵m度的trie樹構成。例如,電子英文詞典,爲了方便用戶快速檢索英語單詞,可以建立一棵trie樹。例如詞典由下面的單詞成:a、b、c、aa、ab、ac、ba、ca、aba、abc、baa、bab、bac、cab、abba、baba、caba、abaca、caaba
再舉一個例子。給出一組單詞,inn, int, at, age, adv, ant, 我們可以得到下面的Trie:
可以看出:
- 每條邊對應一個字母。
- 每個節點對應一項前綴。葉節點對應最長前綴,即單詞本身。
- 單詞inn與單詞int有共同的前綴“in”, 因此他們共享左邊的一條分支,root->i->in。同理,ate, age, adv, 和ant共享前綴"a",所以他們共享從根節點到節點"a"的邊。
查詢操縱非常簡單。比如要查找int,順着路徑i -> in -> int就找到了。
1.插入過程
對於一個單詞,從根開始,沿着單詞的各個字母所對應的樹中的節點分支向下走,直到單詞遍歷完,將最後的節點標記爲紅色,表示該單詞已插入trie樹。
2. 查找過程
其方法爲:
(1) 從根結點開始一次搜索;
(2) 取得要查找關鍵詞的第一個字母,並根據該字母選擇對應的子樹並轉到該子樹繼續進行檢索;
即從根開始按照單詞的字母順序向下遍歷trie樹,一旦發現某個節點標記不存在或者單詞遍歷完成而最後的節點未標記爲紅色,則表示該單詞不存在,若最後的節點標記爲紅色,表示該單詞存在。如下圖中:trie樹中存在的就是abc、d、da、dda四個單詞。在實際的問題中可以將標記顏色的標誌位改爲數量count等其他符合題目要求的變量。
- // stdafx.h : include file for standard system include files,
- // or project specific include files that are used frequently, but
- // are changed infrequently
- //
- #pragma once
- #include <stdio.h>
- #include "stdlib.h"
- #include <iostream>
- #include <string.h>
- using namespace std;
- //宏定義
- #define TRUE 1
- #define FALSE 0
- #define NULL 0
- #define OK 1
- #define ERROR 0
- #define INFEASIBLE -1
- #define OVERFLOW -2
- const int num_chars = 26;
- class Trie {
- public:
- Trie();
- Trie(Trie& tr);
- virtual ~Trie();
- int trie_search(const char* word, char* entry ) const;
- int insert(const char* word, const char* entry);
- int remove(const char* word, char* entry);
- protected:
- struct Trie_node{
- char* data; //若不爲空,表示從root到此結點構成一個單詞
- Trie_node* branch[num_chars]; //分支
- Trie_node(); //構造函數
- };
- Trie_node* root; //根結點(指針)
- };
- // Test.cpp : Defines the entry point for the console application.
- //
- #include "stdafx.h"
- Trie::Trie_node::Trie_node() {
- data = NULL;
- for (int i=0; i<num_chars; ++i)
- branch[i] = NULL;
- }
- Trie::Trie():root(NULL) {}
- Trie::~Trie(){}
- int Trie::trie_search(const char* word, char* entry ) const {
- int position = 0; //層數
- char char_code;
- Trie_node *location = root; //從根結點開始
- while( location!=NULL && *word!=0 ) {
- if (*word >= 'A' && *word <= 'Z')
- char_code = *word-'A';
- else if (*word>='a' && *word<='z')
- char_code = *word-'a';
- else return 0;// 不合法的單詞
- //轉入相應分支指針
- location = location->branch[char_code];
- position++;
- word++;
- }
- //找到,獲取數據,成功返回
- if ( location != NULL && location->data != NULL ) {
- strcpy(entry,location->data);
- return 1;
- }
- else return 0;// 不合法的單詞
- }
- int Trie::insert(const char* word, const char* entry) {
- int result = 1, position = 0;
- if ( root == NULL ) root = new Trie_node; //初始插入,根結點爲空
- char char_code;
- Trie_node *location = root; //從根結點開始
- while( location!=NULL && *word!=0 ) {
- if (*word>='A' && *word<='Z') char_code = *word-'A';
- else if (*word>='a' && *word<='z') char_code = *word-'a';
- else return 0;// 不合法的單詞
- //不存在此分支
- if( location->branch[char_code] == NULL )
- location->branch[char_code] = new Trie_node; //創建空分支
- //轉入分支
- location = location->branch[char_code];
- position++;word++; }
- if (location->data != NULL) result = 0;//欲插入的單詞已經存在
- else { //插入數據
- location->data = new char[strlen(entry)+1]; //分配內存
- strcpy(location->data, entry); //給data賦值表明單詞存在
- }
- return result;
- }
- int main(){
- Trie t;
- char entry[100];
- t.insert("a", "DET");
- t.insert("abacus","NOUN");
- t.insert("abalone","NOUN");
- t.insert("abandon","VERB");
- t.insert("abandoned","ADJ");
- t.insert("abashed","ADJ");
- t.insert("abate","VERB");
- t.insert("this", "PRON");
- if (t.trie_search("this", entry))
- cout<<"'this' was found. pos: "<<entry<<endl;
- if (t.trie_search("abate", entry))
- cout<<"'abate' is found. pos: "<<entry<<endl;
- if (t.trie_search("baby", entry))
- cout<<"'baby' is found. pos: "<<entry<<endl;
- else
- cout<<"'baby' does not exist at all!"<<endl;
- }
3. 查找分析
1. 字符串檢索,詞頻統計,搜索引擎的熱門查詢
事先將已知的一些字符串(字典)的有關信息保存到trie樹裏,查找另外一些未知字符串是否出現過或者出現頻率。
舉例:
1)有一個1G大小的一個文件,裏面每一行是一個詞,詞的大小不超過16字節,內存限制大小是1M。返回頻數最高的100個詞。
2)給出N 個單詞組成的熟詞表,以及一篇全用小寫英文書寫的文章,請你按最早出現的順序寫出所有不在熟詞表中的生詞。
3)給出一個詞典,其中的單詞爲不良單詞。單詞均爲小寫字母。再給出一段文本,文本的每一行也由小寫字母構成。判斷文本中是否含有任何不良單詞。例如,若rob是不良單詞,那麼文本problem含有不良單詞。
4)1000萬字符串,其中有些是重複的,需要把重複的全部去掉,保留沒有重複的字符串
5)尋找熱門查詢:搜索引擎會通過日誌文件把用戶每次檢索使用的所有檢索串都記錄下來,每個查詢串的長度爲1-255字節。假設目前有一千萬個記錄,這些查詢串的重複讀比較高,雖然總數是1千萬,但是如果去除重複和,不超過3百萬個。一個查詢串的重複度越高,說明查詢它的用戶越多,也就越熱門。請你統計最熱門的10個查詢串,要求使用的內存不能超過1G。
2. 字符串最長公共前綴
Trie樹利用多個字符串的公共前綴來節省存儲空間,反之,當我們把大量字符串存儲到一棵trie樹上時,我們可以快速得到某些字符串的公共前綴。舉例:
1) 給出N 個小寫英文字母串,以及Q 個詢問,即詢問某兩個串的最長公共前綴的長度是多少. 解決方案:
首先對所有的串建立其對應的字母樹。此時發現,對於兩個串的最長公共前綴的長度即它們所在結點的公共祖先個數,於是,問題就轉化爲了離線 (Offline)的最近公共祖先(Least Common Ancestor,簡稱LCA)問題。
而最近公共祖先問題同樣是一個經典問題,可以用下面幾種方法:
1. 利用並查集(Disjoint Set),可以採用採用經典的Tarjan 算法;
2. 求出字母樹的歐拉序列(Euler Sequence )後,就可以轉爲經典的最小值查詢(Range Minimum Query,簡稱RMQ)問題了;
3. 排序
Trie樹是一棵多叉樹,只要先序遍歷整棵樹,輸出相應的字符串便是按字典序排序的結果。
舉例: 給你N 個互不相同的僅由一個單詞構成的英文名,讓你將它們按字典序從小到大排序輸出。
4 作爲其他數據結構和算法的輔助結構
如後綴樹,AC自動機等。