并查集(Union-Find)

不相交集合的合并与查询 —— 解决连通性问题的高效数据结构

一、什么是并查集

并查集(Union-Find),也称为不相交集合(Disjoint Set Union,DSU),是一种用于处理集合合并与查询问题的树形数据结构。它主要支持两种操作:

并查集的核心思想是:用树(森林)来表示集合,每个集合用一棵树表示,树的根节点作为该集合的唯一标识。初始状态下,每个元素各自独立构成一棵只有根节点的树。

核心能力

  • 判断两个元素是否属于同一个集合(is_connected)
  • 快速合并两个集合(union)
  • 查询当前连通分量数量(count)
  • 近似 O(1) 的时间复杂度内完成以上操作(经过路径压缩和按秩合并优化后)

并查集是图论算法中"基础设施"级别的数据结构,广泛应用于连通分量检测、最小生成树、社交网络分析、动态连通性等场景。掌握并查集是算法学习中最具性价比的投资之一。

二、基本数据结构与实现

2.1 核心数据结构

并查集使用一个父指针数组 parent[] 来维护集合关系。parent[i] 表示元素 i 的父节点。当 parent[i] == i 时,i 就是所在集合的根节点

// 并查集核心数据结构 class UnionFind { private: int[] parent; // 父指针数组,parent[i] 表示 i 的父节点 int[] rank; // 秩数组(树的高度),用于按秩合并优化 int count; // 当前连通分量(集合)的数量 public: // 构造函数:初始化 n 个元素,每个元素自成一集 UnionFind(int n) { count = n; parent = new int[n]; rank = new int[n]; for (int i = 0; i < n; i++) { parent[i] = i; // 每个元素指向自己,构成独立集合 rank[i] = 0; // 初始高度为 0 } } };

初始状态示意图

假设有 6 个元素(0-5),初始化后每个元素都是一棵独立的树:

  0    1    2    3    4    5
  ↑    ↑    ↑    ↑    ↑    ↑
 自身  自身  自身  自身  自身  自身

此时连通分量数量 count = 6。

2.2 基础版 Find 操作

查找元素 x 所属集合的根节点。通过沿父指针链向上追溯,直到找到 parent[x] == x 的元素。

// 基础版 Find:查找元素 x 的根节点 int find(int x) { while (parent[x] != x) { x = parent[x]; // 沿父指针向上追溯 } return x; // 返回根节点(集合标识) }

2.3 基础版 Union 操作

将两个元素所在的集合合并。先找到各自的根节点,然后将一个根节点指向另一个。

// 基础版 Union:合并 x 和 y 所在的集合 void unionSets(int x, int y) { int rootX = find(x); int rootY = find(y); if (rootX == rootY) { return; // 已经在同一个集合中,无需合并 } parent[rootX] = rootY; // 将 rootX 的父指针指向 rootY count--; // 连通分量减少 1 }

2.4 判断连通性

// 判断两个元素是否属于同一集合 bool isConnected(int x, int y) { return find(x) == find(y); } // 查询当前连通分量数量 int getCount() { return count; }

基础版本的问题

基础版本的 Find 和 Union 在最坏情况下时间复杂度为 O(n)。例如,当不断将长链的末端合并到新节点时,树会退化成一条链,Find 操作需要逐层遍历整条链。这在大规模数据下是不可接受的,因此需要引入优化策略。

三、优化策略

3.1 路径压缩(Path Compression)

路径压缩是并查集最重要的优化之一。其核心思想是:在执行 Find 操作时,将路径上所有经过的节点直接连接到根节点上,从而扁平化树结构,加速后续的查找操作。

// 带路径压缩的 Find(递归实现) int find(int x) { if (parent[x] != x) { parent[x] = find(parent[x]); // 递归地将 x 的父节点指向根 } return parent[x]; } // 带路径压缩的 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 操作后,树几乎被压平为深度接近 2 的扁平结构。每个节点要么是根,要么直接指向根

注意:路径压缩不改变 rank 值。rank 只反映节点在未压缩时树的高度上界,这一特性保证了按秩合并在路径压缩存在时仍能正常工作。

3.2 按秩合并(Union by Rank)

按秩合并的核心思想是:在合并两个集合时,总是将秩(树的高度)较小的树连接到秩较大的树之下,避免树的不必要增长。

