FILE NO. 044TOPIC 01 DS数据结构难度 入门已整理
并查集:把等价关系收进森林
- 前置知识
- 数组 / 树的基本概念
- 相关题目 / 参考
- LeetCode 547 · 省份数量;LeetCode 684 · 冗余连接
1. 问题描述
维护一组元素的动态等价关系,支持两种操作:
union(a, b):把 a、b 所在的集合合并;find(a):返回 a 所在集合的代表元(根)。
判等价 = find(a) == find(b)。连通性、分组、环检测(逐条加边,两端已同集即成环)都能归到这个模型。
2. 直觉:一棵随时可以变形的树
用「指向父亲」的数组 parent 表示森林:每个集合是一棵树,树根充当代表元。find 沿父指针爬到根;union 把一棵树的根挂到另一棵树的根下。
变形是允许的——树长什么样不重要,重要的是:
这个不变量只关心「同根」,不关心路径长短。于是我们可以理直气壮地压扁路径:find 的路上顺手把每个结点直接挂到根上(路径压缩),树变浅,不变量毫发无损。
3. 两个优化与 α(n)
- 路径压缩:
find时把沿途结点的父指针全部改为根; - 按大小合并(或按秩):永远把小树挂到大树底下,避免森林退化成链。
两个优化叠加后,单次操作的均摊复杂度是 ——阿克曼函数的反函数,对一切现实规模的 都 ,工程上视作常数。
注意一个细节:用了路径压缩后,「按秩」里的 rank 就不再是准确的高度,只是一个足够好用的估计——它只用于合并时的比较,不影响正确性(正确性只靠 §2 的不变量)。
4. 代码实现
union-find
class UnionFind: def __init__(self, n: int): self.parent = list(range(n)) # 初始每个元素自成一棵树 self.size = [1] * n # 按大小合并用的估计值 def find(self, x: int) -> int: root = x while self.parent[root] != root: root = self.parent[root] while self.parent[x] != root: # 路径压缩:第二次爬,逐个挂到根 self.parent[x], x = root, self.parent[x] return root def union(self, a: int, b: int) -> bool: ra, rb = self.find(a), self.find(b) if ra == rb: return False # 已同集,合并无效果 if self.size[ra] < self.size[rb]: ra, rb = rb, ra # 保证 ra 是大树,把小树 rb 挂上去 self.parent[rb] = ra self.size[ra] += self.size[rb] return True5. 边界与常见错误
- 自环 / 重复边:
union(a, a)直接无害返回False,无需特判; - 合并方向写反:
parent[ra] = rb与大小判断不一致时,优化失效(复杂度退化,但不影响正确性——不变量仍在); - 把结点挂到非根上:
parent[a] = rb(挂到了 b 而不是 b 的根),这会破坏不变量,是最危险的笔误; - 忘记初始化 size:按大小合并退化成随机方向。
6. 相关练习
- LeetCode 547 · 省份数量——
union+ 数根的个数; - LeetCode 684 · 冗余连接——第一条使两端已连通的边;
- LeetCode 128 · 最长连续序列——哈希 + 并查集两种视角的对照。
下一篇建议读 043《单调栈:下一个更大元素》。
评论