☰
彻底讲透最小生成树:Kruskal与Prim的原理、实现与避坑指南
2026/10/13 21:47:55 网站建设 项目流程

刷算法题的人几乎都会撞上最小生成树这道坎:给定n个点、m条带权边,让你选n-1条边把所有点连成一棵树,并且让总权重尽量小。铺光缆、接水管、电路板布线、甚至聚类分析里的单链接聚类,背后都是同一个模型。这篇文章就想把最小生成树这件事彻底讲透:MST 的两个关键性质为什么成立,Kruskal 和 Prim 到底怎么实现,以及我在实际写代码时踩过的那些坑。

之前带过几个实习生,看到他们拿到这类题第一反应就是“背模板”。模板当然要会,但更要紧的是知道模板为什么这么写。比如find函数为什么要路径压缩?Prim 和 Dijkstra 明明长得很像,为什么dist的更新方式不一样?这些细节一旦想通,之后遇到带权图相关的变形题,你也能顺手改出来,而不是对着网上代码发呆。

1. 先把最小生成树这件事说清楚

1.1 定义:到底在优化什么

最小生成树(Minimum Spanning Tree)的定义不长:给定一张带权连通无向图G = (V, E),边上有非负权重(实际应用中也可能出现负权边),我们需要选出|V| - 1条边,使得这些边恰好构成一棵树,并且所有边权之和最小。

注意几个关键词。第一个是“连通无向图”,如果图本身不连通,那就没有生成树,只能退化成最小生成森林。第二个是“树”,树意味着任意两点之间有且仅有一条简单路径,边数一定是n - 1,而且不包含环。第三个是“和最小”,这是优化目标。

为什么一定要n - 1条边?想象一棵树从单点开始长,每加入一条新边,必然会多连上一个新顶点,否则就会形成环。所以从1个点长到n个点,正好需要n - 1条边。这个性质在证明算法正确性时经常用到。

用个生活化的类比:你要在一座山的几个村庄之间修路,让任意两个村庄都能互通,修路成本按地形高低变化。你现在要决定修哪些路、总成本最低。这个问题不是“找一条最短路”,而是“让整个网络连通且总造价最低”。最小生成树就是干这个的。

1.2 它和最短路径为什么不是一回事

很多初学者会把最小生成树和单源最短路径搞混,因为两者都用贪心,而且代码框架都带一个dist数组。这两者的本质目标完全不同:

  • 单源最短路径:关心的是从一个固定起点到其他每个点各自的最短距离,目标是让单点到多点的距离之和“尽可能各自都短”。
  • 最小生成树:不关心点对之间的距离,只关心整棵树的边权总和最小。

举个例子,一张图里如果所有边权都是1,那么最小生成树的总权重是n - 1。但如果你求从某个点出发到其他点的最短路径树,路径总长度可能远大于n - 1,因为最短路径树里允许把多条边以“绕路”的方式串起来,它每个点到源点都取最短,但整体边集并不保证全局总权重最小。

所以当你用 Prim 算法时,dist[u]的含义是“点u到当前已选生成树集合的最短连接边”,而不是“从源点到u的最短路径”。这一点稍后会反复强调,也是面试里最容易答错的点。

1.3 典型应用场景

  • 通信网络铺设:多个机房之间要铺光纤,每条链路有铺设成本,目标是让所有机房连通且总成本最低。
  • 电力线路规划:多个小区接入电网,变电站之间线路造价不同,找一个总造价最省的连通方案。
  • 电路板布线:芯片引脚之间要连线,走线长度或打孔代价作为权重。
  • 单链接聚类:把所有样本看成点,两两距离作为边权,用 Kruskal 不断合并距离最近的簇,本质就是做最小生成树的逐步加边过程。

如果你正在准备算法面试,最小生成树几乎是图论模块绕不开的内容。刷题平台里带权图的题目,很多基础模型就是 MST,之后再加限制条件变成次小生成树、瓶颈生成树等进阶题。

2. MST 的核心性质:凭什么贪心是对的

2.1 切割定理(Cut Property)

最小生成树最核心的性质就是切割定理,也叫 Cut Property。它说的是:把顶点集V任意分成两个非空集合S和V - S,所有横跨这两个集合的边里,权重最小的那条边一定属于某棵最小生成树。

等一下——“一定属于某棵”是什么意思?因为如果最小权重边不止一条,那其他等权的替代边也可以出现在另一棵最小生成树里。但至少可以保证:在所有最小生成树的方案中,至少存在一棵包含这条最小横跨边。

