目录
  1. 1. 一、问题定义
  2. 2. 二、四种实现演进
    1. 2.1. 2.1 Quick-Find — 优化 Find
    2. 2.2. 2.2 Quick-Union — 优化 Union
    3. 2.3. 2.3 Weighted Union(按大小/秩合并)
    4. 2.4. 2.4 路径压缩(Path Compression)+ 按秩合并 — 接近 O(1)
  3. 3. 三、复杂度分析 — $O(\alpha(n))$ 的奇迹
    1. 3.1. 3.1 反 Ackermann 函数
    2. 3.2. 3.2 $O(\alpha(n))$ 推导直觉
  4. 4. 四、完整 Java 实现(生产级)
  5. 5. 五、经典应用
    1. 5.1. 5.1 Kruskal 最小生成树
    2. 5.2. 5.2 连通分量标记(Connected Components Labeling)
    3. 5.3. 5.3 渗透问题(Percolation)
    4. 5.4. 5.4 等价关系处理
  6. 6. 六、并查集的变体与扩展
    1. 6.1. 6.1 带权并查集(Weighted DSU)
    2. 6.2. 6.2 可撤销并查集(Rollback DSU)
    3. 6.3. 6.3 持久化并查集(Persistent DSU)
  7. 7. 七、面试常见追问
    1. 7.1. 问题一:为什么路径压缩 + 按秩合并的复杂度接近但非严格 $O(1)$?
    2. 7.2. 问题二:路径压缩中使用递归写法会不会导致栈溢出?
    3. 7.3. 问题三:按秩合并 vs 按大小合并,哪个更好?
    4. 7.4. 问题四:什么时候不应该使用路径压缩?
    5. 7.5. 问题五:并查集能否处理”删除边”的操作?
  8. 8. 八、总结
【数据结构与算法体系】不相交集数据结构

一、问题定义

不相交集数据结构(Disjoint Set Union,DSU),通常称为并查集(Union-Find),维护一组互不相交的动态集合 $S = {S_1, S_2, \ldots, S_k}$。每个集合有一个代表元(representative),通常是集合中的某个特定成员。

核心操作

  • MAKE-SET(x):创建仅包含元素 $x$ 的新集合。$x$ 是代表元。
  • UNION(x, y):将包含 $x$ 和 $y$ 的两个集合合并为一个集合,选择新代表元。
  • FIND(x):返回包含 $x$ 的集合的代表元。

关键约束:$x$ 和 $y$ 始终属于唯一的一个集合。UNION 后原来的两个集合不再存在。

应用场景极广

  • Kruskal 的最小生成树算法
  • 图像处理中的连通分量标记
  • 网络连接性判断
  • 等价类划分
  • 渗透问题(Percolation)
  • 游戏中的朋友圈/公会合并

二、四种实现演进

2.1 Quick-Find — 优化 Find

数据结构:数组 id[]id[x] 表示 $x$ 所属集合的代表元。同一个集合的所有元素具有相同的 id

class QuickFind {
private int[] id;

public QuickFind(int n) {
id = new int[n];
for (int i = 0; i < n; i++) id[i] = i;
}

// O(1)
public int find(int x) {
return id[x];
}

// O(n) — 必须扫描整个数组修改所有属于同一集合的元素
public void union(int x, int y) {
int idX = id[x], idY = id[y];
if (idX == idY) return;
for (int i = 0; i < id.length; i++) {
if (id[i] == idX) id[i] = idY;
}
}

// O(1)
public boolean connected(int x, int y) {
return id[x] == id[y];
}
}

分析find 很快($O(1)$),但 union 太慢($O(n)$)。$n$ 个元素的 $n-1$ 次 union 需要 $O(n^2)$ 时间。

2.2 Quick-Union — 优化 Union

数据结构:使用树结构(森林)parent[x] 指向 $x$ 的父节点,根节点指向自身。每个集合是一棵树,根是代表元。

class QuickUnion {
private int[] parent;

public QuickUnion(int n) {
parent = new int[n];
for (int i = 0; i < n; i++) parent[i] = i;
}

// O(h) — 沿树向上找根,h是树的高度
public int find(int x) {
while (x != parent[x]) {
x = parent[x];
}
return x;
}

// O(h) — 将一棵树的根连到另一棵树的根
public void union(int x, int y) {
int rootX = find(x);
int rootY = find(y);
if (rootX != rootY) {
parent[rootX] = rootY;
}
}
}

分析union 只需 $O(h)$($h$ 是树的高度),但最坏情况下树退化为链($h = n$),每次 findunion 都是 $O(n)$。

