可撤销并查集

定义及用处

由于众所周知,并查集只可以加边而不可以删边,所以有了可撤销并查集。

可撤销并查集只可以按照加入的时间从后到前撤销加边操作。

常用于一些连通性问题,并且很多时候会调整处理的顺序,只要按照上述撤销顺序撤销边即可。

思路与实现

可撤销在有时可以通过改变加入顺序中途撤销求解问题。那又怎么做呢?路径压缩会改变树的形态,所以只用按秩合并(按子树大小合并,小的那颗合并到大的上面去),单次时间复杂度为 logn 级别,因为对于每个节点来说,深度最多是 logn 级别的。

具体的,我们会把所有加入的边压入一个栈,然后当什么时候要撤销时不断从栈顶弹出一条边,撤销掉。而至于具体的撤销步骤,我们假设此边原来是把 y 连向 x,那么我们直接把 y 的父亲设为 y 本身,因为在合并两个集合时是把两个端点都执行了一遍找祖先的操作,所以 y 必定作为其原集合的祖先。我们再把 x 的子树大小减去一个 y 的子树大小,那么就还原成功了。

那么对于一条边为什么一定要是有顺序的撤销呢?如果不是按出栈的顺序撤销,那么必定有比他晚一些连边的集合的大小没法维护,所以必须按出栈顺序撤销。

 模板:

struct DSU{
    int n = 0, tot = 0, fa[N], sz[N], s[N];
    void ins(){n++, fa[n] = n, sz[n] = 1;}//插入节点
    int F(int x){return fa[x] == x? x : F(fa[x]);}//即find查找函数
    void U(int x, int y){//合并函数
        x = F(x), y = F(y);
        if(x == y) return;
        if(sz[x] < sz[y]) swap(x, y);
        s[++tot] = y, fa[y] = x, sz[x] += sz[y];
    }
    void D(){//删除栈顶边
        if(!tot) return;
        int y = s[tot--]; sz[fa[y]] -= sz[y], fa[y] = y;
    }
    void back(int t = 0){while(tot > t) D();}//删除到只剩t条边
}d;
例题:可持久化并查集

我们用可撤销并查集离线玩。直接做很难,因为撤销操作可以撤销之前的撤销操作,这样很可能时嵌套的,所以我们考虑建成一棵树:如果是第 1,3 种操作,那么由 i1 连向 i,表示 i1 执行完后执行 i,如果是第 2 种操作,那么把 k 连向 i,表示 ik 之后(因为撤销了 ik 中间的)。这样建图之后我们发现与一个节点的操作相关的是祖先到他这条路径上的操作。那么一遍深度优先搜索,到了每个节点操作一下了,然后继续递归,返回时去掉操作(即撤销)。

void Dfs(int u){
    t[u] = d.tot;
    if(typ[u] == 1) d.U(a[u], b[u]);//合并操作
    if(typ[u] == 3) ans[u] = (d.F(a[u]) == d.F(b[u]));//询问
    E(i, u) Dfs(e[i].v); d.back(t[u]);
}
int main(){
    scanf("%d%d", &n, &m);
    memset(h, -1, sizeof(h));
    L(i, 1, n) d.ins();
    L(i, 1, m){
        int opt; scanf("%d", &opt), typ[i] = opt;
        if(opt == 1 || opt == 3)
            scanf("%d%d", &a[i], &b[i]), Adde(i - 1, i);
        else//回到历史版本
            scanf("%d", &a[i]), Adde(a[i], i);
    }
    Dfs(0);
    L(i, 1, m) if(typ[i] == 3) printf("%d\n", ans[i]);
    return 0;
}
例题:CF891C

这道题与很多可撤销并查集的题目一样,都是需要通过一系列操作转换成可以使用可撤销并查集。可撤销并查集和其它数据结构一样,只是作为实现过程中的一种工具。

这道题我们离线做。首先,我们知道最小生成树涉及连通性,就是如果第 i 条边选,那么当前他的两个端点必须是不联通的。然后我们可以想到把所有询问的所有边中 w 相同的放在一起处理。对于相同的 w 且处在同一个询问里的边,我们先把所有权值小于他们的都加入,然后对他们判断每条边是否能合法加入,最后更新答案即可。这样的话我们不难发现我们把权值相同且不处在当前询问中的边后加入了,而处于当前询问中的会优先加入,那么加入是合法的可能性会更大。

bool cmp(E x, E y){return x.w < y.w;}//w从小到大排序
bool cmp2(Q x, Q y){return (g[x.x].w == g[y.x].w)? (x.i < y.i) : (g[x.x].w < g[y.x].w);}//按边的权值w排序,w相同按照询问的编号排序,这样可以使边权相同且是同个询问的排在一起。
int main(){
    scanf("%d%d", &n, &m);
    L(i, 1, n) d.ins();
    L(i, 1, m)
        scanf("%d%d%d", &e[i].u, &e[i].v, &e[i].w), g[i] = e[i];
    sort(e + 1, e + m + 1, cmp);
    scanf("%d", &q);
    L(i, 1, q){
        int k, x; scanf("%d", &k), ans[i] = 1;
        L(j, 1, k)
            scanf("%d", &x), t[++tot] = {i, x};//每个询问的每条边加入进去
    }
    sort(t + 1, t + tot + 1, cmp2);
    int id = 1;
    L(i, 1, tot){
        if(!ans[t[i].i]) continue;
        if(i > 1 && (g[t[i - 1].x].w < g[t[i].x].w || t[i - 1].i < t[i].i))//询问不同或w不同
            d.back();
        while(id <= m && e[id].w < g[t[i].x].w)//把小于当前边的w的全部加进去
            d.U(e[id].u, e[id].v, 0), id++;//第三个参数0表示不放到撤销列表中
        if(d.F(g[t[i].x].u) == d.F(g[t[i].x].v))//不合法
            ans[t[i].i] = 0;//更新答案
        else
            d.U(g[t[i].x].u, g[t[i].x].v, 1);//合并
    }
    L(i, 1, q)
        puts(ans[i]? "YES" : "NO");
    return 0;
}

