可撤销并查集

467 阅读4分钟

可撤销并查集

Offer 驾到,掘友接招!我正在参与2022春招打卡活动,点击查看活动详情

前言 or 引入

在LeetCode某次周赛T4中。学到了简单的撤销并查集的做法,非常有趣,并由此进一步地学习了可撤销并查集,特别记录,以防遗忘,首先看LeetCode简单撤销思想的并查集应用题:

2092. 找出知晓秘密的所有专家

题目本身不难,做法有许多,在别的题解中看到了并查集的做法,大意是知晓秘密是一个连通性问题,用并查集主要难点在于同时开会的专家如何处理,通过按开会时间将所有会议排序,每次处理所有相同时间开会的专家,处理完后,将此时刻开完会但不知晓秘密的专家恢复孤立。

Code

const int N = 1e5 + 5;
int p[N];
int find(int x) {
    return x == p[x] ? x : p[x] = find(p[x]);
}

class Solution {
public:
    vector<int> findAllPeople(int n, vector<vector<int>>& meetings, int firstPerson) {
        iota(p, p + n, 0);
        sort(begin(meetings), end(meetings), [](auto&& a, auto&& b){return a[2] < b[2];});
        p[firstPerson] = 0;
        int m = meetings.size();
        for(int i = 0; i < m; i ++) {
            int j = i + 1;
            while(j < m && meetings[j][2] == meetings[i][2]) j ++;
            for(int k = i; k < j; k ++) {
                int pa = find(meetings[k][0]), pb = find(meetings[k][1]);
                p[pa] = pb;
            }
            //将开完会但不知晓秘密的专家恢复孤立
            for(int k = i; k < j; k ++) {
                int pa = find(meetings[k][0]);
                if(pa != find(0)) {
                    p[meetings[k][0]] = meetings[k][0];
                    p[meetings[k][1]] = meetings[k][1];
                }
            }
            i = j - 1;
        }
        vector<int> res;
        for(int i = 0; i < n; i ++) {
            if(find(i) == find(0)) res.push_back(i);
        }
        return res;
    }
};

概述

众所周知,并查集是维护连通性关系的一把好手,普通并查集将两个子树合并到一起后,子树中各自代表的元素即被永远地放入了同一个集合中,但是有些情况下,我们希望撤销之前的操作,使得原本连通的两个集合再次拆开。 实现起来非常简单,用一个栈记录前面的合并操作,撤销时只需要依次取出栈顶元素做合并操作的逆操作即可,注意可撤销并查集不能使用路径压缩优化,因为路径压缩会改变树的结构,从而导致无法恢复原样。仅可以采用按秩合并的优化方法,因此可撤销并查集的复杂度为O(NlogN)O(NlogN);

模板代码

const int N = 5e5 + 10;
using AI2 = array<int, 2>;
AI2 stk[N];
int p[N], sz[N], tt = 0;

// 不带路径压缩的find函数
int find(int x) {
    return x == p[x] ? x : find(p[x]);
}
// 合并操作:合并两个集合并记录操作的两个集合代表元素
bool merge(int a, int b) {
    int pa = find(a), pb = find(b);
    if(pa == pb) {
        stk[tt ++] = {-1, -1};
        return false;
    }
    if(sz[pa] > sz[pb]) swap(pa, pb);
    stk[tt ++] = {pa, pb};
    p[pa] = pb;
    sz[pb] += sz[pa];
    return true;
}
// 撤销操作:合并操作的逆操作
void revocate() {
    auto [x, y] = stk[-- tt];
    if(x == -1) return;
    p[x] = x;
    sz[y] -= sz[x];
}

例题 codeforces 891.C ENVY

题目大意:一张带权无向图中,给m个边和q个询问,每个询问中包含一些边的序号,判断这些边是否可以同时作为某一颗最小生成树的边。

思路

前置知识:若有一颗最小生成树包含边(u, v, w),那么由所有权值小于w的边组成的森林或无向图中,u, v两点必然不连通。

