图论与贪心算法

Kruskal 最小生成树算法详解

Kruskal 算法把所有边按权重排序,只要不闭合成环就把边留下,因而在过程中握着的是一片碎片构成的森林,直到最后才变成一棵树。了解使“拒绝一条边”被证明为安全的环性质,跟随六顶点的完整手算演示,并看清并查集为何是让它跑得快的关键。

阅读约 12 分钟 更新时间:2026 年 8 月 进阶水平
Mohammed Islam Hadjoudj
Mohammed Islam Hadjoudj
资深运筹学工程师

1. Kruskal 算法简介

Kruskal 算法用于构造最小生成树:给定一个连通的无向带权图,它挑选出总代价最小的一组边,使之覆盖所有顶点且不形成任何环。对于 V 个顶点,这组边恰好总是 V - 1 条。

Prim 算法是从一个起点出发、让单棵连通的树向外生长;而 Kruskal 直到最后一刻之前,几乎完全不理会连通性。它把图中所有边按权重排序,从最便宜的一路走到最贵的,只要某条边不会闭合成环就把它留下。除此之外不参考任何信息。既没有起始顶点,也没有“前沿”的概念。

其结果是,Kruskal 在运行过程中大部分时间握着的是一片森林而非一棵树:许多散落在图中的小碎片,各自独立地生长、合并,直到它接受的最后一条边才融合成一棵完整的生成树。正是这种形态上的差异,使它在稀疏图上、在非连通输入上、以及在性能分析器下的表现都与 Prim 不同。

2. 为什么它是对的:环性质

Prim 的正确性依托于一条关于哪些边可以安全接受的定理。Kruskal 则有同样多的时间花在拒绝边上,因此值得把那条为“丢弃”提供依据的姊妹定理也讲清楚。

环性质。对于图中的任意一个环,只要该环上权重最大的边严格重于环上其他所有边,那么它就不属于最小生成树。若有多条边并列最重,则其中至少一条可以被舍弃。

证明与割性质的证明互为镜像。假设某个环上最重的边 e 确实属于某棵最小生成树 T。把 eT 中删去,T 会裂成两个连通块。该环的其余部分仍然横跨这两个连通块,因此环上必有另一条边 f 能把它们重新连通。改用 f,你便重新得到一棵生成树;而由于 e 严格最重,新树严格更轻,这与 T 是最小生成树相矛盾。

现在看 Kruskal 拒绝一条边时发生了什么。它走到边 e,发现两个端点已经处在同一个碎片中。这意味着它们之间早已存在一条路径,而这条路径完全由算法更早接受的边构成,也就是由不重于 e 的边构成。加入 e 会闭合出一个环,而 e 正是该环上最重的一条。依据环性质,丢弃它不会有任何损失。

被接受的边之所以安全,理由与 Prim 相同。当一条边连接两个不同碎片时,它就是横跨“该碎片与其余部分”这个割的最便宜的剩余边,割性质原封不动地适用。Kruskal 同时在两个方向上贪心,而这两个方向都是定理。

示例图中的环 A-C-B-A,权重分别为 1、2、4。环上最重的边 A-B(权重 4)被标记为不属于最小生成树,而环上另外两条较轻的边则被保留。
第 4 节所介绍的六顶点示例图的预览。在环 A-C-B-A 上最重的边是权重为 4 的 A-B,依据环性质它可以被舍弃。

3. 森林是如何合并的

具体来说,算法一开始把每个顶点各自放进一个碎片:V 个碎片,零条边。在遍历排好序的边列表时,它对每条边 (u, v) 只问一个问题:

每接受一条边,碎片数恰好减少一个。从 V 个碎片走到只剩一个,意味着恰好 V - 1 次接受,这正是停止条件。一旦达到这个数目,列表中剩余的每条边都必然会被拒绝,因此循环可以提前退出。

于是整个算法归结为一个数据结构问题:如何在数百万次的调用中,快速回答“是否同一碎片?”与“把这两个碎片合并”?这恰好就是并查集所提供的能力。

