并查集

并查集是一种可以动态维护若干个不重叠的集合,并支持合并与查询的数据结构。

例题:P1551 亲戚

题目描述: 如果 xy 是亲戚,yz 是亲戚,那么 xz 也是亲戚。如果 xy 是亲戚,那么 x 的亲戚都是 y 的亲戚,y 的亲戚也都是 x 的亲戚。现在给出某个亲戚关系图,求任意给出的两个人是否具有亲戚关系。
输入格式: 输入 3 个整数 n,m,p (n,m,p5000),分别表示有 n 个人,m 对亲戚关系,p 对亲戚关系的询问。接下来 m 行,每行两个数说明这两个人是亲戚。接下来 p 行,每行询问两个人是否是亲戚。
输出格式: 对于每次查询,需要输出 Yes 或者 No 表示这次查询是否是亲戚关系。

分析: 将所有有亲戚关系的人归为同一个集合中(同一个家族)。如果想查询两个人是否有亲属关系,只需要判断这两个人是否为同一个家族内。

image

那么,怎么判断两个人是否在同一个家族内呢?可以从每个家族中选出一位“族长”来代表整个家族,这样只需要知道两个人的族长是否为同一人,就能判断出是否属于同一个家族。

规定所有的成员都有一名“负责人”,最开始的时候,所有人都“自成一族”,每个成员都有一个指向自己的箭头,意思是自己的“负责人”就是自己,这时本人就是族长。

image

假如得知 12 是亲戚关系,那么就需要将 12 合并为同一个家族,将 1 的“负责人”改成 2 即可(反过来也可以)。于是,关系就变为:

image

由于 1 号的负责人变成了 2,族长换成了 2,所以家族 1 不复存在。这时,1 号和 2 号在同一个家族中,他们的族长都是 2 号。

假如得知 15 也是亲戚关系。直接将 1 号的“负责人”改成 5 是不行的(不然和 2 号建立起的关系就丢失了),不过可以把 1 号的族长(也就是 2 号)的负责人变成 5 号。这样 1,2,5 三个人都成为亲戚关系了,他们的族长是 5 号。

image

接下来假如得知 34 是亲戚关系,只需要将 3 的负责人变成 4,都归为家族 4。假如 25 是亲戚关系,由于发现 25 的族长都是 5,已经是同一个家族了,所以可以忽略掉。

假如 13 是亲戚关系,将 1 的族长(5 号)的负责人变成 3 的族长(4 号),至此一共就只剩下两个家族了。需要注意的是,查询某位成员的族长要沿着负责人关系一层一层往上遍历,直到发现自己的负责人就是自己,这位成员就是族长。

image

如果需要查询两个人是否是同一个家族的,只需要查询这两个人的族长是否是同一个人。如果要把两个家族合并,就把其中一个家族的族长的负责人指向另外一个家族的族长即可。

这种处理不相交可合并集合关系的数据结构叫做并查集。并查集具有查询、合并这两种基本操作。使用并查集时要先初始化并查集,然后处理这 m 对亲戚关系,如果两个人是亲戚,那么就在并查集上所对应的集合合并起来,然后对于每个询问,只需要把这两个人的集合的代表元素找出来,判断是否相等即可——如果相等,那么这两个人在同一个集合中,即为亲戚,否则不是。

参考代码
#include <cstdio>
const int N = 5005;
int fa[N];
int query(int x) {
return x == fa[x] ? x : query(fa[x]);
}
int main()
{
int n, m, p; scanf("%d%d%d", &n, &m, &p);
for (int i = 1; i <= n; i++) fa[i] = i;
for (int i = 1; i <= m; i++) {
int x, y; scanf("%d%d", &x, &y);
int fx = query(x), fy = query(y);
if (fx != fy) fa[fx] = fy;
}
for (int i = 1; i <= p; i++) {
int x, y; scanf("%d%d", &x, &y);
int fx = query(x), fy = query(y);
if (fx == fy) printf("Yes\n");
else printf("No\n");
}
return 0;
}

