前綴Trie, 又叫字符Tire, trie來自單詞retrieval, 一開始念做tree,後來改唸try, 畢竟它與樹是不同的東西。網上許多文章都搞混了trie與樹。 trie是經過」邊「來儲存字符的一種樹狀結構,所謂邊就是節點與節點間的鏈接。trie每條邊只能存放一個字符。node
它與hash樹很類似,或者說它是哈希樹的變種,哈希樹是用邊來存放一個整數(能夠是一位數或兩位數)。前樹Tire與哈希樹都是多叉樹,換言之,父節點有N個子節點。數組
前綴Tire主要用於字符串的快速檢索,查詢效率比哈希表高。數據結構
前綴Trie的核心思想是空間換時間。利用字符串的公共前綴來下降查詢時間的開銷以達到提升效率的目的。app
前綴Trie樹也有它的缺點, 假定咱們只對字母與數字進行處理,那麼每一個節點至少有52+10個子節點。爲了節省內存,咱們能夠用鏈表或數組。在JS中咱們直接用數組,由於JS的數組是動態的,自帶優化。函數
// by 司徒正美 class Trie { constructor() { this.root = new TrieNode(); } isValid(str) { return /^[a-z1-9]+$/i.test(str); } insert(word) { // addWord if (this.isValid(word)) { var cur = this.root; for (var i = 0; i < word.length; i++) { var c = word.charCodeAt(i); c -= 48; //減小」0「的charCode var node = cur.edges[c]; if (node == null) { var node = (cur.edges[c] = new TrieNode()); node.value = word.charAt(i); node.numPass = 1; //有N個字符串通過它 } else { node.numPass++; } cur = node; } cur.isEnd = true; //檣記有字符串到此節點已經結束 cur.numEnd++; //這個字符串重複次數 return true; } else { return false; } } remove(word){ if (this.isValid(word)) { var cur = this.root; var array = [], n = word.length for (var i = 0; i < n; i++) { var c = word.charCodeAt(i); c = this.getIndex(c) var node = cur.edges[c]; if(node){ array.push(node) cur = node }else{ return false } } if(array.length === n){ array.forEach(function(){ el.numPass-- }) cur.numEnd -- if( cur.numEnd == 0){ cur.isEnd = false } } }else{ return false } } preTraversal(cb){//先序遍歷 function preTraversalImpl(root, str, cb){ cb(root, str); for(let i = 0,n = root.edges.length; i < n; i ++){ let node = root.edges[i]; if(node){ preTraversalImpl(node, str + node.value, cb); } } } preTraversalImpl(this.root, "", cb); } // 在字典樹中查找是否存在某字符串爲前綴開頭的字符串(包括前綴字符串自己) isContainPrefix(word) { if (this.isValid(word)) { var cur = this.root; for (var i = 0; i < word.length; i++) { var c = word.charCodeAt(i); c -= 48; //減小」0「的charCode if (cur.edges[c]) { cur = cur.edges[c]; } else { return false; } } return true; } else { return false; } } isContainWord(str) { // 在字典樹中查找是否存在某字符串(不爲前綴) if (this.isValid(word)) { var cur = this.root; for (var i = 0; i < word.length; i++) { var c = word.charCodeAt(i); c -= 48; //減小」0「的charCode if (cur.edges[c]) { cur = cur.edges[c]; } else { return false; } } return cur.isEnd; } else { return false; } } countPrefix(word) { // 統計以指定字符串爲前綴的字符串數量 if (this.isValid(word)) { var cur = this.root; for (var i = 0; i < word.length; i++) { var c = word.charCodeAt(i); c -= 48; //減小」0「的charCode if (cur.edges[c]) { cur = cur.edges[c]; } else { return 0; } } return cur.numPass; } else { return 0; } } countWord(word) { // 統計某字符串出現的次數方法 if (this.isValid(word)) { var cur = this.root; for (var i = 0; i < word.length; i++) { var c = word.charCodeAt(i); c -= 48; //減小」0「的charCode if (cur.edges[c]) { cur = cur.edges[c]; } else { return 0; } } return cur.numEnd; } else { return 0; } } } class TrieNode { constructor() { this.numPass = 0;//有多少個單詞通過這節點 this.numEnd = 0; //有多少個單詞就此結束 this.edges = []; this.value = ""; //value爲單個字符 this.isEnd = false; } }
咱們重點看一下TrieNode與Trie的insert方法。 因爲字典樹是主要用在詞頻統計,所以它的節點屬性比較多, 包含了numPass, numEnd但很是重要的屬性。測試
insert方法是用於插入重詞,在開始以前,咱們必須斷定單詞是否合法,不能出現 特殊字符與空白。在插入時是打散了一個個字符放入每一個節點中。每通過一個節點都要修改numPass。優化
如今咱們每一個方法中,都有一個c=-48
的操做,其實數字與大寫字母與小寫字母間其實還有其餘字符的,這樣會形成無謂的空間的浪費this
// by 司徒正美 getIndex(c){ if(c < 58){//48-57 return c - 48 }else if(c < 91){//65-90 return c - 65 + 11 }else {//> 97 return c - 97 + 26+ 11 } }
而後相關方法將c-= 48
改爲c = this.getIndex(c)
便可搜索引擎
var trie = new Trie(); trie.insert("I"); trie.insert("Love"); trie.insert("China"); trie.insert("China"); trie.insert("China"); trie.insert("China"); trie.insert("China"); trie.insert("xiaoliang"); trie.insert("xiaoliang"); trie.insert("man"); trie.insert("handsome"); trie.insert("love"); trie.insert("Chinaha"); trie.insert("her"); trie.insert("know"); var map = {} trie.preTraversal(function(node, str){ if(node.isEnd){ map[str] = node.numEnd } }) for(var i in map){ console.log(i+" 出現了"+ map[i]+" 次") } console.log("包含Chin(包括自己)前綴的單詞及出現次數:"); //console.log("China") var map = {} trie.preTraversal(function(node, str){ if(str.indexOf("Chin") === 0 && node.isEnd){ map[str] = node.numEnd } }) for(var i in map){ console.log(i+" 出現了"+ map[i]+" 次") }
二叉搜索樹應該是咱們最先接觸的樹結構了,咱們知道,數據規模爲n時,二叉搜索樹插入、查找、刪除操做的時間複雜度一般只有O(log n),最壞狀況下整棵樹全部的節點都只有一個子節點,退變成一個線性表,此時插入、查找、刪除操做的時間複雜度是O(n)。spa
一般狀況下,前綴Trie的高度n要遠大於搜索字符串的長度m,故查找操做的時間複雜度一般爲O(m),最壞狀況下的時間複雜度才爲O(n)。很容易看出,前綴Trie最壞狀況下的查找也快過二叉搜索樹。
文中前綴Trie都是拿字符串舉例的,其實它自己對key的適宜性是有嚴格要求的,若是key是浮點數的話,就可能致使整個前綴Trie巨長無比,節點可讀性也很是差,這種狀況下是不適宜用前綴Trie來保存數據的;而二叉搜索樹就不存在這個問題。
考慮一下Hash衝突的問題。Hash表一般咱們說它的複雜度是O(1),其實嚴格提及來這是接近完美的Hash表的複雜度,另外還須要考慮到hash函數自己須要遍歷搜索字符串,複雜度是O(m)。在不一樣鍵被映射到「同一個位置」(考慮closed hashing,這「同一個位置」能夠由一個普通鏈表來取代)的時候,須要進行查找的複雜度取決於這「同一個位置」下節點的數目,所以,在最壞狀況下,Hash表也是能夠成爲一張單向鏈表的。
前綴Trie能夠比較方便地按照key的字母序來排序(整棵樹先序遍歷一次就行了),這跟絕大多數Hash表是不一樣的(Hash表通常對於不一樣的key來講是無序的)。
在較理想的狀況下,Hash表能夠以O(1)的速度迅速命中目標,若是這張表很是大,須要放到磁盤上的話,Hash表的查找訪問在理想狀況下只須要一次便可;可是Trie樹訪問磁盤的數目須要等於節點深度。
不少時候前綴Trie比Hash表須要更多的空間,咱們考慮這種一個節點存放一個字符的狀況的話,在保存一個字符串的時候,沒有辦法把它保存成一個單獨的塊。前綴Trie的節點壓縮能夠明顯緩解這個問題,後面會講到。
原理上和普通Trie樹差很少,只不過普通Trie樹存儲的最小單位是字符,可是Bitwise Trie存放的是位而已。位數據的存取由CPU指令一次直接實現,對於二進制數據,它理論上要比普通Trie樹快。
分支壓縮:將一些連結線與節點進行合併,好比i-n-n能夠合併成inn。這種壓縮後的Tire被喚做前綴壓縮Tire,或直接叫前綴樹, 字典樹。
節點映射表:這種方式也是在前綴Trie的節點可能已經幾乎徹底肯定的狀況下采用的,針對前綴Trie中節點的每個狀態,若是狀態總數重複不少的話,經過一個元素爲數字的多維數組(好比Triple Array Trie)來表示,這樣存儲Trie樹自己的空間開銷會小一些,雖然說引入了一張額外的映射表。
前綴樹仍是很好理解,它的應用也是很是廣的。
(1)字符串的快速檢索
字典樹的查詢時間複雜度是O(logL),L是字符串的長度。因此效率仍是比較高的。字典樹的效率比hash表高。
(2)字符串排序
從上圖咱們很容易看出單詞是排序的,先遍歷字母序在前面。減小了不必的公共子串。
(3)最長公共前綴
inn和int的最長公共前綴是in,遍歷字典樹到字母n時,此時這些單詞的公共前綴是in。
(4)自動匹配前綴顯示後綴
咱們使用辭典或者是搜索引擎的時候,輸入appl,後面會自動顯示一堆前綴是appl的東東吧。那麼有多是經過前綴Trie實現的,前面也說了前綴Trie能夠找到公共前綴,咱們只須要把剩餘的後綴遍歷顯示出來便可。