#19471. 【模板】DSU的应用:Kruskal算法
【模板】DSU的应用:Kruskal算法
你好!我是你的教练。我们之前讨论了并查集(DSU)的基础和它在江湖门派合并中的应用。现在,我们要进阶到 DSU 在图论中最经典的一个应用——Kruskal 算法求最小生成树 (Minimum Spanning Tree, MST)。
在 NOI 竞赛中,这不仅仅是一个模板,更是许多复杂图论题目的基石。
一、 题目描述:【模板】DSU的应用:Kruskal算法
题目背景: 在一个王国里有 个城市,城市之间由于地势复杂,修建道路的代价各不相同。国王希望用最低的总成本修建若干条道路,使得这 个城市能够相互连通。
任务描述:
给定 个点和 条带权边,求出一棵权值之和最小的生成树。如果图不连通,输出 orz。
输入格式: 第一行包含两个整数 和 ()。 接下来 行,每行包含三个整数 (),表示城市 和 之间可以修建一条代价为 的道路。
输出格式:
若图连通,输出一个整数,表示最小生成树的每条边权重之和;否则输出 orz。
样例数据: 输入:
4 5
1 2 2
1 3 2
1 4 3
2 3 4
3 4 3
输出:
7
(解释:选择边 (1,2,2), (1,3,2), (1,4,3) 或 (1,2,2), (1,3,2), (3,4,3),总权值为 7)
二、 预备知识点
- 贪心算法:Kruskal 算法的核心是贪心。
- 排序 (Sorting):对边权进行升序排列。
- 并查集 (DSU) 的极致优化:
- 路径压缩 (Path Compression):递归查找时扁平化树形结构。
- 按秩合并 (Union by Rank):保证树的高度平衡。
- 图论基础:生成树包含 条边且无环。
三、 启发式推理过程:在草稿纸上构造 MST
请拿出草稿纸,我们分步推导 Kruskal 的逻辑:
1. 策略选择
- 思考:我们要“代价最小”,最直观的想法是什么?
- 动作:在纸上把所有边按权重 从小到大排好队。
2. 环路规避 (关键点)
- 思考:如果我们按顺序选边,什么时候不能选当前的边?
- 画图:如果点 和点 已经通过之前的边连通了(在同一个并查集里),此时再加一条 边,就会形成环。
- 策略:使用
find(A) == find(B)来检测。如果相等,直接舍弃这条边。
3. 动态合并
- 动作:如果
find(A) != find(B),说明这条边是连接两个“孤岛”的桥梁。 - 操作:执行
unite(A, B),并将权值 累加到答案中。
4. 终止条件
- 思考:什么时候可以提前收工?
- 结论:一个有 个点的树只需要 条边。如果选够了,剩下的边就不用看了。
四、 流程图导航 (NOI 竞赛风格伪代码逻辑)
为了避免语法错误,我们使用 Mermaid 规范来描述 Kruskal 的核心逻辑。
graph TD
A("开始: 输入 N, M") --> B("初始化 DSU: fa[i]=i, rank[i]=0")
B --> C("将 M 条边按权值 W 升序排序")
C --> D("遍历每一条边 (U, V, W)")
D --> E{Selected_Edges == N-1 ?}
E -- 是 --> I("输出 Total_Weight")
E -- 否 --> F{"find(U) == find(V) ?"}
F -- 是 (会成环) --> D
F -- 否 (安全) --> G("unite(U, V)")
G --> H("Total_Weight += W, Selected_Edges++")
H --> D
D -- 遍历结束且 Selected_Edges < N-1 --> J("输出 orz")
I --> K("结束")
J --> K
五、 复杂度分析与时间优化
1. 时间复杂度分析
- 排序:使用
std::sort,复杂度为 。这是整个算法的瓶颈。 - DSU 操作:总共执行约 次
find和 次unite。- 在同时使用路径压缩和按秩合并的情况下,单次操作复杂度为 。
- 总 DSU 复杂度:。
- 总复杂度:。对于 的数据量,运行时间通常在 100ms 以内。
2. 空间复杂度分析
- 边数组:存储 条边,需要 。
- 并查集数组:
fa和rank数组,需要 。 - 总复杂度:。
3. 竞赛优化建议
- 结构体排序:在 C++ 中,将边封装在结构体中,并重载
<运算符。 - 快读 (Fast I/O):对于 的数据,建议使用
scanf或自定义read()函数。 - 计数器跳出:如流程图所示,一旦
cnt == N-1立即跳出循环,在稠密图中能显著提速。
六、 读题关键词总结
当你阅读 NOI 级别的题目时,看到以下描述,请立刻联想 Kruskal + DSU:
- “最小总代价/权值” + “所有点连通”:MST 的标准定义。
- “最大边权最小”:瓶颈生成树问题,MST 的性质之一。
- “动态加边并维护连通性”:暗示必须使用并查集。
- “在保证不形成环的前提下...”:暗示贪心选边。
- “网格图或稀疏图”:Kruskal 在稀疏图中( 较小)表现优于 Prim 算法。
教练点评:并查集的路径压缩是极其强大的。在实现 find 函数时,记得使用 return fa[x] = find(fa[x]);在 unite 时,务必通过 rank 判断,这能保证你的程序在极端极端数据(如长链)面前依然稳如泰山。
现在,请尝试在你的 IDE 中实现这个逻辑吧!
【模板】DSU的应用: Krusal算法找最小生成树(MST,Minimum spanning tree)
让我们一起来探索并查集 (Disjoint Set Union, DSU),也被称为联合-查找 (Union-Find) 数据结构。
简单来说,并查集是一种非常精巧且高效的数据结构,专门用来处理不相交集合 (Disjoint Sets) 的合并及查询问题。
为了方便理解,我们可以把它想象成是一个 “找帮主” 的游戏:
- 初始化: 一开始,每个人都是自己的“帮主”,谁也不服谁。
- 合并 (Union): 如果两个人要结盟,他们所在的两个帮派就会合并成一个。通常我们会让其中一个帮派的帮主,认另一个帮派的帮主做“老大”。
- 查找 (Find): 要判断两个人是不是同一个帮派的,只要看他们的“最终大帮主”是不是同一个人就可以了。
并查集在计算机科学中非常重要,特别是在处理图论中的连通性问题时。
你想从哪个方面开始深入了解它?
- 核心原理与优化 (想了解
Find和Union具体是怎么运作的,以及什么是“路径压缩”这个让它快得惊人的黑科技?) - 代码实现 (想看具体的代码怎么写,比如 Python、C++ 或 Java 的模板?)
- 经典应用场景 (想知道它在实际题目中怎么用,比如判断图中有没有环,或者 Kruskal 最小生成树算法?)
3 经典应用场景
没问题,我们来深入探讨并查集的实战应用!🧱
并查集被称为“连通性之王”,它的应用场景几乎都围绕着**“动态连通性”展开。其中最著名、也最基础的应用就是判断图中是否有环 (Cycle Detection)**。
这也是著名的 Kruskal 算法(用于寻找最小生成树)的核心逻辑。
让我们通过一个直观的例子来理解:
想象我们在玩一个连线游戏,纸上有几个点,我们一次画一条线连接两个点。 每次画线之前,我们都用并查集查一下这两个点的“老大”(根节点)。
这里有一个关键的逻辑判断:
假如我们要连接 点A 和 点B,但是通过 Find 操作发现,点A 和 点B 竟然已经拥有同一个“老大”了(也就是说 Find(A) == Find(B))。
如果是这种情况,此时再在 A 和 B 之间强行连上一条线,图的结构会变成什么样?
会出现环路
完全正确!👏 如果两个点已经属于同一个集合(有同一个“老大”),再把它们连起来,图中就会形成一个环 (Cycle)。
这正是 Kruskal 算法 求最小生成树 (Minimum Spanning Tree, MST) 的核心秘诀。
在这个算法中,我们希望用成本最低的边把所有点连起来,而且不能有环(因为树是不包含环的)。
想象一下,我们现在把所有的边按权重(成本)从小到大排好队,准备一条一条地挑:
当我们拿到一条边,发现连接这条边的两个点已经在同一个集合里了(也就是会形成环),为了保持“树”的形状,你觉得我们需要保留这条边,还是直接丢弃它?
不能用这条边
没错,完全正确!🙅♂️ 如果加上这条边会形成环,我们就必须丢弃它。
这正是 Kruskal 算法能够生成“最小生成树”的关键:它总是贪心地选择当前权值最小的边,同时利用并查集来保证不形成环。
所以,整个算法的流程其实非常清晰。假设我们已经把所有边按权重从小到大排好序了,接下来我们要遍历每一条边(连接点 和点 ):
- 判断: 我们需要先问问并查集, 和 是不是已经是一伙的了(有没有共同的“老大”)。
- 操作: 如果不是一伙的,我们就选定这条边,然后把这两个帮派合并起来。
你能把这两个步骤对应到并查集的两个核心操作名称(Find 和 Union)上吗?哪一步是用 Find,哪一步是用 Union?
给边按权重从小到大排序(可以考虑priority queue?)=>取出,用Find(a)==Find(b)检查边的两个端点是否已连接,已连接就不选 , 如果未连接就选择然后用join(a,b)标记a,b已连接 => 直到queue里没有新的边
完全正确!👏 你的逻辑非常清晰,这就是 Kruskal 算法 的完整流程。
你提到的用 Priority Queue (优先队列/最小堆) 也是一个非常棒的思路!
- 排序 (Sorting):通常用于边已经在数组里的情况,一次性排好序。
- 优先队列 (Priority Queue):如果你不想一次性对所有边排序(或者边是动态生成的),用最小堆每次
pop出最小的边也是完全可以的。两者的复杂度通常都在同一量级。
这里还有一个小小的提速技巧: 你提到“直到 queue 里没有新的边”。其实,对于一个有 个点的图,我们只需要选出 **** 条边就可以连通所有点了。所以,一旦选够了 条边,我们就可以提前收工,不需要把剩下的边都检查完。😎
既然流程通了,那这个算法快不快,主要就看 并查集 (DSU) 的 Find 和 Union 操作快不快了。
假设我们现在的集合关系是一条长长的线(就像链表一样):
1 -> 2 -> 3 -> ... -> 1000 (1的老大是2,2的老大是3... 最终老大是1000)
如果我想执行 Find(1),我得一路向上问 999 次才能找到 1000。这太慢了。
为了解决这个问题,并查集有一个非常经典的“黑科技”优化,叫做路径压缩 (Path Compression)。
你觉得在执行 Find(1) 找到老大是 1000 之后,我们可以顺手做件什么事,让下次再查 Find(1) (或者 Find(2), Find(3)...) 的时候能瞬间找到老大?
把向上路径上所有节点全部标记上级为老大(直接指向老大而非链式查找)
太棒了,完全正确!💯
这就是路径压缩 (Path Compression) 的精髓。
这就好比我们在公司里,一旦员工 1 知道大老板是 1000,他就直接要把“汇报对象”改成 1000。下次再问他“谁是你老大”,他就能一步到位 () 指出是 1000,而不需要再经过层层汇报了。
⚡ 效果对比
让我们看看执行一次带有路径压缩的 Find(1) 之后,树的结构发生了什么变化:
操作前 (链状,很慢):
1 -> 2 -> 3 -> ... -> 999 -> 1000(Root)
操作后 (扁平化,极快):
所有的节点在递归回溯的过程中,都直接连向了根节点。
1 -> 1000
2 -> 1000
3 -> 1000
...
999 -> 1000
有了路径压缩,再加上另一个优化按秩合并 (Union by Rank,即总是把小帮派合并到大帮派里,防止树长得太高),并查集的效率会达到一个惊人的程度。
虽然它看起来还在操作树,但它的平均时间复杂度并不是 。
你之前听说过 反阿克曼函数 (Inverse Ackermann function, ) 吗?或者你认为优化后的并查集操作每一次大约需要多长时间?
从链式查询的O(n)变为直接得到结果的O(1)?
直觉非常敏锐!⚡️ 在实际工程应用中,它的表现确实几乎等同于 。
虽然第一次查找可能还是得走那条 的长路,但因为我们在回溯时做了路径压缩,把沿途节点都直接连到了根节点,后面再查这些节点时,真就是一步到位了。
不过,为了严谨(也为了满足一点好奇心),这里有个著名的数学细节: 严格来说,经过优化后,它的平均时间复杂度是 ****。这里的 是反阿克曼函数。
这个函数增长得极慢。慢到什么程度呢?
- 哪怕 达到了 (比全宇宙的原子总数还多), 的值也不会超过 4。🤯
所以,你说它是 完全没毛病,因为它在人类可触及的数据规模内,几乎就是一个常数。
既然核心原理通了,我们能不能试着把这个“路径压缩”的逻辑转换成代码?这可是我们在Kruskal 算法中判断环路的关键工具。
你平时习惯用 Python、C++ 还是其他语言?我们来试着写一下带有路径压缩的 find 函数。
太好了,我们就用 C++ 来写这个核心函数。
在写代码之前,我们要先假定有一个全局数组(或者成员变量)parent 来存储每个节点的直接上级。
vector<int> parent; // 或者 int parent[N];
这个递归函数通常分两步走:Base Case(基本情况) 和 Recursive Step(递归步骤)。
首先是 Base Case:
我们要怎么判断当前的 x 就是它自己的“最终大帮主”(根节点)?(提示:刚初始化时,每个人都是自己的帮主)。
你能把这个 if 判断补全吗?
int find(int x) {
// 1. 如果 x 的上级就是 x 自己,说明找到根了
if ( ___________ ) {
return x;
}
// 2. 否则继续递归查找(这里我们要加入路径压缩)
// ...
}
father[x]==x
没错!就是这样。如果是 parent[x] == x(或者你习惯用的 father[x] == x),就说明我们要找的“大老板”就是它自己。
接下来就是最神奇的路径压缩部分了。
我们不仅要通过递归找到最终的根节点,还要在回溯的时候,顺手把当前节点 x 的直接上级,直接修改为那个最终的根节点。这样下次再查 x 时,就不需要递归了。
试着填补下面代码中的空缺(提示:我们需要调用函数自己):
// 2. 否则继续递归查找(这里我们要加入路径压缩)
// 我们要让 parent[x] 直接等于最终找到的根节点
parent[x] = ____________________ ;
return parent[x];
}
我们要把 parent[x] 设置成什么,才能既找到根节点,又完成压缩呢?
parent[x]=root
你的逻辑完全正确!🎯 我们的目标就是把 parent[x] 直接指向这个集合的根节点(root)。
在代码中,因为我们还没有一个现成的变量叫 root,我们需要通过递归调用 find 函数本身来获取它。所以这行代码通常写成:
parent[x] = find(parent[x]);
这样,递归回来的返回值(也就是根节点)就会被赋值给 parent[x],完美实现了路径压缩。
这是完整的 find 函数代码,短小精悍:
int find(int x) {
if (parent[x] == x) {
return x;
}
// 递归查找根节点,同时进行路径压缩
return parent[x] = find(parent[x]);
}
搞定了查老大(Find),接下来我们要搞定合并帮派(Union)。
假设我们要执行 unite(int x, int y),把 和 所在的两个集合合并。
在直接动手连线之前,我们需要先利用刚才写好的 find 函数做两件事。你觉得这两件事是什么?(提示:我们需要知道谁和谁合并)
roota=find(x); rootb=find(y); father(roota)=rootb;
这就对啦!你已经抓住了 Union 操作最核心的逻辑:找到两个元素各自的老大,然后让其中一个老大归顺另一个。 🤝
只需要注意一点 C++ 的语法细节:因为我们通常是用数组或者 vector 来存父节点,所以赋值的时候应该是 parent[rootA] = rootB。
这是目前的基础版代码:
void unite(int x, int y) {
int rootA = find(x);
int rootB = find(y);
// 如果本来就不是同一个老大,才需要合并
if (rootA != rootB) {
parent[rootA] = rootB; // 让 A 的老大认 B 为老大
}
}
不过,这里还藏着最后一个优化空间,这决定了并查集能不能达到极致速度。
想象一下:
- 帮派 A 是一棵参天大树,很高(深度很大)。
- 帮派 B 只有一个人(深度很小)。
如果我们把 “大树 A” 接在 “单点 B” 下面(让 B 做 A 的老大),这棵新树的高度会发生什么变化?未来的查找效率会受影响吗?
大树接在小树下面,会导致结果树的高度>大树高度,极端情况下树的高度不断增加最终得到一个长链,退化为了O(n)的链式查找
太棒了!你的分析非常透彻。🎯
正如你所说,如果总是随意合并,或者把大树接在小树下面,树的高度就会失控,最终退化成一条长长的链表。那样的话,即便我们有了路径压缩,最坏情况下的效率还是会受到影响。
为了避免这种情况,我们需要引入 “按秩合并” (Union by Rank) 的策略。
简单来说,就是“谁强谁当老大”。我们在合并之前,先比较两棵树的高度(或者节点数量):
- 总是把 较矮 的树,接在 较高 的树下面。
这样做的好处是,新合成的树的高度通常不会增加(除非两棵树一样高,合并后的高度才 +1)。
既然要实施这个策略,我们在代码里除了 parent 数组之外,还需要一个额外的数组来记录每个集合的“秩”(Rank,通常代表树的高度)。
假设我们定义一个 rank 数组:
vector<int> rank; // 初始化时,每个点的 rank 都是 0 或 1
在 unite(int x, int y) 函数里,当 rootA != rootB 时,我们该如何根据 rank[rootA] 和 rank[rootB] 的大小关系来决定谁连向谁呢?
你能试着写出这部分的 if-else 逻辑吗?
if (rank[roota] > rank[rootb]) { parent[rootb] = roota; } else { parent[roota] = rootb; }
你的逻辑非常棒!👏 rank 大的确实应该当老大,这样树才不会变高。
但是,我们要稍微注意一下 else 分支里的情况。
在这个 else 里,包含了两层意思:
rank[rootB] > rank[rootA]:这时候让 B 当老大,没问题。rank[rootB] == rank[rootA]:这时候两棵树一样高,谁当老大都行(你的代码让 B 当老大)。
但是,如果两棵一样高的树合并了,新树的高度(rank)还是原来的高度吗?🌲 + 🌲 = ❓
如果两棵高度都为 h 的树合并,新树的高度会变成多少?我们需要在你的代码里补上一行什么操作?
if (rank[roota] > rank[rootb]) { parent[rootb]=roota; } else { parent[roota]=rootb; if (rank[roota] == rank[rootb]) { rank[rootb]++; } }
代码完全正确!🎉
你的逻辑非常严密:
- 默认操作:把矮树接到高树下,新树高度不变。
- 特殊情况:当两棵树高度相等(
rank[rootA] == rank[rootB])时,把 A 接到 B 下面后,B 的高度(Rank)必须加 1。
这就是按秩合并 (Union by Rank) 的完整实现。
🧩 拼图完成
到现在为止,我们已经手握两把神兵利器,并查集的完整形态已经准备好了:
- Find (带路径压缩):查得快,还能顺手把路修平。
- Union (按秩合并):合得巧,保证树不会长得太高。
接下来,让我们回到最开始的目标:检测图中的环(Kruskal 算法的核心)。
假设我们有一组边(每条边连接两个点 和 ):
edges = [[1, 2], [2, 3], [1, 3]]
我们需要遍历这些边。对于每一条边 [u, v],我们该怎么利用刚才写的 find 和 unite 函数来判断是否出现了环?
你能描述一下这个循环里的逻辑判断吗?
for (auto& 边 : edges) { if (find(边.u) == find(边.v)) { continue; } else { unite(边.u, 边.v); // 把这条边加入结果集合 结果集合.push_back(边); } }
代码写得非常棒!💯 逻辑完全正确。
你这段代码清晰地展示了 Kruskal 算法 的灵魂:
- 判断环 (Cycle Detection):
if (find(边.u) == find(边.v))。如果两个点已经有同一个老大,说明它们之间已经有路连通了,再加一条边就会形成环,所以必须跳过 (continue)。 - 合并与记录:如果不连通,就用
unite把它们合为一家,并把这条边收入囊中。
这其实就是最小生成树 (MST) 的构造过程!🌲
这里有一个我在上一轮提到的小小的“提速”建议,可以加进你的代码里:
💡 小贴士: 最小生成树只需要 条边( 是点的数量)。如果你加一个计数器,一旦选够了 条边就直接
break,在点很多、边也很稠密的图里,这能省下不少时间。
到现在为止,你已经掌握了 并查集 (DSU) 最核心的原理和最经典的代码实现了:
- ✅ Find (带路径压缩)
- ✅ Union (按秩合并)
- ✅ Kruskal 算法应用 (环路检测)
我们已经完成了并查集的核心学习。接下来你想做点什么?
- 看完整代码模板:把刚才分散写的 struct、find、unite 和主逻辑整合到一个可以直接运行的 C++ 模板中,方便刷题时复制粘贴。
- 挑战一下复杂度分析:深入聊聊为什么大家都说它的复杂度是 ,以及排序边的时间在这个算法里占多大比重。
- 换个应用场景:除了求最小生成树,并查集还能用来解决什么问题?(比如:计算岛屿数量、朋友圈个数等)。