什么是Trie树
Trie树有很多名字,字典树,前缀树。
Trie树主要用来高效存储字符串或者二进制数,可以避免重复元素的存储,同时提高查找效率。
我们以存储26个小写字母为例:
由于有26个字母,所以从根节结点往下开始,每个父结点都最多有26个孩子结点。
1. 插入操作
Trie树的创建是从根节点开始,假设我们要插入字符串”in”。
-
我们一开始位于根,也就是0号节点,我们用
P=0
表示。我们先看P是不是有一条标识着 字符i 的连向子节点的边。发现没有这条边,于是我们就新建一个节点,也就是1号节点,并且将边标识为 字符i。然后我们移动到1号节点,也就是令P=1
。这样我们就把 ”in ”的 i字符 插入到Trie树中了。 -
然后我们再插入字符n,也是先找P,也就是1号节点有没有标记为 字符n 的边,还是没有,于是再新建一个节点2,并且把边标识为 字符n。最后再移动到P=2。这样我们就把n也插入了。
-
由于n是”in”的最后一个字符,所以我们还需要将P=2这个节点标记为终结点。
其它字符串的插入操作同理,如果已有该孩子结点,就不用再创建了,趁着即可,如果没有的话,再创建。
2. 查找操作
如何查询Trie树中是不是包含字符串S?
我们只要从根节点开始,沿着标识着S[0]->S[1] -> S[2] -> S[3] … -> S[S.len]的边移动,
- 如果最后成功到达一个终结点,就说明S在Trie树中;
- 如果最后无路可走,或者到达一个不是终结点的节点,就说明S不在Trie树中。
3. 二维数组模拟Trie树
3.1 实现存储功能
int trie[M][26];
仍以存储26个小写字母的trie树为例,
M的值取决所有输入的字符串中字符的个数总和,最坏情况下可能给每个字符都建立一个结点;
26是因为最多有26个分支。
用一个二维数组来模拟一个Trie树:
一、trie[i]
代表二维数组的第 i 行,每一行的编号 i 即对应上图中Trie树中结点编号p的含义:i==p
注意有可能根节点的26个子节点的结点编号不是1到26,这与这个字符出现的先后顺序有关。
即有可能trie[0][3]=233;
即根节点的第3个分支,结点编号为233,在二维数组的第233行trie[233]
这需要再额外定义一个变量 int idx=1;
维护,0号结点是根节点,板上钉钉,每次从根节点开始搜索、插入,所以idx直接从1开始。 存不同的字符,先出现哪个字符,就先伸出哪条边进行存储。
idx的作用等同于单链表中idx的作用。
二、每一行都有26列,代表有26个分支,这里只是从a-z,即从0到25;
charset[i]=ch;
i从0到25,分别对应小写字母a到z,即charset[i]=i+'a';
三、trie[i][j]=x;
的含义:
- 若x等于0,则说明结点编号为i的结点没有伸出
chaset[j]
的边,即没有到chaset[j]
的子节点; - 若x不等于0,则说明结点编号为i的结点有伸出
chaset[j]
的边,即有到chaset[j]
的子节点,结点编号下标为x必有x>i
那么具体是怎么存储字符的呢?
trie[0]
就是根节点,结点编号i是0,整个二维数组存储的值含义都是它的下一个结点编号是多少,
trir[i][j]=x
这样一个存值的过程,表示从结点i可以到结点x,伸出了一条charset[j]
的边,即存储字符charset[j]
,所以是通过结点之间的边存储的,不是通过结点本身。
3.2 实现标记结束功能
以上面的图中的t-e-a路径为例,图中只有a结点标记了结束标记,所有只有字符串"tea",而没有字符串"te";
再看路径i-n-t, n结点和t结点都有结束标记,所以trie树中存有字符串"in"和"int"。
int cnt[M];
我们再开一个cnt数组来维护Trie树中每个结点编号,即二维数组中的每一行。
cnt[i]=x;
的含义是:以结点编号为i结尾的字符串的个数为x个,然而只有唯一的一条路径能指向结点编号为i的结点,所以即统计某个字符串出现的次数。
题目描述
维护一个字符串集合,支持两种操作:
“I x”向集合中插入一个字符串x;
“Q x”询问一个字符串在集合中出现了多少次。
共有N个操作,输入的字符串总长度不超过 105,字符串仅包含小写英文字母。
输入格式
第一行包含整数N,表示操作数。
接下来N行,每行包含一个操作指令,指令为”I x”或”Q x”中的一种。
输出格式
对于每个询问指令”Q x”,都要输出一个整数作为结果,表示x在集合中出现的次数。
每个结果占一行。
数据范围
1≤N≤2∗104
输入样例:
5
I abc
Q abc
Q ab
I ab
Q ab
输出样例:
1
0
1
算法实现
#include <iostream>
#define read(x) scanf("%d",&x)
using namespace std;
const int M=1e5+10;
int trie[M][26],cnt[M],idx=1; "0号结点是根节点,有效的字符存储是从下标1开始的,i->j的边才表示一个字符:0->1"
"0->1的边每次存储哪个字符是不确定的,i->j同理"
void insert(char *str)
{
int p=0; //从根节点开始插入,p表示节点编号
for (int i=0;str[i]!='\0';i++) {
int t=str[i]-'a'; //指向具体哪个分支
if (trie[p][t]==0) trie[p][t]=idx++; //如果分支不存在的话,开辟一个新结点
p=trie[p][t]; //指向该分支存储在的下一个结点,形成了边,到这一步时才说明插入字符str[i]了
}
cnt[p]++; //统计字符串个数
}
int find(char *str)
{
int p=0; //从根节点开始查找,p表示节点编号
for (int i=0;str[i]!='\0';i++) {
int t=str[i]-'a';
if (!trie[p][t]) return 0; //顶点p->triep[p][t]不可达,说明不存在边str[i]了,即没有存储字符str[i]
p=trie[p][t];
}
return cnt[p];
}
int main()
{
int n;
read(n);
char op[3],str[M];
while (n--) {
scanf("%s%s",op+1,str); "尝试从下标1开始输入字符串,注意'\0'也要占位置"
if(op[1]=='I') insert(str);
else printf("%d\n",find(str));
}
return 0;
}