并查集——朋友圈合并与查询的好帮手
困难4并查集——朋友圈合并与查询的好帮手
想象一下,你在班上有好多小伙伴,大家分成几个“朋友圈”:比如“游戏小队”、“作业互助组”、“篮球爱好者”等等。你经常要问:“小明和小红是不是同一个朋友圈的?”(查询操作)或者“今天‘游戏小队’和‘篮球爱好者’决定合并成‘运动游戏联盟’!”(合并操作)。如果每找一次都要挨个问所有人,那就太慢了。并查集(Disjoint Set Union,DSU)就是专门用来高效处理这种不相交集合(没有交集的几个朋友圈)的合并与查询的数据结构。它用一棵树来表示每个集合,树根就是这个集合的“代表”。通过两种优化——路径压缩和按秩合并——几乎每次操作都只需要 O(α(n)) 时间,α(n) 是一个增长极慢的反阿克曼函数,可以看作常数时间。
一、并查集的三个核心操作
并查集主要做三件事:
- 初始化:每个人单独成一个集合,自己就是自己的“老大”。
- 查找:从一个人出发,沿着“上级”一直往上走,直到找到“老大”(树根)。
- 合并:把两个集合的“老大”连接起来,让其中一个“老大”成为另一个的手下。
下面我们用生活中的例子逐步拆解。
1. 初始化:每人自成“光杆司令”
假设班级有 5 个同学,编号 0~4。一开始谁都不认识谁,所以每个同学自己就是一个朋友圈,自己是自己的“组长”(根节点)。我们用数组 parent 来记录每个人的上级(父节点),初始时 parent[i] = i,表示每个人的老大是自己。
我们可以用一个结构体 DSU 来封装所有操作:
struct DSU {
vector<int> parent; // parent[i] 表示i的父节点(上级)
vector<int> rank; // rank[i] 表示以i为根的树的近似高度(秩)
// 构造函数:初始化n个元素,每人独立
DSU(int n) {
parent.resize(n); // 分配n个位置
rank.resize(n, 0); // 初始秩都为0
for (int i = 0; i < n; i++) {
parent[i] = i; // 每个人都是自己的根
}
}
};
生活例子:开学第一天,没人认识别人,每个同学都是自己小圈子的“组长”。
2. 查找(Find):找到“老大”
查找操作 find(x) 返回元素 x 所在集合的根节点(老大)。怎么做?从 x 开始,不断往父节点走,直到走到一个 parent[x] == x 的节点,那就是根。
int find(int x) {
while (parent[x] != x) {
x = parent[x]; // 往上找上级
}
return x;
}
生活例子:你想知道“小明”的终极老大是谁,就问小明“你的上级是谁?”,小明告诉你“小刚”,你再问小刚“你的上级是谁?”……直到有人回答“我就是老大!”。
路径压缩优化:如果每次查找都一层层往上爬,树可能会变得很高(比如一条链),效率就低。路径压缩的做法是:在查找的同时,把沿途经过的所有节点直接挂到根节点下面,这样下次再找这些节点就一步到位了。
int find(int x) {
if (parent[x] != x) { // 如果x不是根
parent[x] = find(parent[x]); // 递归找根,并压缩路径
}
return parent[x]; // 返回根
}
图解:假设树是 3 → 2 → 1 → 0(3的上级是2,2的上级是1,1的上级是0,0是根)。调用 find(3) 时,会一路递归到根0,然后回溯时把2、3的parent都设为0。第二次再找2或3,直接返回0。
注意:路径压缩用递归实现简洁,但如果数据量很大(比如10^7),递归深度可能爆栈。可以改成迭代版本(先找到根,再二次遍历压缩),但递归在竞赛中常用且足够。
3. 合并(Union):把两个朋友圈合并
合并操作 unite(x, y) 把 x 所在集合和 y 所在集合合并成一个。方法是:先找到两个集合的根 rx 和 ry,如果相同就不需要合并;否则让一个根成为另一个的父节点。
最简单的合并:
void unite(int x, int y) {
int rx = find(x);
int ry = find(y);
if (rx == ry) return; // 已经在同一个集合
parent[rx] = ry; // 把rx接到ry下面(或者反过来)
}
生活例子:两个小圈子想合并,就让一个圈子的老大认另一个圈子的老大当上级。
按秩合并优化:如果总是把大的树接到小的树下,树会越来越高,查找变慢。按秩合并的思路是:维护一个“秩”(rank),通常表示树的高度(或大小)。合并时,把秩小的根接到秩大的根下面,如果秩相等,接完后秩加1。
void unite(int x, int y) {
int rx = find(x);
int ry = find(y);
if (rx == ry) return;
if (rank[rx] < rank[ry]) {
parent[rx] = ry; // rx的秩小,把rx接到ry上
} else if (rank[rx] > rank[ry]) {
parent[ry] = rx; // ry的秩小,把ry接到rx上
} else {
parent[ry] = rx; // 秩相等,接到任一个,另一个秩+1
rank[rx]++; // 新根的秩增加
}
}
为什么这样优化:路径压缩会改变树的高度,所以 rank 其实不是精确高度,只是上界。但按秩合并与路径压缩一起使用时,能保证树的高度始终是 O(log n),实际效果非常好。
二、常见错误(新手容易踩的坑)
-
忘记初始化
parent[i] = i
如果初始化parent全为0,那么find(0)会无限循环(因为parent[0]=0,但其他节点指向0算正常?注意:当所有parent都是0时,0的根是0,但1的根会去找0,而0的根是0,所以1的根是0,表面上没问题。但如果你把初始化混淆了,可能让本来独立的元素指向同一个根,导致错误的合并。一定要每个元素初始化自己为根。 -
递归
find深度过大导致栈溢出
当数据规模很大(比如n=10^6)且树退化为链(即使有路径压缩,第一次查找前可能链很长),递归深度可能超过编译器默认栈空间(大约1MB)。解决方案:- 改用迭代版本 find:
int find(int x) { int root = x; while (parent[root] != root) root = parent[root]; // 找到根 while (x != root) { // 路径压缩 int next = parent[x]; parent[x] = root; x = next; } return root; } - 增加编译器栈空间(不推荐,不通用)。
- 改用迭代版本 find:
-
在合并前忘记
find而直接用x和y的parent
很多人会写成if (parent[x] != parent[y])来判断是否在同一集合。但parent[x]不一定是根(可能只是上级),这样判断会出错。一定要先查找根再比较。 -
按秩合并时秩更新错误
当两棵树秩相等时,合并后新树的秩要加1。如果忘记加,会导致秩信息不准,后续合并可能效率下降。下面是一个错误写法:if (rank[rx] == rank[ry]) { parent[ry] = rx; // 漏掉了 rank[rx]++; }这样合并后,
rx的秩还是原来的值,但实际树高度变大了,下次按秩合并就可能错误地把它当作矮树接给别人。 -
修改
parent时没有先找到根
不要直接parent[x] = y,因为这样只是把 x 的上级改成 y,而不是合并整个集合。正确做法是先求根再连根。 -
路径压缩和按秩合并的先后顺序
两者可以同时使用,没有冲突。但注意:按秩合并依赖的rank在路径压缩后可能不再是真实高度,但这不影响正确性——它只是近似值,用来防止过度增长。
三、完整可运行的代码示例(朋友圈分组问题)
我们设计一个场景:班级有 n 个学生(编号 0 到 n-1)。老师知道 m 对朋友关系,朋友关系是双向的,朋友的朋友也是朋友(即传递性)。问最后形成了几个朋友圈(连通块)?
下面的代码用并查集实现,包含路径压缩和按秩合并,并输出每个学生所属的朋友圈根。
#include <iostream>
#include <vector>
using namespace std;
struct DSU {
vector<int> parent; // 父节点数组
vector<int> rank; // 秩数组(近似高度)
// 初始化:n个元素,每人独立
DSU(int n) {
parent.resize(n);
rank.resize(n, 0);
for (int i = 0; i < n; i++) {
parent[i] = i; // 自己就是根
}
}
// 查找根,带路径压缩(递归版)
int find(int x) {
if (parent[x] != x) {
parent[x] = find(parent[x]); // 路径压缩
}
return parent[x];
}
// 合并两个集合,按秩合并
void unite(int x, int y) {
int rx = find(x); // 找x的根
int ry = find(y); // 找y的根
if (rx == ry) {
return; // 已经在同一集合,无需合并
}
// 确保rx是秩较小的根,方便统一操作
if (rank[rx] < rank[ry]) {
parent[rx] = ry; // 小树接在大树下
} else if (rank[rx] > rank[ry]) {
parent[ry] = rx;
} else {
parent[ry] = rx; // 秩相等,任意接一个,秩+1
rank[rx]++;
}
}
// 判断两个元素是否在同一集合
bool same(int x, int y) {
return find(x) == find(y);
}
};
int main() {
int n = 7; // 0~6号同学
DSU dsu(n);
// 添加朋友关系(合并操作)
dsu.unite(0, 1); // 0和1是朋友
dsu.unite(1, 2); // 1和2是朋友 → 0,1,2成为一组
dsu.unite(3, 4); // 3和4是朋友
dsu.unite(5, 6); // 5和6是朋友
dsu.unite(4, 5); // 4和5是朋友 → 3,4,5,6成为一组
// 查询
cout << "0和2是否在同一组?" << (dsu.same(0, 2) ? "是" : "否") << endl; // 是
cout << "0和3是否在同一组?" << (dsu.same(0, 3) ? "是" : "否") << endl; // 否
cout << "2和6是否在同一组?" << (dsu.same(2, 6) ? "是" : "否") << endl; // 否
// 计算一共有几个朋友圈(即根节点个数)
int groups = 0;
for (int i = 0; i < n; i++) {
if (dsu.find(i) == i) { // 如果自己是根,就是一个集合的代表
groups++;
}
}
cout << "总共有 " << groups << " 个朋友圈。" << endl; // 应该输出2:{0,1,2} 和 {3,4,5,6}
// 输出每个同学所属的根(代表)
cout << "每个同学的根节点:";
for (int i = 0; i < n; i++) {
cout << dsu.find(i) << " ";
}
cout << endl; // 例如:0 0 0 3 3 3 3 (树根可能因合并顺序不同,但同一组根相同)
return 0;
}
运行结果:
0和2是否在同一组?是
0和3是否在同一组?否
2和6是否在同一组?否
总共有 2 个朋友圈。
每个同学的根节点:0 0 0 3 3 3 3
四、并查集的更多应用与相关指引
并查集虽然简单,但它在很多算法问题中都是基础工具:
- 克鲁斯卡尔(Kruskal)最小生成树:按边权从小到大排序,用并查集判断是否形成环。如果边的两个端点不在同一集合,就加入这条边并合并集合。
- 判断无向图中的连通分量个数:把所有边合并后,数一数有多少个根。
- 离线查询:比如题目给出若干“连边”操作和“询问两点是否连通”操作,用并查集可以高效回答。
- 带权并查集:在路径压缩时维护节点到根的距离或其他信息,用于解决“奇偶性”或“相对关系”问题(例如“敌人与朋友”关系)。
- 可撤销并查集:支持时光倒流(撤销上一次合并),常用于离线算法或回滚操作。
进阶学习建议:当你熟悉并查集基础后,可以尝试以下题目(按难度递增):
- 洛谷 P3367 【模板】并查集
- 洛谷 P1551 亲戚
- 洛谷 P1525 [NOIP2010 提高组] 关押罪犯(带权并查集)
- 力扣 547. 省份数量(朋友圈问题)
- 力扣 990. 等式方程的可满足性(并查集维护相等关系)
并查集就像一个高效的社会关系管理局,随时可以回答“这两人是一伙的吗?”并且能在很快的时间内把两伙人合并。掌握它,你就拥有了一把处理“分组”问题的利器!
例题精讲
在并查集中同时使用路径压缩和按秩合并优化后,单次查找操作(Find)的时间复杂度最接近于以下哪个?
并查集只能处理不相交集合的合并与查询,无法用于判断无向图是否存在环。
以下是用路径压缩优化实现的并查集查找操作,请补充空缺位置的代码。
int find(int x) {
if (parent[x] == x) return x;
return parent[x] = ___;
}在并查集的按秩合并优化中,“秩”通常被定义为以下哪一项?
下面是按秩合并的并查集合并操作,请补充空缺的代码。
void merge(int x, int y) {
int rx = find(x), ry = find(y);
if (rx == ry) return;
if (rak[rx] < rak[ry]) {
parent[rx] = ry;
} else if (rak[rx] > rak[ry]) {
parent[ry] = rx;
} else {
parent[ry] = rx;
___;
}
}