可持久化并查集详解
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;
}
带注释版
// n为原数组长度,m为操作总次数,cnt为可持久化线段树的节点计数器,用于动态分配新节点编号
int n,m,cnt;
// root数组存储每个版本线段树的根节点编号;lc、rc分别存储每个树节点的左、右子节点编号;val存储每个树节点对应的权值
// 开N*40的空间是可持久化线段树的常规配置,m次操作最多新增m*logn个节点,2e5*40的空间足以避免越界
int root[N],lc[N*40],rc[N*40],val[N*40];
// 存储初始输入的原数组
int a[N];
/**
* 构建初始版本的线段树
* @param k 当前要创建的树节点的引用
* @param l 当前节点覆盖的区间左端点
* @param r 当前节点覆盖的区间右端点
*/
void build(int &k,int l,int r)
{
// 节点计数器自增,分配一个新的树节点编号
k=++cnt;
// 递归到叶子节点,直接把原数组对应位置的值赋值给当前节点的权值
if(l==r)
{
val[k]=a[l];
return;
}
// 计算当前区间的中点,等价于(l+r)/2,位运算写法效率更高
int mid=l+r>>1;
// 递归构建左子树,覆盖[l, mid]区间
build(lc[k],l,mid);
// 递归构建右子树,覆盖[mid+1, r]区间
build(rc[k],mid+1,r);
}
/**
* 基于旧版本线段树,创建一个执行单点修改后的新版本线段树
* @param pre 旧版本线段树的根节点编号
* @param now 新版本线段树的根节点编号的引用
* @param l 当前操作覆盖的区间左端点
* @param r 当前操作覆盖的区间右端点
* @param pos 要修改的数组下标位置
* @param v 要将pos位置更新为的新值
*/
void change(int pre,int &now,int l,int r,int pos,int v)
{
// 分配新节点作为新版本的当前节点
now=++cnt;
// 直接继承旧版本当前节点的左子树,无需复制整棵树,实现空间复用
lc[now]=lc[pre];
// 直接继承旧版本当前节点的右子树
rc[now]=rc[pre];
// 递归到叶子节点,直接更新当前节点的权值为新值v
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);
}
/**
* 查询指定版本线段树中,pos位置的数组数值
* @param k 要查询的线段树版本的根节点编号
* @param l 当前节点覆盖的区间左端点
* @param r 当前节点覆盖的区间右端点
* @param pos 要查询的数组下标位置
* @return 查询到的pos位置的数值
*/
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);
}
// 由于前面宏定义了int为long long,main函数的返回值要求是int类型,这里用signed表示返回值为有符号32位整数,符合C++标准
signed main()
{
// 关闭C和C++标准输入输出流的同步,大幅提升cin、cout的输入输出效率
ios::sync_with_stdio(false);
// 解除cin和cout的绑定,避免每次cin操作都强制刷新cout缓冲区,进一步提升输入效率
cin.tie(0);
// 解除cout和cin的绑定,优化输出效率
cout.tie(0);
// 读入数组长度n和操作总次数m
cin>>n>>m;
// 读入原数组的所有元素
for(int i=1;i<=n;i++)cin>>a[i];
// 构建第0号初始版本的线段树,对应未执行任何修改的原始数组
build(root,1,n);
// 循环处理m次操作,第i次操作生成第i个版本的线段树
for(int i=1;i<=m;i++)
{
int v,op,pos,x;
// 读入参数:v表示要基于的历史版本号,op是操作类型,pos是操作对应的下标位置
cin>>v>>op>>pos;
// op等于1代表单点修改操作
if(op==1)
{
// 读入要更新的新值x
cin>>x;
// 基于第v号历史版本,创建第i号新版本,将pos位置的值修改为x
change(root[v],root[i],1,n,pos,x);
}
// op不等于1代表单点查询操作
else
{
// 新版本i直接复用第v号历史版本的整棵树,无需创建新节点
root[i]=root[v];
// 查询第v号版本下pos位置的数值并输出
cout<<query(root[i],1,n,pos)<<"\n";
}
}
// 程序正常结束返回0
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;
}
带注释版
int n,m,cnt; // 定义全局变量:n为元素总数,m为操作总次数,cnt为可持久化线段树节点计数器
int root[N]; // 定义全局数组,存储每个版本的可持久化线段树的根节点编号
int lc[N*40],rc[N*40],fa[N*40],dep[N*40]; // 定义全局数组:lc为节点左孩子编号,rc为节点右孩子编号,fa为节点存储的父节点值,dep为节点存储的树的深度(用于按秩合并)
void build(int &k,int l,int r) // 定义可持久化线段树的构建函数,k为当前节点编号,l为当前区间左端点,r为当前区间右端点
{
k=++cnt; // 节点计数器自增,为当前节点分配编号
if(l==r) // 若当前区间为叶子节点
{
fa[k]=l; // 叶子节点的父节点为自身编号
dep[k]=1; // 叶子节点的深度初始化为1
return; // 递归返回
}
int mid=l+r>>1; // 计算当前区间的中点
build(lc[k],l,mid); // 递归构建左子树,区间为[l, mid]
build(rc[k],mid+1,r); // 递归构建右子树,区间为[mid+1, r]
}
void change(int pre,int &now,int l,int r,int pos,int f,int d) // 定义可持久化线段树的修改函数,pre为上一版本的根节点编号,now为当前版本的根节点编号,l为当前区间左端点,r为当前区间右端点,pos为要修改的位置,f为新的父节点值,d为新的深度值
{
now=++cnt; // 节点计数器自增,为当前新版本节点分配编号
lc[now]=lc[pre]; // 复制上一版本节点的左孩子指针
rc[now]=rc[pre]; // 复制上一版本节点的右孩子指针
if(l==r) // 若当前区间为叶子节点
{
fa[now]=f; // 更新叶子节点的父节点值为传入的f
dep[now]=d; // 更新叶子节点的深度值为传入的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) // 定义可持久化线段树的查询函数,k为当前节点编号,l为当前区间左端点,r为当前区间右端点,pos为要查询的位置,返回值为pair类型,第一个元素为父节点值,第二个元素为深度值
{
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) // 定义并查集的查找函数,x为要查找的元素,i为指定的版本号,返回值为元素x在版本i下的根节点编号
{
while(1) // 循环查找,直到找到根节点
{
auto p=query(root[i],1,n,x); // 查询元素x在版本i下的父节点和深度
if(p.fi==x)return x; // 若父节点为自身,说明已经找到根节点,返回x
x=p.fi; // 否则将x更新为父节点,继续循环查找
}
}
signed main() // 主函数,程序入口
{
ios::sync_with_stdio(false); // 关闭C++标准输入输出流与C标准输入输出流的同步,提升输入输出速度
cin.tie(0); // 解除cin与cout的绑定,提升输入输出速度
cout.tie(0); // 解除cout与cin的绑定,提升输入输出速度
// 空行,代码排版使用
cin>>n>>m; // 输入元素总数n和操作总次数m
build(root[0],1,n); // 构建初始版本(版本0)的可持久化线段树
for(int i=1;i<=m;i++) // 循环处理m次操作,i为当前操作的版本号
{
int op; // 定义操作类型变量
cin>>op; // 输入当前操作的类型
if(op==1) // 操作类型1:合并两个元素所在的集合
{
int a,b; // 定义要合并的两个元素a和b
cin>>a>>b; // 输入要合并的两个元素
root[i]=root[i-1]; // 先将当前版本的根节点设置为上一版本的根节点
int x=find(a,i); // 查找元素a在当前版本下的根节点
int y=find(b,i); // 查找元素b在当前版本下的根节点
if(x==y)continue; // 若两个元素的根节点相同,说明已经在同一个集合中,无需合并,跳过本次操作
auto px=query(root[i],1,n,x); // 查询根节点x的父节点和深度
auto py=query(root[i],1,n,y); // 查询根节点y的父节点和深度
if(px.se<py.se)swap(x,y),swap(px,py); // 若x的深度小于y的深度,交换x和y,保证将深度更小的树合并到深度更大的树上,实现按秩合并
// 修改y的fa为x
int tmp; // 定义临时变量存储修改后的根节点
change(root[i],tmp,1,n,y,x,px.se); // 修改元素y的父节点为x,深度为x的深度
root[i]=tmp; // 将当前版本的根节点更新为修改后的根节点
if(px.se==py.se) // 若两个树的深度相等
{
// 修改x的dep+1
change(root[i],tmp,1,n,x,x,px.se+1); // 将x的深度加1
root[i]=tmp; // 将当前版本的根节点更新为修改后的根节点
}
}
else if(op==2) // 操作类型2:回退到指定的历史版本
{
int k; // 定义要回退到的版本号k
cin>>k; // 输入要回退到的版本号
root[i]=root[k]; // 将当前版本的根节点设置为版本k的根节点,实现回退
}
else // 操作类型3:查询两个元素是否在同一个集合中
{
int a,b; // 定义要查询的两个元素a和b
cin>>a>>b; // 输入要查询的两个元素
root[i]=root[i-1]; // 先将当前版本的根节点设置为上一版本的根节点
int x=find(a,i); // 查找元素a在当前版本下的根节点
int y=find(b,i); // 查找元素b在当前版本下的根节点
cout<<(x==y?1:0)<<"\n"; // 若两个元素的根节点相同,输出1,否则输出0
}
}
return 0; // 程序正常结束,返回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;
}
带注释版
// 定义全局变量n、m、cnt,分别表示数据规模、操作次数、可持久化节点的计数器
int n,m,cnt;
// 定义全局数组root、lc、rc、fa、deep,分别存储各版本根节点、左孩子、右孩子、父节点、深度信息
int root[N*40],lc[N*40],rc[N*40],fa[N*40],deep[N*40];
// 定义全局变量lastans,存储上一次查询的结果,用于异或解密输入的x和y
int lastans=0;
// 构建可持久化线段树的函数,参数k为当前节点引用,l和r为当前节点覆盖的区间
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);
}
// 可持久化更新父节点的函数,参数pre为上一版本的根节点,now为当前版本根节点引用,x为新父节点,y为需要更新的节点
void change(int pre,int &now,int x,int y)
{
// 节点计数器自增,分配新节点的编号
now=++cnt;
// 初始化当前遍历区间为1到n
int l=1,r=n;
// 定义cur为当前正在处理的节点
int cur=now;
// 循环直到区间左右边界相等
while(l<r)
{
// 计算区间中点
int mid=l+r>>1;
// 如果y小于等于中点,说明需要更新左子树
if(y<=mid)
{
// 当前节点的右孩子直接复用pre节点的右孩子
rc[cur]=rc[pre];
// 给当前节点分配新的左孩子节点
lc[cur]=++cnt;
// cur移动到左孩子节点
cur=lc[cur];
// pre移动到pre节点的左孩子节点
pre=lc[pre];
// 更新区间右边界为mid
r=mid;
}
// 否则需要更新右子树
else
{
// 当前节点的左孩子直接复用pre节点的左孩子
lc[cur]=lc[pre];
// 给当前节点分配新的右孩子节点
rc[cur]=++cnt;
// cur移动到右孩子节点
cur=rc[cur];
// pre移动到pre节点的右孩子节点
pre=rc[pre];
// 更新区间左边界为mid+1
l=mid+1;
}
}
// 给叶子节点设置父节点为x
fa[cur]=x;
// 叶子节点的深度和pre节点的叶子节点深度一致
deep[cur]=deep[pre];
}
// 查询指定节点在第i个版本下的父节点,参数node为当前版本的根节点,w为要查询的节点编号
int query_fa(int node,int w)
{
// 初始化当前遍历区间为1到n
int l=1,r=n;
// 循环直到区间左右边界相等
while(l<r)
{
// 计算区间中点
int mid=l+r>>1;
// 如果w小于等于中点,移动到左孩子节点,更新区间右边界
if(w<=mid){node=lc[node];r=mid;}
// 否则移动到右孩子节点,更新区间左边界
else{node=rc[node];l=mid+1;}
}
// 返回叶子节点存储的父节点值
return fa[node];
}
// 查询指定节点在第i个版本下的深度,参数node为当前版本的根节点,w为要查询的节点编号
int query_deep(int node,int w)
{
// 初始化当前遍历区间为1到n
int l=1,r=n;
// 循环直到区间左右边界相等
while(l<r)
{
// 计算区间中点
int mid=l+r>>1;
// 如果w小于等于中点,移动到左孩子节点,更新区间右边界
if(w<=mid){node=lc[node];r=mid;}
// 否则移动到右孩子节点,更新区间左边界
else{node=rc[node];l=mid+1;}
}
// 返回叶子节点存储的深度值
return deep[node];
}
// 查找节点x在第i个版本下的根节点,参数x为要查找的节点,i为版本号
int find(int x,int i)
{
// 无限循环直到找到根节点
while(1)
{
// 查询x在第i个版本下的父节点p
int p=query_fa(root[i],x);
// 如果p等于x,说明x本身就是根节点,直接返回x
if(p==x)return x;
// 否则将x更新为p,继续循环查找
x=p;
}
}
// 可持久化更新节点深度的函数,参数pre为上一版本的根节点,now为当前版本根节点引用,w为需要更新深度的节点
void update(int pre,int &now,int w)
{
// 节点计数器自增,分配新节点的编号
now=++cnt;
// 初始化当前遍历区间为1到n
int l=1,r=n;
// 定义cur为当前正在处理的节点
int cur=now;
// 循环直到区间左右边界相等
while(l<r)
{
// 计算区间中点
int mid=l+r>>1;
// 如果w小于等于中点,说明需要更新左子树
if(w<=mid)
{
// 当前节点的右孩子直接复用pre节点的右孩子
rc[cur]=rc[pre];
// 给当前节点分配新的左孩子节点
lc[cur]=++cnt;
// cur移动到左孩子节点
cur=lc[cur];
// pre移动到pre节点的左孩子节点
pre=lc[pre];
// 更新区间右边界为mid
r=mid;
}
// 否则需要更新右子树
else
{
// 当前节点的左孩子直接复用pre节点的左孩子
lc[cur]=lc[pre];
// 给当前节点分配新的右孩子节点
rc[cur]=++cnt;
// cur移动到右孩子节点
cur=rc[cur];
// pre移动到pre节点的右孩子节点
pre=rc[pre];
// 更新区间左边界为mid+1
l=mid+1;
}
}
// 叶子节点的父节点和pre节点的叶子节点父节点一致
fa[cur]=fa[pre];
// 叶子节点的深度比pre节点的叶子节点深度大1
deep[cur]=deep[pre]+1;
}
// 并查集合并操作,参数x和y为要合并的两个节点,i为当前版本号
void unionn(int x,int y,int i)
{
// 查询x在第i个版本下的深度dx
int dx=query_deep(root[i],x);
// 查询y在第i个版本下的深度dy
int dy=query_deep(root[i],y);
// 如果dx小于dy,交换x和y,保证dx>=dy
if(dx<dy)swap(x,y);
// 将y的父节点更新为x,生成新版本的根节点root[i]
change(root[i-1],root[i],x,y);
// 如果dx等于dy,需要将x的深度加1
if(dx==dy)update(root[i],root[i],x);
}
// 主函数,程序入口
signed main()
{
// 关闭C++标准输入输出的同步,提升输入输出速度
ios::sync_with_stdio(false);
// 解除cin和cout的绑定,提升输入输出速度
cin.tie(0);
// 解除cout和cin的绑定,提升输入输出速度
cout.tie(0);
// 输入n和m,分别表示节点总数和操作总次数
cin>>n>>m;
// 构建初始版本(版本0)的可持久化线段树
build(root[0],1,n);
// 定义变量z、x、y,分别表示操作类型、操作参数x、操作参数y
int z,x,y;
// 循环处理m次操作
for(int i=1;i<=m;i++)
{
// 输入操作类型z
cin>>z;
// 如果操作类型为1,执行合并操作
if(z==1)
{
// 输入x和y
cin>>x>>y;
// x异或上一次的查询结果lastans,解密得到真实的x值
x^=lastans;
// y异或上一次的查询结果lastans,解密得到真实的y值
y^=lastans;
// 初始时当前版本根节点复用前一版本的根节点
root[i]=root[i-1];
// 查找x在当前版本下的根节点
x=find(x,i);
// 查找y在当前版本下的根节点
y=find(y,i);
// 如果x和y不是同一个根节点,执行合并操作
if(x!=y)unionn(x,y,i);
}
// 如果操作类型为2,执行版本回退操作
else if(z==2)
{
// 输入x
cin>>x;
// x异或上一次的查询结果lastans,解密得到真实的x值
x^=lastans;
// 当前版本的根节点直接复用第x个版本的根节点,实现版本回退
root[i]=root[x];
}
// 如果操作类型为3,执行查询两个节点是否连通的操作
else
{
// 输入x和y
cin>>x>>y;
// x异或上一次的查询结果lastans,解密得到真实的x值
x^=lastans;
// y异或上一次的查询结果lastans,解密得到真实的y值
y^=lastans;
// 初始时当前版本根节点复用前一版本的根节点
root[i]=root[i-1];
// 查找x在当前版本下的根节点
x=find(x,i);
// 查找y在当前版本下的根节点
y=find(y,i);
// 两个节点的根节点相同则连通,ans为1,否则为0
int ans=(x==y);
// 输出ans
cout<<ans<<"\n";
// 将ans赋值给lastans,用于下一次输入的解密
lastans=ans;
}
}
// 程序正常结束,返回0
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。 - 常用于二分版本、可持久化连通性、动态图问题。
建议刷题顺序:
- 模板题:可持久化并查集。
- 加强版:强制在线 + 迭代优化。
- 带权可持久化并查集。
- 可持久化并查集 + 二分。
- 可持久化并查集 + 线段树合并。
本文来自博客园,作者:Aries2011,转载请注明原文链接:https://www.cnblogs.com/Aries2011/p/23039327

浙公网安备 33010602011771号