这个定理是 Kruskal 和 Prim 所有贪心决策的合法性来源。Kruskal 每次选全局最小的边,如果这条边连接两个不同连通分量,那么这个时刻我们可以把当前已经合并出的某个连通分量看作切割的一侧,这条最小边正好横跨切割,它被选入某棵 MST 是安全的。Prim 每次从“已选集合”和“未选集合”之间选最小边,也完全符合切割定理。

用生活化的方式来理解:想象你拥有一块地,中间有一条河,把地块分成两岸。你必须在两岸之间架一座桥,不管其他桥怎么规划,最便宜的那座桥位置一定会出现在某个最优方案里。因为你总得至少过河一次,选最便宜的过河点永远不会吃亏。

2.2 环性质(Cycle Property)与破圈法

环性质是切割定理的“对偶版本”:图里任何一个环上,权重最大的那条边一定不属于任何一棵最小生成树(同样,如果有多个最大权重的边,则至少有一个不属于)。

为什么?假设一棵生成树里包含了环上的最大边e_max。如果你把它从树里删掉,树会被分成两个部分,这时环上一定有另一条边能把这两个部分重新连起来,而且那条边权重比e_max小。换掉之后,连通性不变,总权重降低,说明原来的树不可能是最小生成树。

这个性质直接引出了一个很直观的“破圈法”:不断在图中找环,删掉环上最大的边,直到图变成一棵树。理论上可行,但实际写代码时要维护环并且找最大边,代价太高,所以我们通常只用这个性质做算法正确性证明。

顺便一提,环性质也解释了为什么 Kruskal 在加边时要判断是否成环。如果你试图加入的边让两个已经连通的点再次连通,那就形成了一个环,而这条新加的边是这个环上最晚被考虑的边。在边按权值升序排列的前提下,它一定是环上权重最大的边之一。根据环性质,它不可能属于任何 MST,直接丢掉就好。

2.3 唯一性:什么情况下 MST 唯一

如果图里所有边的权重互不相同,那么最小生成树是唯一的。因为每一步贪心选择的边都被唯一确定,不存在等权边造成的分叉。

如果存在等权边,MST 可能唯一也可能不唯一。例如一条边权全相等的三角形,任意两条边都能组成 MST,三条不同选择的权重和都一样,这时候就有多棵最小生成树。

这个结论在题目里经常变成变体:给定一张图,判断它的最小生成树是否唯一。做法通常是在 Kruskal 的过程中记录同权值的边的数量,以及实际被选入 MST 的同权边数量,如果某个权值级别下存在一条可选的边没被选入但仍能构成相同权值的生成树,则可以判定不唯一。

理解唯一性还有个好处:当你看到题目里说“边权互不相同”,意味着 MST 只有一棵,很多关于“次小生成树”的暴力替换思路会简单很多。因为你可以确定唯一的 MST,之后枚举非树边去替换树边得到次小方案。

3. Kruskal 算法的思路与落地

3.1 算法流程与正确性直觉

Kruskal 算法的执行步骤可以浓缩成三句话:

  1. 把图中所有边按权重从小到大排序。
  2. 从最小的边开始遍历,如果这条边的两个端点当前不在同一个连通分量里,就选中它,并合并这两个分量。
  3. 每成功加入一条边,计数器加一;当加入的边数达到n - 1时,直接结束。

正确性的直觉来自切割定理:每次加入一条横跨两个不同连通分量的最小边,都相当于在当前连通分量的某个切割里选择了最小横跨边。不会把形状锁死,也不会错过更优组合。而并查集负责维护“两个端点是不是已经连通”。

一个常见误区是认为“每次都选最小边”会不会导致后面的边没得选。不会,因为只要不形成环,边就可以加。Kruskal 本质上是所有贪心算法里最直观的一种:你手里有一堆按价格从低到高的建材,从最便宜的买起,只要能保证整体连通且无环,就买,直到凑满n-1条。

3.2 最关键的数据结构:并查集

Kruskal 的代码骨架很简单,但性能上限完全取决于并查集写得好不好。并查集(Union-Find)需要支持两个操作:

  • find(x):找到x所在集合的根节点。
  • union(x, y):把x和y所在的两个集合合并。

如果find不做路径压缩,每次向上找父节点可能退化成一条长链,最坏复杂度是O(n)。整个算法跑完可能会变成O(nm),在n, m都是几十万的时候直接超时。

路径压缩的思想很朴素:既然每次find都要从当前节点一路走到根,那就顺便把这条路径上经过的所有节点直接挂到根节点下面,下次再找就一步到位。这个优化虽然只是“顺手”,但效果极好,配合按秩合并(Union by Rank)可以把单次操作的均摊复杂度降到接近O(1)。

