在算法刷题的江湖里,有一个简单却极易翻车的经典数据结构——并查集。
它专门解决「集合连通性」问题,比如经典的朋友圈好友判断、网络节点连通、最小生成树判环等场景。看似逻辑通俗易懂,但很多同学写出来的代码要么部分用例超时,要么大数据直接TLE,死活拿不到满分。
今天我们就以经典的「朋友圈连通性判断」真题为载体,从零复盘4版并查集迭代写法:从最原始的暴力实现,到逐步优化,最后解锁竞赛最优解。全程带原理图解、完整可运行代码、真实评测数据对比,帮你彻底吃透并查集的优化核心!
Bilibili 同步视频
一、前置场景:朋友圈连通性真题解析📌
1. 题目需求
给定 n 个人、m 次操作,所有人默认互不认识,每次操作分为两种类型:
-
操作1(合并):
1 b c,让 b、c 两人成为好友(合并两个集合) -
操作2(查询):
2 b c,判断 b、c 是否在同一个朋友圈(判断集合连通)
查询连通则输出 YES,不连通则输出 NO。
2. 样例推演(6人场景)
初始状态:6个人各自独立,互不连通
操作流程:合并1、2 → 查询1、3 → 合并2、4 → 合并4、3 → 查询1、3
结果:第一次查询无交集输出 NO,全部合并后二次查询连通输出 YES。
这道题看似简单,却能完美测出不同并查集写法的性能短板,下面我们逐一代入算法迭代优化!
二、版本1:Quick-Find 暴力查找版(入门小白款)
1. 核心原理
Quick-Find 是最朴素的并查集实现思路,核心逻辑:用数组直接记录每个节点的根节点编号。
查找根节点时,直接读取数组值,时间复杂度 O(1),查找速度极快,这也是它「Quick-Find」名字的由来。
但致命短板在合并操作:每次合并两个集合,需要遍历全部节点,批量修改同集合节点的根标识,数据量大时效率直接崩盘。
2. 原理示意图(Plain Text)
# 初始状态 n=6
parent = [0,1,2,3,4,5,6]
# 合并1、2后:所有根为2的节点改为根1
parent = [0,1,1,3,4,5,6]
# 核心痛点:合并需要全局遍历,节点越多,耗时越爆炸
3. 完整C++代码
#include <iostream>
using namespace std;
const int N = 100010;
int parent[N];
// 初始化:每个节点的根是自己
void init(int n) {
for(int i = 1; i <= n; i++) parent[i] = i;
}
// 查找根节点:O(1)极速查找
int find(int x) {
return parent[x];
}
// 合并集合:暴力遍历,效率极低
void unite(int x, int y) {
int fx = find(x), fy = find(y);
if(fx == fy) return;
// 遍历所有节点,修改根节点
for(int i = 1; i <= 100000; i++) {
if(parent[i] == fy) parent[i] = fx;
}
}
int main() {
// IO优化,避免输入输出超时
ios::sync_with_stdio(false);
cin.tie(0);
int n, m;
cin >> n >> m;
init(n);
while(m--) {
int a, b, c;
cin >> a >> b >> c;
if(a == 1) unite(b, c);
else {
if(find(b) == find(c)) cout << "YESn";
else cout << "NOn";
}
}
return 0;
}
4. 真实评测表现
✅ 优点:查找逻辑极简、新手好理解、单次查询速度快
❌ 缺点:合并操作暴力遍历,时间复杂度 O(n)
🏆 评测结果:90分,大数据用例TLE超时,总运行耗时1788ms
三、版本2:朴素 Quick-Union 树形版(反向翻车款)
1. 核心原理
为了解决 Quick-Find 合并过慢的问题,Quick-Union 放弃了「全局根标识」,改用树形存储结构:数组记录每个节点的父节点,而非直接记录根节点。
合并时无需全局遍历,只需要将一棵树的根节点挂载到另一棵树的根上,合并速度大幅提升。
但新的问题来了:树会退化成长链表!如果合并顺序不合理,树形结构会变成一条长链,查找根节点需要逐层遍历,效率比 Quick-Find 还要拉胯。
2. 原理示意图(Plain Text)
# 树形结构存储,parent[x]代表x的父节点
初始:1-6各自为根
合并1、2:parent[2] = 1
合并1、3:parent[3] = 1
# 最坏情况退化:1<-2<-3<-4<-5<-6,查找6的根需要遍历5次
3. 完整C++代码
#include <iostream>
using namespace std;
const int N = 100010;
int parent[N];
void init(int n) {
for(int i = 1; i <= n; i++) parent[i] = i;
}
// 逐层查找根节点
int find(int x) {
if(parent[x] != x) x = parent[x];
return x;
}
// 朴素合并:直接挂载根节点,无任何优化
void unite(int x, int y) {
int fx = find(x), fy = find(y);
if(fx != fy) parent[fy] = fx;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(0);
int n, m;
cin >> n >> m;
init(n);
while(m--) {
int a, b, c;
cin >> a >> b >> c;
if(a == 1) unite(b, c);
else cout << (find(b)==find(c) ? "YESn" : "NOn");
}
return 0;
}
4. 真实评测表现
✅ 优点:合并操作极简,无需全局遍历
❌ 缺点:树形极易退化,查询效率暴跌
🏆 评测结果:仅50分,性能全面落后Quick-Find,堪称反向优化典范
四、版本3:按秩优化 Quick-Union(控树高优化款)
1. 核心优化思想
朴素 Quick-Union 的核心bug是「树高低失控」,为了稳住树形结构,我们引入按大小(秩)优化:
新增 size 数组,记录每棵树的节点总数。合并时小树挂大树,永远不让短树变长树,从根源限制树的高度,避免链表退化。
2. 原理示意图(Plain Text)
# size数组记录树节点数量
树A:根1,节点数3
树B:根4,节点数2
# 优化规则:小树挂大树
将树B根4挂载到树A根1下,树高始终保持最小,不会超长
3. 完整C++代码
#include <iostream>
using namespace std;
const int N = 100010;
int parent[N];
int sz[N]; // 记录每棵树的节点数量
void init(int n) {
for(int i = 1; i <= n; i++) {
parent[i] = i;
sz[i] = 1; // 初始每个树只有1个节点
}
}
int find(int x) {
while(parent[x] != x) x = parent[x];
return x;
}
// 按大小优化合并:小树嫁大树
void unite(int x, int y) {
int fx = find(x), fy = find(y);
if(fx == fy) return;
// 小集合合并到大集合
if(sz[fx] < sz[fy]) swap(fx, fy);
parent[fy] = fx;
sz[fx] += sz[fy];
}
int main() {
ios::sync_with_stdio(false);
cin.tie(0);
int n, m;
cin >> n >> m;
init(n);
while(m--) {
int a, b, c;
cin >> a >> b >> c;
if(a == 1) unite(b, c);
else cout << (find(b)==find(c) ? "YESn" : "NOn");
}
return 0;
}
4. 真实评测表现
✅ 优点:严格控制树高,杜绝链表退化,性能大幅提升
❌ 缺点:仍存在层级冗余,查询次数多了依然会耗时
🏆 评测结果:70分,相比朴素版提升明显,但仍无法满分
五、版本4:按秩+路径压缩(满分进阶款)
1. 封神优化:路径压缩(扁平化管理)
这里给大家引入并查集最核心、最无敌的优化思想——路径压缩,可以用企业管理的思路通俗理解:
并查集的树形层级毫无意义,我们只需要知道「节点的根是谁」,不需要中间层层父节点。就像企业扁平化管理:所有员工直接对接CEO,跳过所有中层领导,效率拉满!
路径压缩的核心逻辑:在 find 查找根节点的过程中,把路径上所有节点直接挂载到根节点上,一次查询,永久扁平化。
2. 原理示意图(Plain Text)
# 优化前:层级繁琐
1(根) <- 2 <- 3 <- 4
# 查询4的根后,路径压缩扁平化
1(根)、2、3、4 全部直接指向根1
# 后续所有查询都是O(1)极速响应
3. 完整C++代码(双优化叠加)
#include <iostream>
using namespace std;
const int N = 100010;
int parent[N];
int sz[N];
void init(int n) {
for(int i = 1; i <= n; i++) {
parent[i] = i;
sz[i] = 1;
}
}
// 带路径压缩的查找
int find(int x) {
if(parent[x] != x) {
parent[x] = find(parent[x]); // 递归路径压缩,直接挂根
}
return parent[x];
}
// 按秩优化合并
void unite(int x, int y) {
int fx = find(x), fy = find(y);
if(fx == fy) return;
if(sz[fx] < sz[fy]) swap(fx, fy);
parent[fy] = fx;
sz[fx] += sz[fy];
}
int main() {
ios::sync_with_stdio(false);
cin.tie(0);
int n, m;
cin >> n >> m;
init(n);
while(m--) {
int a, b, c;
cin >> a >> b >> c;
if(a == 1) unite(b, c);
else cout << (find(b)==find(c) ? "YESn" : "NOn");
}
return 0;
}
4. 真实评测表现
🏆 评测结果:满分AC,运行耗时仅720ms,性能碾压前三个版本
✅ 双重优化:树高可控 + 路径扁平化,几乎达到常数级时间复杂度
六、版本5:仅路径压缩(竞赛最简最优解🔥)
1. 核心结论
很多同学以为「按秩+路径压缩」是最强组合,但实测发现:单纯路径压缩的效率,和双优化几乎无差距!
更关键的是:路径压缩代码更短、逻辑更简单、不易出错,完全适配竞赛所有数据规模,这也是大佬们刷题的通用写法!
2. 最简AC代码
#include <iostream>
using namespace std;
const int N = 100010;
int parent[N];
void init(int n) {
for(int i = 1; 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 fx = find(x), fy = find(y);
if(fx != fy) parent[fy] = fx;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(0);
int n, m, a, b, c;
cin >> n >> m;
init(n);
while(m--) {
cin >> a >> b >> c;
if(a == 1) unite(b, c);
else cout << (find(b)==find(c) ? "YESn" : "NOn");
}
return 0;
}
七、五版并查集全方位性能对比📊
| 版本 | 核心特点 | 评测得分 | 运行效率 | 适用场景 |
|---|---|---|---|---|
| Quick-Find | 查找快、合并暴力遍历 | 90分 | 极差(1788ms) | 新手入门理解原理 |
| 朴素Quick-Union | 树形存储、无优化 | 50分 | 最差 | 不推荐使用 |
| 按秩优化 | 小树嫁大树、控树高 | 70分 | 中等 | 基础优化练习 |
| 按秩+路径压缩 | 双重优化、极致稳定 | 100分 | 优秀(720ms) | 超大数据严苛场景 |
| 纯路径压缩 | 代码极简、效率拉满 | 100分 | 优秀 | 竞赛刷题通用最优解 |
八、最终刷题总结💡
-
不要用朴素版并查集:极易超时,纯属踩坑
-
路径压缩是性价比最高的优化:一行代码实现,性能提升翻倍,编码零负担
-
竞赛通用模板:直接背诵「纯路径压缩」版本,适配99%并查集题目
-
额外小技巧:代码开头加上
ios::sync_with_stdio(false);cin.tie(0);,解决cin/cout输入输出超时问题,刷题必备!
并查集的核心精髓从来不是复杂的逻辑,而是用极简的优化解决结构冗余问题。吃透这5个版本的迭代逻辑,从此告别并查集超时问题!