这里的实现是暴力的,单次操作时间复杂度最差是 O(n),实际使用时还要学习几种优化方式。

例题:P3367 【模板】并查集

路径压缩

因为只关心每个集合里有哪些点,而不关心这个集合对应的树长什么样,于是可以把点都直接挂在根节点下面,让树高尽量小。

实现的时候只需要在查询操作中,把沿路经过的每个点的父节点都设成根节点即可。

参考代码
#include <cstdio>
const int N = 200005;
int fa[N];
int query(int x) {
return fa[x] == x ? x : fa[x] = query(fa[x]);
}
int main()
{
int n, m; scanf("%d%d", &n, &m);
for (int i = 1; i <= n; i++) fa[i] = i;
for (int i = 1; i <= m; i++) {
int z, x, y; scanf("%d%d%d", &z, &x, &y);
int fx = query(x), fy = query(y);
if (z == 1) {
if (fx != fy) fa[fx] = fy;
} else {
printf("%s\n", fx == fy ? "Y" : "N");
}
}
return 0;
}

优化后单次操作时间复杂度最差情况是 O(logn),一般到不了,平均情况下系数很小,已经足够使用了,不过路径压缩以后树的结构就不是原有结构了。

按秩合并

既然希望树高尽量小,那如果每次让树高小的那边合并到树高大的那边,得到的新树的高度就相对较小了,这就叫按秩合并。

用一个数组 rk 维护树高,如果树高不等,设树高小的那棵树的根节点的父节点为树高大的那棵树的根节点,如果树高相等,则谁当新的根都行,不过 rk 要加 1

参考代码
#include <cstdio>
const int N = 200005;
int fa[N], rk[N];
int query(int x) {
return fa[x] == x ? x : query(fa[x]);
}
int main()
{
int n, m; scanf("%d%d", &n, &m);
for (int i = 1; i <= n; i++) {
fa[i] = i; rk[i] = 1;
}
for (int i = 1; i <= m; i++) {
int z, x, y; scanf("%d%d%d", &z, &x, &y);
int fx = query(x), fy = query(y);
if (z == 1) {
if (fx != fy) {
if (rk[fx] < rk[fy]) {
fa[fx] = fy;
} else if (rk[fx] > rk[fy]) {
fa[fy] = fx;
} else {
fa[fy] = fx; rk[fx]++;
}
}
} else {
printf("%s\n", fx == fy ? "Y" : "N");
}
}
return 0;
}

单次操作时间复杂度最差 O(logn),假设一个点时树高为 1,那么需要两个点能合并成树高为 2 的,需要两个树高为 2 即至少 4 个点才能合并出树高为 3 的,以此类推,树高最多 O(logn) 级别。

启发式合并

另一种按秩合并的方法,按集合中节点数大小合并,这个合并方法也叫启发式合并,有时在其它的合并问题中也会有应用。

用一个数组 sz 维护集合大小,sz 小的树的根的父节点设为 sz 大的树的根,sz 也要相应调整。

参考代码
#include <cstdio>
const int N = 200005;
int fa[N], sz[N];
int query(int x) {
return fa[x] == x ? x : query(fa[x]);
}
int main()
{
int n, m; scanf("%d%d", &n, &m);
for (int i = 1; i <= n; i++) {
fa[i] = i; sz[i] = 1;
}
for (int i = 1; i <= m; i++) {
int z, x, y; scanf("%d%d%d", &z, &x, &y);
int fx = query(x), fy = query(y);
if (z == 1) {
if (fx != fy) {
if (sz[fx] > sz[fy]) {
fa[fy] = fx; sz[fx] += sz[fy];
} else {
fa[fx] = fy; sz[fy] += sz[fx];
}
}
} else {
printf("%s\n", fx == fy ? "Y" : "N");
}
}
return 0;
}

单次操作最差时间复杂度 O(logn),如果一个点所对应的根变化了,那么一定是把这个集合合并到了一个更大的集合里,就是该点所在集合点数起码乘 2,那么对于这个点,它所在的集合的根最多变化 O(logn) 次,每变化一次是该点深度加 1,因此该点所在深度不会超过 O(logn)

