可持久化Trie:能返回过去的字典树
较难3可持久化Trie:像游戏存档一样回溯历史版本
Trie(字典树)就像一本英语词典,能帮你快速查单词。但如果我想看“昨天我记单词到第50个”时的词典版本呢?普通Trie做不到,因为它只有最新状态。可持久化Trie就是给Trie加了“快照”功能:每次插入一个新单词,都会生成新版本,旧版本依然能查到。你可以把它想象成游戏里的存档——每过一关保存一次,随时可以读档回到过去。
这种数据结构常用来处理“区间查询”问题,比如:给定一个数组,多次询问某个区间内的数与给定值异或的最大值。它就像一台可以倒带的时间机器,让你能访问任意历史时刻的字典树。
什么是Trie(字典树)?先复习一下
字典树是一种树形结构,专门用来高效存储和查找字符串或数字的二进制位。比如,我们要插入单词 cat、car、dog:
root
/ \
c d
/ \
a o
/ \ \
t r g
每个节点代表一个字符,从根到叶子是一条完整单词。查找时只需沿着字符走,时间复杂度 O(长度)。对于数字,我们把数字拆成二进制位(比如30位),每一位是0或1,就构成一棵二叉树。这种Trie常用于求最大异或值:因为异或时我们希望每一位尽量不同,所以从高位到低位尽量走相反的分支。
为什么需要可持久化?生活比喻
想象你用复写纸写单词本:每次写一个新单词,就垫一张新复写纸,翻到哪一页就能看到那一页及之前的所有单词。可持久化Trie的做法类似——每次插入只新建从根到叶子路径上的 logN 个节点(N是数值范围,比如2^30),其他节点与旧版本共享。这样既保存了历史版本,又只增加了少量空间。
更贴近生活的例子:你在玩“贪吃蛇”游戏,每次吃到一个食物屏幕就更新一次。你希望回看第3步时的地图,可持久化Trie就像录屏,每一步都单独存一份,但只记录变化的部分(蛇头新位置),其他不变的部分直接引用上一帧。
核心思想:版本共享
可持久化Trie的实现思路和主席树(可持久化线段树)非常相似:
- 每个版本都有一个根节点编号。
- 插入一个新数时,从根出发,新建一条从根到叶子的路径,路径上的每个节点都复制旧版本对应节点的孩子指针和计数,然后在新开的孩子节点上递增计数。
- 对于路径之外的节点,直接指向旧版本的对应节点(共享)。
- 版本之间通过根节点区分。
这样,每个版本都拥有完整的Trie,但实际新增的节点数量只有 O(bit_length) 个,总节点数为 O(N * bit_length)。
常见应用:区间最大异或
最经典的应用是:给定一个长度为 n 的数组 a[1..n],有 q 次询问,每次给一个区间 [l, r] 和一个数 x,要求找出 a[l..r] 中与 x 异或的最大值。
思路:
- 用可持久化Trie维护前缀(即插入 a[1], a[1..2], ..., a[1..n] 后的版本)。
- 查询区间 [l, r] 时,用版本 r 的信息减去版本 l-1 的信息(节点计数相减),就得到只包含区间内数字的Trie。
- 在这个“虚拟”Trie上从高位到低位贪心走相反位,得到最大异或值。
注意:这里使用的是二进制Trie,每个节点只有两个子节点(0和1)。
代码逐段讲解(附详细中文注释)
下面代码假设所有数字在 0~2^30-1 之间(MAXBIT=30)。我们要实现一个可持久化二进制Trie,支持区间最大异或查询。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 300005; // 数组最大长度
const int MAXBIT = 30; // 二进制位数,假设数值小于2^30
// 树节点结构
struct Node {
int ch[2]; // 两个子节点编号,ch[0]表示走0,ch[1]表示走1
int cnt; // 经过该节点的数字个数
} t[MAXN * MAXBIT]; // 节点池,大小 = 版本数 * 位数
int root[MAXN]; // 每个版本对应的根节点编号
int tot; // 当前已使用的节点总数
// 插入一个数 x,返回新版本根节点编号
// pre 是上一个版本的根节点编号
int insert(int pre, int x) {
int now = ++tot; // 新版本根节点编号
int cur = now; // cur指向当前正在构建的节点
for (int i = MAXBIT; i >= 0; i--) { // 从高位到低位
int bit = (x >> i) & 1; // 取出第i位(0或1)
t[cur] = t[pre]; // 复制旧版本该节点的所有信息(包括ch[0],ch[1],cnt)
t[cur].ch[bit] = ++tot; // 为当前位新建一个子节点
// 复制旧版本对应子节点的信息到新子节点(但cnt还没更新)
t[t[cur].ch[bit]] = t[t[pre].ch[bit]];
// 新子节点的计数+1,表示多了一个数经过
t[t[cur].ch[bit]].cnt++;
// 继续向下:pre移动到旧版本的相应子节点,cur移动到新版本的子节点
pre = t[pre].ch[bit];
cur = t[cur].ch[bit];
}
return now; // 返回新版本根编号
}
// 查询区间 (版本lRoot, 版本rRoot] 中的数与 x 的最大异或值
// 注意:区间内数字 = 版本rRoot包含的数字 - 版本lRoot包含的数字
int query(int lRoot, int rRoot, int x) {
int res = 0; // 记录异或结果(最大值)
int curL = lRoot; // 指向左版本当前节点
int curR = rRoot; // 指向右版本当前节点
for (int i = MAXBIT; i >= 0; i--) {
int bit = (x >> i) & 1; // x的第i位
int want = bit ^ 1; // 我们希望走的相反位(0变1,1变0)
// 计算区间内有多少个数在want分支上
int cntWant = t[t[curR].ch[want]].cnt - t[t[curL].ch[want]].cnt;
if (cntWant > 0) { // 有数可以走want分支
res |= (1 << i); // 异或结果的这一位变为1
curL = t[curL].ch[want]; // 两版本都沿着want走
curR = t[curR].ch[want];
} else { // 没有数走want,只能走bit分支
curL = t[curL].ch[bit];
curR = t[curR].ch[bit];
}
}
return res;
}
int main() {
int n, q;
scanf("%d%d", &n, &q); // 读入数组长度和询问次数
// 初始化一个空Trie:只有一个根节点,孩子都为0,cnt=0
tot = 1; // 节点编号从1开始
t[1].ch[0] = t[1].ch[1] = 0; // 0表示空节点
t[1].cnt = 0;
root[0] = 1; // 版本0(空版本)的根是1
// 依次插入数组中的每个数,构建可持久化Trie的前缀版本
for (int i = 1; i <= n; i++) {
int x;
scanf("%d", &x); // 读入a[i]
root[i] = insert(root[i-1], x); // 基于上个版本插入x,得到新版本
}
// 处理询问
while (q--) {
int l, r, x;
scanf("%d%d%d", &l, &r, &x); // 读入左端点、右端点和要查询的数
// 区间[l,r]对应版本l-1和版本r
printf("%d\n", query(root[l-1], root[r], x));
}
return 0;
}
代码关键点解析
- 节点池大小:
t[MAXN * MAXBIT],因为每个版本最多新建MAXBIT+1个节点,n个版本最多n * (MAXBIT+1)个节点,这里取300005 * 31 ≈ 930万,足够。 - 初始化空版本:用
root[0] = 1表示没有插入任何数字的版本,节点1的孩子都是0(表示空),cnt=0。 - insert中的复制:
t[cur] = t[pre]复制了整个节点,然后只修改当前位的孩子指针。这一步保证了新节点除了当前位,其他分支都指向旧版本对应节点(共享)。但注意,我们随后又新建了当前位的孩子节点并复制了旧版本该孩子的信息,再递增计数。 - query中的差分:
cntWant = t[t[curR].ch[want]].cnt - t[t[curL].ch[want]].cnt计算了区间内有多少个数在want分支上。如果大于0,就贪心选择want,否则只能走bit。
新手容易犯的错误
- 忘记初始化空版本:必须有一个根节点代表“没有数字”的状态,否则
root[0]为0会导致后续访问越界。 - 节点数组开太小:可持久化Trie需要
n * (bit位+1) ´ 2左右的空间(因为每个节点有两个孩子)。开小了会段错误。 - 查询时参数顺序搞反:
query(root[l-1], root[r], x)中第一个参数是左边界的前一个版本,第二个是右边界版本。如果写成query(root[r], root[l-1], x)会得到错误结果。 - 复制节点时忘记更新cnt:插入时新节点计数要递增,否则差分时计数不准。
- 二进制位数不足:如果题目中数值范围超过2^30,要相应增大MAXBIT,比如31或60。
完整可运行示例(含输入输出)
上面代码已经是完整可运行的。下面给一个输入输出样例:
输入:
5 3
3 10 5 25 2
1 5 6
2 4 7
1 3 10
解释:数组 [3,10,5,25,2],三次查询:
- [1,5] 中与6异或最大:6与25异或=31,输出31
- [2,4] 中与7异或最大:7与10异或=13,7与5异或=2,7与25异或=30,最大30
- [1,3] 中与10异或最大:10与3异或=9,10与10异或=0,10与5异或=15,最大15
输出:
31
30
15
相关知识点指引
- 可持久化线段树(主席树):和可持久化Trie的实现思路完全一样,只是节点维护的是区间和而不是0/1分支。建议先理解主席树的差分思想,再学可持久化Trie会容易很多。
- 二进制Trie(普通版本):如果你不了解Trie,可以先学普通二进制Trie求最大异或对(LeetCode 421题)。
- 异或的性质:相同为0,不同为1;另外,a^b = c 等价于 a^c = b。贪心从高位到低位求最大异或的核心就是优先让高位为1。
- 区间第k小:可持久化线段树也可以解决区间第k小问题,思路类似:用前缀版本相减得到区间内数字的权值线段树,然后在树上二分。
可持久化Trie把字典树变成了“时间胶囊”,让你能对任意历史版本进行查询,是处理二进制区间问题的利器。掌握了它,你就能轻松解决很多看似复杂的区间异或查询题目啦!
例题精讲
可持久化Trie与普通Trie相比,最主要的特性是什么?
可持久化Trie在进行插入操作时,会修改原版本的所有节点,因此时间复杂度为O(n)(n为Trie中节点总数)。
以下是一个可持久化Trie的插入函数,请补全缺失的部分。
struct Node { int ch[2]; int cnt; } trie[MaxNode];
int root[MaxVersion];
int tot; // 当前节点总数
int insert(int pre, int x) {
int now = ++tot;
trie[now] = trie[pre];
int cur = now;
for (int i = 30; i >= 0; i--) {
int b = (x >> i) & 1;
int nxt = ___;
trie[cur].cnt++;
// 新建节点
int new_node = ++tot;
trie[new_node] = trie[nxt];
trie[cur].ch[b] = new_node;
cur = new_node;
}
trie[cur].cnt++;
return now;
}给定一个整数序列a[1..n],现需要支持多次询问:在区间[l,r]中,选择一个数a[i](l≤i≤r)使得a[i] XOR x最大。若使用可持久化Trie解决,以下预处理和查询方法正确的是?
可持久化Trie的空间复杂度与插入的字符串长度总数成正比,与历史版本数量无关。