1、概述
Trie树,又称字典树,单词查找树或者前缀树,是一种用于快速检索的多叉树结构,如英文字母的字典树是一个26叉树,数字的字典树是一个10叉树。
Trie一词来自retrieve,发音为/tri:/ “tree”,也有人读为/tra?/ “try”。
Trie树的基本性质可以归纳为:
(1)根节点不包含字符,除根节点以外每个节点只包含一个字符。
(2)从根节点到某一个节点,路径上经过的字符连接起来,为该节点对应的字符串。
(3)每个节点的所有子节点包含的字符串不相同。
例子:
在这个Trie结构中,保存了t、to、te、tea、ten、i、in、inn这8个字符串,仅占用8个字节(不包括指针占用的空间)。
搭建Trie的基本算法很简单,无非是逐一把每则单词的每个字母插入Trie。插入前先看前缀是否存在。如果存在,就共享,否则创建对应的节点和边。
比如要插入单词int,就有下面几步:
1.考察前缀"i",发现边i已经存在。于是顺着边i走到节点i。
2.考察剩下的字符串"nt"的前缀"i",发现从节点i出发,已经有边n存在。于是顺着边n走到节点in
3.考察最后一个字符"t",这下从节点in出发没有边t了,于是创建节点in的子节点int,并把边in->int标记为t。
用途:
典型应用是用于统计和排序、查询大量的字符串(但不仅限于字符串),所以经常被搜索引擎系统用于文本的词频统计等。
2、 Trie树的基本实现
题目(1):给你100000个长度不超过10的单词。对于每一个单词,我们要判断他出没出现过,如果出现了,求第一次出现在第几个位置。
分析:这题当然可以用hash来解决,但是本文重点介绍的是trie树,因为在某些方面它的用途更大。
比如说对于某一个单词,我们要询问它的前缀是否出现过。这样hash就不好搞了,而用trie还是很简单。
假设我要查询的单词是abcd,那么在他前面的单词中,以b,c,d,f之类开头的我显然不必考虑。而只要找以a开头的中是否存在abcd就可以了。
同样的,在以a开头中的单词中,我们只要考虑以b作为第二个字母的,一次次缩小范围和提高针对性,这样一个树的模型就渐渐清晰了。
好比假设有b,abc,abd,bcd,abcd,efg,hii 这6个单词,我们构建的树就是如下图这样的:
如上图所示,对于每一个节点,从根遍历到他的过程就是一个单词,如果这个节点被标记为红色,就表示这个单词存在,否则不存在。
那么,对于一个单词,我只要顺着他从根走到对应的节点,再看这个节点是否被标记为红色就可以知道它是否出现过了。把这个节点标记为红色,就相当于插入了这个单词。
这样一来我们查询和插入可以一起完成(重点体会这个查询和插入是如何一起完成的,稍后,下文具体解释),所用时间仅仅为单词长度,在这一个样例,便是10。
我们可以看到,trie树每一层的节点数是26^i级别的。所以为了节省空间。我们用动态链表,或者用数组来模拟动态。空间的花费,不会超过单词数×单词长度。
题目(2): 已知n个由小写字母构成的平均长度为10的单词,判断其中是否存在某个串为另一个串的前缀子串。下面对比3种方法:
最容易想到的:即从字符串集中从头往后搜,看每个字符串是否为字符串集中某个字符串的前缀,复杂度为O(n^2)。
使用hash:我们用hash存下所有字符串的所有的前缀子串,建立存有子串hash的复杂度为O(n*len),而查询的复杂度为O(n)* O(1)= O(n)。
使用trie:因为当查询如字符串abc是否为某个字符串的前缀时,显然以b,c,d....等不是以a开头的字符串就不用查找了。
所以建立trie的复杂度为O(n*len),而建立+查询在trie中是可以同时执行的,建立的过程也就可以成为查询的过程,hash就不能实现这个功能。
所以总的复杂度为O(n*len),实际查询的复杂度也只是O(len)。(说白了,就是Trie树的平均高度h为len,所以Trie树的查询复杂度为O(h)=O(len)。
好比一棵二叉平衡树的高度为logN,则其查询,插入的平均时间复杂度亦为O(logN))。
查询
Trie树是简单但实用的数据结构,通常用于实现字典查询。我们做即时响应用户输入的AJAX搜索框时,就是Trie开始。
本质上,Trie是一颗存储多个字符串的树。相邻节点间的边代表一个字符,这样树的每条分支代表一则子串,而树的叶节点则代表完整的字符串。
和普通树不同的地方是,相同的字符串前缀共享同一条分支。
下面,再举一个例子。给出一组单词,inn, int, at, age, adv, ant, 我们可以得到下面的Trie:
可以看出:
- 每条边对应一个字母。
- 每个节点对应一项前缀。叶节点对应最长前缀,即单词本身。
- 单词inn与单词int有共同的前缀“in”, 因此他们共享左边的一条分支,root->i->in。同理,ate, age, adv, 和ant共享前缀"a",所以他们共享从根节点到节点"a"的边。
查询操纵非常简单。比如要查找int,顺着路径i -> in -> int就找到了。
搭建Trie的基本算法也很简单,无非是逐一把每则单词的每个字母插入Trie。插入前先看前缀是否存在。如果存在,就共享,否则创建对应的节点和边。比如要插入单词add,就有下面几步:
- 考察前缀"a",发现边a已经存在。于是顺着边a走到节点a。
- 考察剩下的字符串"dd"的前缀"d",发现从节点a出发,已经有边d存在。于是顺着边d走到节点ad
- 考察最后一个字符"d",这下从节点ad出发没有边d了,于是创建节点ad的子节点add,并把边ad->add标记为d。
具体Trie树的创建、插入、查询代码如下所示:
/* 功能描述:实现trie树的创建、插入、查询 说明: 对统计对象,要求符合正则"[a-z]*"的格式的单词 若考虑大写,标点和空白字符(空格.TAB.回车换行符), 可修改next数组大小,最多255可包含所有字符 */ #include<stdio.h> #include <stdlib.h> #include<string.h> #define MAX_CHILD 26 //此函数只考虑26个英文字母的情况 typedef struct Tree { int count; //用来标记该节点是个可以形成一个单词,如果count!=0,则从根节点到该节点的路径可以形成一个单词 struct Tree *child[MAX_CHILD]; } Node,*Trie_node; /* child是表示每层有多少种类的数,如果只是小写字母,则26即可,若改为大小写字母,则是52,若再加上数字, 则是62了,这里根据题意来确定。 count可以表示一个字典树到此有多少相同前缀的数目,这里根据需要应当学会自由变化。 */ Node* CreateTrie() //创建trie节点树 { Node *node=(Node*)malloc(sizeof(Node)); memset(node,0,sizeof(Node)); return node; } void insert_node(Trie_node root,char *str) //trie树插入结点 { if(root ==NULL || *str==‘\0‘) return; Node *t=root; while(*str!=‘\0‘) { if(t->child[*str-‘a‘]==NULL) { Node *tmp=CreateTrie(); t->child[*str-‘a‘]=tmp; } t=t->child[*str-‘a‘]; str++; } t->count++; } void search_str(Trie_node root,char *str) //查找串是否在该trie树中 { if(NULL==root || *str==‘\0‘) { printf("trie is empty or str is null\n"); return; } Node *t=root; while(*str!=‘\0‘) { if(t->child[*str-‘a‘]!=NULL) { t=t->child[*str-‘a‘]; str++; } else break; } if(*str==‘\0‘) { if(t->count==0) printf("该字符串不在trie树中,但该串是某个单词的前缀\n"); else printf("该字符串在该trie树中\n"); } else printf("该字符串不在trie树中\n"); } void del(Trie_node root) //释放整个字典树占的堆空间 { int i; for(i=0; i<MAX_CHILD; i++) { if(root->child[i]!=NULL) del(root->child[i]); } free(root); } int main() { int i,n; char str[20]; printf("请输入要创建的trie树的大小:"); scanf("%d", &n); Trie_node root=NULL; root=CreateTrie(); if(root==NULL) printf("创建trie树失败\n"); for(i=0; i<n; i++) { scanf("%s",str); insert_node(root,str); } printf("trie树已建立完成\n"); printf("请输入要查询的字符串:\n"); while(scanf("%s",str)!=NULL) { search_str(root,str); } return 0; }
这个 c 实现版本的 耗内存不支持中文。下面介绍用python实现的(参考网址)
Python内置的dict是用哈希实现的,正好可以解决这两个问题。
- dict采用的是开放寻址法解决冲突,节省了内存,但时间复杂度还是O(1)。
- dict这个哈希表里可以放任意字符作为键,中文当然也不例外。
python版的关键改造就是节点的next表用dict代替,维护的是字符->子节点
的映射。
查找时,若待查询字符是next里的一个键就说明该字符在Trie树里,以这个键得到值就能找到下一节点。插入时也只要插入字符->子节点
的映射就可以了。
#!/usr/bin/env python3 class Trie: root = dict() def insert(self, string): index, node = self.findLastNode(string) for char in string[index:]: new_node = dict() node[char] = new_node node = new_node def find(self, string): index, node = self.findLastNode(string) return (index == len(string)) def findLastNode(self, string): ‘‘‘ @param string: string to be searched @return: (index, node). index: int. first char(string[index]) of string not found in Trie tree. Otherwise, the length of string node: dict. node doesn‘t have string[index]. ‘‘‘ node = self.root index = 0 while index < len(string): char = string[index] if char in node: node = node[char] else: break index += 1 return (index, node) def printTree(self, node, layer): if len(node) == 0: return ‘\n‘ rtns = [] items = sorted(node.items(), key=lambda x:x[0]) rtns.append(items[0][0]) rtns.append(self.printTree(items[0][1], layer+1)) for item in items[1:]: rtns.append(‘.‘ * layer) rtns.append(item[0]) rtns.append(self.printTree(item[1], layer+1)) return ‘‘.join(rtns) def __str__(self): return self.printTree(self.root, 0) if __name__ == ‘__main__‘: tree = Trie() while True: src = input() if src == ‘‘: break else: tree.insert(src) print(tree)
3、应用题目
1、有一个1G大小的一个文件,里面每一行是一个词,词的大小不超过16字节,内存限制大小是1M。返回频数最高的100个词。
2、1000万字符串,其中有些是重复的,需要把重复的全部去掉,保留没有重复的字符串。请怎么设计和实现?
3、 一个文本文件,大约有一万行,每行一个词,要求统计出其中最频繁出现的前10个词,请给出思想,给出时间复杂度分析。
思路:这题是考虑时间效率。用trie树统计每个词出现的次数,时间复杂度是O(n*len)(len表示单词的平均长度)。
然后是找出出现最频繁的前10个词,可以用堆来实现,前面的题中已经讲到了,时间复杂度是O(n*lg10)。
所以总的时间复杂度,是O(n*len)与O(n*lg10)中较大的哪一个。
4、寻找热门查询:搜索引擎会通过日志文件把用户每次检索使用的所有检索串都记录下来,每个查询串的长度为1-255字节。
假设目前有一千万个记录,这些查询串的重复读比较高,虽然总数是1千万,但是如果去除重复和,不超过3百万个。一个查询串的重复度越高,说明查询它的用户越多,也就越热门。
请你统计最热门的10个查询串,要求使用的内存不能超过1G。
(1) 请描述你解决这个问题的思路;
(2) 请给出主要的处理流程,算法,以及算法的复杂度。
4、 Trie树的应用
Trie是一种非常简单高效的数据结构,但有大量的应用实例。
(1) 字符串检索
事先将已知的一些字符串(字典)的有关信息保存到trie树里,查找另外一些未知字符串是否出现过或者出现频率。
举例:
@ 给出N 个单词组成的熟词表,以及一篇全用小写英文书写的文章,请你按最早出现的顺序写出所有不在熟词表中的生词。
@ 给出一个词典,其中的单词为不良单词。单词均为小写字母。再给出一段文本,文本的每一行也由小写字母构成。判断文本中是否含有任何不良单词。例如,若rob是不良单词,那么文本problem含有不良单词。
(2)字符串最长公共前缀
Trie树利用多个字符串的公共前缀来节省存储空间,反之,当我们把大量字符串存储到一棵trie树上时,我们可以快速得到某些字符串的公共前缀。
举例:
@ 给出N 个小写英文字母串,以及Q 个询问,即询问某两个串的最长公共前缀的长度是多少?
解决方案:首先对所有的串建立其对应的字母树。此时发现,对于两个串的最长公共前缀的长度即它们所在结点的公共祖先个数,于是,问题就转化为了离线(Offline)的最近公共祖先(Least Common Ancestor,简称LCA)问题。
而最近公共祖先问题同样是一个经典问题,可以用下面几种方法:
1. 利用并查集(Disjoint Set),可以采用采用经典的Tarjan 算法;
2. 求出字母树的欧拉序列(Euler Sequence )后,就可以转为经典的最小值查询(Range Minimum Query,简称RMQ)问题了;
(关于并查集,Tarjan算法,RMQ问题,网上有很多资料。)
(3)排序
Trie树是一棵多叉树,只要先序遍历整棵树,输出相应的字符串便是按字典序排序的结果。
举例:
@ 给你N 个互不相同的仅由一个单词构成的英文名,让你将它们按字典序从小到大排序输出。
(4) 作为其他数据结构和算法的辅助结构
如后缀树,AC自动机等
5、 总结
Trie树,即字典树,又称单词查找树或键树,是一种树形结构,是一种哈希树的变种。
典型应用: 用于统计和排序大量的字符串(但不仅限于字符串),所以经常被搜索引擎系统用于文本词频统计。
它的优点是:最大限度地减少无谓的字符串比较,查询效率比哈希表高。
Trie的核心思想是空间换时间: 利用字符串的公共前缀来降低查询时间的开销以达到提高效率的目的。
参考:http://dongxicheng.org/structure/trietree/
http://blog.csdn.net/daniel_ustc/article/details/9794433
http://blog.csdn.net/jiutianhe/article/details/8076835