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