问题根源union 总是将第一棵树的根作为第二棵树根的子节点,不控制树的生长方向,导致树可能变得非常高。

2.3 Weighted Union(按大小/秩合并)

改进:始终将较小的树合并到较大的树下。维护 size[x](或 rank[x])记录子树大小。

class WeightedUnion {
private int[] parent;
private int[] size; // size[x] = 以x为根的树中的节点数

public WeightedUnion(int n) {
parent = new int[n];
size = new int[n];
for (int i = 0; i < n; i++) {
parent[i] = i;
size[i] = 1;
}
}

// O(log n) — 树的高度被限制在 log n 以内
public int find(int x) {
while (x != parent[x]) {
x = parent[x];
}
return x;
}

// O(log n)
public void union(int x, int y) {
int rx = find(x), ry = find(y);
if (rx == ry) return;
// 较小的树合并到较大的树
if (size[rx] < size[ry]) {
parent[rx] = ry;
size[ry] += size[rx];
} else {
parent[ry] = rx;
size[rx] += size[ry];
}
}
}

定理:使用按大小合并后,任意树的高度 $\leq \lfloor \log_2 n \rfloor$。

证明:树的高度增加 1 当且仅当两棵高度相等的树合并(较小的树合并到较大的树时,大树的根不变,高度不变)。通过归纳:大小为 $s$ 的树的高度最多为 $\lfloor \log_2 s \rfloor$(因为每次高度增加意味着树的大小至少翻倍)。$\square$

因此 findunion 都降为 $O(\log n)$。

2.4 路径压缩(Path Compression)+ 按秩合并 — 接近 O(1)

路径压缩:在 find(x) 的过程中,将路径上所有节点的父指针直接指向根。这摊平了后续查找的成本。

class UnionFind {
private int[] parent;
private int[] rank; // rank是高度上界(不精确,因为路径压缩不更新rank)

public UnionFind(int n) {
parent = new int[n];
rank = new int[n];
for (int i = 0; i < n; i++) {
parent[i] = i;
rank[i] = 0;
}
}

// 路径压缩:递归版(更简洁)
public int find(int x) {
if (parent[x] != x) {
parent[x] = find(parent[x]); // 压缩路径
}
return parent[x];
}

// 路径压缩:迭代版(无递归栈溢出风险)
public int findIterative(int x) {
// 第一趟:找到根
int root = x;
while (root != parent[root]) {
root = parent[root];
}
// 第二趟:压缩路径
while (x != root) {
int next = parent[x];
parent[x] = root;
x = next;
}
return root;
}

// 按秩合并
public boolean union(int x, int y) {
int rx = find(x), ry = find(y);
if (rx == ry) return false; // 已在同一集合

if (rank[rx] < rank[ry]) {
parent[rx] = ry;
} else if (rank[rx] > rank[ry]) {
parent[ry] = rx;
} else {
parent[ry] = rx;
rank[rx]++; // 高度增加仅在秩相等时
}
return true;
}

public boolean connected(int x, int y) {
return find(x) == find(y);
}
}

三、复杂度分析 — $O(\alpha(n))$ 的奇迹

3.1 反 Ackermann 函数

带有路径压缩和按秩合并的并查集,其 findunion 操作的摊还复杂度为 $O(\alpha(n))$,其中 $\alpha(n)$ 是反 Ackermann 函数。

对于任何可实践的 $n$($n \leq 2^{65536}$),$\alpha(n) \leq 5$。即在实际应用中,每个操作可视为几乎 $O(1)$。

Ackermann 函数(增长极快):
$$A(0, m) = m+1$$
$$A(k+1, 0) = A(k, 1)$$
$$A(k+1, m+1) = A(k, A(k+1, m))$$

$A(1, n) = 2+(n+3)-3 = n+2$(线性)
$A(2, n) = 2n+3$(线性)
$A(3, n) = 2^{n+3}-3$(指数级)
$A(4, n) = 2^{2^{2^{\cdots^2}}} - 3$(塔式指数,高度 $n+3$)
$A(4, 4) \approx 2^{2^{2^{2^{2^{2^{2}}}}}}$ — 远超宇宙中的原子数

反 Ackermann 函数:$\alpha(n) = \min{k \mid A(k, 1) \geq n}$。增长极其缓慢。

3.2 $O(\alpha(n))$ 推导直觉