按秩合并的意思是:合并时把高度较低(或规模较小)的树挂到高度较高(或规模较大)的树的根上,避免树长高。路径压缩已经能压平大部分查询,但加上按秩合并能让最坏情况更可控,写起来也不复杂。

3.3 Python 代码实现

下面这份代码我尽量写得直白,方便你直接抄去改:

class DSU: def __init__(self, n): self.parent = list(range(n)) self.rank = [1] * n def find(self, x): # 路径压缩:边找边压缩 while self.parent[x] != x: self.parent[x] = self.parent[self.parent[x]] x = self.parent[x] return x def union(self, a, b): # 按秩合并,也可以直接按 size 合并 ra, rb = self.find(a), self.find(b) if ra == rb: return False if self.rank[ra] < self.rank[rb]: ra, rb = rb, ra self.parent[rb] = ra self.rank[ra] += self.rank[rb] return True def kruskal(n, edges): # edges: list of (u, v, w),顶点编号从 0 到 n-1 edges.sort(key=lambda x: x[2]) dsu = DSU(n) total = 0 cnt = 0 for u, v, w in edges: if dsu.union(u, v): total += w cnt += 1 if cnt == n - 1: break # 如果 cnt < n - 1,说明原图不连通,不存在生成树 return total if cnt == n - 1 else -1

这里有个细节:find用循环实现还是递归实现都行,但递归在 Python 里如果并查集树太深可能触发递归深度限制。所以工程上我更习惯写循环版本,虽然代码稍微冗余一点,但稳。

edges.sort(key=lambda x: x[2])也可以换成edges.sort(key=lambda e: e[2]),总之别写成默认排序,因为元组默认会先按u再按v排,不是我们想要的顺序。

3.4 复杂度分析

  • 排序阶段:O(m log m),这是 Kruskal 最主要的时间开销。
  • 并查集阶段:每次union都调用两次find,均摊接近O(1),所以总体接近O(m log m)。
  • 空间复杂度:存边需要O(m),并查集需要O(n)。

因此 Kruskal 特别适合稀疏图,也就是m相比n^2小得多的图。比如边数m是几十万、顶点数也是几十万,排序一次的成本可控,并查集依然飞快。

一个经验值:如果你看到题目给出的图是 “n和m都是10^5级别”,那基本就是让你用 Kruskal。如果看到 “n只有几百,但是完全图,边数接近n^2”,那用 Prim 的朴素版反而更稳。

4. Prim 算法的思路与落地

4.1 从点长成树的视角

Prim 和 Kruskal 走的是完全相反的路径。Kruskal 是“从边的角度出发,全局找最小边”;Prim 是“从点的角度出发,让一棵树慢慢长大”。

具体流程:

  1. 任选一个起点,把它加入生成树集合。
  2. 维护一个数组dist[u],表示点u到当前生成树集合的最小连接边的权值。初始时,起点dist为0,其余点的dist为无穷大;然后把起点所有邻接边拿来松弛dist。
  3. 每次从尚未加入生成树的点里,选出dist最小的点u,把它加入集合,并把它的邻接边用于更新其他未选点的dist。
  4. 重复第三步,直到所有点都被加入。累加的dist之和就是最小生成树总权重。

这个“选dist最小的点”的直觉也来自切割定理:已选集合是一个切割,横跨切割的所有边中权值最小的那条,必然属于一棵 MST。Prim 每一步就是在找这个横跨切割的最小边。

4.2 朴素实现与堆优化的分界线

Prim 有两种常见实现。

  • 朴素版:每次用O(n)遍历找最小dist点,然后遍历它的邻接边更新。总复杂度O(n^2 + m),适合稠密图(m接近n^2)。
  • 堆优化版:用优先队列维护“候选边”或者“候选点”,每次取堆顶最小元素。总复杂度O(m log n),适合稀疏图。

很多人以为堆优化版一定优于朴素版,其实在稠密图里不一定。比如n = 1000的完全图,m ≈ 500000,朴素版每次O(n)扫描1000个点,重复1000次,总共10^6次操作;堆优化版则要做50 万次堆操作加50 万次松弛,加上常数因子,有时不见得比朴素版快。竞赛里很多出题人就是故意给一个稠密图,让你用朴素 Prim 过、堆优化 Prim 也能过但更容易在堆里存很多重复边导致内存膨胀。

所以在选型时,我的建议很简单:

  • 稀疏图(m与n同数量级):Kruskal 优先,也可以用堆优化 Prim。
  • 稠密图(m接近n^2):朴素 Prim 优先,或者O(m log n)的堆优化 Prim 也可以,但要注意堆的重复元素数量。