// 按秩合并的 Union 操作 void unionSets(int x, int y) { int rootX = find(x); int rootY = find(y); if (rootX == rootY) return; // 将秩较小的树合并到秩较大的树 if (rank[rootX] < rank[rootY]) { parent[rootX] = rootY; } else if (rank[rootX] > rank[rootY]) { parent[rootY] = rootX; } else { // 秩相等时,一个指向另一个,被指向的秩 +1 parent[rootY] = rootX; rank[rootX]++; } count--; }

按大小合并(Union by Size)

除了按秩合并,另一种常见策略是按大小合并——总是将元素较少的集合合并到元素较多的集合。两种策略效果相近,均能保证树的高度为 O(log n)。

// 按大小合并(size 数组存储集合元素个数) if (size[rootX] < size[rootY]) { parent[rootX] = rootY; size[rootY] += size[rootX]; } else { parent[rootY] = rootX; size[rootX] += size[rootY]; }

3.3 时间复杂度分析

实现方式 Union Find 备注
基础实现(无优化) O(n) O(n) 树可能退化成链表
只按秩合并 O(log n) O(log n) 树高保证 ≤ log₂n
只路径压缩 O(log n) 摊销 O(log n) 扁平化树结构
路径压缩 + 按秩合并 O(α(n)) O(α(n)) 近似 O(1),反阿克曼函数

反阿克曼函数 α(n)

当同时使用路径压缩按秩合并时,并查集操作的时间复杂度为 O(α(n)),其中 α(n) 是反阿克曼函数(Inverse Ackermann Function)

α(n) 的增长速度极其缓慢

  • α(10^3) ≈ 3
  • α(10^6) ≈ 4
  • α(10^100) ≈ 5
  • α(2^65536) ≈ 5

对于任何物理世界中可达到的 n,α(n) ≤ 5,因此我们可以认为并查集的操作时间为 近似常数 O(1)。这一结论的严格证明由 Hopcroft 和 Ullman 给出。

四、实现变体与对比

4.1 QuickFind 实现

QuickFind 是并查集最朴素的实现方式。其核心思想是:使用数组存储每个元素所属的集合 ID,同一个集合中的所有元素具有相同的 ID

// QuickFind 实现 class QuickFind { private: int[] id; // id[i] = 元素 i 所属集合的标识 int count; public: QuickFind(int n) { count = n; id = new int[n]; for (int i = 0; i < n; i++) id[i] = i; } // Find: O(1) — 直接返回集合 ID int find(int x) { return id[x]; } // Union: O(n) — 需要遍历所有元素,修改集合 ID void unionSets(int x, int y) { int idX = find(x); int idY = find(y); if (idX == idY) return; // 将所有 id == idX 的元素改为 idY for (int i = 0; i < id.length; i++) { if (id[i] == idX) { id[i] = idY; } } count--; } };

QuickFind 特点:Find 操作是 O(1),但 Union 操作需要 O(n) 时间遍历整个数组。适用于查询多、合并少的场景。

4.2 QuickUnion 实现

QuickUnion 采用树形结构的父指针表示法,与标准并查集相同。它优化了 Union 的速度,但 Find 可能变慢。

// QuickUnion 实现 class QuickUnion { private: int[] parent; public: QuickUnion(int n) { parent = new int[n]; for (int i = 0; i < n; i++) parent[i] = i; } // Find: O(树高),最坏 O(n) int find(int x) { while (parent[x] != x) x = parent[x]; return x; } // Union: 只需修改一个指针 void unionSets(int x, int y) { int rootX = find(x); int rootY = find(y); if (rootX != rootY) { parent[rootX] = rootY; count--; } } };

4.3 WeightedQuickUnion(带权重优化)

WeightedQuickUnion 是 QuickUnion 的改进版,它通过跟踪树的大小来保证较小的树总是连接到较大的树上,从而避免树的退化。

实现方式 Find 时间复杂度 Union 时间复杂度 构造函数
QuickFind O(1) O(n) O(n)
QuickUnion O(树高) ≈ O(n) O(树高) ≈ O(n) O(n)
WeightedQuickUnion O(log n) O(log n) O(n)
WeightedQuickUnion + Path Compression O(α(n)) O(α(n)) O(n)

五、完整实现(最优版本)

下面给出并查集的最优实现——同时包含路径压缩和按秩合并的完整 C++/Java 风格实现:

// Union-Find 完整实现(路径压缩 + 按秩合并) class UnionFind { private: int[] parent; int[] rank; int count; public: // 构造函数 UnionFind(int n) { count = n; parent = new int[n]; rank = new int[n]; for (int i = 0; i < n; i++) { parent[i] = i; rank[i] = 0; } } // 查找根节点(路径压缩) int find(int x) { if (parent[x] != x) { parent[x] = find(parent[x]); } return parent[x]; } // 合并集合(按秩合并) void unionSets(int x, int y) { int rootX = find(x); int rootY = find(y); if (rootX == rootY) return; if (rank[rootX] < rank[rootY]) { parent[rootX] = rootY; } else if (rank[rootX] > rank[rootY]) { parent[rootY] = rootX; } else { parent[rootY] = rootX; rank[rootX]++; } count--; } // 判断连通性 boolean isConnected(int x, int y) { return find(x) == find(y); } // 获取连通分量数量 int getCount() { return count; } };

六、并查集的典型应用

6.1 连通分量检测(Connected Components)

无向图中,使用并查集可以快速计算出图的连通分量数量。遍历所有边,对每条边的两个端点执行 Union 操作即可。

// 使用并查集计算图的连通分量 int countComponents(int n, int[][] edges) { UnionFind uf = new UnionFind(n); for (int[] edge : edges) { uf.unionSets(edge[0], edge[1]); } return uf.getCount(); }

6.2 Kruskal 最小生成树算法

Kruskal 算法是并查集最经典的应用之一。其算法步骤如下:

  1. 将图中所有边按权重从小到大排序
  2. 初始化并查集,每个顶点自成一个集合
  3. 从小到大遍历每条边 (u, v, w):
    • 如果 u 和 v 不在同一集合中(即不构成环),将边加入 MST,合并 u 和 v
  4. 当选中 n-1 条边时停止,得到最小生成树
// Kruskal 算法求最小生成树 int kruskal(int n, int[][] edges) { // edges: [u, v, weight] Arrays.sort(edges, (a, b) -> a[2] - b[2]); // 按权重排序 UnionFind uf = new UnionFind(n); int mstWeight = 0; int edgeCount = 0; for (int[] edge : edges) { if (!uf.isConnected(edge[0], edge[1])) { uf.unionSets(edge[0], edge[1]); mstWeight += edge[2]; edgeCount++; if (edgeCount == n - 1) break; } } return mstWeight; } // 时间复杂度:O(E log E) —— 排序占主导,并查集操作近似 O(1)

Kruskal vs Prim 算法

  • Kruskal:适合稀疏图,时间复杂度 O(E log E),依赖并查集检测环
  • Prim:适合稠密图,时间复杂度 O(V²) 或 O(E log V),类似 Dijkstra
  • 两者都能求出最小生成树,选择取决于图的稠密程度

6.3 好友关系网络(社交网络)

在社交网络中,使用并查集可以快速判断两个用户是否为间接好友(共同好友的好友),或者统计朋友圈数量

// LeetCode 547. 省份数量(朋友圈) class Solution { public int findCircleNum(int[][] isConnected) { int n = isConnected.length; UnionFind uf = new UnionFind(n); for (int i = 0; i < n; i++) { for (int j = i + 1; j < n; j++) { if (isConnected[i][j] == 1) { uf.unionSets(i, j); } } } return uf.getCount(); } }

6.4 岛屿数量(并查集解法)

虽然 DFS/BFS 是岛屿问题的标准解法,但并查集同样能优雅地解决这一问题:

// LeetCode 200. 岛屿数量(并查集解法) int numIslands(char[][] grid) { if (grid == null || grid.length == 0) return 0; int rows = grid.length, cols = grid[0].length; UnionFind uf = new UnionFind(rows * cols); // 将二维坐标映射为一维索引:id = r * cols + c int[] dirs = {0, 1, 0, -1, 0}; // 右、下方向 for (int r = 0; r < rows; r++) { for (int c = 0; c < cols; c++) { if (grid[r][c] == '1') { for (int d = 0; d < 4; d++) { int nr = r + dirs[d], nc = c + dirs[d + 1]; if (nr >= 0 && nr < rows && nc >= 0 && nc < cols && grid[nr][nc] == '1') { uf.unionSets(r * cols + c, nr * cols + nc); } } } } } // 统计所有 '1' 位置中根节点数量 Set<Integer> roots = new HashSet<>(); for (int r = 0; r < rows; r++) { for (int c = 0; c < cols; c++) { if (grid[r][c] == '1') { roots.add(uf.find(r * cols + c)); } } } return roots.size(); }