如果同时使用启发式合并和路径压缩,单次操作的时间复杂度是反阿克曼函数,可以认为系数非常小。

参考代码
#include <cstdio>
const int N = 200005;
int fa[N], sz[N];
int query(int x) {
return fa[x] == x ? x : fa[x] = query(fa[x]);
}
int main()
{
int n, m; scanf("%d%d", &n, &m);
for (int i = 1; i <= n; i++) {
fa[i] = i; sz[i] = 1;
}
for (int i = 1; i <= m; i++) {
int z, x, y; scanf("%d%d%d", &z, &x, &y);
int fx = query(x), fy = query(y);
if (z == 1) {
if (fx != fy) {
if (sz[fx] > sz[fy]) {
fa[fy] = fx; sz[fx] += sz[fy];
} else {
fa[fx] = fy; sz[fy] += sz[fx];
}
}
} else {
printf("%s\n", fx == fy ? "Y" : "N");
}
}
return 0;
}

拓展:n 个 vector(记作 v1 到 vn),刚开始每个 vector 里只有一个数,会有若干次操作,每次合并两个 vector(把 vx 和 vy 合并,合并完以后放在 vx 里),不需要管内部元素顺序。

核心思想:小的合并到大的里。先比较一下 vx.size() 和 vy.size(),如果 vx.size() 更小,则通过 swap(vx, vy) 保证 vx 是较大的那个。然后把 vy 里的元素,挨个放进 vx 里。整体的合并时间复杂度为 O(nlogn)

swap 的时间复杂度:在 C++11 及以后标准(现在比赛是 C++14),除了数组和 array 以外,都是 O(1) 的。

如果是 setmap 做启发式合并,那么时间复杂度就是 O(nlognlogn)

例题:P1536 村村通

现有村镇道路统计表,表中列出了每条道路直接连通的村镇(村镇从 1N 编号,N1000)。如果要求任何两个村镇间都可以实现交通(不一定是直接的道路相连,只要相互之间可达即可),最少还需要建设多少条道路?

分析:先处理每条存在的边,即把每条存在的边所连接的两个节点用并查集合并起来,在这个过程中可以维护当前还有多少个集合,即有多少个连通块。接下来每添加一条边可以把两个连通块合并成一个,即将连通块个数减一,要实现任何两个村镇间都可以实现交通,即连通块只有一个,答案就是初始的连通块个数减一,输出即可。

参考代码
#include <cstdio>
const int N = 1005;
int fa[N];
int query(int x) {
return fa[x] == x ? x : fa[x] = query(fa[x]);
}
int main()
{
int n, m;
while (scanf("%d", &n) && n != 0) {
scanf("%d", &m);
for (int i = 1; i <= n; i++) fa[i] = i;
int ans = n;
while (m--) {
int x, y; scanf("%d%d", &x, &y);
int qx = query(x), qy = query(y);
if (qx != qy) {
fa[qx] = qy; ans--;
}
}
printf("%d\n", ans - 1);
}
return 0;
}

例题:P1955 [NOI2015] 程序自动分析

解题思路

相等的一定都可以满足,先把相等的用并查集合并(相等具有传递性)。

对于不等关系,如果不等关系的两个点属于不同集合,那没事;如果属于相同集合,那么前后矛盾。

需要离散化(sort + unique + lower_bound),时间复杂度 O(nlogn)

