前言
并查集(Union-Find)是处理动态连通性问题的利器。很多人觉得它很高深,其实核心思想超简单:给每个帮派选一个老大,想知道两个人是不是一伙的,就看他们的老大是不是同一个。
我并没有能力让你看完就精通图论,我只是想让你理解并查集的核心思想和两个优化技巧。掌握这些,90%的连通性问题都能秒。
摘要
从"判断朋友圈数量"问题出发,剖析并查集的核心思想与优化策略。通过路径压缩和按秩合并的图解演示,揭秘如何把连通性判断从O(n)优化到O(α(n))。配合LeetCode高频题目与完整代码模板,给出并查集问题的解题套路。
一、从朋友圈问题说起
周末,哈吉米遇到一道题:
LeetCode 547 - 省份数量(朋友圈)
有 n 个人,编号 0 到 n-1
isConnected[i][j] = 1 表示第 i 个人和第 j 个人是朋友
朋友关系具有传递性:A和B是朋友,B和C是朋友,那么A和C也是朋友
计算有多少个朋友圈
示例:
输入:isConnected = [[1,1,0],
[1,1,0],
[0,0,1]]
输出:2
解释:[0,1]一个朋友圈,[2]一个朋友圈
哈吉米写了个DFS:
Java版本:
public int findCircleNum(int[][] isConnected) {
int n = isConnected.length;
boolean[] visited = new boolean[n];
int count = 0;
for (int i = 0; i < n; i++) {
if (!visited[i]) {
dfs(isConnected, visited, i);
count++;
}
}
return count;
}
private void dfs(int[][] isConnected, boolean[] visited, int i) {
visited[i] = true;
for (int j = 0; j < isConnected.length; j++) {
if (isConnected[i][j] == 1 && !visited[j]) {
dfs(isConnected, visited, j);
}
}
}
南北绿豆:"DFS可以,但并查集更优雅,而且扩展性更强。"
阿西噶阿西:"比如要动态加边、判断是否连通,并查集更快。"
二、江湖帮派的类比
南北绿豆:"想象武侠小说里的江湖帮派。"
场景:
江湖中有10个人,分散在不同帮派
青龙帮:[张三, 李四, 王五]
白虎帮:[赵六, 孙七]
独行侠:[周八, 吴九]
问题:张三和王五是不是一个帮派的?
暴力方法:遍历所有帮派,看张三和王五在不在同一个帮派里。
并查集方法:
- 每个帮派选一个帮主(代表元素)
- 张三的帮主是谁?王五的帮主是谁?
- 如果帮主相同,说明是同一个帮派
青龙帮帮主:张三
白虎帮帮主:赵六
张三的帮主 = 张三(自己就是帮主)
王五的帮主 = 张三
帮主相同 → 同一个帮派 ✓
哈吉米:"懂了,每个连通分量选一个老大,判断的时候只看老大是不是同一个。"
三、并查集的数据结构
阿西噶阿西:"并查集用数组实现,超简单。"
3.1 parent数组
int[] parent = new int[n]; // parent[i]表示i的父节点
初始化:每个元素的父节点是自己(各自为王)
parent = [0, 1, 2, 3, 4, 5]
↑ ↑ ↑ ↑ ↑ ↑
索引: 0 1 2 3 4 5
表示:0的父节点是0(自己是老大)
1的父节点是1(自己是老大)
...
初始化图示:
flowchart TB
A["节点0<br/>parent[0]=0"]
B["节点1<br/>parent[1]=1"]
C["节点2<br/>parent[2]=2"]
D["节点3<br/>parent[3]=3"]
style A fill:#e1f5ff
style B fill:#e1f5ff
style C fill:#e1f5ff
style D fill:#e1f5ff
四、核心操作:find和union
4.1 find:找老大
问题:给定一个元素,找它的代表元素(老大)。
实现:沿着parent数组往上找,直到找到parent[x] == x的节点。
示例:
parent = [0, 0, 1, 2, 2, 5]
元素3的老大是谁?
parent[3] = 2 → 3的父节点是2
parent[2] = 1 → 2的父节点是1
parent[1] = 0 → 1的父节点是0
parent[0] = 0 → 0的父节点是0(找到了!)
所以元素3的老大是0
find操作图示:
flowchart TB
N0["0 老大"]
N1["1"]
N2["2"]
N3["3"]
N3 --> N2
N2 --> N1
N1 --> N0
style N0 fill:#ffe6e6
style N3 fill:#e6ffe6
代码(基础版):
Java版本:
public int find(int x) {
while (parent[x] != x) {
x = parent[x]; // 往上找
}
return x; // 返回老大
}
C++版本:
int find(int x) {
while (parent[x] != x) {
x = parent[x];
}
return x;
}
Python版本:
def find(x):
while parent[x] != x:
x = parent[x]
return x
4.2 union:合并两个集合
问题:把元素x和元素y所在的集合合并。
实现:找到x和y的老大,让一个老大指向另一个老大。
union操作图示:
flowchart TB
subgraph 合并前有两个集合
A0["集合1老大: 0"]
A1["1"]
A2["2"]
A5["集合2老大: 5"]
A6["6"]
end
A1 --> A0
A2 --> A1
A6 --> A5
flowchart TB
subgraph 合并后
B5["5 新老大"]
B0["0"]
B1["1"]
B2["2"]
B6["6"]
end
B0 --> B5
B1 --> B0
B2 --> B1
B6 --> B5
style B5 fill:#e1ffe1
代码(基础版):
Java版本:
public void union(int x, int y) {
int rootX = find(x); // 找x的老大
int rootY = find(y); // 找y的老大
if (rootX != rootY) {
parent[rootX] = rootY; // x的老大认y的老大当老大
}
}
C++版本:
void union_sets(int x, int y) {
int rootX = find(x);
int rootY = find(y);
if (rootX != rootY) {
parent[rootX] = rootY;
}
}
Python版本:
def union(x, y):
rootX = find(x)
rootY = find(y)
if rootX != rootY:
parent[rootX] = rootY
五、优化1:路径压缩
5.1 路径压缩对比
压缩前:
flowchart TB
N0["0 老大"]
N1["1"]
N2["2"]
N3["3"]
N4["4"]
N5["5"]
N1 --> N0
N2 --> N1
N3 --> N2
N4 --> N3
N5 --> N4
执行find(5)后:
flowchart TB
M0["0 老大"]
M1["1"]
M2["2"]
M3["3"]
M4["4"]
M5["5"]
M1 --> M0
M2 --> M0
M3 --> M0
M4 --> M0
M5 --> M0
哈吉米:"所有节点都直接指向老大,查找变成O(1)了!"
5.2 路径压缩代码
Java版本:
public int find(int x) {
if (parent[x] != x) {
parent[x] = find(parent[x]); // 递归找老大,同时压缩路径
}
return parent[x];
}
C++版本:
int find(int x) {
if (parent[x] != x) {
parent[x] = find(parent[x]);
}
return parent[x];
}
Python版本:
def find(x):
if parent[x] != x:
parent[x] = find(parent[x])
return parent[x]
六、优化2:按秩合并
6.1 为什么需要按秩合并
阿西噶阿西:"路径压缩解决了查找慢的问题,但union的时候,如果随便合并,可能会让树变高。"
反例:
集合1:0 ← 1 ← 2 ← 3(树高4)
集合2:5 ← 6(树高2)
如果让5指向0(小树合并到大树):
树高还是4 ✓
如果让0指向5(大树合并到小树):
树高变成5 ❌
南北绿豆:"按秩合并的思想:让小树合并到大树,保持树高尽量小。"
6.2 按秩合并对比
错误合并(大树合并到小树):
flowchart TB
N5["5 老大"]
N6["6"]
N0["0"]
N1["1"]
N2["2"]
N3["3"]
N6 --> N5
N0 --> N5
N1 --> N0
N2 --> N1
N3 --> N2
style N5 fill:#ffe6e6
正确合并(小树合并到大树):
flowchart TB
M0["0 老大"]
M1["1"]
M2["2"]
M3["3"]
M5["5"]
M6["6"]
M1 --> M0
M2 --> M1
M3 --> M2
M5 --> M0
M6 --> M5
style M0 fill:#e1ffe1
用rank数组记录树的高度:
int[] rank = new int[n]; // rank[i]表示以i为根的树的高度
6.3 按秩合并代码
完整的并查集模板:
Java版本:
class UnionFind {
private int[] parent;
private int[] 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] = 1;
}
}
// find:找老大(路径压缩)
public int find(int x) {
if (parent[x] != x) {
parent[x] = find(parent[x]); // 路径压缩
}
return parent[x];
}
// union:合并两个集合(按秩合并)
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]++;
}
}
}
// 判断是否连通
public boolean connected(int x, int y) {
return find(x) == find(y);
}
}
C++版本:
class UnionFind {
private:
vector<int> parent, rank;
public:
UnionFind(int n) : parent(n), rank(n, 1) {
for (int i = 0; i < n; i++) {
parent[i] = i;
}
}
int find(int x) {
if (parent[x] != x) {
parent[x] = find(parent[x]);
}
return parent[x];
}
void union_sets(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]++;
}
}
}
bool connected(int x, int y) {
return find(x) == find(y);
}
};
Python版本:
class UnionFind:
def __init__(self, n):
self.parent = list(range(n))
self.rank = [1] * n
def find(self, x):
if self.parent[x] != x:
self.parent[x] = self.find(self.parent[x])
return self.parent[x]
def union(self, x, y):
rootX, rootY = self.find(x), self.find(y)
if rootX != rootY:
if self.rank[rootX] < self.rank[rootY]:
self.parent[rootX] = rootY
elif self.rank[rootX] > self.rank[rootY]:
self.parent[rootY] = rootX
else:
self.parent[rootY] = rootX
self.rank[rootX] += 1
def connected(self, x, y):
return self.find(x) == self.find(y)
七、例题1:省份数量
7.1 思路分析
南北绿豆:"回到最开始的问题,用并查集解决。"
思路:
- 初始化并查集,n个人各自为王
- 遍历isConnected,如果
isConnected[i][j] == 1,合并i和j - 最后统计有多少个不同的老大
为什么统计老大数量?
阿西噶阿西:"每个连通分量(朋友圈)只有一个老大,有多少个老大就有多少个朋友圈。"
7.2 执行过程演示
示例:isConnected = [[1,1,0],[1,1,0],[0,0,1]]
| 步骤 | 操作 | parent | 说明 |
|---|---|---|---|
| 初始 | - | [0,1,2] | 三个人各自为王 |
| 1 | union(0,1) | [0,0,2] | 1认0当老大 |
| 2 | union(1,0) | [0,0,2] | 已经连通,跳过 |
| 3 | - | [0,0,2] | 其他都不是朋友 |
| 统计 | - | - | parent[0]=0 ✓,parent[2]=2 ✓ |
结果:2个老大 = 2个朋友圈
7.3 代码实现
Java版本:
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);
}
}
}
// 统计有多少个不同的老大
int count = 0;
for (int i = 0; i < n; i++) {
if (uf.find(i) == i) {
count++;
}
}
return count;
}
C++版本:
int findCircleNum(vector<vector<int>>& isConnected) {
int n = isConnected.size();
UnionFind uf(n);
for (int i = 0; i < n; i++) {
for (int j = i + 1; j < n; j++) {
if (isConnected[i][j] == 1) {
uf.union_sets(i, j);
}
}
}
int count = 0;
for (int i = 0; i < n; i++) {
if (uf.find(i) == i) {
count++;
}
}
return count;
}
Python版本:
def findCircleNum(isConnected):
n = len(isConnected)
uf = UnionFind(n)
for i in range(n):
for j in range(i + 1, n):
if isConnected[i][j] == 1:
uf.union(i, j)
count = 0
for i in range(n):
if uf.find(i) == i:
count += 1
return count
八、例题2:冗余连接
8.1 题目
LeetCode 684 - 冗余连接
树可以看成是一个连通且无环的无向图。
给定一个有 n 个节点的图,edges[i] = [ai, bi] 表示边。
找出一条可以删去的边,使得结果图仍然连通且是一棵树。
示例:
输入:edges = [[1,2],[1,3],[2,3]]
输出:[2,3]
解释:删除边[2,3]后,图变成树
8.2 思路分析
南北绿豆:"这题的核心:找到第一条让图产生环的边。"
树的性质:n个节点的树有n-1条边,无环。
思路:
- 按顺序加边
- 加边前,判断两个节点是否已经连通
- 如果已经连通,再加边就会形成环,这条边就是冗余的
8.3 执行过程演示
示例:edges = [[1,2],[1,3],[2,3]]
| 步骤 | 加边 | 操作 | parent | 结果 |
|---|---|---|---|---|
| 初始 | - | - | [0,1,2,3] | - |
| 1 | [1,2] | find(1)=1, find(2)=2 不连通,union | [0,1,1,3] | - |
| 2 | [1,3] | find(1)=1, find(3)=3 不连通,union | [0,1,1,1] | - |
| 3 | [2,3] | find(2)=1, find(3)=1 已经连通! | - | 返回[2,3] ✓ |
8.4 代码实现
Java版本:
public int[] findRedundantConnection(int[][] edges) {
int n = edges.length;
UnionFind uf = new UnionFind(n + 1); // 节点编号从1开始
for (int[] edge : edges) {
int u = edge[0];
int v = edge[1];
// 如果u和v已经连通,这条边就是冗余的
if (uf.connected(u, v)) {
return edge;
}
uf.union(u, v);
}
return new int[]{};
}
C++版本:
vector<int> findRedundantConnection(vector<vector<int>>& edges) {
int n = edges.size();
UnionFind uf(n + 1);
for (auto& edge : edges) {
int u = edge[0], v = edge[1];
if (uf.connected(u, v)) {
return edge;
}
uf.union_sets(u, v);
}
return {};
}
Python版本:
def findRedundantConnection(edges):
n = len(edges)
uf = UnionFind(n + 1)
for u, v in edges:
if uf.connected(u, v):
return [u, v]
uf.union(u, v)
return []
九、例题3:等式方程的可满足性
9.1 题目
LeetCode 990 - 等式方程的可满足性
给定一个由等式组成的数组 equations,equations[i] 的形式为 "a==b" 或 "a!=b"。
判断这些等式是否互相矛盾。
示例:
输入:equations = ["a==b","b!=a"]
输出:false
解释:a==b 说明 a 和 b 相等,但 b!=a 说明不相等,矛盾。
输入:equations = ["a==b","b==c","a==c"]
输出:true
输入:equations = ["a==b","b!=c","c==a"]
输出:false
9.2 思路分析
南北绿豆:"这题巧妙:先处理所有==关系(合并),再检查!=关系(判断连通性)。"
思路:
- 第一遍:遍历所有
==,合并相等的变量 - 第二遍:遍历所有
!=,检查是否连通 - 如果
a!=b但a和b连通,说明矛盾
阿西噶阿西:"比如a==b和b==c合并后,a、b、c是一个集合。如果再有a!=c,就矛盾了。"
9.3 代码实现
Java版本:
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;
}
C++版本:
bool equationsPossible(vector<string>& equations) {
UnionFind uf(26);
for (auto& eq : equations) {
if (eq[1] == '=') {
int x = eq[0] - 'a';
int y = eq[3] - 'a';
uf.union_sets(x, y);
}
}
for (auto& eq : equations) {
if (eq[1] == '!') {
int x = eq[0] - 'a';
int y = eq[3] - 'a';
if (uf.connected(x, y)) {
return false;
}
}
}
return true;
}
Python版本:
def equationsPossible(equations):
uf = UnionFind(26)
for eq in equations:
if eq[1] == '=':
x = ord(eq[0]) - ord('a')
y = ord(eq[3]) - ord('a')
uf.union(x, y)
for eq in equations:
if eq[1] == '!':
x = ord(eq[0]) - ord('a')
y = ord(eq[3]) - ord('a')
if uf.connected(x, y):
return False
return True
十、时间复杂度分析
南北绿豆:"路径压缩+按秩合并,时间复杂度接近O(1)。"
准确说法:O(α(n)),α是阿克曼函数的反函数,增长极慢。
n = 10^9 时,α(n) ≈ 4
几乎可以看成常数时间
哈吉米:"这么快?"
阿西噶阿西:"路径压缩让树高接近1,查找就快;按秩合并让树高始终很小。"
十一、并查集总结
11.1 识别技巧
阿西噶阿西:
- 看到连通性、是否在同一个集合,想并查集
- 看到朋友圈、省份、网络,想并查集
- 看到动态加边、判断环,想并查集
11.2 核心要点
南北绿豆:
- 核心思想:每个连通分量选一个代表元素
- 两个操作:find(找老大)、union(合并)
- 两个优化:路径压缩(查找快)、按秩合并(树高小)
- 时间复杂度:O(α(n)),接近常数
哈吉米:"记住模板,直接套就行了。"
参考资料:
- 《算法第四版》- Robert Sedgewick
- LeetCode题解 - 并查集专题
- 《算法竞赛进阶指南》- 李煜东