引子
前缀Trie, 又叫字符Tire, trie来自单词retrieval, 一最先念作tree,厥后改念try, 毕竟它与树是不一样的东西。网上许多文章都搞混了trie与树。 trie是经由过程”边“来贮存字符的一种树状构造,所谓边就是节点与节点间的衔接。trie每条边只能寄存一个字符。
它与hash树很类似,或者说它是哈希树的变种,哈希树是用边来寄存一个整数(可所以一位数或两位数)。前树Tire与哈希树都是多叉树,换言之,父节点有N个子节点。
前缀Tire重要用于字符串的疾速检索,查询效力比哈希表高。
前缀Trie的中心头脑是空间换时刻。运用字符串的大众前缀来下降查询时刻的开支以到达进步效力的目的。
前缀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
的操纵,实在数字与大写字母与小写字母间实在另有其他字符的,如许会形成无谓的空间的糟蹋
// 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]+" 次")
}
前缀Trie和别的数据构造的比较
前缀Trie与二叉搜刮树
二叉搜刮树应该是我们最早打仗的树构造了,我们晓得,数据范围为n时,二叉搜刮树插进去、查找、删除操纵的时刻复杂度平常只要O(log n),最坏状况下整棵树一切的节点都只要一个子节点,退变成一个线性表,此时插进去、查找、删除操纵的时刻复杂度是O(n)。
平常状况下,前缀Trie的高度n要远大于搜刮字符串的长度m,故查找操纵的时刻复杂度平常为O(m),最坏状况下的时刻复杂度才为O(n)。很轻易看出,前缀Trie最坏状况下的查找也快过二叉搜刮树。
文中前缀Trie都是拿字符串举例的,实在它自身对key的适宜性是有严厉要求的,假如key是浮点数的话,就能够致使全部前缀Trie巨长异常,节点可读性也异常差,这类状况下是不适宜用前缀Trie来保留数据的;而二叉搜刮树就不存在这个题目。
前缀Trie与Hash表
斟酌一下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)
原理上和平常Trie树差不多,只不过平常Trie树存储的最小单元是字符,然则Bitwise Trie寄存的是位罢了。位数据的存取由CPU指令一次直接完成,关于二进制数据,它理论上要比平常Trie树快。
节点紧缩
分支紧缩:将一些连结线与节点举行兼并,比方i-n-n能够兼并成inn。这类紧缩后的Tire被唤作前缀紧缩Tire,或直接叫前缀树, 字典树。
节点映射表:这类体式格局也是在前缀Trie的节点能够已险些完整肯定的状况下采纳的,针对前缀Trie中节点的每一个状况,假如状况总数反复许多的话,经由过程一个元素为数字的多维数组(比方Triple Array Trie)来示意,如许存储Trie树自身的空间开支会小一些,虽然说引入了一张分外的映射表。
前缀Trie的运用
前缀树照样很好明白,它的运用也是异常广的。
(1)字符串的疾速检索
字典树的查询时刻复杂度是O(logL),L是字符串的长度。所以效力照样比较高的。字典树的效力比hash表高。
(2)字符串排序
从上图我们很轻易看出单词是排序的,先遍历字母序在前面。削减了没必要的大众子串。
(3)最长大众前缀
inn和int的最长大众前缀是in,遍历字典树到字母n时,此时这些单词的大众前缀是in。
(4)自动婚配前缀显现后缀
我们运用辞典或者是搜刮引擎的时刻,输入appl,背面会自动显现一堆前缀是appl的东东吧。那末有多是经由过程前缀Trie完成的,前面也说了前缀Trie能够找到大众前缀,我们只须要把盈余的后缀遍历显现出来即可。