并查集
概念
并查集(Disjoint Set Union,简称 DSU,又称 Union-Find)是一种用于维护若干不相交集合的树形数据结构,实现为森林。森林中的每一棵树代表一个集合,树中的结点代表集合中的元素,树的根结点是集合的代表元(根节点),用来唯一标识这个集合。
并查集支持两种核心操作:
- 合并(union):把两个元素所在的集合合并成一个集合;
- 查询(find):查找某一元素所在集合的代表元,常用于判断两个元素是否属于同一个集合(两个元素的代表元相同则在同一集合中)。
初始化
我们使用数组
个元素的编号分别为 ; 存储元素 的父结点,例如元素 的父结点为 ; - 根结点的父结点是它自身,即
当且仅当 是它所在集合的代表元。
初始时,每个元素各自单独构成一个集合,因此每个元素都是自己所在集合的代表元。
const int N = 100010;
int p[N];
void init(int n) {
for (int i = 1; i <= n; i++) {
p[i] = i;
}
}查询(find)
查询元素
// 迭代实现
int find(int x) {
while (p[x] != x) {
x = p[x];
}
return x;
}
// 递归实现
int find(int x) {
if (p[x] != x) return find(p[x]);
return x;
}不加任何优化时,若合并操作使树退化成一条链,一次查询最坏需要从当前结点一路走到根,时间复杂度为
路径压缩
在查询某个元素所属集合的过程中,我们会沿路径询问它的所有祖先,而这条路径上的所有结点显然都属于同一个集合。 因此可以在回溯时把路径上的每个结点直接连接到根结点上,这就是路径压缩。路径压缩只改变树的形态,不改变集合的划分。
// 带路径压缩的 find:找到根后,把路径上的所有结点直接挂到根上
int find(int x) {
if (p[x] != x) p[x] = find(p[x]);
return p[x];
}单独使用路径压缩时,
合并(merge)
合并两个集合,即合并两棵树,只需将一棵树的根结点接到另一棵树的根结点上。合并后,
void merge(int x, int y) {
// 把 x 所在集合合并到 y 所在集合上
p[find(x)] = find(y);
}若不配合优化,合并后树可能退化成链,单次查询、合并的复杂度最坏为
AcWing 836. 合并集合
题意:一共有 M a b 表示把编号为 Q a b 询问
思路:这是并查集模板题。合并直接调用 merge(a, b),询问判断 find(a) == find(b),是则输出 Yes,否则输出 No。
输入样例:
4 5
M 1 2
M 3 4
Q 1 2
Q 1 3
Q 3 4输出样例:
Yes
No
Yes#include <bits/stdc++.h>
using namespace std;
const int N = 1e5 + 10;
int p[N];
void init(int n) {
for (int i = 1; i <= n; i++) p[i] = i;
}
int find(int x) {
if (p[x] != x) p[x] = find(p[x]);
return p[x];
}
void merge(int x, int y) {
p[find(x)] = find(y);
}
int main() {
int n, m;
cin >> n >> m;
init(n);
while (m--) {
char op[2];
int a, b;
scanf("%s%d%d", op, &a, &b);
if (op[0] == 'M') {
merge(a, b);
} else {
if (find(a) == find(b)) cout << "Yes" << endl;
else cout << "No" << endl;
}
}
return 0;
}启发式合并
合并两个集合时,选择哪棵树作为合并后新树的根结点,会影响后续操作的效率。一个自然的想法是:把结点数较少(或深度较小)的树接到结点数较多(或深度较大)的树上,防止树退化成链。这类优化统称为启发式合并。
按秩合并
按秩合并用数组
注意数组名不使用 rank:在 using namespace std; 的环境下会与标准库 std::rank 冲突,导致编译失败。
void init(int n) {
for (int i = 1; i <= n; i++) {
p[i] = i;
rk[i] = 1;
}
}
void merge(int x, int y) {
int px = find(x), py = find(y);
if (px == py) return;
// 深度小的树接到深度大的树上
if (rk[px] > rk[py]) swap(px, py);
p[px] = py;
// 两棵树深度相同,新根 py 的深度加 1
if (rk[px] == rk[py]) rk[py]++;
}性质:一棵树的深度只有在两棵深度相同的树合并时才会增加 1,因此任意时刻树的深度不超过
按大小合并
另一种启发式合并是维护集合大小
void init(int n) {
for (int i = 1; i <= n; i++) {
p[i] = i;
sz[i] = 1;
}
}
void merge(int x, int y) {
int px = find(x), py = find(y);
if (px == py) return;
// 把较小的集合接到较大的集合上
if (sz[px] < sz[py]) swap(px, py);
p[py] = px;
sz[px] += sz[py];
}复杂度
同时使用路径压缩 + 按秩合并(或按大小合并)时,
带权并查集
在基础并查集的基础上,额外维护"每个结点与父结点之间的关系",这样的并查集称为带权并查集。
常见的实现是维护距离数组 find 路径压缩时,
int find(int x) {
if (p[x] != x) {
int root = find(p[x]);
d[x] += d[p[x]]; // 累加前 p[x] 还是原来的父结点
p[x] = root;
}
return p[x];
}合并两棵根分别为
洛谷 P2024. [NOI2001] 食物链
题意:三类动物 1 x y 表示 2 x y 表示
思路:用带权并查集维护 %。
输入样例:
100 7
1 101 1
2 1 2
2 2 3
2 3 3
1 1 3
2 3 1
1 5 5输出样例:
3#include <bits/stdc++.h>
using namespace std;
const int N = 5e4 + 10;
int p[N], d[N]; // d[x] 表示 x 到父结点的距离,路径压缩后为到根结点的距离
int n, k, ans;
int find(int x) {
if (p[x] != x) {
int root = find(p[x]);
d[x] += d[p[x]]; // 累加得到 x 到根的距离
p[x] = root;
}
return p[x];
}
int main() {
scanf("%d%d", &n, &k);
for (int i = 1; i <= n; i++) p[i] = i;
while (k--) {
int o, x, y;
scanf("%d%d%d", &o, &x, &y);
// d[x] % 3 == 0:x 与根同类
// d[x] % 3 == 1:x 吃根
// d[x] % 3 == 2:x 被根吃
if (x > n || y > n) {
ans++;
continue;
}
int px = find(x), py = find(y);
if (o == 1) { // x 与 y 是同类
if (px == py && (d[x] - d[y]) % 3 != 0) ans++;
else if (px != py) {
p[px] = py;
d[px] = d[y] - d[x];
}
} else { // x 吃 y
if (px == py && (d[x] - d[y] - 1) % 3 != 0) ans++;
else if (px != py) {
p[px] = py;
d[px] = d[y] + 1 - d[x];
}
}
}
printf("%d\n", ans);
return 0;
}扩展域并查集
有的题目中元素之间存在多种关系(如"朋友 / 敌人"),此时可以把每个元素拆成多个点(域),用不同的点表示该元素的不同状态,再统一放入同一个并查集中。已知的关系转化为"某些状态点应处于同一集合",用合并表达,这种方法称为扩展域(拆点)并查集。
洛谷 P1892. [BOI2003] 团伙
题意:F x y 表示朋友,E x y 表示敌人),求最多可能的团伙数。
思路:把每个人 F x y 合并 E x y 表示
输入样例:
6
4
E 1 4
F 3 5
F 4 6
E 1 2输出样例:
3#include <bits/stdc++.h>
using namespace std;
const int N = 1010;
int p[N * 2], root[N];
int n, m, ans;
int find(int x) {
if (p[x] != x) p[x] = find(p[x]);
return p[x];
}
int main() {
scanf("%d%d", &n, &m);
// 初始化并查集,每个点拆成两个点,一个表示朋友域,一个表示敌人域
for (int i = 1; i <= 2 * n; i++) p[i] = i;
while (m--) {
char o;
int x, y;
scanf(" %c%d%d", &o, &x, &y);
if (o == 'F') {
p[find(x)] = find(y); // 朋友合并
} else {
p[find(x)] = find(y + n); // x 与 y 的敌人是朋友
p[find(y)] = find(x + n); // y 与 x 的敌人是朋友
}
}
// 统计 1 ~ n 中不同根结点的个数,即团伙数
for (int i = 1; i <= n; i++) root[i] = find(i);
sort(root + 1, root + n + 1);
for (int i = 1; i <= n; i++) {
if (root[i] != root[i - 1]) ans++;
}
printf("%d\n", ans);
return 0;
}洛谷 P2024. [NOI2001] 食物链的扩展域并查集解法:
#include <bits/stdc++.h>
using namespace std;
const int N = 1e5 + 10;
int p[3 * N];
int n, m, ans;
int find(int x) {
return p[x] == x ? p[x] : p[x] = find(p[x]);
}
/*
[1, N] 同类域
[N + 1, 2 * N] 捕食域
[2 * N + 1, 3 * N] 天敌域
*/
int main() {
cin >> n >> m;
for (int i = 1; i <= 3 * n; i++) p[i] = i;
while (m--) {
int o, x, y;
cin >> o >> x >> y;
if (x > n || y > n) ans++;
else {
int px = find(x), py = find(y);
if (o == 1) {
if (find(x + n) == find(y) || find(x) == find(y + n)) ans++;
else {
p[find(x)] = find(y);
p[find(x + n)] = find(y + n);
p[find(x + 2 * n)] = find(y + 2 * n);
}
} else {
if (find(x) == find(y) || find(x) == find(y + n)) ans++;
else {
p[find(x)] = find(y + 2 * n);
p[find(x + n)] = find(y);
p[find(x + 2 * n)] = find(y + n);
}
}
}
}
cout << ans << endl;
return 0;
}应用
并查集常与其他算法配合使用:
- Kruskal 求最小生成树:边按权值从小到大排序,依次加入两端点不在同一集合的边,用并查集判断连通性;
- Tarjan 离线求最近公共祖先(LCA):用并查集维护已经回溯完的子树;
- 维护无向图的连通块数量与大小;
- 离线处理只有加边操作的动态连通性问题(可结合倒序删边)。
洛谷 P3958. [NOIP2017 提高组] 奶酪
题意:奶酪中有
思路:用并查集把所有相互连通的空洞合并。若某个空洞与下表面相切或相交(Yes,否则为 No。为避免浮点误差,代码直接比较距离的平方(long long 足够。
#include <bits/stdc++.h>
using namespace std;
const int N = 1010;
int p[N];
struct Point {
long long x, y, z;
} pt[N];
int T, n, h, r;
int find(int x) {
if (p[x] != x) p[x] = find(p[x]);
return p[x];
}
long long dis(const Point& a, const Point& b) {
long long dx = a.x - b.x;
long long dy = a.y - b.y;
long long dz = a.z - b.z;
return dx * dx + dy * dy + dz * dz;
}
int main() {
scanf("%d", &T);
while (T--) {
scanf("%d%d%d", &n, &h, &r);
for (int i = 1; i <= n; i++) p[i] = i;
vector<int> top, bottom;
for (int i = 1; i <= n; i++) {
scanf("%lld%lld%lld", &pt[i].x, &pt[i].y, &pt[i].z);
if (pt[i].z - r <= 0) bottom.push_back(i); // 与下表面相切或相交
if (pt[i].z + r >= h) top.push_back(i); // 与上表面相切或相交
for (int j = 1; j < i; j++) {
// 球心距 <= 2r,等价于距离平方 <= 4r^2
if (dis(pt[i], pt[j]) <= 4LL * r * r) {
int pi = find(i), pj = find(j);
if (pi != pj) p[pi] = pj;
}
}
}
bool ok = false;
for (int i = 0; i < (int)top.size() && !ok; i++) {
for (int j = 0; j < (int)bottom.size(); j++) {
if (find(top[i]) == find(bottom[j])) {
ok = true;
break;
}
}
}
puts(ok ? "Yes" : "No");
}
return 0;
}参考资料
- OI Wiki - 并查集
- 《算法竞赛进阶指南》(李煜东)