虽然严格证明非常繁复(Tarjan 1975),但直觉如下:

  • 按秩合并确保秩为 $r$ 的节点的子树至少有 $2^r$ 个节点(通过归纳:两个秩为 $r$ 的树合并才产生秩为 $r+1$ 的树,至少 $2^r + 2^r = 2^{r+1}$ 个节点)
  • 路径压缩为节点”付费”——每次 find 遍历路径,将路径上(大多数)节点直接连到根,后续访问这些节点的成本大幅降低
  • 将秩分为若干”组”(以 Ackermann 函数的反函数为界),可证明在任意操作序列中,高级秩组的节点的父指针修改次数受到严格限制

结论:总代价正比于 $n \cdot \alpha(n)$,其中 $\alpha(n)$ 对任何实际输入都可视为常数。


四、完整 Java 实现(生产级)

class DisjointSetUnion {
private final int[] parent;
private final int[] rank;
private int components; // 连通分量数量

public DisjointSetUnion(int n) {
parent = new int[n];
rank = new int[n];
components = n;
for (int i = 0; i < n; i++) {
parent[i] = i;
// rank[i] 默认为 0
}
}

/**
* 查找元素x所在集合的代表元,含路径压缩
*/
public int find(int x) {
if (parent[x] != x) {
parent[x] = find(parent[x]);
}
return parent[x];
}

/**
* 合并x和y所在的集合。返回true若实际发生了合并
*/
public boolean union(int x, int y) {
int rx = find(x), ry = find(y);
if (rx == ry) return false;

// 按秩合并:秩小的作为子树
if (rank[rx] < rank[ry]) {
parent[rx] = ry;
} else if (rank[rx] > rank[ry]) {
parent[ry] = rx;
} else {
parent[ry] = rx;
rank[rx]++;
}
components--;
return true;
}

public boolean connected(int x, int y) {
return find(x) == find(y);
}

public int getComponents() {
return components;
}

// 获取每个集合的大小(需要额外维护size数组)
// 这里展示如果不维护size时如何获取:
public Map<Integer, Integer> getComponentSizes() {
Map<Integer, Integer> sizes = new HashMap<>();
for (int i = 0; i < parent.length; i++) {
int root = find(i);
sizes.put(root, sizes.getOrDefault(root, 0) + 1);
}
return sizes;
}
}

五、经典应用

5.1 Kruskal 最小生成树

并查集是 Kruskal 算法的核心数据结构——用于高效检测加入某条边是否会形成环:

List<Edge> kruskalMST(List<Edge> edges, int n) {
Collections.sort(edges); // 按权重升序
DisjointSetUnion dsu = new DisjointSetUnion(n);
List<Edge> mst = new ArrayList<>();

for (Edge e : edges) {
if (dsu.union(e.u, e.v)) { // 不形成环
mst.add(e);
if (mst.size() == n - 1) break;
}
}
return mst;
}

5.2 连通分量标记(Connected Components Labeling)

在图像处理中,需要标记二值图像中的连通区域。可以用并查集做两趟扫描:

// 二值图像连通分量标记(简化版,4-邻接)
int[] labelComponents(int[][] image, int rows, int cols) {
DisjointSetUnion dsu = new DisjointSetUnion(rows * cols);
int[] parent = new int[rows * cols];
Arrays.fill(parent, -1); // -1 表示背景

// 第一趟:扫描并union相邻前景像素
for (int r = 0; r < rows; r++) {
for (int c = 0; c < cols; c++) {
if (image[r][c] == 0) continue; // 背景
int idx = r * cols + c;
parent[idx] = idx;
// 检查上方和左方
if (r > 0 && image[r-1][c] == 1) dsu.union(idx, (r-1)*cols + c);
if (c > 0 && image[r][c-1] == 1) dsu.union(idx, r*cols + (c-1));
}
}

// 第二趟:为每个根分配标签
Map<Integer, Integer> labelMap = new HashMap<>();
int nextLabel = 1;
int[] labels = new int[rows * cols];
for (int r = 0; r < rows; r++) {
for (int c = 0; c < cols; c++) {
int idx = r * cols + c;
if (parent[idx] != -1) {
int root = dsu.find(idx);
labels[idx] = labelMap.computeIfAbsent(root, k -> nextLabel++);
}
}
}
return labels;
}

5.3 渗透问题(Percolation)

给定 $n \times n$ 的网格,每个 cell 以概率 $p$ 开放。判断是否存在从顶部到底部的连通路径。使用并查集(带虚拟顶层和底层节点),每次开放一个新 cell 时将其与已开放的相邻 cell union。检查虚拟顶层和虚拟底层是否连通即可判断渗透。

5.4 等价关系处理

给定形如 a == ba != b 的约束,判断是否所有约束可同时满足。策略:先用并查集处理所有 == 约束(union(a, b)),再检查所有 != 约束(若 find(a) == find(b) 则矛盾)。