一个包含六个顶点 A 到 F 的带权无向图。最小生成树被高亮显示,使用权重 1 的 A-C、权重 2 的 B-C、权重 2 的 D-E、权重 3 的 E-F 和权重 5 的 B-D,总权重为 13。较重的边 A-B、C-D、C-E 与 D-F 未被使用。
第 4 节示例图的预览,并高亮出 Kruskal 将要构造的那棵树:五条边,总权重 13。

4. 逐步执行演示

取我们最小生成树指南中一直使用的那个六顶点图。它的九条边是:

A-B 4    A-C 1    B-C 2
B-D 5    C-D 6    C-E 7
D-E 2    D-F 8    E-F 3

按权重排序后,就得到算法实际会走的顺序:

A-C 1   B-C 2   D-E 2   E-F 3   A-B 4   B-D 5   C-D 6   C-E 7   D-F 8

每个顶点最初都是孤立的,因此森林起始状态为 {A} {B} {C} {D} {E} {F}

  1. A-C(1)。接受。属于不同碎片。森林变为 {AC} {B} {D} {E} {F}。累计 1。
  2. B-C(2)。接受。B 尚且孤立,C 与 A 在一起。森林变为 {ABC} {D} {E} {F}。累计 3。
  3. D-E(2)。接受。请注意,这里建起的碎片与第一个相距甚远。森林变为 {ABC} {DE} {F}。累计 5。
  4. E-F(3)。接受。森林变为 {ABC} {DEF}。累计 8。
  5. A-B(4)。拒绝。A 与 B 都已在 {ABC} 中,因此这会闭合出环 A-C-B-A。丢弃。
  6. B-D(5)。接受。这条边在剩下的两个碎片之间架起桥梁。森林变为 {ABCDEF}。累计 13

六个顶点接受了五条边,算法随即停止,根本没有去看 C-D(6)、C-E(7)与 D-F(8)。最终得到的树由 A-C(1)、B-C(2)、D-E(2)、E-F(3)、B-D(5)组成,总权重为 13,与 Prim 在同一个图上求得的树完全一致。

第 3 步是这个算法与众不同之处。此时 Prim 无法取用 D-E,因为它的两个端点都还没有接触到那棵正在生长的树。Kruskal 却毫不在意:它欣然在图的另一侧另起一个互不相干的碎片,把连接的事留到以后再说。而第 5 步则展示了环性质的实际作用:被拒绝的,恰好就是它将要形成的那个环上最重的边。

Kruskal 算法在六次决策中所维护的森林。它从六个单顶点碎片开始,逐步合并:先是 AC,再是 ABC,然后是另一处的 DE,接着是 DEF,其间 A-B 被拒绝,最后由 B-D 把两半连成一棵权重为 13 的树。
Kruskal 握着的是森林而非树。各碎片独立生长,直到最后一条被接受的边才融合成一棵生成树。

5. 并查集:让它跑得快的那个结构

判断两个顶点是否已经连通,最朴素的办法是从其中一个出发做一次遍历,看能否到达另一个。这样每条边要花 O(V),会把整个算法拖到 O(V * E),比它本该配合的排序还要糟糕。

并查集(不相交集合结构)能以近乎常数的时间回答这两个问题。它把每个碎片维护成一棵由父指针构成的树,树根处有一个代表元,并提供两个操作:

两项优化让它快到可以从复杂度分析中消失。按秩合并总是把较矮的树挂到较高的树下,避免结构退化成一条链。路径压缩则在每次 find 过程中,把沿途访问到的每个节点直接改指向根,于是随着算法推进,重复查询会越来越便宜。

两者兼备时,对 n 个元素执行 m 次操作的代价为 O(m α(n)),其中 α 是反阿克曼函数。它增长得极其缓慢,对任何能装进可观测宇宙的输入都小于 5,因此 Kruskal 中并查集那部分工作,实际上是与边数成线性的。