参考代码
#include <cstdio>
#include <vector>
#include <algorithm>
const int N = 200005;
int x[N], y[N], e[N], fa[N];
std::vector<int> v;
int query(int x) {
return x == fa[x] ? x : fa[x] = query(fa[x]);
}
int discretize(int x) {
return std::lower_bound(v.begin(), v.end(), x) - v.begin() + 1;
}
void solve() {
int n; scanf("%d", &n);
v.clear();
for (int i = 1; i <= n; i++) {
scanf("%d%d%d", &x[i], &y[i], &e[i]);
v.push_back(x[i]); v.push_back(y[i]);
}
std::sort(v.begin(), v.end());
v.erase(std::unique(v.begin(), v.end()), v.end());
for (int i = 1; i <= n; i++) {
x[i] = discretize(x[i]); y[i] = discretize(y[i]);
}
for (int i = 1; i <= v.size(); i++) fa[i] = i;
for (int i = 1; i <= n; i++) {
if (e[i] == 1) {
int fx = query(x[i]), fy = query(y[i]);
if (fx != fy) fa[fx] = fy;
}
}
for (int i = 1; i <= n; i++) {
if (e[i] == 0) {
int fx = query(x[i]), fy = query(y[i]);
if (fx == fy) {
printf("NO\n"); return;
}
}
}
printf("YES\n");
}
int main()
{
int t; scanf("%d", &t);
for (int i = 1; i <= t; i++) {
solve();
}
return 0;
}

拓展:如果找到一个最大的 k,在只满足前 k 个要求的情况下,是不矛盾的,n105

解题思路

二分 k,然后对前 k 个约束条件还是按先处理等式,再处理不等式的方法做。

例题:P1196 [NOI2002] 银河英雄传说

解题思路

合并集合,想到并查集,问题在于如何求操作 2 的答案。

边带权并查集,用 vi 表示 i 与其父节点之间的信息,比如这里就可以是 i 与其父节点之间的距离,那么对于询问,如果询问的两个点不在同一集合就是 1,否则就是它们到根的距离之差的绝对值再减 1

求一个点到根的距离,可以在并查集查询根节点的时候沿路去加,结合上路径压缩,可以是先计算 x 的父节点到根的距离,再把 vx 加上计算出来的这个数,再把 fax 调整成根。

int query(int x) {
if (x == fa[x]) return x;
int fx = query(fa[x]);
v[x] += v[fa[x]];
return fa[x] = fx;
}

而当 xy 合并的时候:

fa[fx] = fy; v[fx] = sz[fy]; sz[fy] += sz[fx];
参考代码
#include <cstdio>
#include <cmath>
const int N = 30005;
char cmd[5];
int fa[N], v[N], sz[N];
int query(int x) {
if (fa[x] == x) return x;
int fx = query(fa[x]);
v[x] += v[fa[x]];
return fa[x] = fx;
}
int main()
{
int t; scanf("%d", &t);
for (int i = 1; i < N; i++) {
fa[i] = i; sz[i] = 1;
}
for (int i = 1; i <= t; i++) {
int x, y;
scanf("%s%d%d", cmd, &x, &y);
int fx = query(x), fy = query(y);
if (cmd[0] == 'M') {
if (fx != fy) {
fa[fx] = fy;
v[fx] = sz[fy];
sz[fy] += sz[fx];
}
} else {
printf("%d\n", fx != fy ? -1 : abs(v[x] - v[y]) - 1);
}
}
return 0;
}

习题:P1197 [JSOI2008] 星球大战

解题思路

按照常规的思维,这里需要在并查集上实现删点操作,这是比较麻烦的。这里我们可以采用逆向思维,将逐渐摧毁的过程倒过来考虑,变成逐渐重建的过程。

先将所有要摧毁的点摧毁,并将并查集维护好,记录此时的连通块个数,这就是整个摧毁过程完成后的答案。

从最后一次被摧毁的点开始“时光倒流”,让被摧毁的点一个个加回并查集中,在这个过程中记录连通块的个数。