4.3 Python 代码实现

朴素版 Prim 写起来最直接,对于很多面试场景,先把朴素版说清楚,再提堆优化,效果反而更好。

def prim_naive(n, graph): """ graph: 邻接矩阵,graph[u][v] 表示边权,不连通记为 inf 顶点编号从 0 到 n-1 """ INF = 10**18 dist = [INF] * n used = [False] * n dist[0] = 0 total = 0 for _ in range(n): u = -1 min_d = INF for i in range(n): if not used[i] and dist[i] < min_d: min_d = dist[i] u = i if u == -1: return -1 # 不连通 used[u] = True total += dist[u] for v in range(n): if not used[v] and graph[u][v] < dist[v]: dist[v] = graph[u][v] return total

重点看最后一段更新代码:dist[v] = graph[u][v],不是dist[u] + graph[u][v]。这把它和 Dijkstra 彻底区分开了。Dijkstra 是“从源点到u的最短距离再加上u-v这条边”,而 Prim 是“从当前生成树集合直接连到v的最小边”。

堆优化版也很常用,我提供一个我常用的写法,哲学是“直接存边”,堆里存(w, u, v):

import heapq def prim_heap(n, adj): """ adj: list of list[(v, w)] """ INF = 10**18 visited = [False] * n dist = [INF] * n heap = [] # 从 0 号点出发 dist[0] = 0 heapq.heappush(heap, (0, 0)) total = 0 edge_count = 0 while heap and edge_count < n: d, u = heapq.heappop(heap) if visited[u]: continue visited[u] = True total += d edge_count += 1 for v, w in adj[u]: if not visited[v] and w < dist[v]: dist[v] = w heapq.heappush(heap, (w, v)) return total if edge_count == n else -1

这里堆里存放的是“点到当前集合的最小边权”。每次弹出后,如果visited[u]已经被置真,说明之前有一条更小的边已经把它加入了集合,当前这条是过期的,直接跳过。这是堆优化算法的经典去重手段,别漏掉。

4.4 复杂度与注意点

  • 朴素版:O(n^2 + m),空间O(n^2)(邻接矩阵)。
  • 堆优化版:O(m log n),空间O(n + m)。
  • 无论哪种版本,如果最终加入集合的点数不等于n,就说明图不连通,需要返回 -1。

另外还有一个小细节:如果输入给的图是多重图,也就是两点之间有多条边,Prim 邻接表存边时可以在松弛判断里自动选择最小的那条;Kruskal 则在排序之后自然只处理最小的那条,多余的不会影响答案,只是多花一点排序时间。

5. 两种算法怎么选:对比与经验

5.1 不同图规模下的选型表

我整理了一个简单的选型表,查找时可以直接参考:

场景推荐算法时间复杂度原因
稀疏图,m接近nKruskalO(m log m)排序成本低,并查集极快
稠密图,m ≈ n^2朴素 PrimO(n^2)不需要维护堆,常数小
中密度图,n很大堆优化 PrimO(m log n)平衡边和点的扫描成本
需要输出具体选中的边KruskalO(m log m)边集天然有序,输出方便
图存在大量重边邻接表 Prim取决于版本自动按最小边权松弛

这不是绝对的。在实际比赛中,很多人统一用堆优化 Prim 也能打过所有数据。但若遇到n = 10000的稠密图,朴素O(n^2)只有10^8次操作,C++ 下能过,Python 可能会悬。反过来看,n = 10^5的稀疏图,Kruskal 几乎毫无压力。

5.2 我的选型习惯

我自己的偏好是:默认 Kruskal。原因有三个。

第一,Kruskal 的正确性驱动链路简单,边排序之后一路加,中途只需要一个并查集,不容易写错。第二,它在输出具体方案时非常自然,union成功的那条边就是树边,直接记录即可。第三,很多进阶题(比如求 MST 里最大边权的最小值、判断 MST 唯一性)都是基于 Kruskal 的按序处理能力,改动成本低。

Prim 我更多用在稠密图,尤其是题目给了一个邻接矩阵时。这时候你还要把矩阵转成边表去做 Kruskal,不仅多花O(n^2)的空间,排序的O(n^2 log n)也很浪费。直接用朴素 Prim 遍历矩阵,代码量还更短。

