我是好文章的搬运工,原文来自博客园,博主July_,地址:http://www.cnblogs.com/v-July-v/archive/2011/10/22/2316412.html
从Trie树(字典树)谈到后缀树
出处:http://blog.csdn.net/v_JULY_v 。
引言
常关注本blog的读者朋友想必看过此篇文章:从B树、B+树、B*树谈到R 树,这次,咱们来讲另外两种树:Tire树与后缀树。不过,在此之前,先来看两个问题。
第一个问题: 一个文本文件,大约有一万行,每行一个词,要求统计出其中最频繁出现的前10个词,请给出思想,给出时间复杂度分析。
之前在此文:海量数据处理面试题集锦与Bit-map详解中给出的参考答案:用trie树统计每个词出现的次数,时间复杂度是O(n*le)(le表示单词的平均长度),然后是找出出现最频繁的前10个词。也可以用堆来实现(具体的操作可参考第三章、寻找最小的k个数),时间复杂度是O(n*lg10)。所以总的时间复杂度,是O(n*le)与O(n*lg10)中较大的哪一个。
第二个问题:找出给定字符串里的最长回文。例子:输入XMADAMYX。则输出MADAM。这道题的流行解法是用后缀树(Suffix Tree),但其用途远不止如此,它能高效解决一大票复杂的字符串编程问题(当然,它有它的弱点,如算法实现复杂以及空间开销大),概括如下:
- 查询字符串S是否包含子串S1。主要思想是:如果S包含S1,那么S1必定是S的某个后缀的前缀;又因为S的后缀树包含了所有的后缀,所以只需对S的后缀树使用和Trie相同的查找方法查找S1即可(使用后缀树实现的复杂度同流行的KMP算法的复杂度相当)。
- 找出字符串S的最长重复子串S1。比如abcdabcefda里abc同da都重复出现,而最长重复子串是abc。
- 找出字符串S1同S2的最长公共子串。注意最长公共子串(Longest CommonSubstring)和最长公共子序列(LongestCommon Subsequence, LCS)的区别:子串(Substring)是串的一个连续的部分,子序列(Subsequence)则是从不改变序列的顺序,而从序列中去掉任意的元素而获得的新序列;更简略地说,前者(子串)的字符的位置必须连续,后者(子序列LCS)则不必。比如字符串acdfg同akdfc的最长公共子串为df,而他们的最长公共子序列是adf。LCS可以使用动态规划法解决。
- Ziv-Lampel无损压缩算法。 LZW算法的基本原理是利用编码数据本身存在字符串重复特性来实现数据压缩,所以一个很好的选择是使用后缀树的形式来组织存储字符串及其对应压缩码值的字典。
- 找出字符串S的最长回文子串S1。例如:XMADAMYX的最长回文子串是MADAM(此即为上面所说的第二个问题:最长回文问题,本文第二部分将详细阐述此问题)。
- 多模式串的模式匹配问题。(suffer_array+二分)。
本文第一部分,咱们就来了解这个Trie树,然后自然而然过渡到第二部分、后缀树,接着进入第三部分、详细阐述后缀树的构造方法-Ukkonen,最后第四部分、对自动机,KMP算法,Extend-KMP,后缀树,后缀数组,trie树,trie图及其应用做个全文概括性总结。权作此番阐述,以备不时之需,在需要的时候便可手到擒来。ok,有任何问题,欢迎不吝指正或赐教。谢谢。
第一部分、Trie树
什么是Trie树
Trie树,即字典树,又称单词查找树或键树,是一种树形结构,是一种哈希树的变种。典型应用是用于统计和排序大量的字符串(但不仅限于字符串),所以经常被搜索引擎系统用于文本词频统计。它的优点是:最大限度地减少无谓的字符串比较,查询效率比哈希表高。
Trie的核心思想是空间换时间。利用字符串的公共前缀来降低查询时间的开销以达到提高效率的目的。
它有3个基本性质:
- 根节点不包含字符,除根节点外每一个节点都只包含一个字符。
- 从根节点到某一节点,路径上经过的字符连接起来,为该节点对应的字符串。
- 每个节点的所有子节点包含的字符都不相同。
树的构建
分析:这题当然可以用hash来解决,但是本文重点介绍的是trie树,因为在某些方面它的用途更大。比如说对于某一个单词,我们要询问它的前缀是否出现过。这样hash就不好搞了,而用trie还是很简单。
现在回到例子中,如果我们用最傻的方法,对于每一个单词,我们都要去查找它前面的单词中是否有它。那么这个算法的复杂度就是O(n^2)。显然对于100000的范围难以接受。现在我们换个思路想。假设我要查询的单词是abcd,那么在他前面的单词中,以b,c,d,f之类开头的我显然不必考虑。而只要找以a开头的中是否存在abcd就可以了。同样的,在以a开头中的单词中,我们只要考虑以b作为第二个字母的,一次次缩小范围和提高针对性,这样一个树的模型就渐渐清晰了。
好比假设有b,abc,abd,bcd,abcd,efg,hii 这6个单词,我们构建的树就是如下图这样的:
当时第一次看到这幅图的时候,便立马感到此树之不凡构造了。单单从上幅图便可窥知一二,好比大海搜人,立马就能确定东南西北中的到底哪个方位,如此迅速缩小查找的范围和提高查找的针对性,不失为一创举。
ok,如上图所示,对于每一个节点,从根遍历到他的过程就是一个单词,如果这个节点被标记为红色,就表示这个单词存在,否则不存在。
那么,对于一个单词,我只要顺着他从根走到对应的节点,再看这个节点是否被标记为红色就可以知道它是否出现过了。把这个节点标记为红色,就相当于插入了这个单词。
这样一来我们查询和插入可以一起完成(重点体会这个查询和插入是如何一起完成的,稍后,下文具体解释),所用时间仅仅为单词长度,在这一个样例,便是10。
我们可以看到,trie树每一层的节点数是26^i级别的。所以为了节省空间。我们用动态链表,或者用数组来模拟动态。空间的花费,不会超过单词数×单词长度。
前缀查询
- 最容易想到的:即从字符串集中从头往后搜,看每个字符串是否为字符串集中某个字符串的前缀,复杂度为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))。
- 在hash中,例如现在要输入两个串911,911456,如果要同时查询这两个串,且查询串的同时若hash中没有则存入。那么,这个查询与建立的过程就是先查询其中一个串911,没有,然后存入9、91、911;而后查询第二个串911456,没有然后存入9、91、911、9114、91145、911456。因为程序没有记忆功能,所以并不知道911在输入数据中出现过,只是照常以例行事,存入9、91、911、9114、911...。也就是说用hash必须先存入所有子串,然后for循环查询。
- 而trie树中,存入911后,已经记录911为出现的字符串,在存入911456的过程中就能发现而输出答案;倒过来亦可以,先存入911456,在存入911时,当指针指向最后一个1时,程序会发现这个1已经存在,说明911必定是某个字符串的前缀。
查询
可以看出:
- 每条边对应一个字母。
- 每个节点对应一项前缀。叶节点对应最长前缀,即单词本身。
- 单词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树的简单实现。下图所示的测试只是做了一个非常简单的检测而已,先插入j而后查找j,再删除再查找,目的主要是看删除函数是否有效。日后再好好写下trie树用于单词频率统计的实现(单词统计hash表当然也可以实现,只不过如果用trie树统计单词出现频率,想象一下,当树中已有某个单词,再次遍历到同样的单词,便可以迅速高效的查询找到某个单词,为其出现计数+1,这得益于查找高效的所带来的好处)。
//copyright@singmelody //updated@2011 July #include <stdio.h> #include <malloc.h> #include <string.h> #define true 1 #define false 0 struct trieNode { trieNode():isword(false) { memset(next, 0, sizeof(next)); } trieNode *next[26]; bool isword; }Root; void insert(char *tar) { trieNode *p =&Root; int id; while(*tar) { id = *tar-'a'; if(p->next[id] == NULL) { p->next[id] =(trieNode *)malloc(sizeof(trieNode)); } p = p->next[id]; tar++; } p->isword = true; } //找到返回 true 否则返回false int search(char *tar) { trieNode *p = &Root; int id; while(*tar) { id = *tar - 'a'; if (p->next[id] == NULL) { return false; } p = p->next[id]; tar++; } //判断结点是否标记 if (p->isword == true) return true; else return false; } void remove(char *tar) { trieNode *p =&Root; int id; while(*tar) { id = *tar-'a'; p = p->next[id]; tar++; } p->isword = false; } void searchprocess() { char searchstr[20]; printf("Please search: "); scanf("%s",searchstr); printf("Now searching %s: ",searchstr); if (search(searchstr)==true) { printf("Success "); } else { printf("Fail "); } } int main() { //..... return 0; }
Trie树单词频率统计实现
以下是用Trie树统计单词频率的实现,程序尚不完善,有很多地方还需改进。
// trie tree.cpp : 定义控制台应用程序的入口点。 // #include "stdafx.h" //功能:统计一段英文的单词频率(文章以空格分隔,没有标点) //思路:trie节点保存单词频率,然后通过DFS按字典序输出词频 //时空复杂度: O(n*len)(len为单词平均长度) //copyright@yansha 2011.10.25 //updated@July 2011.10.26 //程序尚不完善,有很多地方还需改进。 #include <stdio.h> #include <stdlib.h> #include <string.h> #include <ctype.h> #include <assert.h> #define num_of_letters 26 #define max_word_length 20 // 定义trie树节点 struct Trie { int count; Trie *next[num_of_letters]; }; // 定义根节点 Trie *root = NULL; /** * 建立trie树,同时保存单词频率 */ void create_trie(char *word) { int len = strlen(word); Trie *cur = root, *node; int pos = 0; // 深度为单词长度 for(int i = 0; i < len; ++i) { // 将字母范围映射到0-25之间 pos = word[i] - 'a'; // 如果当前字母没有对应的trie树节点则建立,否则处理下一个字母 if(cur->next[pos] == NULL) //1、这里应该有个查找过程 { node = (Trie *)malloc(sizeof(Trie)); node->count = 0; // 初始化next节点 for(int j = 0; j < num_of_letters; ++j) node->next[j] = NULL; // 开始处理下一个字母 cur->next[pos] = node; } cur = cur->next[pos]; } // 单词频率加1 cur->count++; } /** * 大写字母转化成小写字母 */ void upper_to_lower(char *word, int len) { for (int i = 0; i < len; ++i) { if(word[i] >= 'A' && word[i] <= 'Z') word[i] += 32; } } /** * 处理输入 */ void process_input() { char word[max_word_length]; // 打开统计文件(注意保持文件名一致) FILE *fp_passage = fopen("passage.txt", "r"); assert(fp_passage); // 循环处理单词 while (fscanf(fp_passage, "%s", word) != EOF) { int len = strlen(word); if (len > 0) upper_to_lower(word, len); create_trie(word); } fclose(fp_passage); } /** * 深度优先遍历 */ void trie_dfs(Trie *p, char *queue) { for(int i = 0; i < num_of_letters; ++i) { if(p->next[i] != NULL) { // 定义队列头结点 char *head = queue; // 在末尾增加一个字母 while (*queue != '