AIGC标识 可持久化并查集详解

C++ 可持久化并查集详解

说明

本文系统讲解可持久化并查集(Persistent DSU),覆盖原理、实现、用法、变形、定理、推论、典型例题与高级扩展。

总目录

  1. 可持久化数据结构基础
  2. 并查集基础回顾
  3. 可持久化并查集原理
  4. 可持久化数组(主席树)实现
  5. 可持久化并查集实现
  6. 复杂度分析与优化
  7. 常见变形与用法
  8. 典型例题精讲(含你所给两道题)
  9. 高级话题与研究扩展
  10. 相关定理、推论、公式速查
  11. 代码优化技巧
  12. 与其他可持久化结构结合
  13. 常见错误与调试
  14. 完整模板代码

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 反阿克曼函数

\[\alpha(n)=\min\{k:A(k,k)\ge n\} \]

其中 \(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 优化方向

  1. 单次 change 修改 fa 和 dep:若能一次修改两值,减少一半节点。
  2. 迭代代替递归:减少函数调用开销。
  3. 压缩节点结构:用 int 数组,避免 struct 对齐开销。
  4. 按秩合并用 dep 而非 rank:避免额外维护。
  5. 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 反阿克曼函数

\[\alpha(n)=\min\{k:A(k,k)\ge n\} \]

其中 \(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 主席树节点数估算

\[\text{nodes}\le n+4m\log_2 n \]

对于 \(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. 结语

可持久化并查集是并查集与可持久化数据结构结合的经典产物。
要点:

  1. 不能用路径压缩,只能用按秩合并。
  2. 用可持久化线段树存 fa 和 dep。
  3. 每次操作基于上一版本,产生新根。
  4. 版本回退 \(O(1)\)。
  5. 强制在线需异或 lastans。
  6. 迭代实现比递归快很多。
  7. 双值一次修改可省一半节点。
  8. 数组要开到 N*40。
  9. 常用于二分版本、可持久化连通性、动态图问题。

建议刷题顺序:

  1. 模板题:可持久化并查集。
  2. 加强版:强制在线 + 迭代优化。
  3. 带权可持久化并查集。
  4. 可持久化并查集 + 二分。
  5. 可持久化并查集 + 线段树合并。
posted @ 2026-09-19 18:36  Aries2011  阅读(26)  评论(0)    收藏  举报