字典树,也叫trie树,是一种比较实用的数据结构,无论是在ACM竞赛的题目中,还是字符串相关的某些实际应用领域内,它都能发挥巨大的作用。
首先来看看字典树的本质是什么。它其实是一棵存储了很多字符串的树,这棵树上的每一条边就是某个或某些字符串中的一个字符,而从根节点到某一个特定节点所经过的一条路径上的所有边组成的就是字典树所保存的某一个字符串。不难看出,字典树就是一颗多叉树,它利用字符串的前缀来建立了这棵树,从而达到了节省存储空间(所有相同前缀都只存储一次),优化查询速度(查询单个单词是否存在的时间复杂度仅为该单词的长度)的目的。
再来看看字典树的应用场景。在ACM竞赛中的作用不言而喻,是查询单词、统计前缀等操作,在时间苛刻条件下的首选数据结构。而在现实生活中的应用,字典树就可以大显身手了,比如最常见的对字符串进行字典序排序,当字符串的数量巨大、长度较长时,普通的排序方式可能就显得力不从心,但是如果使用字典树就只需要在建好树后进行先序遍历即可,比如上图中的abc、abcd、abd、.......;再比如同样很常见的输入提示功能,例如百度的搜索框,虽然不知道百度是怎么做的,但是用字典树也可以实现相同的效果,并且效率也不错,只需要事先以各种关键词建立字典树,在输入关键词时动态的搜索字典树,即可做到高效的提示功能,当然也可以将一次完整查询的关键词插入到字典树,这样下次就可以提示搜索历史记录了。
最后需要具体实现字典树这一经典的数据结构了。其实想要实现字典树,只需要解决两个问题即可:1、如何表达字典树的多叉属性 2、如何标记字符串的结束。其实这两个问题就是对应字典树的插入和查询操作。
首先解决字典树的多叉性,这主要体现在其节点的结构上。考虑到每一个节点可能不止延伸出一条边,不难想到用一个指针数组去保存当前节点可以到达的所有下一个节点。
最后至于如何标记字符串的结束,其实也并不复杂,只需在每一个节点上存储一个标记,记录该节点是否是存储信息的结尾即可。
所以,综合这些需要保存的信息,可以使用一个结构体对它们进行封装,以此来表示字典树的一个节点。
const int MAXN = 26; struct Trie { // 代表当前节点可以延伸出的边,数量可变 Trie *Next[MAXN]; // 标记当前节点是否是保存信息的结尾,也可以代表前缀个数 int Flag; Trie() { // 初始化以该信息为前缀的信息个数 Flag = 1; memset(Next, NULL, sizeof(Next)); } }*root;
接下来就需要实现字典树的插入和查询操作,以及最后的释放占用空间操作了。
1、插入操作
将某一个信息插入字典树,其实就是依次将该信息的前缀信息保存在对应节点中,并修改相应标记的值即可。
void Insert(char *str) { int len = strlen(str); Trie *p = root, *q; // 将str的每一个字符插入trie树 for(int i=0; i<len; ++i) { int id = str[i] - 'a'; // 如果没有边,则新建一个trie节点,产生一条边,用以代表该字符 if(p->Next[id] == NULL) { q = new Trie(); p->Next[id] = q; p = p->Next[id]; } // 如果存在边,则沿着该边走 else { p = p->Next[id]; // 累加以该信息为前缀的信息个数 ++(p->Flag); } } }
2、查询操作
查询某个信息是否存在于字典树中,实质上也是将该信息的前缀信息与字典树上存储的对应位置的信息进行匹配,然后判断标记的值即可。
int Query(char *str) { int len = strlen(str); Trie *p = root; // 在trie树上顺序搜索str的每一个字符 for(int i=0; i<len; ++i) { int id = str[i] - 'a'; p = p->Next[id]; // 若为空集,表示不存以此为前缀的信息 if(p == NULL) return 0; } // 返回以该信息为前缀的信息个数 return p->Flag; }
3、删除操作
递归释放字典树的每一个节点占用的空间即可。
void Free(Trie* T) { if(T == NULL) return; // 释放trie树的每一个节点占用的内存 for(int i=0; i<MAXN; ++i) { if(T->Next[i]) Free(T->Next[i]); } delete(T); }
以一道例题,演示字典树的用法,HDOJ:1251,时空转移( 点击打开链接),题目如下:
统计难题
Time Limit: 4000/2000 MS (Java/Others) Memory Limit: 131070/65535 K (Java/Others)Total Submission(s): 23388 Accepted Submission(s): 9761
注意:本题只有一组测试数据,处理到文件结束.
题目说的很明白了,就是给出一个字符串,统计在已有的字符串中以该字符串为前缀的字符串的个数。
分析:
涉及到字符串前缀,如果不能暴力解决的话,方法几乎只有字典树了,再说了统计前缀本身也是字典树的功能之一。需要注意的是,可能动态分配内存在不同的平台下的处理方式有些差别,这题如果使用G++交的话可能会超时,换成使用C++提交即可。
源代码:
#include <cstdio> #include <cstdlib> #include <cstring> const int MAXN = 26; struct Trie { // 代表当前节点可以延伸出的边,数量可变 Trie *Next[MAXN]; // 标记当前节点是否是保存信息的结尾,也可以代表前缀个数 int Flag; Trie() { // 初始化以该信息为前缀的信息个数 Flag = 1; memset(Next, NULL, sizeof(Next)); } }*root; void Insert(char *str) { int len = strlen(str); Trie *p = root, *q; // 将str的每一个字符插入trie树 for(int i=0; i<len; ++i) { int id = str[i] - 'a'; // 如果没有边,则新建一个trie节点,产生一条边,用以代表该字符 if(p->Next[id] == NULL) { q = new Trie(); p->Next[id] = q; p = p->Next[id]; } // 如果存在边,则沿着该边走 else { p = p->Next[id]; // 累加以该信息为前缀的信息个数 ++(p->Flag); } } } int Query(char *str) { int len = strlen(str); Trie *p = root; // 在trie树上顺序搜索str的每一个字符 for(int i=0; i<len; ++i) { int id = str[i] - 'a'; p = p->Next[id]; // 若为空集,表示不存以此为前缀的信息 if(p == NULL) return 0; } // 返回以该信息为前缀的信息个数 return p->Flag; } void Free(Trie* T) { if(T == NULL) return; // 释放trie树的每一个节点占用的内存 for(int i=0; i<MAXN; ++i) { if(T->Next[i]) Free(T->Next[i]); } delete(T); } int main() {//freopen("sample.txt", "r", stdin); char str[15]; // 创建trie树的根节点 root = new Trie(); while(gets(str) && str[0]!='\0') { Insert(str); } while(~scanf("%s", str)) { printf("%d\n", Query(str)); } Free(root); return 0; }
其余类似的题目还有,HDOJ:1004、1671,UESTC:1060。