参考代码
#include <cstdio>
#include <vector>
using namespace std;
const int N = 400005;
vector<int> graph[N];
int ans[N], target[N], fa[N];
bool destroy[N]; // 用于标记某点是否被摧毁
int query(int x) {
return fa[x] == x ? x : fa[x] = query(fa[x]);
}
int main()
{
int n, m; scanf("%d%d", &n, &m);
for (int i = 0; i < n; i++) fa[i] = i; // 并查集初始化
for (int i = 1; i <= m; i++) {
int x, y; scanf("%d%d", &x, &y);
graph[x].push_back(y); graph[y].push_back(x); // 建图
}
int k; scanf("%d", &k);
for (int i = 1; i <= k; i++) {
scanf("%d", &target[i]);
destroy[target[i]] = true; // 标记这个点已经被摧毁
}
int cnt = n - k;
for (int i = 0; i < n; i++)
if (!destroy[i]) {
for (int to : graph[i])
if (!destroy[to]) { // i和to都没被摧毁
int qi = query(i), qt = query(to);
if (qi != qt) {
fa[qi] = qt; cnt--; // 合并后连通块的数量减1
}
}
}
ans[k + 1] = cnt; // 整个摧毁过程完成后剩下的连通块个数
for (int i = k; i >= 1; i--) { // “时光倒流”
int cur = target[i];
destroy[cur] = false; cnt++; // 修复该点
for (int to : graph[cur])
if (!destroy[to]) { // 修复i<->to这条边
int qc = query(cur), qt = query(to);
if (qc != qt) {
fa[qc] = qt; cnt--; // 合并
}
}
ans[i] = cnt; // 修复当前点后,连通块的个数
}
for (int i = 1; i <= k + 1; i++) printf("%d\n", ans[i]);
return 0;
}

扩展域并查集

在普通的并查集中,通常只有一个维度(元素本身)。而在扩展域并查集中,会对元素进行扩展,让它拥有多个维度(通常是某些性质)。将原本的一个点拆成多个点,分别表示与该点满足某种关系的点构成的集合。

例题:P2024 [NOI2001] 食物链

对于动物 xy,可能有 xyxy 是同类,xy 吃三种关系。因此考虑扩展到三倍空间,分别表示三类不同物种的域。

设有 n 只动物,则并查集的空间为 3n,其中 [1,n] 部分为 A 类物种域,[n+1,2n] 部分为 B 类物种域,[2n+1,3n] 部分为 C 类物种域。

A 中的 xB 中的 y 合并时,相当于关系 xy;当 C 中的 xC 中的 y 合并时,相当于关系 xy 是同类······。

由于不知道某个动物具体属于 A,B 还是 C,所以三种情况都要考虑。

也就是说,每当有一句真话时,需要做三次合并。

#include <cstdio>
const int N = 50005;
int fa[N * 3];
int query(int x) {
return fa[x] == x ? x : fa[x] = query(fa[x]);
}
int main()
{
int n, k; scanf("%d%d", &n, &k);
for (int i = 1; i <= n * 3; i++) fa[i] = i;
int ans = 0;
while (k--) {
int op, x, y; scanf("%d%d%d", &op, &x, &y);
if (x > n || y > n || op == 2 && x == y) {
ans++; continue;
}
if (op == 1) {
if (query(x) == query(y + n) || query(y) == query(x + n)) {
ans++; continue;
}
fa[query(x)] = query(y);
fa[query(x + n)] = query(y + n);
fa[query(x + n * 2)] = query(y + 2 * n);
} else {
if (query(x) == query(y) || query(y) == query(x + n)) {
ans++; continue;
}
fa[query(x)] = query(y + n);
fa[query(x + n)] = query(y + n * 2);
fa[query(x + n * 2)] = query(y);
}
}
printf("%d\n", ans);
return 0;
}

习题:P5937 [CEOI1999] Parity Game

题解

分析:首先可以想到前缀和,可以把 lr 的区间和看成前缀和之差 sum[r]sum[l1],而这个式子的结果要么是奇数要么是偶数,当其为偶数时,说明 sum[r]sum[l1] 的奇偶性需要相同,当其为奇数时,说明 sum[r]sum[l1] 的奇偶性需要不同。

注意到 2m 远小于 n,需要对最多 2m 个位置做离散化处理。把每一个位置扩展成两个域:奇数域和偶数域。不妨令 [1,2m] 表示奇数域,[2m+1,4m] 表示偶数域,而 ii+2m 是同一个位置分别对应到奇数域和偶数域上。则两个位置的前缀和奇偶性相同可以被表达为奇数域和奇数域合并、偶数域和偶数域合并;奇偶性不同可以被表达为奇数域和对方的偶数域合并、偶数域和对方的奇数域合并。

