Disjoint Set
有 8 台设备,编号 0..7。开始时彼此没有连接,之后网络只会增加连线,不会删除已有连线。
系统不断收到两类请求:
addEdge(u,v):在设备 u、v 之间加入一条无向连接connected(u,v)?:询问此刻 u 与 v 是否存在一条路径相连这类问题的状态是一张不断加边的无向图,查询可以穿插在任意两次加边之间。
例如,在先后加入 (0,1)、(2,3) 后,connected(0,3) 仍为 false;再加入 (1,2) 后,答案立刻变成 true。
关键限制是:只有加边,没有删边。 因此两个点一旦连通,以后就不会再次分开。
如果允许任意删边,单纯的并查集就不足以维护完整的动态连通性;本章研究的是只增边的情形。
深度优先搜索或广度优先搜索当然可以回答一次 connected(u,v):从 u 出发搜索,看能否到达 v。
但连续查询时会重复计算。
find、union、connected 三个操作及其关系parent[] 表示互不相交的集合一条连接如果跨越两个连通分量,就会把它们合并成一个更大的连通分量;如果两端本来已经连通,分量划分不变。
连通分量把所有顶点分成若干互不重叠的组
这些组只会合并,不会拆分
因此后续算法不必保存“0 到 6 具体走哪条路径”
只要维护每个顶点属于哪个组
设全集为
\[ U=\{0,1,\ldots,7\}. \]
一个 划分 是若干非空子集 \(C_1,C_2,\ldots,C_k\),满足:
\[ C_i\cap C_j=\varnothing\quad(i\ne j), \qquad C_1\cup C_2\cup\cdots\cup C_k=U. \]
定义关系
\[ u\sim v \quad\Longleftrightarrow\quad u\text{ 与 }v\text{ 之间存在路径}. \]
它满足自反性(reflexive)、对称性(symmetric)和传递性(transitive),因此是一个 等价关系(equivalence relation)。每个连通分量正好是一个 等价类:
\[ [u]=\{v\in U\mid v\sim u\}. \]
为了快速判断两个元素是否属于同一个等价类,可以让每个集合选一个 代表元。
例如当前划分是:
每个集合选定一个元素作为代表元;只要这个选择保持一致,就可以用它标识当前集合。
现在把集合操作写成并查集的三个接口:
| 并查集操作 | 含义 |
|---|---|
find(x) |
返回 x 所在等价类的代表元 |
union(a,b) |
合并 a、b 所在的两个等价类 |
connected(a,b) |
判断二者是否属于同一个等价类 |
union(a,b) 合并的是 a、b 所在的两个完整集合,不是只修改 a 与 b 这两个元素。
最直观的办法,是让每个元素直接保存自己的集合编号:
这样查询集合编号很快;但如果一条新边把 B 与 A 连起来,就必须把 B 中所有成员的编号都改成 A。
结论:查询便宜,但一次集合合并可能要改写很多元素。
如果每个集合用一棵树表示:
于是一次结构合并只需要修改 一个父指针。
关键不变量(invariant):沿
parent不断向上走,最终一定到达唯一的根;拥有同一个根的元素属于同一个集合。
本章主实现统一使用 0-based 下标,并让 parent[] 始终只表示父指针:
find先不做任何优化。find(x) 只做一件事:沿父指针一直走到根。
find 沿父指针找根find(…) 返回元素所属集合的代表元;允许压缩父指针,但不能改变集合划分本身。
find 只需不断向根前进。
find 不改结构,所以本次经过多少条父指针边就付出多少成本,时间为 O(h);若 union 把树拉成长链,重复查询也会重复付同样代价。
union一次 union(a,b) 并不是简单地把 b 挂到 a。
必须先找到两个集合的根:
只有 ra != rb 时,才允许建立根到根的新父指针。
union 连接两个根union(…) 只合并两个不同代表元所在的集合;同集合输入不应制造新的父子关系。
union 先把两个参数归约为代表元;根相同说明它们已经属于同一等价类,再合并既无意义也会破坏后续的规模或秩统计。
对朴素森林来说:
find(x):沿根路径走,时间是 \(O(h)\)union(a,b):本质上是两个 find 加一次根连接所以问题全部集中到一个量:树高 \(h\)。
如果链接方向完全随意,\(h\) 最坏能有多大?
如果依次把旧树挂到新的根下面,union(1,0), union(2,1), ..., union(6,5) 会把森林逐步拉成一条链。
最终 find(0) 需要走:
因此最坏情况下:
\[ h=n-1,\qquad find=O(n) \]
策略只有一句:小树挂到大树下面,只让较小连通分量的结点整体增加一层。
为了知道哪棵树更大,只增加一个 size[]:
初始时:
若把 rb 挂到 ra:
这样不需要改变 parent[] 的含义。
int find(int x) {
Objects.checkIndex(x, parent.length);
while (parent[x] != x) {
x = parent[x];
}
return x;
}
boolean union(int a, int b) {
int rootA = find(a);
int rootB = find(b);
if (rootA == rootB) {
return false;
}
if (size[rootA] < size[rootB]) {
int temporary = rootA;
rootA = rootB;
rootB = temporary;
}
parent[rootB] = rootA;
size[rootA] += size[rootB];
return true;
}find(…) 返回元素所属集合的代表元;允许压缩父指针,但不能改变集合划分本身。
find 的代表元语义完全不变;按大小合并不改变集合定义,只约束根连接的方向来控制未来查找路径。
union(…) 只合并两个不同代表元所在的集合;同集合输入不应制造新的父子关系。
O(log n)。
看任意一个结点 x。
它的深度只有在 自己所在的整棵树被挂到另一棵树下面 时才会增加 1。
但按大小合并保证:另一棵树至少和它当前所在树一样大。
因此每当 x 的深度增加一次,它所在集合的大小至少翻倍:
\[ 1\rightarrow 2\rightarrow 4\rightarrow 8\rightarrow \cdots \]
最多翻倍 \(\log_2 n\) 次,所以树高被控制在:
\[ O(\log n) \]
按大小合并已经避免了极端长链,但 find 仍可能经过多个中间结点。
例如:
第一次 find(6) 结束后,我们已经知道 6、4、2 的代表元都是 1。
下一次查询为什么还要重新走
6 -> 4 -> 2 -> 1?
这就引出路径压缩:让一次 find 顺便改善以后要访问的结构。
find(6) 先沿 6 -> 4 -> 2 -> 1 找到根,再在递归回程时按 2 -> 4 -> 6 的顺序更新父指针。
find(…) 返回元素所属集合的代表元;允许压缩父指针,但不能改变集合划分本身。
find 的路径;与按大小合并合用后得到均摊 O(α(n))。
findfinal class DisjointSet {
private final int[] parent;
private final int[] size;
DisjointSet(int n) {
if (n < 0) {
throw new IllegalArgumentException("n must be non-negative");
}
parent = new int[n];
size = new int[n];
for (int i = 0; i < n; i++) {
parent[i] = i;
size[i] = 1;
}
}
int find(int x) {
Objects.checkIndex(x, parent.length);
if (parent[x] != x) {
parent[x] = find(parent[x]);
}
return parent[x];
}DisjointSet 表示一组不断合并的互斥集合;类内操作只能改变森林形状或合并集合,不能制造重叠分区。
parent 决定集合森林,size 只在根上有决策意义;把结构与规模分开保存,既能找代表元,又能控制 union 的连接方向。
DisjointSet(n) 建立 n 个互不相交的单元素集合;每个元素初始都是自己的根,集合大小都为 1。
find(…) 返回元素所属集合的代表元;允许压缩父指针,但不能改变集合划分本身。
find 递归到根后在回程压缩路径;改的是树形而不是集合划分,所以正确性不变,却能降低后续操作的均摊成本。
union 与 connectedboolean union(int a, int b) {
int rootA = find(a);
int rootB = find(b);
if (rootA == rootB) {
return false;
}
if (size[rootA] < size[rootB]) {
int temporary = rootA;
rootA = rootB;
rootB = temporary;
}
parent[rootB] = rootA;
size[rootA] += size[rootB];
return true;
}
boolean connected(int a, int b) {
return find(a) == find(b);
}union(…) 只合并两个不同代表元所在的集合;同集合输入不应制造新的父子关系。
find 同时完成代表元查询和路径压缩;若根相同,说明连通关系早已成立,本次 union 不能再次改变集合大小。
connected(…) 只比较两个元素的代表元,判断它们是否已经属于同一集合。
connected 不维护额外连通状态:两个元素属于同一连通分量当且仅当压缩后的代表元相同。
find 是 \(O(1)\) 吗?不是。
按大小合并已经保证树高至多 \(O(\log n)\),因此一次尚未被压缩过的 find 仍可能走完整条根路径:
\[ T_{\text{single find}}=O(\log n) \]
路径压缩的收益在于:这次走过的路径会被改短,后面的操作因此更便宜。
所以 \(O(\alpha(n))\) 不是“每一次操作的最坏情况”,而是对 一串操作 做均摊分析后得到的平均均摊上界。
设并查集有 \(n\) 个元素,初始化之后执行 \(m\) 次 find / union / connected。
当同时使用 按大小合并 + 路径压缩 时,有经典结论:
\[ T(m,n)=O\!\left(m\alpha(n)\right) \]
因此每个操作的均摊代价为:
\[ O(\alpha(n)) \]
初始化两个数组还需要 \(O(n)\),所以若把构造过程也算进去:
\[ O\!\left(n+m\alpha(n)\right) \]
union 和 connected 都只调用常数次 find,因此具有同样的均摊数量级。
只使用按大小合并时,一个结点每向下增加一层,它所在集合的规模至少翻倍。
如果一个结点最终位于深度 \(d\),那么它所在集合至少经历了:
\[ 1,2,4,8,\ldots,2^d \]
因此 \(2^d\le n\),也就是 \(d\le \log_2 n\)。这解释了为什么树高最多是 \(O(\log n)\)。
路径压缩又做了一件更强的事:一次 find 走过一条长路径后,会把沿途结点直接接到更高的祖先。这次走得远,会让以后走得更短。
所以接下来不能只盯着“一次 find 最长走多远”,而要计算:同一个结点在一串操作中,还会被重复经过多少次?
设一次 find(x) 经过了 x → p → q → ... → 根。回程时,路径压缩会把这些结点直接接向根。
这意味着:同一段长路径不会原样反复出现。 某个结点这次付出了较大的查找代价,下一次从它出发时,路径已经明显变短。
因此,分析不能把每次 find 都按当前树高单独收费;要把一长串操作放在一起,看一个结点还能多少次遇到“很远的父结点”。
为了计数,可以把父结点所在集合的规模分成越来越大的等级。一个结点若再次付出较高代价,它的父结点通常已经跳到了更高的等级;这种升级不可能无限发生。
直观上,等级边界会增长得非常快,例如:
\[ 1,\ 2,\ 4,\ 16,\ 65536,\ \ldots \]
这串数只用来说明“等级跨度越来越大”。严格证明使用阿克曼型函数(Ackermann-type function)构造分层;覆盖到规模 \(n\) 需要多少层,就由反阿克曼函数(inverse Ackermann function) \(\alpha(n)\) 描述。
阿克曼型函数增长得极快。反过来,\(\alpha(n)\) 问的是:要经过多少层这种快速增长,才能覆盖到 \(n\)?
所以即使 \(n\) 跨过极大的数量级,\(\alpha(n)\) 也只会增加很小的整数。
把所有 find 的父指针访问合在一起计算,得到:
\[ T(m,n)=O\!\left(m\alpha(n)\right) \]
因此单次操作的均摊代价是 \(O(\alpha(n))\);它非常接近常数,但严格来说仍不是 \(O(1)\)。
| 实现 | 单次操作最坏情况 | 一串操作的均摊代价 |
|---|---|---|
| 任意方向合并 | \(O(n)\) | \(O(n)\) |
| 按大小合并 | \(O(\log n)\) | \(O(\log n)\) |
| 按大小合并 + 路径压缩 | \(O(\log n)\) | \(O(\alpha(n))\) |
这里的 \(\alpha(n)\) 是反阿克曼函数,增长极慢。
“接近常数”是工程直觉;严格复杂度仍写作均摊 \(O(\alpha(n))\)。
普通并查集非常适合:
| 操作 | 支持 |
|---|---|
| 新增关系 / 加边 | ✓ |
| 合并两个连通分量 | ✓ |
| 查询是否同组 | ✓ |
| 删除一条边后自动拆分连通分量 | ✗ |
删除边可能让一个连通分量分裂成多个集合,而普通 union / find 没有“拆分”操作。
因此这里解决的是只增边的动态连通问题,不是可以任意加边、删边的一般动态连通问题。
给定一张无向图,初始时可以没有边。之后只会不断加入新边,并在任意时刻询问两个顶点是否连通。
需要支持两类操作:
如果每次查询都重新做深度优先搜索或广度优先搜索,会反复发现已经知道的连通关系。
由于这里只加边、不删边,连通分量只会合并。因此真正需要长期保存的状态不是每条搜索路径,而是当前的连通分量划分。
每加入一条边,就合并它两端所在的集合;每次查询,只比较两个端点的代表元:
已经形成的连通分量由并查集直接保留下来,不必为每次查询重新遍历整张图。
DynamicConnectivity 用并查集保存当前连通分量;新增边只会合并集合,不会拆分已有连通关系。
ds 是唯一持久状态;它记录截至当前所有已加入边诱导出的连通分量,而不是保存边本身。
DynamicConnectivity(vertexCount) 从没有边的图开始,因此每个顶点初始都是独立连通分量。
addEdge(u,v) 把新边的连通效果压缩成一次 union;它只可能合并两个连通分量,不会拆分任何已有连通分量。
union(u,v):若两端原本不同组就合并,若已经同组结构自然不变。这正适合只增边的动态连通问题。
connected(…) 只比较两个元素的代表元,判断它们是否已经属于同一集合。
给定一张连通、无向、带权图,希望选出一部分边,使所有顶点仍然连通,同时边权总和尽可能小。
如果图有 \(V\) 个顶点,那么最终需要得到一棵包含全部顶点、恰有 \(V-1\) 条边的树。这就是最小生成树(minimum spanning tree,MST)问题。
假设已经选出的边构成一片森林。现在考虑候选边 (u,v):
u、v 已经在同一个连通分量中,再加入这条边就会形成环(cycle);u、v 属于不同连通分量,这条边会把两个分量连接起来,不会形成环。所以每考虑一条边,都需要快速回答:两个端点当前是否已经连通?
把所有边按权重从小到大处理。对每条边 (u,v):
这样,并查集负责维护“当前已经连成哪些分量”;边权顺序负责决定先考虑哪条边。
这套贪心算法称为克鲁斯卡尔算法(Kruskal)。
边按权重从小到大被检查;并查集记录当前生成森林的连通分量。
static List<Edge> kruskal(int n, List<Edge> edges) {
if (n < 0) {
throw new IllegalArgumentException("n must be non-negative");
}
ArrayList<Edge> sorted = new ArrayList<>(edges);
sorted.sort(Comparator.comparingInt(Edge::weight));
DisjointSet ds = new DisjointSet(n);
ArrayList<Edge> tree = new ArrayList<>(Math.max(0, n - 1));
for (Edge edge : sorted) {
if (edge.u() < 0 || edge.u() >= n || edge.v() < 0 || edge.v() >= n) {
throw new IndexOutOfBoundsException("edge endpoint outside graph");
}
if (ds.union(edge.u(), edge.v())) {
tree.add(edge);
if (tree.size() == n - 1) {
break;
}
}
}
if (n > 0 && tree.size() != n - 1) {
throw new IllegalArgumentException("graph is disconnected");
}
return List.copyOf(tree);
}kruskal(…) 按边权从小到大选安全边;并查集用来判断加入当前边是否会形成环。
union(u,v) 成功表示该边连接两个不同连通分量,因此加入后不会成环;边权顺序则保证优先考虑更轻的候选边。
n-1 条无环边即可停止;若所有边检查完仍不足 n-1 条,则说明输入图不连通。
设图有 \(V\) 个顶点、\(E\) 条边。
| 阶段 | 成本 |
|---|---|
| 按权重排序全部边 | \(O(E\log E)\) |
| 初始化并查集 | \(O(V)\) |
最多检查 \(E\) 条边,每条做一次 union |
均摊 \(O(E\alpha(V))\) |
因此总时间为:
\[ O\!\left(E\log E + E\alpha(V) + V\right) \]
对通常的连通图,\(E\ge V-1\),且比较排序(comparison sort)的 \(E\log E\) 项占主导,因此常写成:
\[ O(E\log E) \]
并查集自身空间是 \(O(V)\);若把输入边列表与输出的最小生成树也算入存储,则分别还需要 \(O(E)\) 与 \(O(V)\)。
本章主线固定使用:
教材和其它实现中还常见两种写法:
parent[root] 保存负的集合大小或秩,从而把元数据压进同一个数组它们都可以得到同一类高效并查集;本章不再用第二份完整实现打断主线。
find 返回集合代表元,union 合并两个集合,connected 用两个代表元是否相同给出判断。parent[] 把每个元素指向父节点或自身代表元。find 退化到线性时间。find 回程时把路径节点直接接近根。{0},{1},{2},{3},{4} 开始,依次执行 union(0,1)、union(2,3)、union(1,2),写出每一步连通分量。6 -> 4 -> 2 -> 1,使用本章递归 find(6) 后,parent[2]、parent[4]、parent[6] 按什么顺序更新?最终分别是什么?union(u,v) 返回 false 时,克鲁斯卡尔算法必须跳过候选边 (u,v)。