可持久化并查集详解
C++ 可持久化并查集详解
说明
本文系统讲解可持久化并查集(Persistent DSU),覆盖原理、实现、用法、变形、定理、推论、典型例题与高级扩展。
总目录
- 可持久化数据结构基础
- 并查集基础回顾
- 可持久化并查集原理
- 可持久化数组(主席树)实现
- 可持久化并查集实现
- 复杂度分析与优化
- 常见变形与用法
- 典型例题精讲(含你所给两道题)
- 高级话题与研究扩展
- 相关定理、推论、公式速查
- 代码优化技巧
- 与其他可持久化结构结合
- 常见错误与调试
- 完整模板代码
1. 可持久化数据结构基础
1.1 什么是可持久化
可持久化(Persistent):数据结构在修改时保留历史版本,允许访问任意历史版本。
两种类型:
- 部分可持久化(Partially Persistent):所有版本可读,只有最新版本可写。
- 完全可持久化(Fully Persistent):所有版本可读可写。
并查集通常使用部分可持久化。
1.2 路径复制(Path Copying)
修改一个节点时,复制从根到该节点的路径上所有节点,形成新版本。
未修改的子树共享旧版本,节省空间。
复杂度:单次修改 \(O(\log n)\) 新节点。
v0 v1
/ \ / \
A B 修改A A' B
/ \ / \ -----> / \
... ... ...
1.3 路径压缩 vs 路径复制
- 路径压缩会修改大量节点,导致可持久化空间爆炸。
- 可持久化并查集不能用路径压缩,只能用按秩合并。
- 按秩合并保证树高 \(O(\log n)\)。
1.4 可持久化的三种方式
| 方法 | 空间 | 时间 | 适用 |
|---|---|---|---|
| 路径复制 | \(O(\log n)\)/次 | \(O(\log n)\) | 树形结构 |
| Fat Node | 摊还 \(O(1)\) | \(O(\log m)\) | 一般 |
| Node Copying | \(O(1)\) | 摊还 \(O(1)\) | 完全可持久化 |
2. 并查集基础回顾
2.1 定义
并查集(Disjoint Set Union, DSU)维护不相交集合,支持:
find(x):查 \(x\) 所在集合代表元。union(x,y):合并两集合。
2.2 优化
- 路径压缩:
f[x]=find(f[x]),摊还 \(O(\alpha(n))\)。 - 按秩合并:小树连大树,树高 \(O(\log n)\)。
- 同时使用:\(O(\alpha(n))\)。
2.3 反阿克曼函数
其中 \(A\) 是阿克曼函数。
对于所有实际 \(n\),\(\alpha(n)\le4\)。
2.4 并查集复杂度
| 优化 | 单次摊还 |
|---|---|
| 无 | \(O(n)\) |
| 仅路径压缩 | \(O(\log n)\) |
| 仅按秩合并 | \(O(\log n)\) |
| 两者 | \(O(\alpha(n))\) |
2.5 定理(Tarjan 1975)
仅用路径压缩的并查集,单次操作摊还 \(O(\log n)\),序列操作 \(O(n\log n)\)。
同时使用路径压缩与按秩合并,摊还 \(O(\alpha(n))\)。
3. 可持久化并查集原理
3.1 核心思想
- 把
f数组(父指针)与rank/dep数组存入可持久化数组。 - 每次操作基于上一版本,产生新版本。
find不使用路径压缩(会破坏历史版本),仅按秩合并。- 树高 \(O(\log n)\),
find复杂度 \(O(\log n)\)。
3.2 数据结构选择
用可持久化数组存储 fa[i] 和 dep[i]。
常见实现:可持久化线段树(主席树)。
3.3 为什么不用路径压缩
路径压缩会一次性修改从 \(x\) 到根的路径上所有节点。
在可持久化结构上,每次操作需复制整条路径,空间 \(O(\log n)\) 变 \(O(\log^2 n)\) 甚至更高。
而且路径压缩是摊还的,与可持久化的版本隔离语义冲突。
3.4 按秩合并保证树高
引理:按秩合并的并查集,树高 \(\le \lfloor\log_2 n\rfloor+1\)。
证明:秩为 \(r\) 的树至少有 \(2^r\) 个节点,由归纳可得。
推论:find 复杂度 \(O(\log n)\)。
3.5 可持久化并查集复杂度
| 操作 | 时间 | 空间 |
|---|---|---|
| 建树 | \(O(n)\) | \(O(n)\) |
| union | \(O(\log^2 n)\) | \(O(\log n)\) |
| find | \(O(\log^2 n)\) | \(O(1)\) |
| 回退 | \(O(1)\) | \(O(1)\) |
其中 \(O(\log^2 n)\) 来自:主席树查询 \(O(\log n)\),树高 \(O(\log n)\)。
3.6 图示
版本0: [1][2][3][4][5] 每个点自成一集合
版本1: union(1,2) -> fa[2]=1
版本2: union(3,4) -> fa[4]=3
版本3: union(1,3) -> fa[3]=1
每个版本对应一个可持久化线段树的根 root[i]。
4. 可持久化数组(主席树)
4.1 定义
可持久化数组支持:
- 单点修改,保留历史版本。
- 单点查询任意历史版本。
4.2 实现
以值域 \([1,n]\) 建线段树,每个叶节点存一个数组元素。
修改时复制路径。
4.3 建树
#include <bits/stdc++.h>
#define int long long
#define COUT cout<<"\n------------------------------------------------------------------------------------------\n";
#define fi first
#define se second
#define be begin()
#define en end()
using namespace std;
const int N=2e5+5;
const int mod=998244353;
const int inf=1e18;
int n,m,cnt;
int root[N],lc[N*40],rc[N*40],val[N*40];
int a[N];
void build(int &k,int l,int r)
{
k=++cnt;
if(l==r)
{
val[k]=a[l];
return;
}
int mid=l+r>>1;
build(lc[k],l,mid);
build(rc[k],mid+1,r);
}
void change(int pre,int &now,int l,int r,int pos,int v)
{
now=++cnt;
lc[now]=lc[pre];
rc[now]=rc[pre];
if(l==r)
{
val[now]=v;
return;
}
int mid=l+r>>1;
if(pos<=mid)change(lc[pre],lc[now],l,mid,pos,v);
else change(rc[pre],rc[now],mid+1,r,pos,v);
}
int query(int k,int l,int r,int pos)
{
if(l==r)return val[k];
int mid=l+r>>1;
if(pos<=mid)return query(lc[k],l,mid,pos);
else return query(rc[k],mid+1,r,pos);
}
signed main()
{
ios::sync_with_stdio(false);
cin.tie(0);
cout.tie(0);
cin>>n>>m;
for(int i=1;i<=n;i++)cin>>a[i];
build(root[0],1,n);
for(int i=1;i<=m;i++)
{
int v,op,pos,x;
cin>>v>>op>>pos;
if(op==1)
{
cin>>x;
change(root[v],root[i],1,n,pos,x);
}
else
{
root[i]=root[v];
cout<<query(root[i],1,n,pos)<<"\n";
}
}
return 0;
}
4.4 复杂度
- 建树:时间 \(O(n)\),空间 \(O(n)\)。
- 修改:时间 \(O(\log n)\),空间 \(O(\log n)\)。
- 查询:时间 \(O(\log n)\),空间 \(O(1)\)。
4.5 主席树空间估算
每次修改新建 \(\lceil\log_2 n\rceil+1\) 个节点。
总节点数 \(\le n+4\cdot m\cdot\log_2 n\)。
对于 \(n,m=2\cdot10^5\):约 \(2\cdot10^5+4\cdot2\cdot10^5\cdot18\approx1.46\cdot10^7\)。
每个节点 3 个 int(64 位下 8 字节),约 350MB。
实际使用中数组开 40*N 左右。
5. 可持久化并查集实现
5.1 存储设计
fa[]、dep[]各用一棵可持久化线段树。- 但更常见:两棵合为一棵,或者共用根但分两次修改。
- 推荐:
fa和dep分别存储,但共用版本根root[i]。
实际有两种方案:
方案 A:两棵树,共用版本号
root_fa[i] 和 root_dep[i]。
每次版本 i 对应两个根。
方案 B:一棵树,节点存 (fa, dep)
结构紧凑,但修改时 fa 和 dep 可能同时更新,需一次修改双值。
常用方案 B,或者把 fa 与 dep 放在同一个主席树节点。
5.2 方案 A:双树共用版本
#include <bits/stdc++.h>
#define int long long
#define COUT cout<<"\n------------------------------------------------------------------------------------------\n";
#define fi first
#define se second
#define be begin()
#define en end()
using namespace std;
const int N=2e5+5;
const int mod=998244353;
const int inf=1e18;
int n,m,cnt;
int rootfa[N],rootdep[N];
int lc[N*40],rc[N*40],fa[N*40],dep[N*40];
void build(int &k,int l,int r)
{
k=++cnt;
if(l==r)
{
fa[k]=l;
dep[k]=1;
return;
}
int mid=l+r>>1;
build(lc[k],l,mid);
build(rc[k],mid+1,r);
}
void change(int pre,int &now,int l,int r,int pos,int v,int *arr)
{
now=++cnt;
lc[now]=lc[pre];
rc[now]=rc[pre];
if(l==r)
{
arr[now]=v;
return;
}
int mid=l+r>>1;
if(pos<=mid)change(lc[pre],lc[now],l,mid,pos,v,arr);
else change(rc[pre],rc[now],mid+1,r,pos,v,arr);
}
int query(int k,int l,int r,int pos,int *arr)
{
if(l==r)return arr[k];
int mid=l+r>>1;
if(pos<=mid)return query(lc[k],l,mid,pos,arr);
else return query(rc[k],mid+1,r,pos,arr);
}
signed main()
{
ios::sync_with_stdio(false);
cin.tie(0);
cout.tie(0);
cin>>n>>m;
build(rootfa[0],1,n);
// rootdep[0] 与 rootfa[0] 共享结构
rootdep[0]=rootfa[0];
for(int i=1;i<=m;i++)
{
int op;
cin>>op;
if(op==1)
{
int a,b;
cin>>a>>b;
rootfa[i]=rootfa[i-1];
rootdep[i]=rootdep[i-1];
// find + union
}
else if(op==2)
{
int k;
cin>>k;
rootfa[i]=rootfa[k];
rootdep[i]=rootdep[k];
}
else
{
int a,b;
cin>>a>>b;
rootfa[i]=rootfa[i-1];
rootdep[i]=rootdep[i-1];
// query
}
}
return 0;
}
注意:方案 A 中
fa和dep在两棵树中,修改时两棵树都要复制路径,节点数翻倍。
一般直接用方案 B:单树双值。
5.3 方案 B:单树双值(推荐)
节点存 fa 与 dep。
修改时可能只改 fa,或只改 dep,或同时改。
需要在 change 中复制整个路径。
#include <bits/stdc++.h>
#define int long long
#define COUT cout<<"\n------------------------------------------------------------------------------------------\n";
#define fi first
#define se second
#define be begin()
#define en end()
using namespace std;
const int N=2e5+5;
const int mod=998244353;
const int inf=1e18;
int n,m,cnt;
int root[N];
int lc[N*40],rc[N*40],fa[N*40],dep[N*40];
void build(int &k,int l,int r)
{
k=++cnt;
if(l==r)
{
fa[k]=l;
dep[k]=1;
return;
}
int mid=l+r>>1;
build(lc[k],l,mid);
build(rc[k],mid+1,r);
}
void change(int pre,int &now,int l,int r,int pos,int f,int d)
{
now=++cnt;
lc[now]=lc[pre];
rc[now]=rc[pre];
if(l==r)
{
fa[now]=f;
dep[now]=d;
return;
}
int mid=l+r>>1;
if(pos<=mid)change(lc[pre],lc[now],l,mid,pos,f,d);
else change(rc[pre],rc[now],mid+1,r,pos,f,d);
}
pair<int,int>query(int k,int l,int r,int pos)
{
if(l==r)return {fa[k],dep[k]};
int mid=l+r>>1;
if(pos<=mid)return query(lc[k],l,mid,pos);
else return query(rc[k],mid+1,r,pos);
}
int find(int x,int i)
{
while(1)
{
auto p=query(root[i],1,n,x);
if(p.fi==x)return x;
x=p.fi;
}
}
signed main()
{
ios::sync_with_stdio(false);
cin.tie(0);
cout.tie(0);
cin>>n>>m;
build(root[0],1,n);
for(int i=1;i<=m;i++)
{
int op;
cin>>op;
if(op==1)
{
int a,b;
cin>>a>>b;
root[i]=root[i-1];
int x=find(a,i);
int y=find(b,i);
if(x==y)continue;
auto px=query(root[i],1,n,x);
auto py=query(root[i],1,n,y);
if(px.se<py.se)swap(x,y),swap(px,py);
// 修改 y 的 fa 为 x
int tmp;
change(root[i],tmp,1,n,y,x,px.se);
root[i]=tmp;
if(px.se==py.se)
{
// 修改 x 的 dep+1
change(root[i],tmp,1,n,x,x,px.se+1);
root[i]=tmp;
}
}
else if(op==2)
{
int k;
cin>>k;
root[i]=root[k];
}
else
{
int a,b;
cin>>a>>b;
root[i]=root[i-1];
int x=find(a,i);
int y=find(b,i);
cout<<(x==y?1:0)<<"\n";
}
}
return 0;
}
6. 复杂度分析与优化
6.1 时间复杂度
- 建树:\(O(n)\)。
find:树高 \(O(\log n)\),每层主席树查询 \(O(\log n)\),总 \(O(\log^2 n)\)。union:两次find+ 两次修改,\(O(\log^2 n)\)。- 回退:\(O(1)\)。
6.2 空间复杂度
- 初始:\(O(n)\)。
- 每次
union:最多两次change,每次 \(O(\log n)\),总 \(O(\log n)\)。 - \(m\) 次操作:\(O(n+m\log n)\)。
6.3 优化方向
- 单次
change修改fa和dep:若能一次修改两值,减少一半节点。 - 迭代代替递归:减少函数调用开销。
- 压缩节点结构:用
int数组,避免struct对齐开销。 - 按秩合并用
dep而非rank:避免额外维护。 find中缓存根:减少重复查询。
6.4 优化后的迭代版本(关键优化)
见第 14 章完整模板。
7. 常见变形与用法
7.1 可持久化并查集 + 回退操作
题面中"回到第 k 次操作之后的状态"就是版本回退。
直接把当前版本号设为 \(k\),或把 root[i]=root[k]。
7.2 强制在线
大部分可持久化题目强制在线,输入需异或 lastans。
7.3 可持久化并查集 + 二分
配合可持久化版本,二分查找满足条件的最早版本。
例:最早使 \(a,b\) 连通的操作编号。
7.4 可持久化并查集 + 线段树合并
维护每个集合的额外信息(如权值和、大小),需另一棵可持久化线段树。
7.5 可持久化并查集 + 可持久化平衡树
用可持久化 Treap 代替主席树,支持分裂合并。
但常数较大,一般不推荐。
7.6 带权可持久化并查集
节点存 fa、dep、w(到父节点的权)。
find 返回根与路径权值和。
用于可持久化带权并查集(如判断奇偶性)。
7.7 可持久化并查集 + 背包
每个版本是一个背包状态。
可用于动态连通性下的 DP。
7.8 可撤销并查集 vs 可持久化并查集
| 特性 | 可撤销 | 可持久化 |
|---|---|---|
| 回退 | 只能栈序回退 | 任意版本 |
| 路径压缩 | 可以(用栈记录) | 不可以 |
| 复杂度 | \(O(\log n)\) | \(O(\log^2 n)\) |
| 空间 | \(O(m)\) | \(O(m\log n)\) |
| 在线 | 离线 | 在线 |
7.9 可持久化并查集 + 动态图连通性
动态图连通性可用线段树分治 + 可撤销并查集。
可持久化并查集处理"版本查询"型问题。
7.10 可持久化并查集 + Kruskal 重构树
Kruskal 重构树本身带版本信息,可看作可持久化并查集的一种变体。
8. 典型例题精讲
8.1 例 1:可持久化并查集(普通版)
题面
\(n\) 个集合,\(m\) 次操作:
1 a b:合并 \(a,b\) 所在集合。2 k:回到第 \(k\) 次操作之后的状态。3 a b:询问 \(a,b\) 是否同集合。
输入样例
5 6
1 1 2
3 1 2
2 1
3 0 3
2 1
3 1 2
输出样例
1
0
1
解析
- 建主席树存
fa与dep。 find不使用路径压缩,仅按秩合并。- 每次操作
root[i]基于root[i-1]。 - 操作 2 直接把
root[i]=root[k]。
标准代码
#include <bits/stdc++.h>
#define int long long
#define COUT cout<<"\n------------------------------------------------------------------------------------------\n";
#define fi first
#define se second
#define be begin()
#define en end()
using namespace std;
const int N=2e5+5;
const int mod=998244353;
const int inf=1e18;
int n,m,cnt;
int root[N],lc[N*40],rc[N*40],fa[N*40],dep[N*40];
void build(int &k,int l,int r)
{
k=++cnt;
if(l==r)
{
fa[k]=l;
dep[k]=1;
return;
}
int mid=l+r>>1;
build(lc[k],l,mid);
build(rc[k],mid+1,r);
}
void change(int pre,int &now,int l,int r,int pos,int f,int d)
{
now=++cnt;
lc[now]=lc[pre];
rc[now]=rc[pre];
if(l==r)
{
fa[now]=f;
dep[now]=d;
return;
}
int mid=l+r>>1;
if(pos<=mid)change(lc[pre],lc[now],l,mid,pos,f,d);
else change(rc[pre],rc[now],mid+1,r,pos,f,d);
}
pair<int,int>query(int k,int l,int r,int pos)
{
if(l==r)return {fa[k],dep[k]};
int mid=l+r>>1;
if(pos<=mid)return query(lc[k],l,mid,pos);
else return query(rc[k],mid+1,r,pos);
}
int find(int x,int i)
{
while(1)
{
auto p=query(root[i],1,n,x);
if(p.fi==x)return x;
x=p.fi;
}
}
signed main()
{
ios::sync_with_stdio(false);
cin.tie(0);
cout.tie(0);
cin>>n>>m;
build(root[0],1,n);
for(int i=1;i<=m;i++)
{
int op;
cin>>op;
if(op==1)
{
int a,b;
cin>>a>>b;
root[i]=root[i-1];
int x=find(a,i);
int y=find(b,i);
if(x==y)continue;
auto px=query(root[i],1,n,x);
auto py=query(root[i],1,n,y);
if(px.se<py.se)swap(x,y),swap(px,py);
int tmp;
change(root[i],tmp,1,n,y,x,px.se);
root[i]=tmp;
if(px.se==py.se)
{
change(root[i],tmp,1,n,x,x,px.se+1);
root[i]=tmp;
}
}
else if(op==2)
{
int k;
cin>>k;
root[i]=root[k];
}
else
{
int a,b;
cin>>a>>b;
root[i]=root[i-1];
int x=find(a,i);
int y=find(b,i);
cout<<(x==y?1:0)<<"\n";
}
}
return 0;
}
8.2 例 2:可持久化并查集加强版
题面
同上,但强制在线:
- \(a,b,k\) 均需异或
lastans。 lastans初值 0。- \(n,m\le2\cdot10^5\)。
输入样例
5 6
1 1 2
3 1 2
2 1
3 0 3
2 1
3 1 2
输出样例
1
0
1
解析:用迭代版本 change、query_fa、query_deep。
修正后完整代码
#include <bits/stdc++.h>
#define int long long
#define COUT cout<<"\n------------------------------------------------------------------------------------------\n";
#define fi first
#define se second
#define be begin()
#define en end()
using namespace std;
const int N=2e5+5;
const int mod=998244353;
const int inf=1e18;
int n,m,cnt;
int root[N*40],lc[N*40],rc[N*40],fa[N*40],deep[N*40];
int lastans=0;
void build(int &k,int l,int r)
{
k=++cnt;
if(l==r){fa[k]=l;deep[k]=1;return;}
int mid=l+r>>1;
build(lc[k],l,mid);
build(rc[k],mid+1,r);
}
void change(int pre,int &now,int x,int y)
{
now=++cnt;
int l=1,r=n;
int cur=now;
while(l<r)
{
int mid=l+r>>1;
if(y<=mid)
{
rc[cur]=rc[pre];
lc[cur]=++cnt;
cur=lc[cur];
pre=lc[pre];
r=mid;
}
else
{
lc[cur]=lc[pre];
rc[cur]=++cnt;
cur=rc[cur];
pre=rc[pre];
l=mid+1;
}
}
fa[cur]=x;
deep[cur]=deep[pre];
}
int query_fa(int node,int w)
{
int l=1,r=n;
while(l<r)
{
int mid=l+r>>1;
if(w<=mid){node=lc[node];r=mid;}
else{node=rc[node];l=mid+1;}
}
return fa[node];
}
int query_deep(int node,int w)
{
int l=1,r=n;
while(l<r)
{
int mid=l+r>>1;
if(w<=mid){node=lc[node];r=mid;}
else{node=rc[node];l=mid+1;}
}
return deep[node];
}
int find(int x,int i)
{
while(1)
{
int p=query_fa(root[i],x);
if(p==x)return x;
x=p;
}
}
void update(int pre,int &now,int w)
{
now=++cnt;
int l=1,r=n;
int cur=now;
while(l<r)
{
int mid=l+r>>1;
if(w<=mid)
{
rc[cur]=rc[pre];
lc[cur]=++cnt;
cur=lc[cur];
pre=lc[pre];
r=mid;
}
else
{
lc[cur]=lc[pre];
rc[cur]=++cnt;
cur=rc[cur];
pre=rc[pre];
l=mid+1;
}
}
fa[cur]=fa[pre];
deep[cur]=deep[pre]+1;
}
void unionn(int x,int y,int i)
{
int dx=query_deep(root[i],x);
int dy=query_deep(root[i],y);
if(dx<dy)swap(x,y);
change(root[i-1],root[i],x,y);
if(dx==dy)update(root[i],root[i],x);
}
signed main()
{
ios::sync_with_stdio(false);
cin.tie(0);
cout.tie(0);
cin>>n>>m;
build(root[0],1,n);
int z,x,y;
for(int i=1;i<=m;i++)
{
cin>>z;
if(z==1)
{
cin>>x>>y;
x^=lastans;
y^=lastans;
root[i]=root[i-1];
x=find(x,i);
y=find(y,i);
if(x!=y)unionn(x,y,i);
}
else if(z==2)
{
cin>>x;
x^=lastans;
root[i]=root[x];
}
else
{
cin>>x>>y;
x^=lastans;
y^=lastans;
root[i]=root[i-1];
x=find(x,i);
y=find(y,i);
int ans=(x==y);
cout<<ans<<"\n";
lastans=ans;
}
}
return 0;
}
注意:上述代码在样例上正确,但大数组 TLE 时需进一步优化(见第 11 章)。
9. 高级话题与研究扩展
9.1 可持久化并查集 + 二分答案
问题:给 \(m\) 次合并操作,问最早第几次操作后 \(a,b\) 连通。
解法:可持久化并查集 + 二分版本号。
复杂度 \(O(\log m\cdot\log^2 n)\)。
int solve(int a,int b,int l,int r)
{
while(l<r)
{
int mid=l+r>>1;
if(find(a,mid)==find(b,mid))r=mid;
else l=mid+1;
}
return l;
}
9.2 可持久化并查集 + 线段树合并
维护每个集合的权值和、最大/最小等。
每个集合对应一棵动态开点线段树。
合并时线段树合并,并把新根存入可持久化数组。
复杂度 \(O(n\log n)\)。
9.3 可持久化并查集 + 带权
节点存 fa、dep、w(到父节点权值)。
find 返回根与路径异或和。
常用于可持久化奇偶并查集。
pair<int,int>find(int x,int i)
{
int w=0;
while(1)
{
auto p=query(root[i],1,n,x);
if(p.fi==x)return {x,w};
w^=p.se;
x=p.fi;
}
}
9.4 可持久化并查集 + 哈希
用可持久化线段树存哈希,判断两版本是否相同。
用于可持久化字符串、可持久化图同构。
9.5 可持久化并查集 + 分块
把版本分块,块内用普通并查集,块间用可持久化。
适合版本数巨大但查询集中在某段。
9.6 可持久化并查集 + 随机化
用随机秩代替 dep,期望 \(O(\log n)\) 树高。
节省一次 change。
9.7 可持久化并查集 + 可持久化 Treap
用可持久化 Treap 代替主席树。
支持分裂合并,常数较大。
9.8 可持久化并查集 + 动态树
Link-Cut Tree 也可维护连通性。
但 LCT 不支持任意版本回退。
可持久化 LCT 是研究课题。
9.9 可持久化并查集 + 可持久化栈
用可持久化栈代替数组,简化实现。
但 find 无法快速定位,需配合平衡树。
9.10 可持久化并查集 + 并行算法
多线程版本可持久化并查集。
研究热点,涉及并发控制。
10. 相关定理、推论、公式速查
10.1 按秩合并树高定理
定理:按秩合并的并查集,秩为 \(r\) 的树至少有 \(2^r\) 个节点。
推论:树高 \(\le\lfloor\log_2 n\rfloor\)。
10.2 反阿克曼函数
其中 \(A\) 为阿克曼函数。\(\alpha(n)\le4\)(\(n<2^{65536}\))。
10.3 Tarjan 定理
路径压缩 + 按秩合并,摊还 \(O(\alpha(n))\)。
10.4 可持久化复杂度定理
定理:可持久化并查集单次 union 时间 \(O(\log^2 n)\),空间 \(O(\log n)\)。
证明:find 每层主席树查询 \(O(\log n)\),树高 \(O(\log n)\),总 \(O(\log^2 n)\)。每次 change 新建 \(O(\log n)\) 节点。
10.5 主定理(Master Theorem)
若 \(T(n)=aT(n/b)+f(n)\),则:
- 若 \(f(n)=O(n^{\log_b a-\epsilon})\),\(T(n)=\Theta(n^{\log_b a})\)。
- 若 \(f(n)=\Theta(n^{\log_b a})\),\(T(n)=\Theta(n^{\log_b a}\log n)\)。
- 若 \(f(n)=\Omega(n^{\log_b a+\epsilon})\),\(T(n)=\Theta(f(n))\)。
用于分析线段树复杂度。
10.6 空间-时间权衡
| 结构 | 空间 | 时间 |
|---|---|---|
| 普通并查集 | \(O(n)\) | \(O(\alpha(n))\) |
| 可撤销并查集 | \(O(m)\) | \(O(\log n)\) |
| 可持久化并查集 | \(O(m\log n)\) | \(O(\log^2 n)\) |
| 带权可持久化 | \(O(m\log n)\) | \(O(\log^2 n)\) |
10.7 主席树节点数估算
对于 \(n,m=2\cdot10^5\),约 \(1.5\cdot10^7\)。
10.8 编码技巧公式
- 左子:
k<<1或lc[k]。 - 中点:
l+r>>1。 - 异或在线:
x^=lastans。 - 版本根:
root[i]。 - 节点计数:
++cnt。
11. 代码优化技巧
11.1 迭代代替递归
递归函数调用开销大。
将 change、query、find、update 全改为迭代。
11.2 数组连续化
用 int 数组而非 struct,避免对齐开销。
fa[]、dep[]、lc[]、rc[] 分开。
11.3 内存池
预分配大数组,用 cnt 计数。
避免 vector 扩容。
11.4 快读快写
对于 \(2\cdot10^5\) 数据,cin 加 sync_with_stdio(false) 足够。
若更大,用 getchar 快读。
11.5 减少 change 次数
union 时若能一次修改 fa 和 dep,减少节点。
用一个 change 修改双值。
11.6 缓存 query 结果
find 中每次 query 都是 \(O(\log n)\)。
若同一版本多次 find,可缓存。
11.7 优化 find
find 中若 x 是根,直接返回,避免一次 query。
11.8 二进制优化
lc、rc 用 int 而非 long long。
#define int long long 会浪费空间,但方便。
11.9 迭代式 change 模板
void change(int pre,int &now,int x,int y)
{
now=++cnt;
int l=1,r=n,cur=now;
while(l<r)
{
int mid=l+r>>1;
if(y<=mid)
{
rc[cur]=rc[pre];
lc[cur]=++cnt;
cur=lc[cur];
pre=lc[pre];
r=mid;
}
else
{
lc[cur]=lc[pre];
rc[cur]=++cnt;
cur=rc[cur];
pre=rc[pre];
l=mid+1;
}
}
fa[cur]=x;
deep[cur]=deep[pre];
}
11.10 迭代式 query
int query_fa(int node,int w)
{
int l=1,r=n;
while(l<r)
{
int mid=l+r>>1;
if(w<=mid){node=lc[node];r=mid;}
else{node=rc[node];l=mid+1;}
}
return fa[node];
}
11.11 迭代式 find
int find(int x,int i)
{
while(1)
{
int p=query_fa(root[i],x);
if(p==x)return x;
x=p;
}
}
11.12 内联函数
用 inline 或 #define 宏,减少调用开销。
11.13 优化后完整代码(推荐用于加强版)
#include <bits/stdc++.h>
#define int long long
#define COUT cout<<"\n------------------------------------------------------------------------------------------\n";
#define fi first
#define se second
#define be begin()
#define en end()
using namespace std;
const int N=2e5+5;
const int mod=998244353;
const int inf=1e18;
int n,m,cnt;
int root[N],lc[N*40],rc[N*40],fa[N*40],deep[N*40];
int lastans=0;
inline void build(int &k,int l,int r)
{
k=++cnt;
if(l==r){fa[k]=l;deep[k]=1;return;}
int mid=l+r>>1;
build(lc[k],l,mid);
build(rc[k],mid+1,r);
}
inline void change(int pre,int &now,int x,int y)
{
now=++cnt;
int l=1,r=n,cur=now;
while(l<r)
{
int mid=l+r>>1;
if(y<=mid)
{
rc[cur]=rc[pre];
lc[cur]=++cnt;
cur=lc[cur];
pre=lc[pre];
r=mid;
}
else
{
lc[cur]=lc[pre];
rc[cur]=++cnt;
cur=rc[cur];
pre=rc[pre];
l=mid+1;
}
}
fa[cur]=x;
deep[cur]=deep[pre];
}
inline int query_fa(int node,int w)
{
int l=1,r=n;
while(l<r)
{
int mid=l+r>>1;
if(w<=mid){node=lc[node];r=mid;}
else{node=rc[node];l=mid+1;}
}
return fa[node];
}
inline int query_deep(int node,int w)
{
int l=1,r=n;
while(l<r)
{
int mid=l+r>>1;
if(w<=mid){node=lc[node];r=mid;}
else{node=rc[node];l=mid+1;}
}
return deep[node];
}
inline int find(int x,int i)
{
while(1)
{
int p=query_fa(root[i],x);
if(p==x)return x;
x=p;
}
}
inline void update(int pre,int &now,int w)
{
now=++cnt;
int l=1,r=n,cur=now;
while(l<r)
{
int mid=l+r>>1;
if(w<=mid)
{
rc[cur]=rc[pre];
lc[cur]=++cnt;
cur=lc[cur];
pre=lc[pre];
r=mid;
}
else
{
lc[cur]=lc[pre];
rc[cur]=++cnt;
cur=rc[cur];
pre=rc[pre];
l=mid+1;
}
}
fa[cur]=fa[pre];
deep[cur]=deep[pre]+1;
}
inline void unionn(int x,int y,int i)
{
int dx=query_deep(root[i],x);
int dy=query_deep(root[i],y);
if(dx<dy)swap(x,y);
change(root[i-1],root[i],x,y);
if(dx==dy)update(root[i],root[i],x);
}
signed main()
{
ios::sync_with_stdio(false);
cin.tie(0);
cout.tie(0);
cin>>n>>m;
build(root[0],1,n);
int z,x,y;
for(int i=1;i<=m;i++)
{
cin>>z;
if(z==1)
{
cin>>x>>y;
x^=lastans;
y^=lastans;
root[i]=root[i-1];
x=find(x,i);
y=find(y,i);
if(x!=y)unionn(x,y,i);
}
else if(z==2)
{
cin>>x;
x^=lastans;
root[i]=root[x];
}
else
{
cin>>x>>y;
x^=lastans;
y^=lastans;
root[i]=root[i-1];
x=find(x,i);
y=find(y,i);
int ans=(x==y);
cout<<ans<<"\n";
lastans=ans;
}
}
return 0;
}
11.14 双值一次修改优化
将 fa 和 dep 的修改合并为一次 change:
inline void change(int pre,int &now,int pos,int f,int d)
{
now=++cnt;
int l=1,r=n,cur=now;
while(l<r)
{
int mid=l+r>>1;
if(pos<=mid)
{
rc[cur]=rc[pre];
lc[cur]=++cnt;
cur=lc[cur];
pre=lc[pre];
r=mid;
}
else
{
lc[cur]=lc[pre];
rc[cur]=++cnt;
cur=rc[cur];
pre=rc[pre];
l=mid+1;
}
}
fa[cur]=f;
deep[cur]=d;
}
union 中:
int dx=query_deep(root[i],x);
int dy=query_deep(root[i],y);
if(dx<dy)swap(x,y),swap(dx,dy);
change(root[i-1],root[i],y,x,dx);
if(dx==dy)change(root[i],root[i],x,x,dx+1);
11.15 进一步优化:find 传根
避免重复查根:
int find_root(int x,int i)
{
while(1)
{
int p=query_fa(root[i],x);
if(p==x)return x;
x=p;
}
}
11.16 减少 query_deep 调用
union 时若已知 x,y 是根,可直接查 dep。
11.17 使用 unsigned 优化
节点编号用 int,其他用 int 即可。
11.18 空间释放
若版本数超过 \(m\),需释放旧版本。
但可持久化语义要求保留所有版本。
11.19 二分优化
二分版本时,从根节点二分可减少常数。
11.20 编译器优化
-O2、-O3 开启。
#pragma GCC optimize("O3")。
12. 与其他可持久化结构结合
12.1 可持久化线段树 + 可持久化并查集
最常见组合,已述。
12.2 可持久化 Trie + 并查集
Trie 存字符串集合,并查集维护等价关系。
可用于可持久化字符串归并。
12.3 可持久化平衡树 + 并查集
Treap 代替主席树,支持分裂合并。
适合动态插入删除。
12.4 可持久化栈 + 并查集
栈存版本历史,适合"回退"型问题。
12.5 可持久化队列 + 并查集
双栈模拟队列,用于 BFS 可持久化。
12.6 可持久化图 + 并查集
可持久化邻接表,用于动态图连通性。
12.7 可持久化哈希 + 并查集
判断两版本是否同构。
12.8 可持久化 Kruskal 重构树
把每次合并建新节点,形成树。
可持久化版本对应子树。
12.9 可持久化动态树
LCT + 可持久化,研究课题。
12.10 可持久化 2-SAT
并查集 + 2-SAT 结合,用于可持久化约束。
13. 常见错误与调试
13.1 头文件拼写错误
<bids/stdc++.h> 应为 <bits/stdc++.h>。不然会被卡头文件
13.2 数组开小
N*20不够,应为N*40或更大。- 对于 \(n,m=2\cdot10^5\),
N*40=8\cdot10^6足够。
13.3 未处理强制在线
忘记 x^=lastans。
忘记更新 lastans。
13.4 混用元素编号与节点编号
deep[x] 中 x 是元素编号,应改为 query_deep(root[i],x)。
13.5 change 不可持久化
直接修改旧节点,破坏历史版本。
应 now=++cnt,复制路径。
13.6 update 错误
应复制 fa,且 deep 加一。
update(root[i],root[i],x) 应把新根赋给 root[i]。
13.7 路径压缩误用
可持久化并查集不能用路径压缩。
应用按秩合并。
13.8 递归超时
递归 find + query 嵌套,常数巨大。
改为迭代。
13.9 版本号错位
root[i] 应基于 root[i-1]。
操作 2 直接 root[i]=root[k]。
13.10 union 未判断同集合
若 x==y 直接跳过。
否则可能错误修改。
13.11 合并方向错误
按秩合并:小秩连大秩。
dep 相等时,被连的树 dep 加一。
13.12 节点编号溢出
cnt 用 int,最大 \(8\cdot10^6\),不溢出。
若更大,用 long long。
13.13 内存超限
减少节点数:
- 双值一次修改。
- 用
int而非long long。 - 释放不用版本。
13.14 调试方法
- 打印每个版本的
fa、dep。 - 对比暴力。
- 检查节点数。
- 检查版本根。
14. 完整模板代码
14.1 可持久化并查集(普通版)
#include <bits/stdc++.h>
#define int long long
#define COUT cout<<"\n------------------------------------------------------------------------------------------\n";
#define fi first
#define se second
#define be begin()
#define en end()
using namespace std;
const int N=2e5+5;
const int mod=998244353;
const int inf=1e18;
int n,m,cnt;
int root[N],lc[N*40],rc[N*40],fa[N*40],dep[N*40];
void build(int &k,int l,int r)
{
k=++cnt;
if(l==r){fa[k]=l;dep[k]=1;return;}
int mid=l+r>>1;
build(lc[k],l,mid);
build(rc[k],mid+1,r);
}
void change(int pre,int &now,int l,int r,int pos,int f,int d)
{
now=++cnt;
lc[now]=lc[pre];
rc[now]=rc[pre];
if(l==r){fa[now]=f;dep[now]=d;return;}
int mid=l+r>>1;
if(pos<=mid)change(lc[pre],lc[now],l,mid,pos,f,d);
else change(rc[pre],rc[now],mid+1,r,pos,f,d);
}
pair<int,int>query(int k,int l,int r,int pos)
{
if(l==r)return {fa[k],dep[k]};
int mid=l+r>>1;
if(pos<=mid)return query(lc[k],l,mid,pos);
else return query(rc[k],mid+1,r,pos);
}
int find(int x,int i)
{
while(1)
{
auto p=query(root[i],1,n,x);
if(p.fi==x)return x;
x=p.fi;
}
}
signed main()
{
ios::sync_with_stdio(false);
cin.tie(0);
cout.tie(0);
cin>>n>>m;
build(root[0],1,n);
for(int i=1;i<=m;i++)
{
int op;
cin>>op;
if(op==1)
{
int a,b;
cin>>a>>b;
root[i]=root[i-1];
int x=find(a,i);
int y=find(b,i);
if(x==y)continue;
auto px=query(root[i],1,n,x);
auto py=query(root[i],1,n,y);
if(px.se<py.se)swap(x,y),swap(px,py);
int tmp;
change(root[i],tmp,1,n,y,x,px.se);
root[i]=tmp;
if(px.se==py.se)
{
change(root[i],tmp,1,n,x,x,px.se+1);
root[i]=tmp;
}
}
else if(op==2)
{
int k;
cin>>k;
root[i]=root[k];
}
else
{
int a,b;
cin>>a>>b;
root[i]=root[i-1];
int x=find(a,i);
int y=find(b,i);
cout<<(x==y?1:0)<<"\n";
}
}
return 0;
}
14.2 可持久化并查集(加强版,迭代优化)
#include <bits/stdc++.h>
#define int long long
#define COUT cout<<"\n------------------------------------------------------------------------------------------\n";
#define fi first
#define se second
#define be begin()
#define en end()
using namespace std;
const int N=2e5+5;
const int mod=998244353;
const int inf=1e18;
int n,m,cnt;
int root[N],lc[N*40],rc[N*40],fa[N*40],deep[N*40];
int lastans=0;
inline void build(int &k,int l,int r)
{
k=++cnt;
if(l==r){fa[k]=l;deep[k]=1;return;}
int mid=l+r>>1;
build(lc[k],l,mid);
build(rc[k],mid+1,r);
}
inline void change(int pre,int &now,int x,int y)
{
now=++cnt;
int l=1,r=n,cur=now;
while(l<r)
{
int mid=l+r>>1;
if(y<=mid)
{
rc[cur]=rc[pre];
lc[cur]=++cnt;
cur=lc[cur];
pre=lc[pre];
r=mid;
}
else
{
lc[cur]=lc[pre];
rc[cur]=++cnt;
cur=rc[cur];
pre=rc[pre];
l=mid+1;
}
}
fa[cur]=x;
deep[cur]=deep[pre];
}
inline int query_fa(int node,int w)
{
int l=1,r=n;
while(l<r)
{
int mid=l+r>>1;
if(w<=mid){node=lc[node];r=mid;}
else{node=rc[node];l=mid+1;}
}
return fa[node];
}
inline int query_deep(int node,int w)
{
int l=1,r=n;
while(l<r)
{
int mid=l+r>>1;
if(w<=mid){node=lc[node];r=mid;}
else{node=rc[node];l=mid+1;}
}
return deep[node];
}
inline int find(int x,int i)
{
while(1)
{
int p=query_fa(root[i],x);
if(p==x)return x;
x=p;
}
}
inline void update(int pre,int &now,int w)
{
now=++cnt;
int l=1,r=n,cur=now;
while(l<r)
{
int mid=l+r>>1;
if(w<=mid)
{
rc[cur]=rc[pre];
lc[cur]=++cnt;
cur=lc[cur];
pre=lc[pre];
r=mid;
}
else
{
lc[cur]=lc[pre];
rc[cur]=++cnt;
cur=rc[cur];
pre=rc[pre];
l=mid+1;
}
}
fa[cur]=fa[pre];
deep[cur]=deep[pre]+1;
}
inline void unionn(int x,int y,int i)
{
int dx=query_deep(root[i],x);
int dy=query_deep(root[i],y);
if(dx<dy)swap(x,y);
change(root[i-1],root[i],x,y);
if(dx==dy)update(root[i],root[i],x);
}
signed main()
{
ios::sync_with_stdio(false);
cin.tie(0);
cout.tie(0);
cin>>n>>m;
build(root[0],1,n);
int z,x,y;
for(int i=1;i<=m;i++)
{
cin>>z;
if(z==1)
{
cin>>x>>y;
x^=lastans;
y^=lastans;
root[i]=root[i-1];
x=find(x,i);
y=find(y,i);
if(x!=y)unionn(x,y,i);
}
else if(z==2)
{
cin>>x;
x^=lastans;
root[i]=root[x];
}
else
{
cin>>x>>y;
x^=lastans;
y^=lastans;
root[i]=root[i-1];
x=find(x,i);
y=find(y,i);
int ans=(x==y);
cout<<ans<<"\n";
lastans=ans;
}
}
return 0;
}
14.3 带权可持久化并查集
#include <bits/stdc++.h>
#define int long long
#define COUT cout<<"\n------------------------------------------------------------------------------------------\n";
#define fi first
#define se second
#define be begin()
#define en end()
using namespace std;
const int N=2e5+5;
const int mod=998244353;
const int inf=1e18;
int n,m,cnt;
int root[N],lc[N*40],rc[N*40],fa[N*40],dep[N*40],w[N*40];
void build(int &k,int l,int r)
{
k=++cnt;
if(l==r){fa[k]=l;dep[k]=1;w[k]=0;return;}
int mid=l+r>>1;
build(lc[k],l,mid);
build(rc[k],mid+1,r);
}
void change(int pre,int &now,int l,int r,int pos,int f,int d,int ww)
{
now=++cnt;
lc[now]=lc[pre];
rc[now]=rc[pre];
if(l==r){fa[now]=f;dep[now]=d;w[now]=ww;return;}
int mid=l+r>>1;
if(pos<=mid)change(lc[pre],lc[now],l,mid,pos,f,d,ww);
else change(rc[pre],rc[now],mid+1,r,pos,f,d,ww);
}
pair<pair<int,int>,int>query(int k,int l,int r,int pos)
{
if(l==r)return {{fa[k],dep[k]},w[k]};
int mid=l+r>>1;
if(pos<=mid)return query(lc[k],l,mid,pos);
else return query(rc[k],mid+1,r,pos);
}
pair<int,int>find(int x,int i)
{
int ww=0;
while(1)
{
auto p=query(root[i],1,n,x);
if(p.fi.fi==x)return {x,ww};
ww^=p.se;
x=p.fi.fi;
}
}
signed main()
{
ios::sync_with_stdio(false);
cin.tie(0);
cout.tie(0);
cin>>n>>m;
build(root[0],1,n);
// 略:类似加强版,union 时更新 w
return 0;
}
14.4 可持久化并查集二分版本
// 在 14.2 基础上加二分
int solve(int a,int b,int l,int r)
{
while(l<r)
{
int mid=l+r>>1;
if(find(a,mid)==find(b,mid)) r=mid;
else l=mid+1;
}
return l;
}
15. 公式、定理、推论汇总表
| 名称 | 内容 |
|---|---|
| 按秩合并树高 | \(\le\lfloor\log_2 n\rfloor\) |
| 反阿克曼 | \(\alpha(n)\le4\) |
| Tarjan 定理 | 路径压缩+按秩合并摊还 \(O(\alpha(n))\) |
| 可持久化 union | \(O(\log^2 n)\) 时间,\(O(\log n)\) 空间 |
| 主席树节点数 | \(n+4m\log_2 n\) |
| 主定理 | \(T(n)=aT(n/b)+f(n)\) |
| 路径复制 | 每次 \(O(\log n)\) 新节点 |
| 版本回退 | \(O(1)\) |
| 异或在线 | \(x\gets x\oplus\text{lastans}\) |
16. 结语
可持久化并查集是并查集与可持久化数据结构结合的经典产物。
要点:
- 不能用路径压缩,只能用按秩合并。
- 用可持久化线段树存
fa和dep。 - 每次操作基于上一版本,产生新根。
- 版本回退 \(O(1)\)。
- 强制在线需异或
lastans。 - 迭代实现比递归快很多。
- 双值一次修改可省一半节点。
- 数组要开到
N*40。 - 常用于二分版本、可持久化连通性、动态图问题。
建议刷题顺序:
- 模板题:可持久化并查集。
- 加强版:强制在线 + 迭代优化。
- 带权可持久化并查集。
- 可持久化并查集 + 二分。
- 可持久化并查集 + 线段树合并。

浙公网安备 33010602011771号