这篇文章来介绍一个比较重要的数据结构:字典树(TrieTree)。其中 trie 一词来自于英语单词 retrieval 【检索】。
背景知识
首先来谈谈为什么会出现这么个东西。现在有一堆单词(也可能是其他的东西),然后给出一个单词,让你检查一下它是不是在这一堆里面。当然我们可以直接遍历所有单词,一个一个去比较,作为一个程序员,这样很明显是十分愚蠢的。那么我们就得想办法优化一下。怎么优化呢,想想你去英语词典里找单词,难道你要从第一页开始一个词一个词找吗?显然不是。正常人的做法是先找首字母,找到一个范围,再去那个范围里找第二个字母,以此类推。那么我们的字典树就是这样一种数据结构。
所谓字典树,就是把前面几个字母一样的单词(不一定是单词,也可以是别的,因为单词较简单,这里用查找单词为例)放在一起,这样不但节省了空间,而且想找某个单词也比较容易,只要沿着字母顺序捋下来,看看有没有符合的就好。这样形成了一种树状结构,支持插入和删除操作。可能树状并不太好理解,那我们就画图来模拟一下。
首先我们插入单词 abc 和 bcd:
这时候我们再插入单词 abd,bc。这时候注意到前缀ab已经存在了,所以只需要在 ab 下面补上新的单词就行了。添加 bc 的时候我们发现,它就在字典树中,那这时候我们就要区分一下 bcd 和 bc 了,那么我们在每次插入单词的时候有意地去标记一下单词结尾:
差不多就是这个样子了。现在想要查询一下 abc,沿着红色的路径走下来,找到了 abc;想要查询一下 bc,沿着绿色的路径走下来找到了 bc。注意我们想要查询 ab 的时候就出了一些小问题:ab 在树里,但是 ab 并没有被标记成一个单词,所以也算是查询失败。
这就是大致思路了,下面我们来实现吧。
实现方式
首先我们自然想到用指针来实现,但是回过头想想,以存储单词(全是小写字母)为例,一个节点需要指出26个指针,那未免有点疯狂。所以我们这里选择用数组来建树。
我们直接用一道例题来说明:acwing.Trie字符串统计
维护一个字符串集合,支持两种操作:
- I x 向集合中插入一个字符串 x;
- Q x 询问一个字符串在集合中出现了多少次。
共有 N 个操作,所有输入的字符串总长度不超过 10^5,字符串仅包含小>写英文字母。输入格式
第一行包含整数 N,表示操作数。
接下来 N 行,每行包含一个操作指令,指令为 I x 或 Q x 中的一种。
输出格式
对于每个询问指令 Q x,都要输出一个整数作为结果,表示 x 在集合中出现的次数。
每个结果占一行。
数据范围
1≤N≤2∗10^4
输入样例:5
I abc
Q abc
Q ab
I ab
Q ab输出样例:
1
0
1
不难看出,这是一道典型的字典树题目。不过我们需要多维护一个东西,也就是如果一个单词被插入了两次,那么在询问的时候要给出被插入的次数。这个好说,我们只要在每个单词结尾加一个计数器就好。重点是怎么用数组构造字典树,这也是最难的一部分。所以下面我们就来重点说说怎么实现数组字典树。
题目要求提到,总结点不会超过100000个,并且由于我们存储的是小写字母,想想上面美丽的插图,我们可以知道每个节点的“儿子”(也就是分支)不会超过26个。不需要思考,我们直接给出答案:开一个二维数组,第一维存储是哪个节点,第二维存储那个节点的儿子。还是很抽象,我们举个例子:
再次之前,我们要打破一个观念:数组是一种从开始到结尾的顺序结构。这里的数组会被我们使用的错综复杂。
现在要存储 abc
存储完是这样的一种样子。解释一下上面意思(这是最重要的,一定要看懂!!):
- 首先第一维的0(也就是第一行)代表根节点,第二维(也就是26列)代表26个字母。首先根节点多了一个分支 a ,这时候节点多了一个,就要找一行来代表多出来的这个节点,为了方便,我们一行一行的用。所以我们现在用到了第二行(代表第一个节点。第一行是根,不能用),这时候根节点的 a (第一列)就要指向第二行了,我们用到操作:
//son数组即我们构造的二维数组
son[0][0] = 1;
来代表根节点有一个子节点(“儿子”)a。这里可能有点绕,一定要理解这一步。
这一步总结起来就是:当前行的某一列元素指向其它行,代表当前行的子节点是指向的那一行,并且这个子节点存储的元素用当前行的列来表示。(这点很抽象,但是一定要懂,否则全部都无法理解)
具象化来说就是:第一行的第一列指向了第二行,代表第一行的子节点是第二行,并且这个子节点的元素是第一列(也就是 a )。
- 以此类推,第二行的第二列指向了第三行,代表第一个节点的子节点之一是第三行,并且这个子节点的元素是 b 。以此类推,第二个节点(第三行)的子节点之一是第四行,并且存储的元素是 c 。
到这里可能还比较好理解,下一步的理解难度要更高:插入一个单词 bcd 。
插入完后的数组是这样的:
为了清晰,我把后面的暂时截掉了(反正现在用不到后面的字母)。这就比较抽象了,但是我们理智的分析一下:
第一行的第二列指向了第五行,代表根节点现在有了一个新的子节点 b ,这个节点用第五行来表示(因为前面已经用了4个节点(一个根节点加上三个子节点 a b c ),所以只能从第五行开始用了)。理解了这一点,后面的就好说了,我们不再过多赘述。
现在我们进入最抽象的一步:插入单词 abd。操作完是这样的:
紫色是最新的操作。那么解释一下: ab 已经有了,所以可以直接用前面的第一行第一列指向第二行,第二行第二列指向第三行来表示 ab,然后这时候要给 b 添加一个节点d,所以我们从存储 b 的节点(也就是第三行)的第四列(代表d)引出一条线指向第八行(因为前七行已经被别人占用了),这样就代表了 b 有一个子节点 d ,并且存储在第八行。
不能理解的反复看看,记住一定要颠覆数组是顺序结构的这样一种固有观念。
下面我们用代码来实现所述,同时也是例题的题解:
#include <iostream>
using namespace std;
const int N = 100010;
//son用来构造树,cnt用来表示以每个节点结尾的单词插入了几次,idx表示当前用到了那个节点
//定义全局变量防止爆栈,同时全局变量自动初始化为0
int son[N][26], cnt[N], idx;
//用来存储输入的字符串
char str[N];
void insert(char * str)
{
//从根节点开始
int p = 0;
//遍历当前字符串
for (int i = 0; str[i]; i ++ )
{
//因为用的是int数组来建树,所以要把对应字符转化成数字
int u = str[i] - 'a';
//如果不存在需要的节点,那么就创建节点
if (!son[p][u]) son[p][u] = ++ idx;
//推进当前查找进度
p = son[p][u];
}
//单词计数器
cnt[p] ++ ;
}
int query(char * str)
{
int p = 0;
for (int i = 0; str[i]; i ++ )
{
int u = str[i] - 'a';
//找不到匹配的节点,那么就返回0
if (!son[p][u]) return 0;
p = son[p][u];
}
return cnt[p];
}
int main()
{
int n;
cin >> n;
while (n -- )
{
/*
有好多测试点会输一些空格啊啥的,用字符串可以有效避免这些无效字
符,所以即使用字符,也用数组存好一点
*/
char op[2];
scanf("%s%s", op, str);
if (*op == 'I') insert(str);
else printf("%d\n", query(str));
}
return 0;
}