Trie 树
基本概念
\(Trie\) 树是一种与字符串相关的数据结构,又名字典树,前缀树。它的主要思想是 用空间换时间。它可以用来 统计、排序或者保存大量的字符串。假设待查询的字符串为 \(s\),那么 \(Trie\) 树单次查询复杂度为 \(O(|s|)\)。
变种 \(01\ Trie\) 是一种维护与 异或 相关问题的数据结构。可以快速求出若干个数两两异或和中 大于/小于 某个值的所有值之和
算法思想
基本思想
\(Trie\) 树的主要思想就是减少存储相同的前缀,从而优化暴力的时空复杂度。\(Trie\) 树是一棵带权树,每条边的边权为一个字母。我们把 \(Trie\) 树的根结点视作一个空串,对于一个结点 \(u\),从根结点到结点 \(u\) 的路径就是结点 \(u\) 所对应的前缀或字符串。假如我们有 \(5\) 个单词 \(Ling, Lion, Yue, Zheng, YYUT\),那么构造出的 \(Trie\) 树应该如下图所示:
如图,我们可以发现 \(Trie\) 树具有一些基本的性质:
-
\(Trie\) 树的根结点为空串,通常编号为 \(0\)
-
\(Trie\) 树的每个结点存储一个字符串
-
\(Trie\) 树中从根结点到结点 \(u\) 路径上的字符连接而成的字符串就是结点 \(u\) 所存储的字符串
-
相同的前缀所对应的结点会被合并,避免重复建立结点
-
每个结点 \(u\) 的后代所对应的字符串拥有相同的前缀
上图中的单词 \(Ling\) 和 \(Lion\) 具有相同的前缀 \(Li\),所以我们把存储 \(Li\) 的结点和它的祖先合并在一起,只用了 \(2\) 个结点保存相同的前缀。单词 \(Yue\) 和单词 \(YYUT\) 具有相同的前缀 \(Y\),所以存储 \(Y\) 的结点被合并成同一个结点。单词 \(Zheng\) 与其他单词没有公共前缀,所以单独存储。
理解了 \(Trie\) 树的性质,我们尝试构造一棵 \(Trie\) 树。\(Trie\) 树主要支持的操作有插入和查询,有些 \(Trie\) 树还支持删除,本文不对这些特殊的 \(Trie\) 树进行讨论。题目通常会需要求出某个特定条件的值,因此每个结点还可能根据需要额外维护一个附加权值。
假设我们现在要插入一个字符串 \(s\)。我们从字符串开头开始考虑,假如根结点 \(r\) 存在一个子结点 \(u\),使得 \(w_{r, u} = s_0\),那么此时 \(s\) 和其他的单词存在共同前缀 \(s_0\),可以共用已经建好的结点,继续向下递归。否则,说明 \(s\) 此后的部分与其他单词没有共同前缀,建立一个新的结点 \(u\) 并赋上边权 \(s_0\)。递归进行以上插入,直到字符串 \(s\) 中的字符全部都被插入为止。最后得到的 \(Trie\) 树符合以上全部性质。
下面我们通过几张图解模拟在上图的 \(Trie\) 树中插入单词 \(Lily\) 的过程:
首先,我们遍历根结点的至多 \(26\) 个子结点。发现 \(s_0 = L\) 且 \(Trie\) 树中已经存在后代的前缀为 \(L\) 的结点,说明我们可以把存储 \(Lily\) 的结点置为该结点的后代,所以进入该结点的子树继续递归插入。
类似于上一步,遍历当前结点的至多 \(26\) 个子结点。发现 \(s_1 = i\) 且 \(Trie\) 树中已经存在后代的前缀为 \(Li\) 的结点,说明我们可以把存储 \(Lily\) 的结点置为该结点的后代,所以进入该结点的子树继续递归插入。
遍历当前结点的所有子结点,发现无法找到一个父子边权为 \(l\) 的子结点,说明此时 \(Trie\) 树中不存在后代前缀为 \(Lil\) 的结点,我们需要新建一个结点。因此新建一个结点,进入该结点的子树递归插入。
由于当前结点的子树为空,所以类似上一步地新建父子边权为\(y\) 的结点,字符串 \(Lily\) 成功插入 \(Trie\) 树,递归结束。在 \(Trie\) 树插入一个长度为 \(l\) 的字符串,时间复杂度为 \(O(l)\)。
在 \(Trie\) 树中查询某个 前缀 \(s\) 是否出现过也十分简单。我们只需要从根结点开始查找是否存在父子边权为 \(s_0\) 的结点,递归查找;在当前结点的子结点中查找是否存在父子边权为 \(s_1\) 的结点,递归查找……如果某一次查找的时候发现不存在满足条件的子结点,说明这个 前缀 没有被插入 \(Trie\) 树。反之,如果每次查找都可以找到满足条件的子结点,说明这个 前缀 在 \(Trie\) 树中存在。
如果想要查询字符串 \(s\) 是否出现,我们还需要给 \(Trie\) 树中的结点打上标记。因为假设已经插入了某一个字符串,如果直接按上面的方法查询该字符串的某一个前缀,此时我们会发现这个前缀在 \(Trie\) 树中存在对应的结点。如果我们没有把这个前缀作为字符串插入过 \(Trie\) 树,算法就会得出错误的结果。因此我们需要给每一个结点都打上标记,表示当前结点存储的前缀是否是被插入过的字符串。如果最终找到的结点被打过标记,我们才认为这个字符串被插入过 \(Trie\) 树,否则我们查询到的仅仅是某个字符串的前缀而已。
具体的实现可以在插入字符串的过程中实现。假设我们插入了一个字符串 \(s\),那么我们把存储 \(s\) 的结点打上标记即可。具体到上图,插入单词 \(Ling\) 时我们只需要把左下角的结点打上标记即可。
例题讲解
给出 \(n\) 个字符串和 \(m\) 个查询,每次查询给出一个字符串 \(s\)。如果 \(s\) 没有被给出,输出 WRONG
,如果 \(s\) 被给出并且是第一次被查询,输出 OK
,如果 \(s\) 被给出并且不是第一次被查询,输出 REPEAT
。
这道题是一道 \(Trie\) 树的模板题,但是我们还需要维护每个字符串被查询的次数。这时我们需要给每个存储被给出字符串的结点增加一个附加权值,表示这个字符串是否被查询过。输出 WRONG
的情况对应字符串没有被插入过 Trie
树;输出 OK
对应字符串被插入过 Trie
树且附加权值为 false
,在输出 OK
后将附加权值置为 true
;输出 REPEAT
的情况对应字符串被插入过 \(Trie\) 树且附加权值为 true
。下面给出例题的参考代码。
参考代码
#include <cstdio>
#include <cstring>
using namespace std;
const int maxn = 3e5 + 5;
int n, m, cnt;
int son[maxn][26];
bool vis[maxn];
char s[60];
void insert(char *s)
{
int t = 0, len = strlen(s);
for (int i = 0; i < len; i++)
{
int c = s[i] - 'a';
if (!son[t][c])
son[t][c] = ++cnt;
t = son[t][c];
}
}
int search(char *s)
{
int t = 0, len = strlen(s);
for (int i = 0; i < len; i++)
{
int c = s[i] - 'a';
if (!son[t][c])
return 0;
t = son[t][c];
}
if (!vis[t])
{
vis[t] = true;
return 1;
}
return 2;
}
int main()
{
int res;
scanf("%d", &n);
for (int i = 1; i <= n; i++)
{
scanf("%s", s);
insert(s);
}
scanf("%d", &m);
for (int i = 1; i <= m; i++)
{
scanf("%s", s);
res = search(s);
if (!res)
puts("WRONG");
else if (res == 1)
puts("OK");
else
puts("REPEAT");
}
return 0;
}
\(01\ Trie\)
基本思想
\(01\ Trie\) 是 \(Trie\) 树衍生出的一种数据结构,它可以用来维护与 异或 相关的题目。\(01\ Trie\) 对 \(Trie\) 的改动十分简单,我们把若干个数转换成二进制表示,也就是若干个 \(01\) 串,然后把这些 \(01\) 串插入 \(Trie\) 树,得到的 \(Trie\) 树称之为 \(01\ Trie\)。
注意,\(01\ Trie\) 需要统一存储的二进制位数为 \(\lceil \log_2 \max(a_i)\rceil\)。高于这一位的二进制位上一定是 \(0\),对异或和维护而言没有意义。代码实现可以使用 (x >> 1) & 1
判断十进制数 \(x\) 的第 \(i\) 位是否为 \(1\)。
例如有 \(4\) 个十进制数 \(1, 2, 4, 5\),位数最多为 \(3\),那么它们对应的二进制表达分别为 \(001, 010, 100, 101\),建出的 \(01\ Trie\) 应该如下图所示:
例题选讲
给出 \(n\) 个数,求从 \(n\) 个数中选出两个数进行异或运算可以得到的最大结果。\(1 \leq n \leq 10^5, 0 \leq A_i \leq 2^{31}\)
还是一道 \(01\ Trie\) 的模板题,需要用到一些贪心的思想。我们对这 \(n\) 个数建出一棵 \(01\ Trie\),然后枚举参与运算的其中一个数,问题就变成了给出一个数,求这 \(n\) 个数中与它最大的异或和。
我们可以发现,想令异或和最大,那么我们应该尽量保证高位的数不相同。原因是假设我们令从左往右数第 \(x\) 位不同,那么即使后面所有位上的数字都不相同,损失最多为 \(\sum\limits_{i = 0}^{x - 1} 2^i < 2^x\)。如果我们把第 \(x\) 位取相反的数,后面可能的贡献总和也一定比第 \(x\) 位的损失要小。
于是我们可以想出一个贪心策略。对于给定的数 \(x\),我们可以二进制从左往右的第 \(31\) 位对应的深度 \(2\) 开始遍历。假设当前遍历到了从左往右的第 \(i\) 位,若深度为 \(i\) 的结点中存在父子边权与 \(x\) 的第 \(i\) 位相反的结点,我们递归进入该结点;反之进入另外一个唯一的结点递归。
如果感觉解析讲得不是很清楚,可以参考代码进行理解。
参考代码
#include <cstdio>
#include <algorithm>
using namespace std;
typedef long long ll;
const int maxn = 1e5 + 5;
const int maxt = 4e6 + 5;
int n, cnt;
int a[maxn], val[maxt];
int son[maxt][2];
void insert(int x)
{
int t = 0;
for (int i = 31; i >= 0; i--)
{
int c = (x >> i) & 1;
if (!son[t][c]) son[t][c] = ++cnt;
t = son[t][c];
}
}
ll query(int x)
{
int t = 0;
ll res = 0;
for (int i = 31; i >= 0; i--)
{
int c = (x >> i) & 1;
if (son[t][!c]) t = son[t][!c], res |= (1ll << i);
else t = son[t][c];
}
return res;
}
int main()
{
ll ans = 0;
scanf("%d", &n);
for (int i = 1; i <= n; i++)
{
scanf("%d", &a[i]);
insert(a[i]);
}
for (int i = 1; i <= n; i++) ans = max(ans, query(a[i]));
printf("%lld\n", ans);
return 0;
}