因此,判断某条边(u, v, w)是否可以作为某一颗最小生成树的边,即可以用并查集维护所有边权小于w的边所构成的连通关系,若在加入边(u, v, w)前u, v已连通(换句话说,加入该条边会产生环),则一定不能是最小生成树的边,若加入边(u, v, w)时u,v未连通,则根据kruskal算法,(u,v, w)一定可以是某颗最小生成树的边。

对于本题而言,对于某个询问中边权相同的边,依次将每条边加入并查集,若全部加完均不产生环,则ok。而对于不同询问中边权相等的边,我们需要在处理之前把上次询问中加入的边权等于w的边全部撤销以消除上次询问的影响。

此时整理出最终的做法,先将所有边按边权排序(用一个id数组完成,不能改变边的序号),然后将所有的询问离线,记录边序号与询问序号,将所有询问按第一关键字边权,第二关键字询问序号排序(为了同时处理同一次询问)。然后依次处理每一批具有相同边权值以及相同询问序号的边,处理前用双指针将所有边权小于当前询问边的边加入并查集,并在下一次询问前撤销,具体看代码:

Code

#include <bits/stdc++.h>
using namespace std;

const int N = 5e5 + 10;
using AI2 = array<int, 2>;
AI2 stk[N];
int p[N], sz[N], tt = 0;

int find(int x) {
    return x == p[x] ? x : find(p[x]);
}

bool merge(int a, int b) {
    int pa = find(a), pb = find(b);
    if(pa == pb) {
        stk[tt ++] = {-1, -1};
        return false;
    }
    if(sz[pa] > sz[pb]) swap(pa, pb);
    stk[tt ++] = {pa, pb};
    p[pa] = pb;
    sz[pb] += sz[pa];
    return true;
}

void revocate() {
    auto [x, y] = stk[-- tt];
    if(x == -1) return;
    p[x] = x;
    sz[y] -= sz[x];
}

using AI3 = array<int, 3>;
AI3 edges[N];
AI2 query[N];
int id[N];

int main () {
    int n, m;
    cin >> n >> m;
    for(int i = 1; i <= m; i ++) {
        int a, b, c;
        scanf("%d%d%d", &a, &b, &c);
        edges[i] = {a, b, c};
    } 
    fill_n(sz, N, 1);
    iota(p, p + N, 0);
    iota(id, id + N, 0);
    //将所有边按边权排序
    sort(id + 1, id + m + 1, [&](auto&& a, auto&& b){
        return edges[a][2] < edges[b][2];
    });
    int q;
    cin >> q;
    vector<bool> ans(q, true);
    int idx = 0;
    for(int i = 0; i < q; i ++) {
        int k;
        scanf("%d", &k);
        while(k --) {
            int x;
            scanf("%d", &x);
            query[idx ++] = {x, i};
        }
    }
    //将所有询问按(边权,询问序号)作双关键字排序
    sort(query, query + idx, [&](auto&& a, auto&& b) {
        if(edges[a[0]][2] == edges[b[0]][2]) return a[1] < b[1];
        return edges[a[0]][2] < edges[b[0]][2];
    });
    for(int i = 0, j = 1; i < idx; i ++) {
        //双指针加入所有边权小于当前询问边权的边
        while(j <= m && edges[id[j]][2] < edges[query[i][0]][2]) {
            merge(edges[id[j]][0], edges[id[j]][1]);
            j ++;
        }
        int k = i + 1;
        //找出同一批次询问并且边权相同的所有询问边
        while(k < idx && query[i][1] == query[k][1] &&
        edges[query[i][0]][2] == edges[query[k][0]][2]) 
            k ++;
        // 剪枝,该询问代表的询问序号前面已经失败则不用继续处理
        if(ans[query[i][1]]) {
            //依次处理,有环则标记该询问序号失败
            for(int l = i; l < k; l ++) {
                if(!merge(edges[query[l][0]][0], edges[query[l][0]][1]))
                    ans[query[l][1]] = false;
            }
            //撤销
            for(int l = i; l < k; l ++) revocate();
        }
        i = k - 1;
    }
    for(int i = 0; i < q; i ++) {
        if(ans[i]) puts("YES");
        else puts("NO");
    }
    return 0;
}

复杂度分析

  • 时间复杂度O(M)O(M)
  • 空间复杂度O(MlogM)O(MlogM)


欢迎讨论指正