【算法图解】并查集:谁是你的老大
并查集(Union-Find/DSU)用“集合代表元”解决动态连通性问题。本文讲清 find/union、路径压缩、按秩合并、复杂度为何接近 O(1),并给出连通块计数与最小生成树中的典型用法。
16 分钟阅读
小明
【算法图解】并查集:谁是你的老大
并查集(Union-Find / Disjoint Set Union, DSU)是一个非常“像人”的数据结构。
你可以把每个集合想成一个帮派:
- 每个人都有一个“老大”(代表元)
- 同一个帮派的人最终能找到同一个老大
- 两个帮派合并就是“认同一个老大”
它专门解决一类问题:动态连通性。
典型问题:
- 两个点是否连通?
- 连通块数量是多少?
- 加边过程中是否形成环?
1. 两个基本操作:find 与 union
并查集维护两个数组:
parent[x]:x 的父节点rank/size:用于按秩合并(可选但强烈建议)
1.1 find:找老大
function find(x) {
while (parent[x] !== x) x = parent[x]
return x
}
1.2 union:合并两个集合
function union(a, b) {
const ra = find(a)
const rb = find(b)
if (ra === rb) return false
parent[rb] = ra
return true
}
朴素版 union 会退化(像链表一样高)。
优化的关键是两招:
- 路径压缩
- 按秩/按大小合并
2. 路径压缩:让每个人直接认老大
路径压缩让 find 变得几乎“摊还 O(1)”。
递归写法最短:
function find(x) {
if (parent[x] !== x) parent[x] = find(parent[x])
return parent[x]
}
它会把 x 到老大路径上的所有节点都直接挂到老大下面。
3. 按秩合并:小树挂到大树
如果每次都把小树挂到大树,树的高度会很低。
这里用 size 实现:
function union(a, b) {
let ra = find(a)
let rb = find(b)
if (ra === rb) return false
if (size[ra] < size[rb]) {
;[ra, rb] = [rb, ra]
}
parent[rb] = ra
size[ra] += size[rb]
return true
}
4. 复杂度:为什么说它接近 O(1)?
路径压缩 + 按秩合并的并查集,单次操作的摊还复杂度是:
- $O(\alpha(n))$
$\alpha(n)$ 是反阿克曼函数,增长极慢:
- 在宇宙尺度的 n 下也小于 5
所以工程里你可以近似认为:
- find/union 都是“常数级”
5. 典型用法一:连通块数量
初始化每个点自成一派:
count = n
每 union 成功一次(两个集合合并):
count--
最后 count 就是连通块数量。
6. 典型用法二:判断是否成环(无向图)
遍历每条边 (u, v):
- 如果
find(u) === find(v),说明 u 和 v 已经连通 - 再加这条边就会形成环
7. 典型用法三:最小生成树(Kruskal)
Kruskal 思想:
- 边按权重从小到大排序
- 依次尝试加入
- 如果会成环就跳过
“会成环吗?”这一步用并查集最省事。
8. 面试回答模板
- 并查集维护集合代表元,用 find/union 解决动态连通性。
- 通过路径压缩 + 按秩合并,把复杂度摊还到接近 O(1)。
- 常用于连通块计数、成环检测、Kruskal。
总结
并查集的核心不是代码量,而是两点:
- find 要做路径压缩
- union 要按秩/按大小
写对这两点,你的 DSU 就“又快又稳”。