可撤销并查集与分治

可撤销并查集常数巨小,常和分治一起使用,只可惜是带了两只 log 的。

套路?

  1. 将边加入并查集中。
  2. 递归解决问题。
  3. 在递归的最后一层处理询问。
  4. 撤销边,并返回。

框架:

void DNC(int l, int r){
    if(l == r){
        //update ans
        return;
    }
    int mid = l + r >> 1, tm = d.tot;//tm为加边前的状态,回退用
    L(i, mid + 1, r)
        //add edge
    DNC(l, mid), d.back(tm);//回到历史状态
    L(i, l, mid)
        //add edge
    DNC(mid + 1, r), d.back(tm);//回到历史状态
}
例题:CF1681F

这题是可撤销并查集与分治结合的模板题。

首先,我们能得到一个结论,对于一条边而言,把图上所有权值等于该边的边全部删除,那么新图中,这条边的两个端点的联通块大小的乘积即对这条边对答案的贡献。

我们不妨把权值看作节点,而权值最多有不超过 n 个,然后按照上述分治的模板加上连边再统计答案即可。而至于连边的话就是递归左边,把右边权值的所有边加入;递归右边,把左边权值的所有边加入。因为我们要把除了权值 w 的所有边都给加入,而这样刚好可以实现。

时间分治/线段树分治

即我们每条边有个存在的时间,然后要求对每个时间进行查询一些并查集可做的操作,比如是否联通啊,或者什么判定二分图啊之类的。

暴力的不用说,想必大家都会,对每个时间开个并查集,然后暴力加边,暴力查询。

我们把暴力优化一下,我们采用线段树——我们把边加到线段树的节点上。即我们对线段树的每个节点开个动态数组,然后像普通线段树的修改那样把区间的动态数组里加入这条边(不需要懒标记),然后我们离线查询,遍历一遍整颗线段树处理出所有答案。进入一个节点时,我们加入属于这整个区间的边(当前节点的动态数组里的边);出去时,删除之前加入的边。那么我们对每个时间节点维护的是根到其路径上所有的边构成的并查集,也就是这个时间节点里存在的所有边。

模板(这是处理询问的模板):

void Query(int p, int l, int r) {
    //加边
    if (l == r) {
        //处理询问
        return;
    }
    int mid = l + r >> 1;
    Query(p << 1, l, mid), Query(p << 1 | 1, mid + 1, r);
    //删边
}

我们思考时间复杂度是什么,即建树以及最后的遍历线段树是 O(n) 的,中间的加边是操作数乘上一个 log 的。为什么最后的遍历是 O(n) 的呢?因为加边和删边的数量是固定的,与线段树的时间复杂度是隔开的,毫不相关,所以复杂度还是遍历整颗线段树的时间复杂度。

所以最高的时间复杂度是边数乘一个并查集的复杂度。

例题:二分图 /【模板】线段树分治

最模板的题,按照上述模板做即可,代码贴一段。

void Update(int p, int l, int r, int x, int y, pair<int, int> pr){
    if(x <= l && r <= y){
        t[p].push_back(pr); return;
    }
    int mid = l + r >> 1;
    if(x <= mid) Update(p << 1, l, mid, x, y, pr);
    if(mid < y) Update(p << 1 | 1, mid + 1, r, x, y, pr);
}
void Query(int p, int l, int r){
    int ti = d.top;
    for(auto pr: t[p]){
        int x = pr.first, y = pr.second;
        if(d.F(x) == d.F(y)){d.back(ti); return;}
        d.U(x, y + n), d.U(x + n, y);
    }
    if(l == r){ans[l] = 1, d.back(ti); return;}
    int mid = l + r >> 1;
    Query(p << 1, l, mid), Query(p << 1 | 1, mid + 1, r);
    d.back(ti);
}
例题:[SDOI2008] 洞穴勘测

首先,我们通过排序和处理能得到每条边存在的时间(这里的时间是对操作而言的),然后进行上述模板题。询问的话利用动态数组存每个时间点的询问。还是比较模板化。

posted @   徐子洋  阅读(1570)  评论(0编辑  收藏  举报  
相关博文:
阅读排行:
· 分享一个免费、快速、无限量使用的满血 DeepSeek R1 模型,支持深度思考和联网搜索!
· 使用C#创建一个MCP客户端
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· ollama系列1:轻松3步本地部署deepseek,普通电脑可用
· 按钮权限的设计及实现
点击右上角即可分享
微信分享提示