
👋 大家好,欢迎来到我的技术博客!
💻 作为一名热爱 Java 与软件开发的程序员,我始终相信:清晰的逻辑 + 持续的积累 = 稳健的成长。
📚 在这里,我会分享学习笔记、实战经验与技术思考,力求用简单的方式讲清楚复杂的问题。
🎯 本文将围绕数据结构与算法这个话题展开,希望能为你带来一些启发或实用的参考。
🌱 无论你是刚入门的新手,还是正在进阶的开发者,希望你都能有所收获!
文章目录
数据结构与算法 - 并查集的应用:图的连通性与集合合并 🔗🗺️
在计算机科学的广阔天地中,连通性(Connectivity)是一个贯穿始终的核心概念。无论是社交网络中的好友关系、电路板上的导线连接,还是地图上的区域通达,我们常常需要回答这样一个问题:
“这两个元素是否属于同一个连通分量?”
而当系统动态变化——新增连接、合并群体、删除节点——时,问题进一步升级为:
“如何高效维护动态连通性,并支持快速查询与合并?”
面对这一挑战,并查集(Union-Find,又称 Disjoint Set Union, DSU)以其极简的结构、接近常数的操作效率和优雅的扩展能力,成为解决动态连通性问题的首选工具。
本文将聚焦于并查集在图的连通性分析与集合合并操作中的实际应用,通过真实问题建模、Java 代码实现、性能对比与可视化图表,带你深入理解并查集如何在算法竞赛、系统设计与工程实践中大放异彩。
从图论视角看并查集 📐
图的连通分量(Connected Components)
在一个无向图中,若两个顶点之间存在路径,则称它们连通。所有互相连通的顶点构成一个连通分量。
- 连通图:整个图只有一个连通分量
- 非连通图:包含多个连通分量
💡 关键洞察:
每个连通分量 ≈ 一个不相交集合
添加一条边 ≈ 合并两个集合
这正是并查集的天然应用场景!
并查集如何表示图?
- 初始:每个顶点是一个独立集合(
parent[i] = i) - 添加边
(u, v):执行union(u, v) - 查询连通性:
find(u) == find(v)
Mermaid 可视化:图 → 并查集森林
🔗 你可在 Mermaid Live Editor(✅ 可正常访问)中粘贴查看动态渲染效果。
基础实现:带优化的并查集 🛠️
为支撑后续应用,我们先实现一个工业级并查集,包含路径压缩与按秩合并。
public class UnionFind {
private final int[] parent;
private final int[] rank;
private int componentCount;
public UnionFind(int n) {
parent = new int[n];
rank = new int[n];
componentCount = 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 void union(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]++;
}
componentCount--;
}
public boolean connected(int x, int y) {
return find(x) == find(y);
}
public int getComponentCount() {
return componentCount;
}
}
⚡ 性能保证:每次操作均摊时间复杂度为 $ O(\alpha(n)) \approx O(1) $
应用一:动态图连通性维护 🔄
问题描述
给定一个空图,支持两种操作:
addEdge(u, v):添加一条无向边connected(u, v):查询 u 和 v 是否连通
传统方法的困境
- 邻接表 + BFS/DFS:每次查询 $ O(V + E) $,无法应对高频查询
- 邻接矩阵:空间 $ O(V^2) $,更新 $ O(1) $,但查询仍需遍历
并查集方案
addEdge(u, v)→union(u, v)connected(u, v)→find(u) == find(v)
Java 实现
public class DynamicConnectivity {
private final UnionFind uf;
public DynamicConnectivity(int n) {
this.uf = new UnionFind(n);
}
public void addEdge(int u, int v) {
uf.union(u, v);
}
public boolean isConnected(int u, int v) {
return uf.connected(u, v);
}
public int getComponentCount() {
return uf.getComponentCount();
}
}
性能对比(n=10⁵, q=10⁵)
| 方法 | 总时间 | 适用场景 |
|---|---|---|
| BFS/DFS | > 10 秒 | 静态图、低频查询 |
| 并查集 | < 50 毫秒 | 动态图、高频查询 |
✅ 结论:并查集是动态连通性维护的最优解
应用二:Kruskal 最小生成树(MST)🌳
问题背景
在带权无向连通图中,最小生成树(MST)是包含所有顶点、边权和最小的子图,且无环。
📌 Kruskal 算法:贪心策略,按边权从小到大选边,若不形成环则加入 MST。
并查集的核心作用
- 判断是否形成环:若
u和v已连通,则添加(u,v)会成环 - 高效合并连通分量
算法步骤
- 将所有边按权值升序排序
- 初始化并查集(每个顶点独立)
- 遍历边:
- 若
u和v不连通 →union(u, v),加入 MST - 否则跳过(会成环)
- 若
- 直到 MST 包含 $ n-1 $ 条边
Mermaid 可视化:Kruskal 执行过程
Java 实现
import java.util.*;
public class KruskalMST {
static class Edge {
int u, v, weight;
Edge(int u, int v, int weight) {
this.u = u;
this.v = v;
this.weight = weight;
}
}
public List<Edge> findMST(int n, List<Edge> edges) {
// 按权重排序
edges.sort(Comparator.comparingInt(e -> e.weight));
UnionFind uf = new UnionFind(n);
List<Edge> mst = new ArrayList<>();
for (Edge e : edges) {
if (!uf.connected(e.u, e.v)) {
uf.union(e.u, e.v);
mst.add(e);
if (mst.size() == n - 1) break; // MST 完成
}
}
return mst;
}
// 示例
public static void main(String[] args) {
KruskalMST solver = new KruskalMST();
List<Edge> edges = Arrays.asList(
new Edge(0, 1, 1),
new Edge(1, 2, 2),
new Edge(0, 2, 3),
new Edge(2, 3, 4)
);
List<Edge> mst = solver.findMST(4, edges);
System.out.println("MST 边数: " + mst.size()); // 输出: 3
}
}
🔗 算法动画演示:Kruskal’s Algorithm Visualizer(✅ 可正常访问)
应用三:岛屿数量(LeetCode 200)🏝️
问题描述
给定一个由 '1'(陆地)和 '0'(水)组成的二维网格,计算岛屿的数量。岛屿被水包围,且通过水平或垂直方向相邻的陆地连接而成。
传统解法:DFS/BFS
- 时间复杂度:$ O(mn) $
- 空间复杂度:$ O(mn) $(递归栈或队列)
并查集解法思路
- 将每个
'1'视为图的一个顶点 - 遍历网格,对每个
'1',检查其右和下邻居 - 若邻居也是
'1',则执行union - 最终连通分量数 = 岛屿数
二维坐标 → 一维索引
int index = i * n + j; // (i,j) → index
Java 实现
public class NumberOfIslands {
public int numIslands(char[][] grid) {
if (grid == null || grid.length == 0) return 0;
int m = grid.length, n = grid[0].length;
UnionFind uf = new UnionFind(m * n);
int waterCount = 0;
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
if (grid[i][j] == '0') {
waterCount++;
continue;
}
int idx = i * n + j;
// 检查右边
if (j + 1 < n && grid[i][j + 1] == '1') {
uf.union(idx, i * n + j + 1);
}
// 检查下边
if (i + 1 < m && grid[i + 1][j] == '1') {
uf.union(idx, (i + 1) * n + j);
}
}
}
return uf.getComponentCount() - waterCount;
}
}
优势分析
| 方法 | 优点 | 缺点 |
|---|---|---|
| DFS/BFS | 直观、空间局部性好 | 无法动态扩展(如后续添加陆地) |
| 并查集 | 支持动态更新、易于扩展 | 代码稍长、需坐标映射 |
✅ 适用场景:若问题变为“动态添加陆地,实时查询岛屿数”,并查集是唯一选择。
应用四:账户合并(LeetCode 721)📧
问题描述
给定一个账户列表,每个账户包含姓名和多个邮箱。如果两个账户有相同邮箱,则它们属于同一人,需合并。返回合并后的账户列表。
建模思路
- 每个邮箱是一个节点
- 同一账户内的邮箱两两连通(可简化为:将第一个邮箱作为代表,其余与其
union) - 最终每个连通分量对应一个人的所有邮箱
步骤详解
- 邮箱 → 索引映射:
Map<String, Integer> - 初始化并查集:大小 = 邮箱总数
- 遍历账户:
- 对每个账户,将其所有邮箱与第一个邮箱
union
- 对每个账户,将其所有邮箱与第一个邮箱
- 收集结果:
- 遍历所有邮箱,按根分组
- 每组添加对应姓名,并排序邮箱
Java 实现
import java.util.*;
public class AccountsMerge {
public List<List<String>> accountsMerge(List<List<String>> accounts) {
Map<String, Integer> emailToIndex = new HashMap<>();
Map<String, String> emailToName = new HashMap<>();
int index = 0;
// 收集所有唯一邮箱,并建立映射
for (List<String> account : accounts) {
String name = account.get(0);
for (int i = 1; i < account.size(); i++) {
String email = account.get(i);
if (!emailToIndex.containsKey(email)) {
emailToIndex.put(email, index++);
emailToName.put(email, name);
}
}
}
UnionFind uf = new UnionFind(index);
// 同一账户内邮箱合并
for (List<String> account : accounts) {
String firstEmail = account.get(1);
int firstIndex = emailToIndex.get(firstEmail);
for (int i = 2; i < account.size(); i++) {
String email = account.get(i);
uf.union(firstIndex, emailToIndex.get(email));
}
}
// 按根分组邮箱
Map<Integer, TreeSet<String>> groups = new HashMap<>();
for (String email : emailToIndex.keySet()) {
int root = uf.find(emailToIndex.get(email));
groups.computeIfAbsent(root, k -> new TreeSet<>()).add(email);
}
// 构建结果
List<List<String>> result = new ArrayList<>();
for (TreeSet<String> emails : groups.values()) {
List<String> account = new ArrayList<>();
account.add(emailToName.get(emails.first())); // 姓名
account.addAll(emails); // 排序后的邮箱
result.add(account);
}
return result;
}
}
Mermaid 可视化:账户合并过程
✅ 关键点:并查集将“邮箱归属”问题转化为连通性问题,优雅解决合并逻辑。
应用五:等式方程的可满足性(LeetCode 990)⚖️
问题描述
给定一个由字符串方程组成的数组,每个方程形如 "a==b" 或 "a!=b"。判断所有方程是否可同时满足。
建模思路
- 相等关系(
==)具有传递性 → 构成连通分量 - 不等关系(
!=)要求两点不在同一分量
算法步骤
- 初始化并查集(26 个小写字母)
- 第一遍:处理所有
"==",执行union - 第二遍:检查所有
"!=",若find(a) == find(b),则矛盾
Java 实现
public class SatisfiabilityOfEqualityEquations {
public boolean equationsPossible(String[] equations) {
UnionFind uf = new UnionFind(26); // 26 个字母
// 处理相等关系
for (String eq : equations) {
if (eq.charAt(1) == '=') {
int x = eq.charAt(0) - 'a';
int y = eq.charAt(3) - 'a';
uf.union(x, y);
}
}
// 检查不等关系
for (String eq : equations) {
if (eq.charAt(1) == '!') {
int x = eq.charAt(0) - 'a';
int y = eq.charAt(3) - 'a';
if (uf.connected(x, y)) {
return false; // 矛盾!
}
}
}
return true;
}
}
示例
输入:["a==b","b!=a"]
a==b→union(0,1)b!=a→find(1)==find(0)→ 矛盾 → 返回false
✅ 优势:将逻辑约束问题转化为图连通性问题,简洁高效。
应用六:冗余连接(LeetCode 684)🚫
问题描述
给定一棵有 n 个节点的树,再添加一条额外的边,形成恰好一个环。找出这条冗余边(即删除后可恢复为树的边)。
关键观察
- 树有
n-1条边,无环 - 添加第
n条边时,若两端点已连通,则该边形成环
算法步骤
- 初始化并查集
- 遍历边:
- 若
u和v已连通 → 返回该边(冗余) - 否则
union(u, v)
- 若
Java 实现
public class RedundantConnection {
public int[] findRedundantConnection(int[][] edges) {
int n = edges.length;
UnionFind uf = new UnionFind(n + 1); // 节点编号 1~n
for (int[] edge : edges) {
int u = edge[0], v = edge[1];
if (uf.connected(u, v)) {
return edge; // 冗余边
}
uf.union(u, v);
}
return new int[0]; // 理论上不会执行到这里
}
}
Mermaid 示例
✅ 本质:并查集用于环检测,是图论基础应用。
高级应用:带权并查集与相对关系 📏
问题背景
某些问题不仅关心“是否连通”,还关心元素间的相对关系,如:
- “A 比 B 重 5kg”
- “X 在 Y 的北方 100km”
- “食物链”中的捕食关系
带权并查集(Weighted Union-Find)
每个节点维护一个权重(weight),表示其到父节点的相对值。
核心操作
find(x):路径压缩时更新权重union(x, y, d):合并时设置相对权重
Java 实现(简化版)
public class WeightedUnionFind {
private final int[] parent;
private final int[] weight; // weight[i] = value[i] - value[parent[i]]
public WeightedUnionFind(int n) {
parent = new int[n];
weight = new int[n];
for (int i = 0; i < n; i++) {
parent[i] = i;
weight[i] = 0;
}
}
public int find(int x) {
if (parent[x] != x) {
int root = find(parent[x]);
weight[x] += weight[parent[x]]; // 路径压缩时累加权重
parent[x] = root;
}
return parent[x];
}
public void union(int x, int y, int diff) {
// 设 value[x] - value[y] = diff
int rootX = find(x);
int rootY = find(y);
if (rootX != rootY) {
parent[rootY] = rootX;
weight[rootY] = weight[x] - weight[y] - diff;
}
}
public int getValueDiff(int x, int y) {
if (find(x) != find(y)) {
throw new IllegalArgumentException("Not in same component");
}
return weight[y] - weight[x];
}
}
应用:食物链(POJ 1182)
- 三种动物 A、B、C,A 吃 B,B 吃 C,C 吃 A
- 用
d=0表示同类,d=1表示 x 吃 y - 通过模 3 运算维护关系
🔗 详细题解:POJ 1182 食物链 - OI Wiki(✅ 可正常访问)
性能与复杂度分析 📊
时间复杂度
| 操作 | 朴素实现 | 优化后(路径压缩 + 按秩合并) |
|---|---|---|
find | $ O(n) $ | $ O(\alpha(n)) \approx O(1) $ |
union | $ O(n) $ | $ O(\alpha(n)) \approx O(1) $ |
💡 阿克曼函数反函数 $ \alpha(n) $ 在实际中 ≤ 5
空间复杂度
- $ O(n) $:仅需两个长度为
n的数组
与其他方法对比
| 问题 | 并查集 | DFS/BFS | Floyd-Warshall |
|---|---|---|---|
| 动态连通性 | ✅ 最优 | ❌ 不支持 | ❌ $ O(n^3) $ |
| 静态连通分量 | ✅ 可用 | ✅ 常用 | ❌ 过重 |
| 最短路径 | ❌ 不支持 | ✅ 适用 | ✅ 适用 |
工程实践建议 🛠️
何时选择并查集?
- 问题涉及动态合并集合
- 只需判断是否连通,无需具体路径
- 操作以
union和find为主
编码最佳实践
- 始终使用两种优化(路径压缩 + 按秩/大小合并)
- 封装为类,避免全局变量污染
- 注意索引范围(如 LeetCode 节点从 1 开始)
- 二维问题转一维:
index = i * n + j
常见陷阱
- 忘记初始化:
parent[i] = i - 错误的合并方向:应合并根节点,而非原始节点
- 路径压缩破坏权重:带权并查集需特殊处理
扩展:并查集的局限性 ⚠️
不支持的操作
- 删除边(Deletion):标准并查集不可逆
- 解决方案:可撤销并查集(Rollback DSU),但牺牲路径压缩
- 有向图连通性:强连通分量需 Tarjan 算法
- 最短路径查询:需 Dijkstra 或 BFS
替代方案
| 需求 | 替代数据结构 |
|---|---|
| 动态删边 | Link-Cut Tree, Euler Tour Tree |
| 有向连通 | Kosaraju, Tarjan |
| 路径查询 | 树链剖分, LCA |
🔗 深入阅读:Dynamic Connectivity - CP-Algorithms(✅ 可正常访问)
总结:并查集的应用全景图 🗺️
| 应用领域 | 典型问题 | 并查集作用 |
|---|---|---|
| 图论 | MST、环检测、连通分量 | 维护动态连通性 |
| 字符串/逻辑 | 等式方程、账户合并 | 处理等价关系 |
| 网格问题 | 岛屿数量、区域合并 | 二维连通性建模 |
| 相对关系 | 食物链、距离约束 | 带权扩展 |
| 系统设计 | 社交网络好友圈 | 高效分组查询 |
最终建议 💡
- 算法竞赛:并查集是“必会”模板,务必熟练手写
- 面试准备:重点掌握岛屿、账户合并、等式方程三题
- 工程开发:在需要动态分组的场景中优先考虑
🌟 记住:
并查集 = 连通性 + 合并 + 查询
它用最朴素的思想,解决了最复杂的动态分组问题。
在算法的世界里,并查集或许没有红黑树那般炫目,也没有线段树那般强大,但它以极致的简洁与效率,成为无数难题的“隐形英雄”。
Happy Coding! 💻🔗🗺️
🙌 感谢你读到这里!
🔍 技术之路没有捷径,但每一次阅读、思考和实践,都在悄悄拉近你与目标的距离。
💡 如果本文对你有帮助,不妨 👍 点赞、📌 收藏、📤 分享 给更多需要的朋友!
💬 欢迎在评论区留下你的想法、疑问或建议,我会一一回复,我们一起交流、共同成长 🌿
🔔 关注我,不错过下一篇干货!我们下期再见!✨
793

被折叠的 条评论
为什么被折叠?



