一、Trie简介
Trie,又称字典树或前缀树,常用来存储和查询字符串。假定接下来提到的字符串均由小写字母构成,那么Trie将是一棵 26 26 26 叉树。
给定五个字符串,分别为 acd
、abd
、be
、cbe
、cbf
,Trie将以以下形式存储这些字符串:
可以发现,这棵字典树用边来代表字母,而从根节点到树上某一节点的路径就代表了一个字符串。举个例子,
1
→
2
→
5
→
6
1\to2\to 5\to 6
1→2→5→6 表示的就是字符串 abd
。
二、用数组实现Trie
前面提到过,Trie是一棵
26
26
26 叉树,假设我们要存储的所有字符串的总长度不超过
N
N
N,这意味着树中最多有
N
+
1
N+1
N+1 个节点(最坏情况下,每个字符串都单独形成了一条路径,最后算上根节点一共
N
+
1
N+1
N+1 个),保险起见,我们可以开一个二维数组 son[N + 10][26]
用来存储Trie。
那这个数组的含义是什么呢?如下图
编号为
u
u
u 的节点指向了编号为
v
v
v 的节点,边对应了字母
c
c
c,反映到 son
数组中就是:
son[u][c] = v
由于下标只能是整数,所以我们将 a ∼ z a\sim z a∼z 映射为 0 ∼ 25 0\sim 25 0∼25。
例如,对于最开始提到的Trie,其对应的 son
数组为
son[1][0] = 2 // 即son[1][a] = 2
son[2][2] = 3 // 即son[2][c] = 3
son[3][3] = 4 // 即son[3][d] = 4
son[2][1] = 5 // 即son[2][b] = 5
son[5][3] = 6 // 即son[5][d] = 6
son[1][1] = 7 // 即son[1][b] = 7
son[7][4] = 8 // 即son[7][e] = 8
son[1][2] = 9 // 即son[1][c] = 9
son[9][1] = 10 // 即son[9][b] = 10
son[10][4] = 11 // 即son[10][e] = 11
son[10][5] = 12 // 即son[10][f] = 12
现规定从
0
0
0 开始对节点逐个编号,编号为
0
0
0 的节点是根节点(Trie为空时仅含根节点),同时 son
数组进行全0初始化。我们来看如何将字符
c
c
c 插入到Trie中。
如果 son[0][c] == 0
成立,说明Trie中不含字符
c
c
c,此时应当令 son[0][c] = idx
,其中 idx
为新建立的节点的编号。否则,
c
c
c 已存在于Trie中,无需插入。
三、存储与查询
根据前面的讨论,我们不仅需要用 son
数组来存储Trie,还需要用 idx
来为每个节点编号。但,仅仅有这两个变量就足够了吗?
考虑这两个字符串:ab
,abcd
,显然,后者在Trie中形成的路径包含前者,所以在存储这两个字符串的时候仅会得到一条路径,那如何说明我们存储了两个字符串呢?很简单,只需在每个字符串的末尾打上标记,如下图所示:
对应到代码中就是再开一个 cnt
数组(全0初始化),并令 cnt[2] = 1, cnt[4] = 1
即可。
如果同一个字符串存储了多次,只需在每次存储的时候执行 cnt[p]++
即可,其中 p
是字符串末尾所对应的节点的编号。
到此为止,我们可以勾勒出Trie的雏形:
const int N = 1e5 + 10;
int son[N][26], cnt[N], idx; // 声明为全局变量
⚠️ 若将其直接声明在结构体中,则由于结构体中的变量存储在栈区,会造成内存溢出,从而导致
Process finished with exit code 139 (interrupted by signal 11: SIGSEGV)
。
存储一个字符串的实现如下:
void insert(const string &s) {
int p = 0; // 初始时位于根节点
for (int i = 0; i < s.size(); i++) {
int c = s[i] - 'a';
if (!son[p][c]) son[p][c] = ++idx; // 如果节点不存在则创建节点
p = son[p][c]; // 移动至下一个节点处
}
cnt[p]++;
}
查询操作的实现与存储类似,只需沿着路径移动即可:
int query(const string &s) {
int p = 0;
for (int i = 0; i < s.size(); i++) {
int c = s[i] - 'a';
if (!son[p][c]) return 0;
p = son[p][c];
}
return cnt[p]; // 返回字符串的个数
}
存储和查询的时间复杂度均为 O ( len ( s ) ) O(\text{len}(s)) O(len(s)),其中 s s s 是字符串。
四、应用:最大异或对
原题链接:https://www.acwing.com/problem/content/description/145/
Trie不仅可以存储字符串,还能存储整数。在存储字符串时,Trie中的每条边对应了一个字符,在存储整数时,Trie中的每条边非 0 0 0 即 1 1 1,即Trie存储的是整数的二进制表示,此时Trie是一棵二叉树。
在存储一个整数时,一般是从它的最高位开始依次存储到最低位。例如,对于数字 5 5 5,它的二进制表示为 101 101 101,Trie将以以下形式存储:
在存储多个整数的时候,我们无法确保所有数字的二进制位数都相同,所以需要找到二进制位数最多的那个数字,剩下的数字都要按照这个位数进行存储。例如,现有三个数字: 2 2 2, 4 4 4, 8 8 8,其二进制表示分别为 10 10 10, 100 100 100, 1000 1000 1000,但在存储的时候我们需要按照 0010 0010 0010, 0100 0100 0100, 1000 1000 1000 进行存储。
回到题目,因为
x
∈
[
0
,
2
31
)
x\in[0,2^{31})
x∈[0,231),所以
x
x
x 的二进制表示最多有
31
31
31 位,由此可知所有的整数都需要按照
31
31
31 位的二进制数来存储。判断最高位是
1
1
1 还是
0
0
0 只需看 x >> 30 & 1
的值。
向Trie中插入一个整数的实现如下:
void insert(int x) {
int p = 0;
for (int i = 30; ~i; i--) {
int c = x >> i & 1;
if (!son[p][c]) son[p][c] = ++idx;
p = son[p][c];
}
}
一个问题是,son
数组应该开多大?由于Trie是一棵二叉树,所以 son
数组的第二维的大小是
2
2
2,son
数组第一维的大小是Trie中所有节点的数量。考虑最坏的情形,每个整数都单独形成了一条路径,因此存储每个整数都需要
31
31
31 个节点,存储
N
N
N 个整数需要
31
N
31N
31N 个节点,算上最后的根节点一共有
31
N
+
1
31N+1
31N+1 个,而
N
=
1
0
5
N=10^5
N=105,保险起见,我们可以令第一维的大小为
31
⋅
1
0
5
+
10
=
3100010
31\cdot 10^5+10=3100010
31⋅105+10=3100010,从而可知Trie的初始化应当如下
const int M = 3100010;
int son[M][2], idx;
接下来是本题的重点,即如何寻找最大的异或对?传统的暴力解法是枚举每一个 a [ i ] a[i] a[i],然后再枚举每一个 a [ j ] a[j] a[j],计算 a [ i ] ⊕ a [ j ] a[i]\oplus a[j] a[i]⊕a[j] 并更新最大值。但这样做无疑是 O ( n 2 ) O(n^2) O(n2),而我们又无法避开枚举 a [ i ] a[i] a[i],因此只能从「枚举 a [ j ] a[j] a[j]」着手优化。
在暴力解法中,「枚举 a [ j ] a[j] a[j]」的时间复杂度为 O ( n ) O(n) O(n),要想通过本题,我们必须将其降低至 O ( log n ) O(\log n) O(logn) 甚至更低,为此考虑用Trie来存储这些整数。固定 a [ i ] a[i] a[i],什么样的 a [ j ] a[j] a[j] 才能使 a [ i ] ⊕ a [ j ] a[i]\oplus a[j] a[i]⊕a[j] 最大呢?显然当 a [ j ] a[j] a[j] 的每一位与 a [ i ] a[i] a[i] 都不相同时, a [ i ] ⊕ a [ j ] a[i]\oplus a[j] a[i]⊕a[j] 达到最大值。但这样的 a [ j ] a[j] a[j] 不一定存在于Trie中,因此我们从 a [ i ] a[i] a[i] 的最高位开始,寻找Trie中是否有数字与 a [ i ] a[i] a[i] 的最高位不同,如果有,则沿着相应路径继续搜索,否则,也必须沿着错误的路径进行搜索。
int search(int x) {
int p = 0, res = 0;
for (int i = 30; ~i; i--) {
int c = x >> i & 1;
if (son[p][!c]) {
res += 1 << i;
p = son[p][!c];
} else p = son[p][c];
}
return res;
}
本题AC代码:
#include <iostream>
#include <algorithm>
using namespace std;
const int M = 3100010, N = 100010;
int a[N], son[M][2], idx;
void insert(int x) {
int p = 0;
for (int i = 30; ~i; i--) {
int c = x >> i & 1;
if (!son[p][c]) son[p][c] = ++idx;
p = son[p][c];
}
}
int search(int x) {
int p = 0, res = 0;
for (int i = 30; ~i; i--) {
int c = x >> i & 1;
if (son[p][!c]) {
res += 1 << i;
p = son[p][!c];
} else p = son[p][c];
}
return res;
}
int main() {
ios::sync_with_stdio(false), cin.tie(nullptr);
int n;
cin >> n;
for (int i = 0; i < n; i++) {
cin >> a[i];
insert(a[i]);
}
int res = 0;
for (int i = 0; i < n; i++) res = max(res, search(a[i]));
cout << res << endl;
return 0;
}
References
[1] https://oi-wiki.org/string/trie/
[2] https://www.acwing.com/activity/content/punch_the_clock/11/