#include <cstdio>
#include <algorithm>
using namespace std;
const int N = 5005;
int a[N], b[N], num[N * 2], n, m, fa[N * 4];
bool eq[N];
char q[10];
int getid(int x) {
return lower_bound(num + 1, num + 2 * m + 1, x) - num;
}
int query(int x) {
return fa[x] == x ? x : fa[x] = query(fa[x]);
}
int main()
{
scanf("%d%d", &n, &m);
for (int i = 1; i <= m; i++) {
scanf("%d%d%s", &a[i], &b[i], q);
a[i]--; num[i] = a[i]; num[i + m] = b[i];
eq[i] = q[0] == 'e';
}
sort(num + 1, num + 2 * m + 1);
for (int i = 1; i <= 4 * m; i++) fa[i] = i;
int ans = 0;
for (int i = 1; i <= m; i++) {
int x = getid(a[i]), y = getid(b[i]); // 离散化后对应的值
if (!eq[i]) { // 奇偶性不等
// 如果之前出现过奇偶性相同的情况,说明矛盾
if (query(x) == query(y)) break;
fa[query(x)] = query(y + 2 * m);
fa[query(y)] = query(x + 2 * m);
} else { // 奇偶性相等
// 如果之前出现过奇偶性相反的情况,说明矛盾
if (query(x) == query(y + 2 * m) || query(y) == query(x + 2 * m)) break;
fa[query(x)] = query(y);
fa[query(x + 2 * m)] = query(y + 2 * m);
}
ans++;
}
printf("%d\n", ans);
return 0;
}

习题:P9869 [NOIP2023] 三值逻辑

参考代码
#include <cstdio>
const int N = 200005;
// a数组记录每个变量最终被赋值为的东西
// [1,n] True set, [n+1,2n] False set, 2n+1 T, 2n+2 F, 2n+3 U
int a[N], fa[N];
char v[5];
int opposite(int x, int n) {
if (x <= n) return x + n;
else if (x <= 2 * n) return x - n;
else if (x == 2 * n + 1) return x + 1;
else if (x == 2 * n + 2) return x - 1;
else return x;
}
int query(int x) {
return fa[x] == x ? x : fa[x] = query(fa[x]);
}
int main()
{
int c, t; scanf("%d%d", &c, &t);
while (t--) {
int n, m; scanf("%d%d", &n, &m);
for (int i = 1; i <= 2 * n + 3; i++) {
if (i <= n) a[i] = i;
fa[i] = i;
}
for (int i = 1; i <= m; i++) {
scanf("%s", v);
if (v[0] == '+' || v[0] == '-') {
int x, y; scanf("%d%d", &x, &y);
// 注意是a[x]=a[y]而不是a[x]=y
// 因为一条赋值语句是先要计算等号右边的表达式的
// 如赋值语句ax=ay指的是把ax赋值为当时ay对应的值
// 假如之后的赋值语句将ay赋值成其他值了最后ax和ay的值并不相等
if (v[0] == '+') a[x] = a[y];
else a[x] = opposite(a[y], n);
} else {
int x; scanf("%d", &x);
if (v[0] == 'T') a[x] = 2 * n + 1;
else if (v[0] == 'F') a[x] = 2 * n + 2;
else a[x] = 2 * n + 3;
}
}
for (int i = 1; i <= n; i++) {
fa[query(i)] = fa[query(a[i])];
fa[query(opposite(i, n))] = fa[query(opposite(a[i], n))];
}
int ans = 0;
for (int i = 1; i <= n; i++) {
if (query(i) == query(2 * n + 3) || query(i) == query(n + i))
ans++;
}
printf("%d\n", ans);
}
return 0;
}
posted @   RonChen  阅读(119)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 分享一个免费、快速、无限量使用的满血 DeepSeek R1 模型,支持深度思考和联网搜索!
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· ollama系列1:轻松3步本地部署deepseek,普通电脑可用
· 按钮权限的设计及实现
· 【杂谈】分布式事务——高大上的无用知识?
点击右上角即可分享
微信分享提示