6. 实现与伪代码

由于难点都由并查集承担,算法本身相当简短。

function Kruskal(V, edges):
    将 edges 按权重升序排序
    对每个顶点 v 执行 makeSet(v)      // V 个单元素碎片
    mst = []

    for 按序取出每条边 (u, v, w):
        if find(u) != find(v):         // 分属不同碎片
            union(u, v)
            mst.append((u, v, w))
            if size(mst) == V - 1:     // 提前退出:树已完成
                break

    return mst

有两处细节值得说明。break 对正确性并非必需,因为其后的每条边反正都会被拒绝;但在稠密图上,它能跳过列表的绝大部分。而完全去掉 break 也不是缺陷:正是这一点把它变成了求最小生成森林的算法,详见下文。

find(u) != find(v) 这个比较,是全篇唯一涉及环的地方。任何位置都没有显式的环检测,而这正是该思路的优雅之处:连通性的记账工作隐式地完成了它。

Kruskal 算法在示例图上的运行。边按权重排序并从最便宜的开始考虑:A-C、B-C、D-E 与 E-F 被加入,权重为 4 的 A-B 因会形成环而被跳过,B-D 则完成了这棵树。总权重为 13。
这段伪代码所产生的结果,放到图上来看:排好序的边从最便宜的一路走下来,其中只有 A-B 被拒绝。

7. 时间与空间复杂度

由于代价集中在一处,所有有价值的优化都针对排序。如果边本来就已排好序,或权重是可用基数排序、计数排序处理的小整数,整个算法就降到 O(E α(V)),非常接近线性。部分排序或惰性建堆同样有帮助:你很少需要完整的顺序,因为算法通常远在触及最重的那些边之前就已停止。

8. Kruskal 与 Prim

两者都是贪心算法,都由同一对定理提供依据,而且在边权互不相同的图上返回完全相同的树。实际差别源自各自在过程中保持连通的对象。

              Kruskal                    Prim
结构          碎片构成的森林             一棵不断生长的树
驱动          排好序的边列表             优先队列
依赖          并查集                     堆(或 V x V 扫描)
代价          O(E log E)                 O(E log V),稠密 O(V^2)
擅长          稀疏图                     稠密图
非连通        给出生成森林               只覆盖一个连通分量

经验准则就是稠密程度。在稀疏图E 很小,排序开销低,Kruskal 胜出。在 E 接近 V2稠密图上,给约 V2 条边排序要花 O(V2 log V),而配合邻接矩阵的 Prim 只需平直的 O(V2),于是反超。

在同一个六顶点图上对 Prim 与 Kruskal 的并排比较。Prim 从 A 出发,按 A、C、B、D、E、F 的顺序让一棵连通的树向外生长;Kruskal 则构建出若干互不相连的碎片,最后才合并。两者都给出总权重为 13 的树。
通往同一答案的两条路径。Prim 始终保持一棵连通的树;Kruskal 任由碎片在各处出现,最后才把它们合并。

9. 实践要点与常见陷阱

生成森林与非连通图

这里 Kruskal 拥有一项真正的结构性优势。把它跑在非连通图上,它根本无法凑满 V - 1 条被接受的边。它会把边列表走完,而留下的正是一片最小生成森林:每个连通分量各自的最小生成树,一趟算完,无需任何特殊处理。

Prim 从单一起点做不到这一点。从某个顶点启动后,它覆盖该顶点所在的分量便停止,悄无声息地返回一棵看似合法、实则只覆盖了图之一部分的树。要补齐其余部分,就必须察觉这一缺口,并对每个分量各从一个未访问顶点重新启动一次。这个计数同时也是诊断依据:若 Kruskal 结束时接受了 V - 1 条边,说明图是连通的;若只接受了 V - k 条,说明图有 k 个连通分量。

最大生成树