boolean satisfiable(int n, int[][] equations) {
DisjointSetUnion dsu = new DisjointSetUnion(26); // 26个小写字母
// 先处理所有 ==
for (int[] eq : equations) {
if (eq[1] == '=') dsu.union(eq[0] - 'a', eq[3] - 'a');
}
// 再检查所有 !=
for (int[] eq : equations) {
if (eq[1] == '!' && dsu.connected(eq[0] - 'a', eq[3] - 'a')) {
return false;
}
}
return true;
}

六、并查集的变体与扩展

6.1 带权并查集(Weighted DSU)

除了维护集合关系,还在边上维护”权值”。例如在食物链问题中,dist[x] 表示 $x$ 与父节点之间的关系类型(0: 同类, 1: 吃, 2: 被吃):

class WeightedDSU {
int[] parent, rank;
int[] dist; // dist[x] = x 到 parent[x] 的关系值

int find(int x) {
if (parent[x] != x) {
int p = parent[x];
parent[x] = find(parent[x]);
dist[x] = (dist[x] + dist[p]) % 3; // 路径压缩时更新距离
}
return parent[x];
}

boolean union(int x, int y, int relation) {
// relation: x 与 y 的关系
int rx = find(x), ry = find(y);
if (rx == ry) {
return (dist[x] - dist[y] + 3) % 3 == relation;
}
parent[rx] = ry;
dist[rx] = (dist[y] - dist[x] + relation + 3) % 3;
return true;
}
}

6.2 可撤销并查集(Rollback DSU)

支持 undo 操作的并查集。需要改为按秩合并(不能用路径压缩),用栈记录每次 union 的修改。常用于离线算法(如分治、线段树分治)。

6.3 持久化并查集(Persistent DSU)

使用持久化数组(如基于平衡树或线段树实现),可以在 $O(\log n)$ 时间内访问历史版本的并查集状态。


七、面试常见追问

问题一:为什么路径压缩 + 按秩合并的复杂度接近但非严格 $O(1)$?

回答:虽然对任何现实输入 $\alpha(n) \leq 5$ 与常数无异,但从渐近分析角度,$\alpha(n)$ 随 $n$ 趋于无穷而发散(尽管极其缓慢)。Tarjan 在 1975 年严格证明下界为 $\Omega(\alpha(n))$,因此不可能达到 $O(1)$。

问题二:路径压缩中使用递归写法会不会导致栈溢出?

回答:在最坏情况下(链状结构),递归深度可以达到 $n$。对于 $n > 10^4$ 的场景,使用迭代版 find(两趟法)是更安全的选择。或者将递归改为尾递归 + 编译器优化(但 Java 目前不支持尾递归优化)。

问题三:按秩合并 vs 按大小合并,哪个更好?

回答:二者等效(Tarjan 证明二者与路径压缩组合后都是 $O(\alpha(n))$)。按秩合并的常数略小(不用维护 $size$ 的加法),但差别微乎其微。实践中按大小合并更直观——“把小的挂到大的下面”。

问题四:什么时候不应该使用路径压缩?

回答:当需要支持 undo 操作或需要知道树的具体结构时,路径压缩破坏了树的结构信息。此时只用按秩合并($O(\log n)$ 每操作),但保留了父子关系用于回滚。

问题五:并查集能否处理”删除边”的操作?

回答:标准并查集不支持删除(它只知道合并)。如果需要支持边删除+在线查询连通性,需要更强大的数据结构(如 Link-Cut Tree 或 Euler Tour Tree)。或者离线处理(从后往前,将删除变为添加)。


八、总结

并查集(Disjoint Set Union)以其极致的简洁性和几乎常数级的复杂度,成为算法设计中不可或缺的工具:

算法演进:
QuickFind → find O(1), union O(n)
QuickUnion → union O(h), find O(h) (树可退化为链,h=n)
Weighted → O(log n) 保证(按大小合并)
+ 路径压缩 → O(α(n)) ≈ O(1) (Tarjan 1975)

核心设计思想:

  • 按秩/大小合并:防止树退化为链
  • 路径压缩:所有触及的节点直接受益,后续更快
  • 摊还分析:$\alpha(n)$ 的反 Ackermann 分析是算法分析历史上的经典篇章

并查集的应用远超图算法范畴——它是处理等价关系动态连通性的通用工具。下一篇【数据结构与算法体系】van-Emde-Boas-树 将带你进入另一种极致:当 key 的取值范围有限时,优先队列操作可以达到 $O(\log \log n)$。

打赏
  • 微信
  • 支付宝

评论