6.5 等式方程的可满足性

LeetCode 990. 等式方程的可满足性

给定一个由表示变量之间关系的字符串方程组成的数组,每个字符串方程长度为 4:
形式为 "a==b" 或 "a!=b"。判断是否可以为所有变量分配整数以同时满足所有方程。

解法思路:先处理所有 "==" 等式,将相等变量 Union 到同一集合;再处理所有 "!=" 不等式,如果不等号两端的变量已属于同一集合,则返回 false。

// LeetCode 990. 等式方程的可满足性 public boolean equationsPossible(String[] equations) { UnionFind uf = new UnionFind(26); // 26 个小写字母 // 第一遍:处理所有等式 for (String eq : equations) { if (eq.charAt(1) == '=') { uf.unionSets(eq.charAt(0) - 'a', eq.charAt(3) - 'a'); } } // 第二遍:检查所有不等式是否冲突 for (String eq : equations) { if (eq.charAt(1) == '!') { if (uf.isConnected(eq.charAt(0) - 'a', eq.charAt(3) - 'a')) { return false; } } } return true; }

七、带权并查集

带权并查集(Weighted Union-Find)

在标准的并查集中,我们只关心元素之间的连通关系(是否在同一集合)。而在一些场景中,我们还需要知道元素之间的相对关系,例如:

  • 节点 A 比节点 B 多多少分
  • 节点 A 到 B 的距离是多少
  • A 和 B 的种类关系(如食物链问题)

带权并查集通过在父指针上附加权重信息来维护这些相对关系。

// 带权并查集(以距离/差值关系为例) class WeightedUnionFind { private: int[] parent; double[] weight; // weight[i] 表示 i 到 parent[i] 的权值 int count; public: WeightedUnionFind(int n) { parent = new int[n]; weight = new double[n]; for (int i = 0; i < n; i++) { parent[i] = i; weight[i] = 1.0; // 到自身的权值为 1.0 } } // 带权查找:返回根节点,同时更新路径上的权重 int find(int x) { if (parent[x] != x) { int originalParent = parent[x]; parent[x] = find(parent[x]); weight[x] *= weight[originalParent]; // 权重累积 } return parent[x]; } // 带权合并:合并集合,并设置 x 和 y 之间的权值关系 void unionSets(int x, int y, double value) { int rootX = find(x); int rootY = find(y); if (rootX == rootY) return; // 设置 parent[rootX] = rootY,并计算权值 // 关系:x / y = value // 即:weight[x] * 根权重关系 = value * weight[y] parent[rootX] = rootY; weight[rootX] = value * weight[y] / weight[x]; } // 查询 x 与 y 的比例关系(如果连通) double getRatio(int x, int y) { if (find(x) != find(y)) { return -1; // 不连通,无法确定关系 } return weight[x] / weight[y]; } };

带权并查集的经典题目

  • LeetCode 399. 除法求值: 给出 a/b=2.0, b/c=3.0,求 a/c 的值
  • LeetCode 721. 账户合并: 合并同名账户下的所有邮箱
  • POJ 1182 食物链: 三类动物之间的捕食关系(模 3 余数关系)

7.1 例题:除法求值

// LeetCode 399. 除法求值 // 给定 equations: [["a","b"],["b","c"]] 和 values: [2.0, 3.0] // 求 queries: [["a","c"],["b","a"]] -> [6.0, 0.5] public double[] calcEquation( List<List<String>> equations, double[] values, List<List<String>> queries ) { // 变量名 → 整数 ID 映射 Map<String, Integer> map = new HashMap<>(); int id = 0; for (List<String> eq : equations) { if (!map.containsKey(eq.get(0))) map.put(eq.get(0), id++); if (!map.containsKey(eq.get(1))) map.put(eq.get(1), id++); } WeightedUnionFind uf = new WeightedUnionFind(id); for (int i = 0; i < equations.size(); i++) { int a = map.get(equations.get(i).get(0)); int b = map.get(equations.get(i).get(1)); uf.unionSets(a, b, values[i]); // a / b = values[i] } double[] result = new double[queries.size()]; for (int i = 0; i < queries.size(); i++) { String x = queries.get(i).get(0); String y = queries.get(i).get(1); if (!map.containsKey(x) || !map.containsKey(y)) { result[i] = -1.0; } else { result[i] = uf.getRatio(map.get(x), map.get(y)); } } return result; }

八、更多应用与扩展

8.1 并查集在图像处理中的应用