5.3 边界情况与变体

  • 不连通图:两种算法都应该在结束时判断有没有选够n - 1条边,没有就返回 -1。
  • 次小生成树:先求 MST,然后枚举每一条非树边,把它加进树里形成一个环,删掉环上除它之外最大的那条边,这样得到一个候选总权重。所有候选里最小的就是次小生成树。这个过程中,Kruskal 的输出能力太方便了。
  • 瓶颈生成树:最小化生成树里的最大边权。它并不要求总权重最小,而是要求最大的边尽量小。实际上任意一棵 MST 同时也是某一棵最小瓶颈生成树,但反过来不成立。一个经典做法是对边权做二分或直接用 MST。
  • 最小生成森林:如果图本身不连通,可以分别对每个连通分量求 MST,总权重求和。并查集的写法天然适合。

6. 实操中的常见坑与调试实录

6.1 并查集路径压缩和按秩合并缺一不可

很多新手写 Kruskal 时,并查集只写一个最简单版本:

def find(x): while parent[x] != x: x = parent[x] return x

在链状结构下,这个find会非常慢,每次都要走到树的最深处。数据量大时,明明排序没问题、逻辑也没问题,程序就是超时。你以为是算法复杂度不对,其实只是并查集没加路径压缩。

我一般的建议是:就算数据量小,也养成写路径压缩的习惯。这不是炫技,而是避免你在数据范围改大后突然翻车。

6.2 负权边能不能用 MST?

可以。MST 的定义只要求选出的树边权总和最小,不要求边权非负。Kruskal 和 Prim 都能处理负权边。

Prim 的朴素实现里,dist初始为无穷大,然后逐渐被负权边更新变小,完全没问题。唯一要注意的是,如果你用-1表示不连通,而图中恰好有负权边,就会出错。所以建议使用一个足够大的INF常量,而不是-1来当“无穷大”。

6.3 常见错误速查表

错误现象可能原因解决办法
结果偏大Prim 里把dist[v]写成dist[u] + w,沿用了 Dijkstra 写法改成min(dist[v], w)
死循环堆优化 Prim 里没判断visited,反复弹出同一个点visited[u] = True加在弹出点处
输出边数不够图不连通,或break条件写错循环结束后检查cnt == n - 1
排序后结果不对用元组(u, v, w)排序时没指定key使用lambda e: e[2]显式按权排序
内存爆炸稠密图用了边表加堆,重复元素过多改朴素 Prim 或限制堆里重复项
数据编号从 1 开始数组开小了,越界开n + 1大小的并查集和边表

6.4 自己踩过的两个教训

第一次写 Prim 堆优化版本时,我把dist更新写成了 Dijkstra 风格,结果对拍小数据时发现求出的总权重比真实 MST 大。后来我把dist定义为“到当前集合的最小边”,再重新看松弛代码,才发现我把两个算法混在一起了。这个错很典型,建议初学者把 Prim 的dist语义用荧光笔高亮,反复念三遍:它不属于某个点,它属于一个不断变大的集合。

另一次是在处理多重边时,我用邻接矩阵存图,结果两点之间后读入的边覆盖了先读入的边,漏掉了真正的最小边。后来改成存邻接表或用min合并,问题就解决了。所以在实现前,先明确输入会不会出现“同一对点之间有多条边”,如果可能,Kruskal 的排序天然会保留最小的那条,而邻接矩阵和邻接表都需要做最小值合并。

6.5 验证算法正确性的终极武器:对拍

当你怀疑自己写的 MST 算法有 bug,又找不出哪里错,最可靠的办法是写一个暴力对照。

对小数据量(比如n ≤ 8),可以枚举所有边集的子集,检查是不是一棵生成树(连通且无环),记录最小总权重。然后随机生成多组带权图,把暴力结果和 Kruskal / Prim 的结果做对比。如果有任何一组不一致,就手动打印图,逐步调试。

我记得给一个模拟项目写连通性验证脚本时,就是靠这个方法抓到一个隐藏 bug:我当时对“不连通图”的判断只放在了 Kruskal 的循环里,但 Prim 的朴素版没写u == -1的判断,结果在一个孤立点上返回了无穷大的总权重。对拍一跑立刻现形。

一点经验分享

最小生成树这两个算法,其实不需要背太多代码。你把切割定理和环性质想明白,把并查集写扎实,剩下就是语感问题。我在实际工作中用到它的场景远比想象中多,比如网络拓扑的连通性优化、传感器网络的节能路由设计,甚至某些资源调度问题都能抽象成边权模型。

最后再分享一个小习惯:拿到任何带权无向图题目,先问自己三个问题——图是否连通?有没有自环和重边?边权是否可能相同?这三个信息基本决定了你选用哪种算法,以及要不要做特殊判断。做完这些再动手写代码,效率会高很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询