把升序排序改成降序,算法的每一行依然成立。环性质会转化为在取相反数后的权重上的割性质论证,于是你得到的是最重的生成树。把权重取相反数、算法原样运行,效果同样良好。

逆向删除法

Kruskal 的镜像版本:把边从最重到最轻排序,逐条删除,除非删掉它会让图断开。它由同一条环性质反过来读所支撑,得到的树也相同。它很少被采用,因为每次删除后的连通性检测远比一次并查集查询昂贵。

权重相同

当多条边权重相同时,图可能拥有不止一棵最小生成树,你得到哪一棵取决于排序如何处理这些并列项。上面的例子中就有一处并列:B-C 与 D-E 权重都是 2。由此产生的各棵树同样最优,因此任何拿固定边列表去比对的测试都很脆弱。应当改为比较总权重

自环与重边

自环必定通不过 find(u) != find(v) 这一判断,会被自动丢弃,无需特殊处理。在重边之中,最便宜的那条会先被走到并接受,其余的随后作为成环边被拒绝。Kruskal 对杂乱输入的容忍度异常之高。

10. 变体与实际应用

网络与基础设施规划

最初的动机:用尽可能少的电缆、管道或轨道,把一组固定站点连接起来。当输入本来就是一份带成本的候选连接清单时(这类数据通常正是如此呈现的),Kruskal 以边为中心的视角便格外契合。

层次聚类

运行 Kruskal 并记录碎片合并的先后顺序,你实际上就完成了一次单连接凝聚式聚类。这串合并序列正是树状图,而在 V - k 条边处提前停止,恰好留下 k 个簇。正是这种等价关系,解释了最小生成树为何在无监督学习中如此常见。

图像分割

把像素视为顶点、把灰度差异视为边权,Felzenszwalb 与 Huttenlocher 的分割算法本质上就是 Kruskal,只是把合并条件换成了“碎片内部差异与当前边权的比较”。

电路与版图设计

在固定焊盘之间最小化总走线长度,正是一个最小生成树问题,而候选走线构成的边列表,本就是布线器手头现成的数据。

11. 学术资源与历史

与被至少三次独立发现的 Prim 算法不同,这个算法的归属十分清晰。

关于该问题完整的归属考据,参见 Graham 与 Hell 的论文。若需要割性质、环性质以及两个算法的严谨证明,标准参考书是 Cormen、Leiserson、Rivest 与 Stein 合著的 Introduction to Algorithms 中关于最小生成树的一章。完整文献信息列于本文末尾。

常见问题

为什么 Kruskal 算法可以安全地拒绝一条边?

因为环性质:任意一个环上最重的那条边都可以不进入最小生成树。当 Kruskal 拒绝一条边时,它的两个端点已经处在同一个碎片中,这意味着它们之间早已存在一条路径,而该路径由更早被接受、因而不重于当前这条的边构成。于是被拒绝的边正是它将要闭合的那个环上最重的一条,丢弃它不会有任何损失。

Kruskal 算法为什么需要并查集?

算法必须对每条边判断其两个端点是否已经连通。用图遍历来回答这个问题,每条边要花 O(V),会主导整个运行时间。并查集借助按秩合并与路径压缩,能以近乎常数的时间作答,使全部连通性判断的总代价为 O(E alpha(V)),其中 alpha 是反阿克曼函数,对任何实际输入都小于 5。

什么时候该用 Kruskal 而不是 Prim?

在稀疏图上、在输入本来就是边列表时、或者在图可能非连通时,优先选 Kruskal。它的代价由 O(E log E) 的排序主导,当 E 较小时非常便宜;而在非连通图上,它一趟就能自然地给出最小生成森林。在稠密图上则优先选 Prim,因为采用邻接矩阵的实现只需平直的 O(V^2),并且避免了给大约 V 平方条边排序。

观察 Kruskal 接受与拒绝每条边

先给边排序,再看并查集放行或拒绝每一条边。在实时图上逐步运行 Kruskal。

打开 Kruskal 可视化工具

权威参考文献与延伸阅读