前言
正常的trie树一般用来解决字符串
问题,特殊的比如01字典树
,可以用来解决最大异或对(传送门),这里不在过多赘述。今天介绍的是trie树的可持久化。
可持久化是什么?
持久化是将程序数据在持久状态和瞬时状态间转换的机制。通俗的讲,就是瞬时数据(比如内存中的数据,是不能永久保存的)持久化为持久数据(比如持久化至数据库中,能够长久保存)——百度百科
可持久化是一个重要的算法思想,我对可持久化的理解就是:我们需要历史版本的信息,可以支持回溯到某个历史版本,并且可以基于当前版本扩展出另一个新的版本。
通俗来讲,可持久化就是指一个数据结构能查询历史记录,我们需要借助该结构,查询过去版本中有用的信息。(思想类似于git,分布式版本控制系统)
为什么需要可持久化?
好比最大异或对那题,如果每次都是询问一个区间,Trie树就不好处理,因为不能对每个区间都新建一颗Trie树,那样空间开销太大。这时候就需要我们的可持久化Trie树。
可持久化Trie
优点:
从某个版本开始,能够遍历到该版本内的所有节点
先来看个例子:
现有4个字符串:cat、rat、cab、frg
下图是这几个字符构成的Trie树
每次只修改被添加或值被修改的节点,而保留没有被改动的节点,在上一个版本的基础上连边,使最后每个版本的Trie树的根遍历所能分离出的Trie树都是完整且包含全部信息的。
构建可持久化Trie树的过程如下:
第一个版本:(cat)
第二个版本:(cat、rat)
第三个版本:(cat、rat、cab)
第四个版本:(cat、rat、cab、frg)
这样一颗持久化Trie就构建好了,对于每一个版本,都可以从该版本根节点出发,找到历史各个版本的信息。
例题
传送门:最大异或和
题目描述
对一个长度为 n {n} n 的数组 a {a} a 维护 m {m} m次以下操作:
A x
:添加操作,在数组的末尾添加一个数 x {x} x,数组的长度 n {n} n 自增 1 {1} 1。Q l r x
:询问操作,给出查询区间 [ l , r ] {[l,r]} [l,r] 和一个值 x {x} x,求当 l < = p < = r {l <= p <= r} l<=p<=r时,使得: a [ p ] ⊕ a [ p + 1 ] ⊕ . . . ⊕ a [ n ] ⊕ x {a[p]⊕ a[p+1]⊕ ... ⊕ a[n] ⊕x } a[p]⊕a[p+1]⊕...⊕a[n]⊕x 最大,输出最大值
思路
-
维护前缀异或和: s [ i ] = s [ 1 ] ⊕ s [ 2 ] ⊕ . . . ⊕ s [ i ] {s[i] = s[1]⊕s[2]⊕...⊕s[i]} s[i]=s[1]⊕s[2]⊕...⊕s[i]
-
如果询问是 1 {1} 1 ~ r {r} r,那么答案就是 s [ p − 1 ] ⊕ s [ n ] ⊕ x {s[p - 1] ⊕ s[n] ⊕ x} s[p−1]⊕s[n]⊕x
- 那么问题就转化为了:两个数异或最大,即最大异或对问题
- s [ n ] ⊕ x = C {s[n] ⊕ x}=C s[n]⊕x=C,即找到区间中某数与C异或最大,trie树上贪心去找
- 遍历到二进制某位:1就看0是否存在,0就看1是否存在
-
而询问是 l {l} l ~ r {r} r,这时就需要多维护一个信息 m a x {max} max_ i d {id} id。
-
问题转化为: 在第 r {r} r个版本之前去找,并且选择走 0 ∣ 1 {0|1} 0∣1的路线的节点中,该节点中至少存在一个数,它的下标 ≥ L {≥L} ≥L。也就等价于,下标最大值 ≥ L {≥L} ≥L。
-
m a x {max} max_ i d {id} id:表示当前节点所能表示的数的最大编号(数是 s [ i ] {s[i]} s[i]编号 i {i} i)
-
保证在trie树中找到的路线上每个节点都在区间范围内
-
最终结果: s [ p ] ⊕ C {s[p]⊕C} s[p]⊕C( l − 1 < = p < = r − 1 {l-1<=p<=r-1} l−1<=p<=r−1)
代码
#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;
// 3e5原始,3e5操作,1e7开25位够
const int N = 6e5 + 10, M = 25 * N;
int n, m;
int s[N];
int tr[M][2], max_id[M];
int root[N], idx;
// i是第i个插入的数的i, p是上一个插入的数的节点号, q是当前节点号, k是现在取到第k位
// s[i] 是当前处理的数
void insert(int i, int k, int p, int q) {
// 已经处理完了最后一位
if (k < 0) {
// 当前q为叶节点,记录当前节点所能到达的最大范围i
max_id[q] = i;
return ;
}
// 取出当前要处理的数s[i]的第k位
int v = s[i] >> k & 1;
// 如果前一个节点有当前节点没有的分支,指向过去,这就等价于拷贝了历史信息
if (p) tr[q][v ^ 1] = tr[p][v ^ 1];
// 现在才是正常的trie树插入
// 当前新开一个节点
tr[q][v] = ++idx;
// 递归插入二进制的下一位
// 之前已经复制了v的不同方向路径的信息(v:0 1 两个方向)
// 现在v这条方向也要考虑
// 如果p这条v路径存在(等价于图的虚线处),向下延迟,暂时不需要复制
insert(i, k - 1, tr[p][v], tr[q][v]);
// 向上回溯,每个点保存子节点最大范围的值
max_id[q] = max(max_id[tr[q][0]], max_id[tr[q][1]]);
}
// 非递归版本
// 发现max_id其实就是当前新加的节点在前缀和数组s的位置
void insert(int k, int p, int q) {
max_id[q] = k;
for (int i = 23; i >= 0; i--) {
int v = s[k] >> i & 1;
if(p) tr[q][v ^ 1] = tr[p][v ^ 1];
tr[q][v] = ++idx;
max_id[tr[q][v]] = k;
q = tr[q][v], p = tr[p][v];
}
}
// 在[l, r]区间中,找一值与C异或最大
int query(int l, int r, int C) {
int p = root[r];
// C是s[n] ^ x, 从高位到低位逐位检索二进制每一位上能跟C异或结果最大的数
for (int i = 23; i >= 0; i--) {
int v = C >> i & 1;
// 0是空节点,默认max_id[0] = 0的话,当l==0,就会令p跳到空节点上来
// 由于每个节点至少有一条s[i]的完整路径
// 那么tr[p][v ^ 1]是空,则tr[p][v]一定存在路
// 那必不能 满足if,去走一条没路的路
// 所以max_id[0] = -1,能判除这类情况
if (max_id[tr[p][v ^ 1]] >= l) p = tr[p][v ^ 1];
else p = tr[p][v]; // 退而求其次
}
return C ^ s[max_id[p]];
}
int main()
{
scanf("%d%d", &n, &m);
// 前缀和,初始化第0个版本
s[0] = 0;
max_id[0] = -1;
root[0] = ++ idx;
// insert(0, 23, 0, root[0]);
insert(0, 0, root[0]);
for (int i = 1; i <= n; i++) {
int x; scanf("%d", &x);
s[i] = s[i - 1] ^ x;
root[i] = ++ idx;
// insert(i, 23, root[i - 1], root[i]);
insert(i, root[i - 1], root[i]);
}
char op[2];
int l, r, x;
while(m--) {
scanf("%s", op);
if (op[0] == 'A') {
scanf("%d", &x);
n++;
s[n] = s[n - 1] ^ x;
root[n] = ++idx;
// insert(n, 23, root[n - 1], root[n]);
insert(n, root[n - 1], root[n]);
} else{
scanf("%d%d%d", &l, &r, &x);
printf("%d\n", query(l - 1, r - 1, s[n] ^ x));
}
}
return 0;
}