专题 20并查集(Disjoint Set Union):动态连通性利器
有一类问题画风很固定:「把 A 和 B 分到一组」「问 A 和 C 是不是同组」,并且分组操作是边给边查的。 用 DFS 也能判断连通性(专题 12 讲过),但 DFS 是「静态」的——图一旦建好就定死。 如果连线是一条条动态加进来的,每次都重跑 DFS 太慢。并查集(DSU)专门解决这种动态连通性:合并两组、查询两点是否同组,都能做到近乎 O(1)。 本章先给你一个带路径压缩 + 按秩合并的完整模板,再用 4 道题把它用熟,并讲清「什么时候该用并查集、什么时候 DFS 更顺手」。
20.1并查集解决什么:动态连通性
并查集管理的是若干个互不相交的集合,支持两个操作:
- union(a, b):把 a 所在的集合和 b 所在的集合合并成一个;
- find(a):返回 a 所在集合的「代表元」(根),用它来判断两点是否同组——
find(a) == find(b)即同组。
一个好懂的类比:把每个人看成节点,一开始人人「单干」(各自是一个集合)。union 就是「结拜」——两人拜把子后属于同一个帮派;帮派用「老大(根)」来标识。问「A 和 B 是不是一伙的」,只要看两人的老大是不是同一个。
初始:1 2 3 4 5 (5 个独立集合,各自是自己的根)
union(1,2):1 和 2 同帮,老大设为 1
union(3,4):3 和 4 同帮,老大设为 3
union(2,3):2 的老大是 1,3 的老大是 3 → 把 1 和 3 合并,老大统一
现在 find(1)==find(4) → true(1-2-3-4 一伙)
find(5) 还是单独 → 与 1 不同伙
和 DFS 连通性的区别:DFS 是「图建好后一次性判断」,并查集支持边加边查——每来一条新边就 union,每来一个提问就 find,全程 Online,均摊 O(1)。
一个直觉:并查集 vs 拉帮结派
把并查集想象成校园里的「社团合并」:
| 现实操作 | 并查集对应 |
|---|---|
| 新生入学,各自独立 | 初始化 parent[i]=i |
| 两个社团联谊合并 | union(a, b) |
| 问「小明和小红是不是一个社团」 | find(a) == find(b) |
| 社团用「社长」标识身份 | 根节点 root 是集合代表 |
| 小社团并入大社团,避免管理链过长 | 按秩合并(矮挂高) |
| 每次问话顺手记住直属上级,下次直接找社长 | 路径压缩 |
正因为「问话时顺手压缩路径」「合并时小并大」,所以无论社团怎么分合,每次查询都接近 O(1)——这正是并查集快的核心。
20.2模板:find + union
最关键的优化有两个,缺一不可,否则会退化:
- 路径压缩(Path Compression):find 时顺手把沿途节点的父指针直接指向根,下次再 find 就一步到位;
- 按秩合并(Union by Rank):合并时把「较矮的树」挂到「较高的树」下面,避免树被拉成链。
两个优化一起上,单次 find/union 的均摊时间复杂度近似 O(1)(严格说是反阿克曼函数 α(n),增长极慢,可当常数)。
参考代码(通用模板)
class DSU {
int[] parent; // parent[i] = i 所在树的根的父(根指向自己)
int[] rank; // rank[i] = 以 i 为根的树的高度上界(用于按秩合并)
public DSU(int n) {
parent = new int[n];
rank = new int[n];
for (int i = 0; i < n; i++) {
parent[i] = i; // 初始:每个人自立门户
rank[i] = 0; // 高度从 0 起
}
}
// 找根 + 路径压缩:沿途节点的父都直接指向根
public int find(int x) {
if (parent[x] != x) {
parent[x] = find(parent[x]); // 递归压缩,把 x 的父改成根
}
return parent[x];
}
// 合并两集合:按秩合并,矮树挂到高树底下
public void union(int x, int y) {
int rx = find(x);
int ry = find(y);
if (rx == ry) return; // 已经同根,无需合并
if (rank[rx] < rank[ry]) {
parent[rx] = ry; // rx 较矮,挂到 ry 下
} else if (rank[rx] > rank[ry]) {
parent[ry] = rx; // ry 较矮,挂到 rx 下
} else {
parent[ry] = rx; // 一样高,任选其一,rx 高度 +1
rank[rx]++;
}
}
// 判同组
public boolean connected(int x, int y) {
return find(x) == find(y);
}
}
parent[a] = b,这会破坏已有的树结构。另外路径压缩后 rank 只是上界估计,不用精确维护高度,按秩合并仍然有效。
20.3省份数量(中等,力扣 547)
中等 核心:对称矩阵建集合,数根个数
题意:有 n 个城市,给一个 n×n 的矩阵 isConnected,isConnected[i][j] = 1 表示城市 i 和城市 j 直接相连(矩阵对称,i=j 为 1)。
「省份」是连通的城市组成的集合(直接或间接相连都算一省)。返回省份数量。
思路推导:每个连通块 = 一个集合
这题本质是「无向图的连通分量计数」。并查集视角:每个城市是节点,isConnected[i][j]=1 就 union(i, j)。全部边 union 完后,有多少个「根节点」(parent[x]==x 的 x)就有多少个省份。
isConnected = [[1,1,0],[1,1,0],[0,0,1]]
城市 0-1 相连 → union(0,1)
城市 2 独立
集合:{0,1}, {2} → 2 个省份
根节点是 0 和 2(parent[x]==x 的个数 = 2)
参考代码
public int findCircleNum(int[][] isConnected) {
int n = isConnected.length;
DSU dsu = new DSU(n);
// 只扫上三角(矩阵对称,避免重复 union)
for (int i = 0; i < n; i++) {
for (int j = i + 1; j < n; j++) {
if (isConnected[i][j] == 1) {
dsu.union(i, j); // 相连就合并
}
}
}
// 数根:parent[x] == x 的就是一个省份的代表
int count = 0;
for (int i = 0; i < n; i++) {
if (dsu.parent[i] == i) count++;
}
return count;
}
复杂度
建图遍历矩阵 O(n²),每次 union/find 近似 O(1);总 O(n²)。空间 O(n)(DSU 数组)。边界:n=1 返回 1;全 0 矩阵(除对角)返回 n 个独立省份。
巩固练习(先自己想 15 分钟,卡住再看括号里的提示)
20.4冗余连接(中等,力扣 684)
中等 核心:逐条加边,第一次成环的边即答案
题意:给你一棵树(本应 n 个节点 n-1 条边,连通无环),但被多加了一条边变成 n 个节点 n 条边,于是出现一个环。 给你这 n 条边(顺序给出),找出那条导致成环的冗余边返回(若有多种答案,返回在输入里最后出现的那条)。
思路推导:边加边,第一次「已经连通」的就是环
树的性质:n 个节点、连通、无环,则恰有 n-1 条边。现在多一条,多出的那条必然让某两个已经连通的点又连了一次——也就是形成了环。
于是做法很直接:按输入顺序逐条 union 两端点。对当前边 (u, v),如果 find(u) == find(v) 说明 u 和 v 在加这条边之前就已经同组(已经连通),那这条边就是「多余的那条」,记下它。继续处理后续边,保留最后一条触发连通的边即可。
边按顺序: [[1,2],[2,3],[3,4],[1,4],[1,5]]
union(1,2): 1-2
union(2,3): 1-2-3
union(3,4): 1-2-3-4
union(1,4): find(1)==find(4) 已连通!→ 这条 [1,4] 是冗余
union(1,5): 接上 5(它不构成环,不记)
答案 [1,4]
参考代码
public int[] findRedundantConnection(int[][] edges) {
int n = edges.length; // 节点编号 1..n
DSU dsu = new DSU(n + 1); // 下标从 1 开始,多开一位
for (int[] e : edges) {
int u = e[0], v = e[1];
if (dsu.connected(u, v)) {
return e; // 已连通却还来一条边 → 冗余,直接返回
}
dsu.union(u, v); // 否则正常合并
}
return new int[]{};
}
复杂度
遍历边 O(n),每次 find/union 近似 O(1),总 O(n)。空间 O(n)。边界:题目保证有解,所以循环内必 return;节点从 1 编号,DSU 开 n+1 避免下标错位。
connected 正好回答「两端是否已连通」。
巩固练习(先自己想 15 分钟,卡住再看括号里的提示)
20.5等式方程的可满足性(中等,力扣 990)
中等 核心:先合并所有 ==,再检查 !=
题意:给一组字符串方程,形如 "a==b" 或 "a!=b",每个方程用一个小写字母变量。问这些方程能否同时成立(存在一种变量赋值让所有方程都满足)。
思路推导:两遍扫描,顺序不能反
== 表示「必须相等」,把相等的变量合并成一个集合(并查集 union)。
!= 表示「必须不等」,即不能在同一集合。关键洞察:先做所有 == 合并,把「被迫相等」的关系全部落实;再扫描 !=,如果某条 != 的两端已经被合并到同一集合(说明它们被迫相等),矛盾,返回 false。
为什么顺序不能反?因为 == 是「合并约束」,必须全部收集完才能知道谁和谁最终同组;如果边扫 == 边查 !=,可能漏掉后续 == 把本应相等的再合并。所以:第一遍只 union 所有 ==,第二遍才 check 所有 !=。
方程: ["a==b","b==c","a==c","b!=c"]
第一遍 union:a-b, b-c, a-c → a,b,c 同组(根为 a)
第二遍 check:b!=c → find(b)==find(c) 同组!矛盾 → false
参考代码
public boolean equationsPossible(String[] equations) {
DSU dsu = new DSU(26); // 变量只有 a~z,26 个
// 第一遍:只合并所有 ==
for (String eq : equations) {
if (eq.charAt(1) == '=') { // "a==b"
int x = eq.charAt(0) - 'a';
int y = eq.charAt(3) - 'a';
dsu.union(x, y);
}
}
// 第二遍:检查所有 !=
for (String eq : equations) {
if (eq.charAt(1) == '!') { // "a!=b"
int x = eq.charAt(0) - 'a';
int y = eq.charAt(3) - 'a';
if (dsu.connected(x, y)) { // 本应不等却同组 → 矛盾
return false;
}
}
}
return true;
}
复杂度
两遍扫描各 O(m)(m 是方程数),每次 find/union 近似 O(1),总 O(m)。空间 O(26)=O(1)。边界:只有 == 或只有 != 都正常;单变量自比较如 "a==a" 自然通过。
巩固练习(先自己想 15 分钟,卡住再看括号里的提示)
- 冗余连接(力扣 684,中等)——并查集检测成环(提示:见 20.4,边加边查连通)。
- 连通网络的操作次数(力扣 1319,困难)——与本题同并查集建连通块;差异在算「还需要几根线把若干连通块连成一棵」(提示:连通块数-1 就是所需额外连线,前提是有足够空闲线)。
- 账户合并(力扣 721,中等)——等价类合并,字符串节点(提示:email→id 映射后套模板)。
20.6岛屿数量的并查集视角
专题 12 里我们用 DFS 解了「岛屿数量」:对每个陆地格子 DFS 把整块连通陆地淹掉,统计 DFS 启动次数。 那并查集能不能做?能,但要想清楚什么时候该用哪个。
两种解法对比
| 维度 | DFS(专题 12) | 并查集(本章) |
|---|---|---|
| 适用场景 | 静态图,一次问「有几块连通」 | 动态加边/删边,边给边查 |
| 岛屿数量写法 | 遍历格子,遇陆地 DFS 标记,计数 | 把所有相邻陆地 union,最后数根 |
| 优势 | 代码直观、不需额外结构、常数小 | 支持「边加陆地边问连通块数」 |
选型思维:如果题目是「给一张固定的图,问连通块数」,DFS 更顺手——它直接原地标记,不用维护 parent 数组。但如果题目变成「每次新增一块陆地,实时返回当前岛屿数」(力扣 305 岛屿数量 II),DFS 每次重算就太慢,必须用并查集:每加一块新陆地,把它和上下左右已有的陆地 union,并查集 O(1) 维护。
一句话选型:
图是死的、只问一次 → DFS / BFS(专题 12)
边加边查、动态连通 → 并查集(本章)
动态版岛屿数量(力扣 305)的并查集写法骨架
为了让你更直观感受「动态」为何必须用并查集,这里给「每次加一块陆地实时返回岛屿数」的骨架:
// 关键思路:维护并查集 + 当前岛屿数 count
// 每加一块陆地 (r,c):
// 先把它当新岛屿,count++
// 看上下左右四个邻居,若已是陆地且不在同一集合 → union,并 count--
// (union 成功说明两个岛屿合体,岛屿数减一)
// 返回 count 即可。
// 这样每次加地都是 O(1) 级操作,而 DFS 版本每次要重扫全图 O(mn)。
对比之下,静态题(专题 12 的原版岛屿数量)用 DFS 逐个淹掉连通块,代码更短、不需维护 parent 数组,是更省心的选择。所以工具没有绝对优劣,看问题是否「动态」。
20.7本专题常见坑
- find 忘路径压缩,退化成链。不写
parent[x] = find(parent[x])的话,树可能被拉成一条长链,find 退化到 O(n)。务必压缩。 - union 忘判根,直接
parent[a] = b破坏集合。必须先find出两个根 rx、ry,若相等直接 return;再把一个根挂到另一个下。直接改父指针会切断原有树。 - 数根时数了非根节点。统计连通块/省份数必须数
parent[x] == x的节点;若数错成「数调用 find 后相等的」或漏判根,结果会偏小或偏大。 - 下标从 1 还是 0 没对齐。冗余连接节点从 1 编号,DSU 要开 n+1 并直接用 1 下标;等式方程用
c-'a'得 0~25。下标错位会导致数组越界或逻辑错。 - 按秩合并的 rank 维护错。只在「两树等高」合并时才
rank[root]++,不等高时不要动 rank(矮的挂高的,高树高度不变)。
20.8自测题
Q1.(概念题)并查集的「路径压缩」和「按秩合并」分别解决什么问题?都加上后单次操作复杂度是多少?
Q2.(概念题)岛屿数量题,什么时候该用并查集而不是 DFS?两者的本质区别?
Q3.(动手题)用并查集数省份数量:给定 n 个城市、边列表 edges,union 所有边后,怎么数连通块数?写出计数那几行。
int cnt=0; for(int i=0;i<n;i++) if(dsu.parent[i]==i) cnt++; 数 root 个数即省份数。详见 20.3。