可持久化学习笔记
一、可持久化线段树
给一个数组,支持两种操作,并询问区间最大值:
- 区间加
- 回退到
t 时刻的版本
考虑暴力存下每一个时间的线段树,回退时直接用
观察到每次修改操作只有
发现根的信息一定会变化,即根一定会新建,所以可以单独将每个时间的根的编号存下来,可以用于访问特定时间。
回到本题,对于操作一,从新根向下遍历,新建有变化的点,无变化则直接连向旧树上的点。
对于操作二,直接将当前根用
#include <bits/stdc++.h>
#define int long long
using namespace std;
const int N = 40000010;
int n, m, a[N], tag[N], mx[N], tot, ls[N], rs[N], rot[N], la = 0;
//tag: 懒标记,mx:区间最大值,tot:最新节点编号,ls:左儿子,rot:根编号
int clone(int x) {//新建(复制)节点
tot++;
tag[tot] = tag[x];
ls[tot] = ls[x];
rs[tot] = rs[x];
mx[tot] = mx[x];
return tot;
}
void pushup(int x) {mx[x] = max(mx[ls[x]] + tag[ls[x]], mx[rs[x]] + tag[rs[x]]);}//合并
int build(int l, int r) {//建树
int x = ++tot;//动态开点
if(l == r) {
mx[x] = a[l];
tag[x] = 0;
return x;
}
int mid = (l + r) >> 1;
ls[x] = build(l, mid);
rs[x] = build(mid + 1, r);
pushup(x);
return x;
}
int modify(int x, int l, int r, int ql, int qr, int v) {//区间加
x = clone(x);//x节点被修改,所以新开(复制)一个x节点,不会影响到原树
if(ql <= l && r <= qr) {
tag[x] += v;//标记永久化
return x;
}
int mid = (l + r) >> 1;
if(ql <= mid) ls[x] = modify(ls[x], l, mid, ql, qr, v);
if(mid < qr) rs[x] = modify(rs[x], mid + 1, r, ql ,qr, v);
//建出或指向原树的新树的左右儿子
pushup(x);
return x;//返回新建的x节点
}
int query(int x, int l, int r, int ql, int qr, int laz) {//查询区间最大值
if(ql <= l && r <= qr) return mx[x] + laz + tag[x];
int mid = (l + r) >> 1, tmp = -0x7fffffffffff;
if(ql <= mid) tmp = max(tmp, query(ls[x], l, mid, ql, qr, laz + tag[x]));
if(mid < qr) tmp = max(tmp, query(rs[x], mid + 1, r, ql ,qr, laz + tag[x]));
return tmp;
}
signed main() {
cin >> n >> m;
for(int i = 1; i <= n; i++) scanf("%lld", &a[i]);
rot[0] = build(1, n);//以0号节点为根建初始状态(时刻0)的树
for(int k = 1; k <= m; k++) {
int tt, l, r, z;
scanf("%lld%lld%lld%lld", &tt, &l, &r, &z);
tt = ((tt ^ la) % k + k) % k;
rot[k] = modify(rot[tt], 1, n, l, r, z);//k版本在tt版本基础上修改
la = query(rot[k], 1, n, l, r, 0);
printf("%lld\n", la);
}
return 0;
}
有几个要注意的点:动态开点、标记永久化、空间开够。
二、可持久化权值线段树(主席树)
查询区间
如果区间只有固定的一个,可以发现能用权值线段树解决。那么,如果每个区间能对应一个权值线段树,那本题就做完了。
考虑可持久化,在序列中,以第
发现权值线段树是可减的,即可以用位置
可以把权值线段树类比为一个桶,截取区间的方法类比为前缀和来方便理解。
#include <bits/stdc++.h>
#define int long long
using namespace std;
const int N = 6400010;
int n, m, rot[N], ls[N], rs[N], num[N], tot, a[N], b[N];
//num:以当前节点为根的子树内含有的数的个数
int clone(int x) {
int t = ++tot;
ls[t] = ls[x], rs[t] = rs[x];
num[t] = num[x] + 1;
return t;
}
int build(int l, int r) {
int x = ++tot;
if(l == r) return x;
int mid = (l + r) >> 1;
ls[x] = build(l, mid);
rs[x] = build(mid + 1, r);
return x;
}
int modify(int x, int l, int r, int q) {
x = clone(x);
if(l == r) return x;//找到q应该在的位置并返回
int mid = (l + r) >> 1;
if(q <= mid) ls[x] = modify(ls[x], l, mid, q);
else rs[x] = modify(rs[x], mid + 1, r, q);
return x;
}
int query(int x, int y, int l, int r, int k) {
int mid = (l + r) >> 1;
int v = num[ls[y]] - num[ls[x]];//相减得到区间[l,r]所对应的树的信息
if(l == r) return l;
if(v >= k) return query(ls[x], ls[y], l, mid, k);//权值线段树的查找
return query(rs[x], rs[y], mid + 1, r, k - v);
}
signed main() {
cin >> n >> m;
for(int i = 1; i <= n; i++)
scanf("%lld", &a[i]), b[i] = a[i];
sort(a + 1, a + n + 1);
int nm = unique(a + 1, a + n + 1) - a - 1, la = 0;
rot[0] = build(1, nm);
for(int i = 1; i <= n; i++) {
int tmp = lower_bound(a + 1, a + nm + 1, b[i]) - a;//离散化
rot[i] = modify(rot[i - 1], 1, nm, tmp);//i在i-1版本上更新
}
while(m--) {
int l, r, k, L, R, K;
scanf("%lld%lld%lld", &l, &r, &k);
L = l, R = r, K = k;
la = a[query(rot[L - 1], rot[R], 1, nm, K)];
printf("%lld\n", la);
}
return 0;
}
注意:和可持久化线段树的区分、离散化、需要满足可减性。
三、可持久化并查集
只需要对每个节点维护
四、可持久化Trie树
一般是可持久化0/1Trie,用于维护区间异或相关信息。在写法上和主席树类似,即
询问区间也和主席树一样,因为Trie树显然是可减的。可以看一道经典的例题P4735最大异或和来加深理解。
题目给定一个非负整数序列
有
A x:添加操作,表示在序列末尾添加一个数x ,序列的长度N 加1 。Q l r x:询问操作,你需要找到一个位置p ,满足l \le p \le r ,使得:a[p] \oplus a[p+1] \oplus ... \oplus a[N] \oplus x 最大,输出最大值。
操作一直接在序列末尾插入即可,对于操作二,预处理一个数组
由于
#include <bits/stdc++.h>
using namespace std;
const int N = 40000010;
int n, m, tot, num[N], ch[N][2], rot[N], a[N], s[N];
//num:子树内原节点数
void insert(int x, int y, int v) {//x在y版本基础上插入v
for(int i = 28; i >= 0; i--) {
num[x] = num[y] + 1;//类比之前的clone()
int tmp = ((v & (1 << i)) ? 1 : 0);
if(tmp) {
if(!ch[x][1]) ch[x][1] = ++tot;
ch[x][0] = ch[y][0];//为变化,所以指向旧树
x = ch[x][1];
y = ch[y][1];
}
else {
if(!ch[x][0]) ch[x][0] = ++tot;
ch[x][1] = ch[y][1];
x = ch[x][0];
y = ch[y][0];
}
}
num[x] = num[y] + 1;
}
int query(int x, int y, int v) {//在"y-x"的树内查询与v异或最大的值异或v后的结果
int res = 0;
for(int i = 28; i >= 0; i--) {
int tmp = ((v & (1 << i)) ? 1 : 0);
if(num[ch[x][!tmp]] - num[ch[y][!tmp]])
res += (1 << i), x = ch[x][!tmp], y = ch[y][!tmp];
else x = ch[x][tmp], y = ch[y][tmp];
}
return res;
}
signed main() {
cin >> n >> m;
for(int i = 1; i <= n; i++) scanf("%d", &a[i]);
for(int i = 1; i <= n; i++) s[i] = s[i - 1] ^ a[i];//前缀异或和
for(int i = 1; i <= n; i++)
rot[i] = ++tot, insert(rot[i], rot[i - 1], s[i]);//类比主席树
while(m--) {
char opt; cin >> opt;
if(opt == 'A') {//末尾插入
int x; scanf("%d", &x);
a[++n] = x;
s[n] = s[n - 1] ^ x;
rot[n] = ++tot;
insert(rot[n], rot[n - 1], s[n]);
}
else {//查询
int l, r, x, res;
scanf("%d%d%d", &l, &r, &x);
l--, r--;
if(l == 0) res = max(s[n] ^ x, query(rot[r], rot[0], s[n] ^ x));
else res = query(rot[r], rot[l - 1], s[n] ^ x);
printf("%d\n", res);
}
}
return 0;
}
五、可持久化平衡树
由于Splay复杂度是均摊的,可持久化后复杂度会错,而旋式Treap的可持久化不好写,所以一般选择非旋Treap来写可持久化。
直接套可持久化的套路,即复制出要修改的节点,其原本节点的儿子若不被修改,则直接指过去,否则继续递归下去。
- clone: 复制节点,可持久化平衡树的关键操作
int new_node(int x = 0) {
t[++tot] = (node) {...};
return tot;
}
int clone(int x) {
int p = new_node();
t[p] = t[x]; return p;
}
- pushdown: 在下传标记前要复制节点
#define ls t[x].l
#define rs t[x].r
void push_down(int x) {
if(ls) ls = clone(ls);
if(rs) rs = clone(rs);
...
t[x].tag = 0;
if(ls) ...
if(rs) ...
}
- split: 向下递归前,要复制选择的节点
void split(int x, int k, int &p1, int &p2) {
if(!x) {p1 = p2 = 0; return ;}
if(t[x].tag) push_down(x);
if(t[ls].siz < k) {
p1 = clone(x);
split(t[p1].r, k - t[ls].siz - 1, t[p1].r, p2);
push_up(p1);
}
else {
p2 = clone(x);
split(t[p2].l, k, p1, t[p2].l);
push_up(p2);
}
}
- merge: 合并前要复制节点
int merge(int x, int y) {
if(!x || !y) return x + y;
if(rnd() % (t[x].siz + t[y].siz) < t[x].siz) {
push_down(x);
int p = clone(x);
t[p].r = merge(t[x].r, y);
push_up(p);
return p;
}
else {
push_down(y);
int p = clone(y);
t[p].l = merge(x, t[y].l);
push_up(p);
return p;
}
}
然后有一些要注意的点:
- 要注意因为有复制操作,所以要记得在复制前
pushdown。 - 发现
merge中有一句rnd() % (t[x].siz + t[y].siz) < t[x].siz,是因为复制节点后会出现大量相同的堆值,从而导致树会不平衡,所以不记录堆值,用这种方法代替。 - 如果复制操作太多,空间可能会炸,所以在题目不限制的情况下,可以考虑定期重构。
void dfs(int x) { if(!x) return ; push_down(x); dfs(t[x].l), tp[++tcnt] = t[x].val, dfs(t[x].r); } void rebuild() { tcnt = 0, dfs(rt), rt = 0, tot = 0; for(int i = 1; i <= tcnt; i++) rt = merge(rt, new_node(tp[i])); }
六、常见拓展
例一:字符串树(父子关系建树)
给定一棵树,每条边上有一个字符串。每次给出一组询问
树的节点个数
首先考虑一个简化的问题:在序列上,每个点对应一个字符串,求区间
显然可以用可持久化Trie解决,对于每个询问,截取出询问所需要的区间所对应的Trie树,将
然后发现可以将这个思路转化到树上,即每个节点在其父亲的版本上更新,比父亲版本多了连接父子的边的字符串。
预处理出LCA,就可以通过将
分别对两条链统计答案并相加即可。
#include <bits/stdc++.h>
#define int long long
using namespace std;
const int N = 2000010;
int n, q;
int rot[N], ch[N][27], num[N], tot;
string s;
int id = 0, h[N], logg[N], dep[N], f[N][27];
struct node {
int v, next;
string c;
}p[N];
void add(int u, int v) {
id++;
p[id].v = v;
p[id].next = h[u];
p[id].c = s;
h[u] = id;
}
void init(int x, int fa) {//LCA预处理
f[x][0] = fa, dep[x] = dep[fa] + 1;
for(int i = 1; i <= logg[dep[x]]; i++)
f[x][i] = f[f[x][i - 1]][i - 1];
for(int i = h[x]; i; i = p[i].next)
if(p[i].v != fa) init(p[i].v, x);
}
int lca(int x, int y) {//求LCA
if(dep[x] < dep[y]) swap(x, y);
while(dep[x] > dep[y]) x = f[x][logg[dep[x] - dep[y]] - 1];
if(x == y) return x;
for(int i = logg[dep[x]] - 1; i >= 0; i--)
if(f[x][i] != f[y][i])
x = f[x][i], y = f[y][i];
return f[x][0];
}
void insert(int x, int y) {//x在y的版本上插入s
int siz = s.size();
for(int i = 0; i < siz; i++) {
num[x] = num[y] + 1;
int o = s[i] - 'a';
if(!ch[x][o]) ch[x][o] = ++tot;
for(int j = 0; j < 26; j++)
if(o != j) ch[x][j] = ch[y][j];
x = ch[x][o], y = ch[y][o];
}
num[x] = num[y] + 1;
}
int query(int x, int y) {//在"y-x"的树上"插入"s并统计答案
int siz = s.size();
for(int i = 0; i < siz; i++) {
int o = s[i] - 'a';
x = ch[x][o], y = ch[y][o];
}
return num[y] - num[x];
}
void dfs(int x) {
for(int i = h[x]; i; i = p[i].next) {
if(p[i].v == f[x][0]) continue;
rot[p[i].v] = ++tot;
s = p[i].c; insert(rot[p[i].v], rot[x]);//儿子版本在父亲版本上更新
dfs(p[i].v);
}
}
signed main() {
cin >> n;
for(int i = 1; i < n; i++) {
int u, v; scanf("%lld%lld", &u, &v);
cin >> s;
add(u, v), add(v, u);
}
for(int i = 1; i <= n; i++)
logg[i] = logg[i - 1] + (1 << logg[i - 1] == i);
rot[1] = 0;
init(1, 0); dfs(1);
cin >> q;
while(q--) {
int u, v; scanf("%lld%lld", &u, &v);
cin >> s;
int z = lca(u, v), fz = f[z][0];
//截取链并查询答案
int t1 = query(rot[z], rot[u]);
int t2 = query(rot[z], rot[v]);
printf("%lld\n", t1 + t2);
}
return 0;
}
例二:树上统计(可加性)
给定一棵树,每个点有权值。每次给一组询问
树的节点个数
和上一道题一样,根据树的关系建主席树,问题在于如何求解两条链上所有点的第
显然直接合并两条链对应的权值线段树是不行的,但是思考我们建主席树后为什么可以截取区间,即截取区间为什么有正确性。
原因是主席树的信息是可减的,那么反之主席树的信息也是可加的。那就可以将两棵树相加来达到合并的目的。
体现在代码中即两棵树一起跑,左右儿子的大小都分别是两棵树左右儿子大小的和。
#include <bits/stdc++.h>
#define int long long
using namespace std;
const int N = 4000010;
int n, m, tot, ls[N], rs[N], a[N], la = 0, b[N], nm, L[N], R[N];
int id = 0, h[N], logg[N], dep[N], f[N][25], rot[N], num[N];
struct node {
int v, next;
}p[N];
void add(int u, int v) {
id++;
p[id].v = v;
p[id].next = h[u];
h[u] = id;
}
void init(int x, int fa) {//LCA预处理
f[x][0] = fa, dep[x] = dep[fa] + 1;
for(int i = 1; i <= logg[dep[x]]; i++)
f[x][i] = f[f[x][i - 1]][i - 1];
for(int i = h[x]; i; i = p[i].next)
if(p[i].v != fa)
init(p[i].v, x);
}
int lca(int x, int y) {//求LCA
if(dep[x] < dep[y]) swap(x, y);
while(dep[x] > dep[y]) x = f[x][logg[dep[x] - dep[y]] - 1];
if(x == y) return x;
for(int i = logg[dep[x]] - 1; i >= 0; i--)
if(f[x][i] != f[y][i])
x = f[x][i], y = f[y][i];
return f[x][0];
}
int build(int l, int r) {//建树
int x = ++tot;
L[x] = l, R[x] = r;
if(l == r) return x;
int mid = (l + r) >> 1;
ls[x] = build(l, mid);
rs[x] = build(mid + 1, r);
return x;
}
int clone(int x) {
int t = ++tot;
ls[t] = ls[x], rs[t] = rs[x];
num[t] = num[x] + 1;
L[t] = L[x], R[t] = R[x];
return t;
}
int modify(int x, int l, int r, int v) {
x = clone(x);
if(l == r) return x;
int mid = (l + r) >> 1;
if(v <= mid) ls[x] = modify(ls[x], l, mid, v);
else rs[x] = modify(rs[x], mid + 1, r, v);
return x;
}
void dfs(int x) {
int tmp = lower_bound(a + 1, a + nm + 1, b[x]) - a;//离散化
rot[x] = modify(rot[f[x][0]], 1, nm, tmp);//根据父子关系建树
for(int i = h[x]; i; i = p[i].next) {
if(p[i].v == f[x][0]) continue;
dfs(p[i].v);
}
}
int query(int a1, int a2, int b1, int b2, int l, int r, int k) {//"a2-a1","b2-b1"分别表示两条链对应的树
if(l == r) return l;
int mid = (l + r) >> 1;
int v = num[ls[a2]] - num[ls[a1]] + num[ls[b2]] - num[ls[b1]];
if(v >= k) return query(ls[a1], ls[a2], ls[b1], ls[b2], l, mid, k);
return query(rs[a1], rs[a2], rs[b1], rs[b2], mid + 1, r, k - v);
}
signed main() {
cin >> n >> m;
for(int i = 1; i <= n; i++) scanf("%lld", &a[i]), b[i] = a[i];
for(int i = 1; i < n; i++) {
int u, v; scanf("%lld%lld", &u, &v);
add(u, v), add(v, u);
}
sort(a + 1, a + n + 1);
nm = unique(a + 1, a + n + 1) - a - 1;//离散化
rot[0] = build(1, nm);
for(int i = 1; i <= n; i++)
logg[i] = logg[i - 1] + (1 << logg[i - 1] == i);
init(1, 0); dfs(1);
while(m--) {
int x, y, k; scanf("%lld%lld%lld", &x, &y, &k);
x = x ^ la;
int z = lca(x, y), fz = f[lca(x, y)][0];//询问的是点,要注意是否重或漏了LCA所在点
int tmp = query(rot[fz], rot[x], rot[z], rot[y], 1, nm, k);
la = a[tmp];
printf("%lld\n", la);
}
return 0;
}
例三:子区间连续子串(节点存哈希)
给定一个序列,每次给出一组询问
序列长度、询问个数
如果要直接去尝试匹配,会十分的麻烦甚至于不可做。考虑
在新节点所构成的序列上建主席树,每次查询询问给出的
代码就是主席树板子加了一个哈希,就不放了。
例四:区间异或和(分块预处理)
给定一个序列,每次给出一组询问
区间长度
直接用可持久化01Trie去处理显然是不行的,因为无法快速确定一个值去放入01Trie查找使异或值最大。
观察数据范围,发现远小于一般
预处理出
预处理时,枚举每一个
对于每一个询问区间
对于散块区间
其中,
#include <bits/stdc++.h>
#define int long long
#define ll int
using namespace std;
const int N = 1000010;
const int M = 31;
struct trie {
int ch[N][2], tot;
void clear() {
tot = 0;
memset(ch, 0, sizeof(ch));
}
void insert(int v) {
int x = 0;
for(ll i = M; i >= 0; i--) {
int tmp = ((v & (1 << i)) ? 1 : 0);
if(tmp) {
if(!ch[x][1]) ch[x][1] = ++tot;
x = ch[x][1];
}
else {
if(!ch[x][0]) ch[x][0] = ++tot;
x = ch[x][0];
}
}
}
int query(int v) {
int res = 0, x = 0;
for(ll i = M; i >= 0; i--) {
int tmp = ((v & (1 << i)) ? 1 : 0);
if(ch[x][!tmp]) res += (1 << i), x = ch[x][!tmp];
else x = ch[x][tmp];
}
return res;
}
}T;
int n, m, blk, bl[N], a[N], L[N], R[N], cnt;
int tot, num[N], ch[N][2], rot[N], s[N];
int ans[210][N], la = 0;
void insert(int x, int y, int v) {
for(ll i = M; i >= 0; i--) {
num[x] = num[y] + 1;
int tmp = ((v & (1 << i)) ? 1 : 0);
if(tmp) {
if(!ch[x][1]) ch[x][1] = ++tot;
ch[x][0] = ch[y][0];
x = ch[x][1];
y = ch[y][1];
}
else {
if(!ch[x][0]) ch[x][0] = ++tot;
ch[x][1] = ch[y][1];
x = ch[x][0];
y = ch[y][0];
}
}
num[x] = num[y] + 1;
}
int query(int x, int y, int v) {
int res = 0;
for(ll i = M; i >= 0; i--) {
int tmp = ((v & (1 << i)) ? 1 : 0);
if(num[ch[x][!tmp]] - num[ch[y][!tmp]])
res += (1 << i), x = ch[x][!tmp], y = ch[y][!tmp];
else x = ch[x][tmp], y = ch[y][tmp];
}
return res;
}
signed main() {
cin >> n >> m; blk = sqrt(n);
int mod = n;
for(int i = 1; i <= n; i++) scanf("%d", &a[i]);
for(int i = 1; i <= n; i++) s[i] = s[i - 1] ^ a[i];//前缀异或和
for(int i = 1; i <= n; i++)
bl[i] = (i - 1) / blk + 1;
for(int i = 1; i <= n + 1; i++) {
if(bl[i] != bl[i - 1]) {
R[cnt] = i - 1;
cnt++;
L[cnt] = i;
}
}//分块预处理
cnt--;
for(int i = 1; i <= cnt; i++) {
int p = R[i];
ans[i][p] = a[p];
T.clear();//清空
T.insert(s[p]), T.insert(s[p - 1]);
for(int j = p + 1; j <= n; j++) {
ans[i][j] = max(ans[i][j - 1], T.query(s[j]));//预处理ans数组
T.insert(s[j]);
}
}
for(int i = 1; i <= n; i++)
rot[i] = ++tot, insert(rot[i], rot[i - 1], s[i]);//正常在序列上建可持久化01Trie
while(m--) {
int x, y; scanf("%lld%lld", &x, &y);
int l = min(((x + la) % mod) + 1, ((y + la) % mod) + 1);
int r = max(((x + la) % mod) + 1, ((y + la) % mod) + 1);
int p = R[bl[l]];
int res = ans[bl[l]][r];//"整块"的答案
for(int i = l - 1; i <= min(p - 1, r); i++)//计算"散块"的答案
res = max(res, query(rot[r], rot[l - 2], s[i]));
printf("%lld\n", res);
la = res;
}
return 0;
}
注意此处说的"整块"和"散块"并非分块一般意义上的整块和散块,只是为方便描述而提出的概念。
例五:Persistent Bookcase(操作树)
维护一个二维零一矩阵(
- 将
(i,j) 置一 - 将
(i,j) 置零 - 将第
i 行零一反转 - 回到第
K 次操作后的状态 - 每次操作后输出全局一共有多少个一
显然可以把矩阵展开成序列,操作一二就是正常的单点修改。对于操作三,看成区间取反打标记即可,操作四正常回退,询问正常做就行。
但是这里介绍另一种算法,操作树。至于为什么要把这东西放在可持久化,可能是它们能处理的东西有部分相似吧。
可以发现如果本题去除了操作四,按顺序去用
因为一共
可以发现去除操作四后的求解可以看成一条链,每次从
对于操作四外其他操作,连边
代码就不放了,原题是CF707D,可以去那里看。
总结一下操作树,这是一种离线做法,并且要满足可以很方便回溯才行。比如操作是区间加,就可以用区间减来回溯。
如果不能方便的回溯,就必须去记录整个状态,那空间就远远超过可持久化了,要是用动态开点,还不如直接写可持久化。
对比可持久化,优点:空间小、好理解、常熟小,缺点:必须离线、不能维护复杂状态。
例六:字符串移位
维护一个字符串,支持三种操作:
- 将区间
[l,r] 复制一遍加到p 后面 - 将区间
[l,r] 删除 - 将区间
[l,r] 区间内的字母做恺撒位移,在字母表中向后移一位
可以用平衡树解决。发现第三个操作就是带取模的区间加,打标记即可,而第一个操作因为每次要复制
考虑可持久化平衡树,发现其在用 split 将区间截出来时,已经相当于将其复制了一遍,直接加到
操作二则是将区间