在图像处理中,并查集可用于连通区域标记(Connected Component Labeling)。将每个像素视为一个节点,相邻且颜色相近的像素执行 Union 操作,最终得到所有连通区域。

8.2 动态连通性(Dynamic Connectivity)

网络连接场景中,当网络中的节点可以动态加入或删除连接时,并查集可以高效地回答"节点 A 和 B 是否连通"的查询。典型应用包括:

8.3 并查集的可持久化

通过可持久化线段树(主席树)实现并查集的可持久化版本,可以回退到历史版本。这在一些需要撤销操作的场景中非常有用(如离线动态图连通性问题)。

8.4 并查集的撤销操作

标准的并查集不支持撤销(即 Union 操作不可逆)。但通过按秩合并 + 栈记录操作历史,可以实现可撤销并查集

// 可撤销并查集(不支持路径压缩!) class RollbackUnionFind { int[] parent; int[] size; Stack<int[]> history; // 记录被修改的 parent 和 size RollbackUnionFind(int n) { parent = new int[n]; size = new int[n]; for (int i = 0; i < n; i++) { parent[i] = i; size[i] = 1; } history = new Stack<>(); } int find(int x) { while (parent[x] != x) x = parent[x]; return x; } boolean unionSets(int a, int b) { int ra = find(a), rb = find(b); if (ra == rb) { history.push(new int[]{-1, -1, -1, -1}); return false; } // 按大小合并 if (size[ra] < size[rb]) { int t = ra; ra = rb; rb = t; } history.push(new int[]{ra, size[ra], rb, parent[rb]}); parent[rb] = ra; size[ra] += size[rb]; return true; } void rollback() { int[] prev = history.pop(); if (prev[0] != -1) { int ra = prev[0], sa = prev[1]; int rb = prev[2], prb = prev[3]; size[ra] = sa; parent[rb] = prb; } } }

九、LeetCode 经典习题

以下题目可以帮助巩固并查集的学习:

题目编号 题目名称 难度 考察知识点
200 岛屿数量 中等 二维坐标映射、连通分量
547 省份数量(朋友圈) 中等 邻接矩阵、连通分量计数
684 冗余连接 中等 Union-Find 检测环
721 账户合并 中等 字符串映射、集合合并
990 等式方程的可满足性 中等 先等后不等、冲突检测
399 除法求值 中等 带权并查集、图论
128 最长连续序列 困难 Union-Find 或哈希表
765 情侣牵手 困难 贪心 + Union-Find
778 水位上升的泳池中游泳 困难 二分 + Union-Find

十、核心要点总结

十一、进一步思考

深入探讨

  1. 为什么路径压缩和按秩合并同时使用时光复杂度能达到 O(α(n))? 这涉及复杂的摊还分析(Amortized Analysis)。核心思路是:每次路径压缩都会"展平"树结构,使得后续操作的成本更低。通过将节点按秩分组并进行势能分析,可以证明总的操作次数为 O(m α(n))。
  2. 并查集在处理动态图(Dynamic Graph)中的应用: 当图的边可以动态增加时(没有删除),并查集可以在线性时间内处理所有连通性查询。但当边可以删除时,需要使用动态树(Link-Cut Tree)离线分治(Offline Divide & Conquer)来解决。
  3. 并查集与图染色问题的联系: 利用带权并查集可以解决二分图判定问题。通过维护节点到根节点的"奇偶性"关系(模 2 权值),可以在 Union 操作时检测是否产生奇环。
  4. 并查集在并行计算中的潜力: 由于并查集操作之间存在数据依赖,完全并行化比较困难。但近年来的研究提出了可并行化的并查集算法(如 Wait-Free Union-Find),在 GPU 等并行架构上实现了高效连通分量计算。

工程实践建议

  • 始终同时使用路径压缩和按秩合并,以获取近似 O(1) 的优异性能
  • 使用数组而非指针实现 parent 数组,以获得更好的缓存局部性和更低的访存开销
  • 在 C++ 中注意递归深度:递归实现 Find 更简洁,但在极端情况下可能导致栈溢出;建议使用迭代实现
  • 在 Java 中优先使用 int 数组而非 ArrayList,避免装箱拆箱开销
  • 按大小合并通常比按秩合并更适用于可撤销场景,因为 size 信息在路径压缩时不需要维护
  • 千万级节点的并查集运行依然高效,内存占用仅约 8-16 MB(视优化策略而定)