并查集
并查集是一种可以动态维护若干个不重叠的集合,并支持合并与查询的数据结构。
例题:P1551 亲戚
题目描述: 如果
和 是亲戚, 和 是亲戚,那么 和 也是亲戚。如果 和 是亲戚,那么 的亲戚都是 的亲戚, 的亲戚也都是 的亲戚。现在给出某个亲戚关系图,求任意给出的两个人是否具有亲戚关系。
输入格式: 输入个整数 ,分别表示有 个人, 对亲戚关系, 对亲戚关系的询问。接下来 行,每行两个数说明这两个人是亲戚。接下来 行,每行询问两个人是否是亲戚。
输出格式: 对于每次查询,需要输出Yes
或者No
表示这次查询是否是亲戚关系。
分析: 将所有有亲戚关系的人归为同一个集合中(同一个家族)。如果想查询两个人是否有亲属关系,只需要判断这两个人是否为同一个家族内。
那么,怎么判断两个人是否在同一个家族内呢?可以从每个家族中选出一位“族长”来代表整个家族,这样只需要知道两个人的族长是否为同一人,就能判断出是否属于同一个家族。
规定所有的成员都有一名“负责人”,最开始的时候,所有人都“自成一族”,每个成员都有一个指向自己的箭头,意思是自己的“负责人”就是自己,这时本人就是族长。
假如得知
由于
假如得知
接下来假如得知
假如
如果需要查询两个人是否是同一个家族的,只需要查询这两个人的族长是否是同一个人。如果要把两个家族合并,就把其中一个家族的族长的负责人指向另外一个家族的族长即可。
这种处理不相交可合并集合关系的数据结构叫做并查集。并查集具有查询、合并这两种基本操作。使用并查集时要先初始化并查集,然后处理这
参考代码
#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; }
这里的实现是暴力的,单次操作时间复杂度最差是
例题: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; }
优化后单次操作时间复杂度最差情况是
按秩合并
既然希望树高尽量小,那如果每次让树高小的那边合并到树高大的那边,得到的新树的高度就相对较小了,这就叫按秩合并。
用一个数组
参考代码
#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; }
单次操作时间复杂度最差
启发式合并
另一种按秩合并的方法,按集合中节点数大小合并,这个合并方法也叫启发式合并,有时在其它的合并问题中也会有应用。
用一个数组
参考代码
#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; }
单次操作最差时间复杂度
如果同时使用启发式合并和路径压缩,单次操作的时间复杂度是反阿克曼函数,可以认为系数非常小。
参考代码
#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; }
拓展:有
核心思想:小的合并到大的里。先比较一下 vx.size() 和 vy.size(),如果 vx.size() 更小,则通过 swap(vx, vy) 保证 vx 是较大的那个。然后把 vy 里的元素,挨个放进 vx 里。整体的合并时间复杂度为
swap
的时间复杂度:在 C++11 及以后标准(现在比赛是 C++14),除了数组和 array 以外,都是
如果是 set
和 map
做启发式合并,那么时间复杂度就是
例题:P1536 村村通
现有村镇道路统计表,表中列出了每条道路直接连通的村镇(村镇从
到 编号, )。如果要求任何两个村镇间都可以实现交通(不一定是直接的道路相连,只要相互之间可达即可),最少还需要建设多少条道路?
分析:先处理每条存在的边,即把每条存在的边所连接的两个节点用并查集合并起来,在这个过程中可以维护当前还有多少个集合,即有多少个连通块。接下来每添加一条边可以把两个连通块合并成一个,即将连通块个数减一,要实现任何两个村镇间都可以实现交通,即连通块只有一个,答案就是初始的连通块个数减一,输出即可。
参考代码
#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),时间复杂度
参考代码
#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; }
拓展:如果找到一个最大的
解题思路
二分
例题:P1196 [NOI2002] 银河英雄传说
解题思路
合并集合,想到并查集,问题在于如何求操作 2 的答案。
边带权并查集,用
求一个点到根的距离,可以在并查集查询根节点的时候沿路去加,结合上路径压缩,可以是先计算
int query(int x) { if (x == fa[x]) return x; int fx = query(fa[x]); v[x] += v[fa[x]]; return fa[x] = fx; }
而当
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] 食物链
对于动物
设有
当
由于不知道某个动物具体属于
也就是说,每当有一句真话时,需要做三次合并。
#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
题解
分析:首先可以想到前缀和,可以把
注意到
#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; }
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 分享一个免费、快速、无限量使用的满血 DeepSeek R1 模型,支持深度思考和联网搜索!
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· ollama系列1:轻松3步本地部署deepseek,普通电脑可用
· 按钮权限的设计及实现
· 【杂谈】分布式事务——高大上的无用知识?