1.字典树
曾经遇到这样一个问题:很多单词,这些单词只含小写字母,并且不会有重复的单词出现,现在要统计出以某个字符串为前缀的单词数量,单词本身也是自己的前缀。先看看用常规的方法解决这个问题的复杂度。假设单词表容量为M,需要统计的前缀数量为N,前缀的平均长度是L,则常规算法思路是:对于每个前缀搜索每个单词,看看这个前缀是不是这个单词的前缀,如果是数量+1。这样的话时间复杂度为O(N*M*L),如果N相当大的话,这个算法的复杂度将无法接受啦。其实这就是字典树的典型应用啦。
我们先学习一下字典树,在解决上面的问题。字典树又称trie树,从名字上看很显然是一种树形结构了。字典树有以下几个特点:1.利用串的公共前缀,节约内存;2.根结点不包含任何字母;3.其余结点仅包含一个字母;4.每个结点的子节点包含字母不同。看看一个例子吧:下面就是一个字典树(图片来自百度百科)
上面的树就是一颗典型的字典树了,字典树中存储的单词包括:abc、abcd、abd、b、bcd、efg、hig,即:所有标记为红心的才是单词的结尾字母。对比上面的trie树的特点仔细看一下,理解一下到底什么是字典树。实际上字典树包括常见的两种种操作是:查找和插入操作。我觉得有必要看看字典树的基础代码:(代码)
1 //字典树的每个节点 2 struct node 3 { 4 bool isWord;//判断当前字母是否是单词最后一个字母的标志 5 node *next[26];//后继结点可能是26个小写字母 6 node()//构造函数 7 { 8 isWord = false; 9 for(int i = 0 ; i < 26 ; i++) 10 { 11 next[i] = NULL; 12 } 13 } 14 }; 15 //字典树 16 class TrieTree 17 { 18 public: 19 node *root; 20 TrieTree() 21 { 22 root = NULL; 23 } 24 //向字典树中插入字符串str 25 void Insert(string str) 26 { 27 if(!root)//判断根节点是否为空 28 root = new node; 29 node *location = root; 30 for(int i = 0 ; i < str.length() ; i++) 31 { 32 int num = str[i] - 'a'; 33 //获得当前str[i]具体是哪个小写字母,并指导应该存入哪个子节点, 34 //在字典树中,字母b永远只可能属于第二个子节点,同样的道理,字母d永远的属于第四个节点 35 //应该注意的是:这样的做法浪费了大量的空节点空间。 36 if(location->next[num] == NULL) 37 { 38 location->next[num] = new node; 39 } 40 location = location->next[num]; 41 } 42 location->isWord = true;//插入后当前节点应该是一个字符串的最后一个节点 43 } 44 //在字典树中查找字符串str 45 bool Search(string str) 46 { 47 node *location = root; 48 for(int i=0;i<str.length();i++) 49 { 50 int num = str[i] - 'a'; 51 if(location->next[num] == NULL) 52 return false; 53 location = location->next[num]; 54 } 55 //虽然str中的所有字符全在字典树的某个路径上,但是只有isword = true时才是真正的单词 56 return location->isWord; 57 } 58 };
小结:看过上面的代码,是否发现这个代码有什么问题呢??即尽管这个实现方式查找的效率很高,时间复杂度是O(m),m是要查找的单词中包含的字母的个数。但是确浪费大量存放空指针的存储空间。因为不可能每个节点的子节点都包含26个字母的。话又说回来,另外一个方面公共前缀只存储了一次,有减少了存储空间。所以对于这个问题,应该个人觉得应该这样想,字典树存在的意义是解决快速搜索的问题,所以采取以空间换时间的作法也毋庸置疑。
现在字典树的知识学完了,是时候解决一下文章开头提到的问题了。我们的解决思路是:把给出的所有单词建立一个字典树,仿照上面的的代码,只需要在客户端每次调用插入单词的操作,就可以建立一个字典树了。但是本题要解决的问题是,统计出以某个字符串为前缀的单词总数,想一想怎么改动一下呢?实际上只需要在struct node中做一些改动,改动如下:
1 struct Trienode 2 { 3 int count; 4 bool isWord;//判断当前字母是否是单词最后一个字母的标志 5 node *next[26];//后继结点可能是26个小写字母 6 node()//构造函数 7 { 8 count = 1; 9 isWord = false; 10 for(int i = 0 ; i < 26 ; i++) 11 { 12 next[i] = NULL; 13 } 14 } 15 };
看出来了吧,加入了一个count成员,用来记录每个节点在单词中出现的次数,然后在对应的插入操作(Insert)中,每次插入时经过的节点作count++操作;这样进行搜索(Search)时,若搜索成功只需要返回搜索终止节点的count变量的值,就是以某个字符串为前缀的单词总数啦。现在不妨来分析一下算法的时间复杂度,每次查找的时间复杂度是O(L),L是要搜索的前缀平均长度。现在有N个前缀需要处理,那么整个时间复杂度就是O(N*L),然后还要加上建字典树的时间复杂度,单词数为M,不妨设单词的平均长度是L',那么建字典树的时间复杂度就是O(M*L'),所以整个算法的时间复杂度是O(N*L + M*L'),想必之前的复杂度O(N*M*L),这是个不小的改进。当然我们也说了,这引入一些辅助空间,加快了搜索的速度。实际应用中M往往是大量的,比如搜索引擎中的字典,要在百度中进行搜索,那么采用字典树的地位就举足轻重啦。采用字典树可以大大的降低搜索的复杂度。
2.字典树的应用和好处
那么字典树到底有哪些典型的应用呢?
1.字典树在串的快速检索中的应用。
给出N个单词组成的熟词表,以及一篇全用小写英文书写的文章,请你按最早出现的顺序写出所有不在熟词表中的生词。在这道题中,我们可以用字典树,先把熟词建一棵树,然后读入文章进行比较,这种方法效率是比较高的。
2. 字典树在“串”排序方面的应用
给定N个互不相同的仅由一个单词构成的英文名,让你将他们按字典序从小到大输出用字典树进行排序,采用数组的方式创建字典树,这棵树的每个结点的所有儿子很显然地按照其字母大小排序。对这棵树进行先序遍历即可。
3. 字典树在最长公共前缀问题的应用
对所有串建立字典树,对于两个串的最长公共前缀的长度即他们所在的结点的公共祖先个数,于是,问题就转化为最近公共祖先问题。
使用字典树的好处:
1.利用字符串的公共前缀来节约存储空间。
2.最大限度地减少无谓的字符串比较,查询效率比较高。例如:若要查找的字符长度是5,而总共有单词的数目是26^5=11881376,利用trie树,利用5次比较可以从11881376个可能的关键字中检索出指定的关键字,而利用二叉查找树时间复杂度是O( log2n ),所以至少要进行log211881376=23.5次比较。可以看出来利用字典树进行查找速度是比较快的。
学习中的一点总结,欢迎拍砖哦^^