"你的朋友的朋友就是你的朋友!" 🤝
📖 一、什么是并查集?从朋友圈说起
1.1 生活中的场景
场景:社交网络中的朋友圈
有10个人:A, B, C, D, E, F, G, H, I, J
关系:
- A和B是朋友
- B和C是朋友
- D和E是朋友
- F、G、H、I、J都是独立的
问题:
1. A和C在同一个朋友圈吗?(是,通过B连接)
2. A和D在同一个朋友圈吗?(否)
3. 总共有几个朋友圈?(7个:{ABC}, {DE}, {F}, {G}, {H}, {I}, {J})
用并查集解决:
1. 初始化:每个人都是独立的圈子
{A} {B} {C} {D} {E} {F} {G} {H} {I} {J}
2. 合并朋友关系:
Union(A, B): {AB} {C} {D} {E} {F} {G} {H} {I} {J}
Union(B, C): {ABC} {D} {E} {F} {G} {H} {I} {J}
Union(D, E): {ABC} {DE} {F} {G} {H} {I} {J}
3. 查询:
Find(A) == Find(C)? → 是(同一个圈子)
Find(A) == Find(D)? → 否(不同圈子)
1.2 专业定义
并查集(Union-Find / Disjoint Set Union, DSU) 是一种树形的数据结构,用于处理一些不相交集合的合并及查询问题。
核心操作:
- ✅ Find:查找元素所在集合的代表(根节点)
- ✅ Union:合并两个集合
- ⚡ 时间复杂度:近乎 O(1) (使用路径压缩和按秩合并)
典型应用:
- 判断图的连通性
- 最小生成树(Kruskal算法)
- 社交网络朋友圈
- 图像处理(连通区域)
🎨 二、并查集的结构
2.1 基本结构(数组实现)
核心思想:用数组存储每个元素的父节点
parent[i] = i的父节点
示例:{ABC} {DE} {F}
元素: A B C D E F
索引: 0 1 2 3 4 5
parent: 0 0 1 3 3 5
树形表示:
A(0) D(3) F(5)
/ \ |
B C E
2.2 初始化
class UnionFind {
private int[] parent; // 父节点数组
private int count; // 集合数量
public UnionFind(int n) {
parent = new int[n];
count = n;
// 初始化:每个元素的父节点是自己
for (int i = 0; i < n; i++) {
parent[i] = i;
}
}
}
初始状态图:
索引: 0 1 2 3 4
parent: 0 1 2 3 4
每个元素都是独立的集合:
{0} {1} {2} {3} {4}
💻 三、核心操作
3.1 Find操作(查找根节点)
基础版本:
// 查找x的根节点
public int find(int x) {
while (parent[x] != x) {
x = parent[x];
}
return x;
}
查找过程:
查找元素2的根:
0
/|\
1 2 3
|
4
步骤:
2 → parent[2]=0 → parent[0]=0 (找到根!)
返回: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 (parent[root] != root) {
root = parent[root];
}
// 路径压缩:将路径上所有节点直接连到根
while (x != root) {
int next = parent[x];
parent[x] = root;
x = next;
}
return root;
}
路径压缩效果:
压缩前:
0
/|\
1 2 3
|
4
|
5
查找5:5 → 4 → 1 → 0
压缩后:
0
/ | \ \
1 2 3 4
|
5
再次查找5:5 → 4 → 0 (更快!)
多次压缩后:
0
/ | \ \ \
1 2 3 4 5
所有节点直接连到根!查找O(1)!
3.2 Union操作(合并集合)
基础版本:
// 合并x和y所在的集合
public void union(int x, int y) {
int rootX = find(x);
int rootY = find(y);
if (rootX != rootY) {
parent[rootX] = rootY; // 将x的根连到y的根
count--; // 集合数量减1
}
}
问题:可能导致树很深!
Union(0,1), Union(1,2), Union(2,3), Union(3,4)
结果:
4
|
3
|
2
|
1
|
0
退化成链表!查找O(n)!😰
优化版(按秩合并):⭐
class UnionFind {
private int[] parent;
private int[] rank; // 树的高度(秩)
private int count;
public UnionFind(int n) {
parent = new int[n];
rank = new int[n];
count = n;
for (int i = 0; i < n; i++) {
parent[i] = i;
rank[i] = 1; // 初始高度为1
}
}
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) {
// 按秩合并:矮树连到高树
if (rank[rootX] < rank[rootY]) {
parent[rootX] = rootY;
} else if (rank[rootX] > rank[rootY]) {
parent[rootY] = rootX;
} else {
parent[rootY] = rootX;
rank[rootX]++; // 高度相同时,合并后高度+1
}
count--;
}
}
public boolean connected(int x, int y) {
return find(x) == find(y);
}
public int getCount() {
return count;
}
}
按秩合并效果:
合并两棵树:
树1 (rank=2): 树2 (rank=1):
0 3
/
1
按秩合并后(矮树连到高树):
0
/ \
1 3
rank[0] = 2(高度不变)
如果都是rank=1:
0 2
合并后:
0
|
2
rank[0] = 2(高度+1)
🎯 四、完整实现与应用
4.1 完整的并查集类
public class UnionFind {
private int[] parent;
private int[] rank;
private int count;
public UnionFind(int n) {
parent = new int[n];
rank = new int[n];
count = n;
for (int i = 0; i < n; i++) {
parent[i] = i;
rank[i] = 1;
}
}
// 查找(路径压缩)
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) {
if (rank[rootX] < rank[rootY]) {
parent[rootX] = rootY;
} else if (rank[rootX] > rank[rootY]) {
parent[rootY] = rootX;
} else {
parent[rootY] = rootX;
rank[rootX]++;
}
count--;
}
}
// 判断是否连通
public boolean connected(int x, int y) {
return find(x) == find(y);
}
// 获取集合数量
public int getCount() {
return count;
}
// 测试
public static void main(String[] args) {
UnionFind uf = new UnionFind(10);
System.out.println("初始集合数:" + uf.getCount());
// 建立连接
uf.union(0, 1);
uf.union(1, 2);
uf.union(3, 4);
System.out.println("建立连接后集合数:" + uf.getCount());
// 查询连通性
System.out.println("0和2连通吗?" + uf.connected(0, 2)); // true
System.out.println("0和3连通吗?" + uf.connected(0, 3)); // false
// 继续合并
uf.union(2, 3);
System.out.println("合并后0和4连通吗?" + uf.connected(0, 4)); // true
System.out.println("最终集合数:" + uf.getCount());
}
}
4.2 应用1:朋友圈数量(LeetCode 547)
// 有n个人,给定朋友关系矩阵,求朋友圈数量
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.union(i, j);
}
}
}
return uf.getCount();
}
// 测试
int[][] friends = {
{1, 1, 0, 0, 0},
{1, 1, 0, 0, 0},
{0, 0, 1, 1, 0},
{0, 0, 1, 1, 0},
{0, 0, 0, 0, 1}
};
System.out.println("朋友圈数量:" + findCircleNum(friends)); // 3
4.3 应用2:岛屿数量(LeetCode 200)
// 给定二维网格,1表示陆地,0表示水,求岛屿数量
public int numIslands(char[][] grid) {
if (grid == null || grid.length == 0) return 0;
int rows = grid.length;
int cols = grid[0].length;
UnionFind uf = new UnionFind(rows * cols);
int waterCount = 0;
for (int i = 0; i < rows; i++) {
for (int j = 0; j < cols; j++) {
if (grid[i][j] == '0') {
waterCount++;
} else {
// 与右边和下边的陆地合并
if (j + 1 < cols && grid[i][j + 1] == '1') {
uf.union(i * cols + j, i * cols + j + 1);
}
if (i + 1 < rows && grid[i + 1][j] == '1') {
uf.union(i * cols + j, (i + 1) * cols + j);
}
}
}
}
return uf.getCount() - waterCount;
}
// 测试
char[][] grid = {
{'1','1','0','0','0'},
{'1','1','0','0','0'},
{'0','0','1','0','0'},
{'0','0','0','1','1'}
};
System.out.println("岛屿数量:" + numIslands(grid)); // 3
4.4 应用3:冗余连接(LeetCode 684)
// 找出导致图中出现环的那条边
public int[] findRedundantConnection(int[][] edges) {
int n = edges.length;
UnionFind uf = new UnionFind(n + 1);
for (int[] edge : edges) {
int u = edge[0];
int v = edge[1];
// 如果已经连通,说明这条边是冗余的
if (uf.connected(u, v)) {
return edge;
}
uf.union(u, v);
}
return new int[0];
}
// 测试
int[][] edges = {{1,2}, {1,3}, {2,3}};
int[] result = findRedundantConnection(edges);
System.out.println("冗余边:[" + result[0] + "," + result[1] + "]"); // [2,3]
4.5 应用4:最小生成树(Kruskal算法)
class Edge implements Comparable<Edge> {
int u, v, weight;
public Edge(int u, int v, int weight) {
this.u = u;
this.v = v;
this.weight = weight;
}
@Override
public int compareTo(Edge other) {
return this.weight - other.weight;
}
}
// Kruskal算法求最小生成树
public int minCostConnectPoints(int[][] edges, int n) {
List<Edge> edgeList = new ArrayList<>();
// 构建边列表
for (int[] edge : edges) {
edgeList.add(new Edge(edge[0], edge[1], edge[2]));
}
// 按权重排序
Collections.sort(edgeList);
UnionFind uf = new UnionFind(n);
int totalCost = 0;
int edgeCount = 0;
// 贪心选边
for (Edge edge : edgeList) {
if (!uf.connected(edge.u, edge.v)) {
uf.union(edge.u, edge.v);
totalCost += edge.weight;
edgeCount++;
// n个节点的树有n-1条边
if (edgeCount == n - 1) {
break;
}
}
}
return totalCost;
}
📊 五、时间复杂度分析
5.1 不同实现的复杂度
| 实现方式 | Find | Union | 说明 |
|---|---|---|---|
| 基础版 | O(n) | O(n) | 树可能退化成链 |
| 路径压缩 | O(log n) | O(log n) | 改进查找 |
| 按秩合并 | O(log n) | O(log n) | 改进合并 |
| 路径压缩+按秩合并 | O(α(n)) | O(α(n)) | 近乎O(1) ⭐ |
α(n) 是什么?
α(n) = 反阿克曼函数
- 增长极其缓慢
- α(10^80) ≈ 4
- 实际应用中可以认为是常数
所以:优化后的并查集近乎O(1)!⚡
5.2 空间复杂度
空间复杂度:O(n)
- parent数组:n个元素
- rank数组:n个元素
总共:2n = O(n)
🎓 六、经典面试题
面试题1:并查集的两大优化是什么?
答案:
- 路径压缩:查找时将路径上所有节点直接连到根
- 按秩合并:合并时将矮树连到高树
面试题2:为什么需要路径压缩?
答案: 防止树退化成链表,保证查找效率。
没有压缩:
4→3→2→1→0 查找O(n)
路径压缩后:
4→0
3→0
2→0 查找O(1)
1→0
0
面试题3:并查集能做什么?
答案:
- 判断图的连通性
- 检测环
- 最小生成树(Kruskal)
- 动态连通性问题
面试题4:并查集和DFS/BFS的区别?
答案:
| 特性 | 并查集 | DFS/BFS |
|---|---|---|
| 时间复杂度 | O(α(n)) ≈ O(1) | O(V+E) |
| 动态性 | 支持动态合并 ⭐ | 静态 |
| 空间复杂度 | O(n) | O(n) |
| 适用场景 | 动态连通性 | 静态遍历 |
面试题5:如何判断图中是否有环?
答案:
public boolean hasCycle(int[][] edges, int n) {
UnionFind uf = new UnionFind(n);
for (int[] edge : edges) {
int u = edge[0];
int v = edge[1];
// 如果两个顶点已连通,添加边会形成环
if (uf.connected(u, v)) {
return true;
}
uf.union(u, v);
}
return false;
}
🎪 七、趣味小故事
故事:江湖帮派的合并
从前,江湖上有很多独立的帮派。
初始状态:
{少林} {武当} {峨眉} {华山} {昆仑}
每个帮派都是独立的
结盟过程(Union操作):
1. 少林和武当结盟
→ {少林武当} {峨眉} {华山} {昆仑}
2. 峨眉和华山结盟
→ {少林武当} {峨眉华山} {昆仑}
3. 武当和峨眉结盟(少林武当 + 峨眉华山合并)
→ {少林武当峨眉华山} {昆仑}
查询盟友(Find操作):
问:少林和华山是盟友吗?
答:Find(少林) == Find(华山)? → 是!
问:少林和昆仑是盟友吗?
答:Find(少林) == Find(昆仑)? → 否!
路径压缩的妙用:
原来的盟约关系链:
少林 → 武当 → 峨眉 → 华山
路径压缩后:
少林 ⟍
武当 ─→ 盟主
峨眉 ⟋
华山 ⟋
每个帮派直接认识盟主,查询更快!
这就是并查集的智慧——高效管理联盟关系!🎯
📚 八、知识点总结
核心要点 ✨
- 定义:处理不相交集合的合并与查询
- 核心操作:Find查找、Union合并
- 两大优化:
- 路径压缩
- 按秩合并
- 时间复杂度:O(α(n)) ≈ O(1)
- 应用:连通性、检测环、最小生成树
记忆口诀 🎵
并查集来真神奇,
集合合并和查询。
路径压缩很关键,
按秩合并也重要。
朋友圈问题它能解,
岛屿数量不用愁。
近乎常数时间度,
面试必考要记牢!
模板代码 📝
class UnionFind {
private int[] parent, rank;
private int count;
public UnionFind(int n) {
parent = new int[n];
rank = new int[n];
count = n;
for (int i = 0; i < n; i++) {
parent[i] = i;
rank[i] = 1;
}
}
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), rootY = find(y);
if (rootX != rootY) {
if (rank[rootX] < rank[rootY]) {
parent[rootX] = rootY;
} else if (rank[rootX] > rank[rootY]) {
parent[rootY] = rootX;
} else {
parent[rootY] = rootX;
rank[rootX]++;
}
count--;
}
}
public boolean connected(int x, int y) {
return find(x) == find(y);
}
public int getCount() {
return count;
}
}
🌟 九、总结彩蛋
恭喜你!🎉 你已经掌握了并查集这个强大的工具!
记住:
- 🔗 查找 + 合并
- ⚡ 路径压缩 + 按秩合并
- 🎯 O(α(n)) ≈ O(1)
- 💪 动态连通性问题的利器
最后送你一张图
优化前: 优化后:
0 0
| / | \
1 1 2 3
|
2
|
3
树深:O(n) 树浅:O(1)
已完成10个知识点文档!继续加油! 💪😄
📖 参考资料
- 《算法导论》第21章 - 不相交集合的数据结构
- 《算法(第4版)》- Union-Find
- LeetCode并查集专题
- Robert Sedgewick - 并查集详解
作者: AI算法导师
最后更新: 2025年11月
难度等级: ⭐⭐⭐ (中级)
预计学习时间: 2-3小时
💡 温馨提示:并查集是解决连通性问题的最佳选择,模板代码一定要记住!