想象你是一位村长,管辖着六座散落在深山里的村庄。上级给了一笔修路款,要求是把所有村庄连通——哪怕绕道——但总造价必须最低。你面前的地图上标着每条候选路段的造价:有的要开山凿洞,贵;有的走平地,便宜。怎么选才能既连通又最省钱?这就是图论中著名的“最小生成树”问题。解决它有两套经典套路:Prim算法像“圈地扩张”——从自家门口出发,每次把最便宜能连进来的新村庄圈进来;Kruskal算法像“精打细算的采购员”——把所有候选路段按造价从低到高排好队,一条条看,能修就修,但绝不修出环路。这两种思路都是贪心算法的代表,也是网络设计、电路布线、AI聚类分析中常见的优化工具。

岗位关联

最小生成树是 AI 从业者理解图结构、贪心策略与聚类分析的必备基石,在网络优化、知识图谱等落地场景中频繁出现。

目录
  1. 可视化演示
  2. 一、概念与定义
  3. 二、Prim选点法
  4. 三、Kruskal选边法
  5. 四、算法对比与选择
  6. 五、实际应用
  7. 六、实现与优化
  8. AI 应用场景
  9. Python 演示
  10. 常见误区
  11. 面试题

可视化演示

通过Prim选点法与Kruskal选边法的动画演示,直观展示贪心策略构建MST的过程,对比两种算法的复杂度与适用场景。

本节配有交互动效(进入视口后自动加载,约 3KB)。

Prim:贪心选点扩展MST 从A出发逐步选最小权边扩展 绿=MST边 粉=候选边 T=O(V²)稠密 / O(ElgV)堆优化 Prim适合稠密图,每次选树内外最小权边

一、概念与定义

修路之前,村长得先看懂地图。地图上每个村庄是一个“顶点”,每一条候选路段是一条“边”,边上的数字就是修路造价。如果任意两个村庄之间都能通过若干条路互通,这张图就是“连通图”。在这张图上,哪怕删掉很多候选路段,只要保证所有村庄依然互相连通,剩下的路网仍然是一张连通图。

在候选路段中选出一部分来修,让所有村庄连通且不出现环路——这种结构在数学上正好是一棵树。图论把这种“连通所有顶点且无环”的子图称为生成树。一棵生成树恰好用最少的边把所有顶点串在一起,不多不少,刚刚好。

生成树有个铁律:n 个顶点的连通图,任意一棵生成树恰好有 n-1 条边。因为每多连通一个新顶点,只需要额外修一条路把它挂到已有网络里。

明白了这个铁律,村长心里就有底了:修 5 条路就能串起 6 个村庄。但省钱的学问在于——选哪 5 条?在所有可能的生成树中,边权总和最小的那一棵,就是最小生成树(MST)。

∣ET∣=∣V∣−1|E_T| = |V| - 1
一棵有 n 个顶点的树,恰好有 n-1 条边,这是树最核心的结构特征。
  1. 顶点数 |V|这里的 V 是全部顶点(村庄)的集合,|V| 表示顶点的总个数。
    ∣V∣|V|
  2. 边数 |E_T|E_T 是生成树中边的集合,|E_T| 表示生成树里的边数(修路条数)。
    ∣ET∣|E_T|
  3. 减一的含义第一个顶点不需要任何边就能“存在”,从第二个顶点起每加入一个顶点才需要一条新边,所以总数是 |V|-1。
    ∣V∣−1|V| - 1

二、Prim选点法

Prim 的风格像“圈地扩张”。村长先把 0 号村划进自己地盘,然后趴在地上数:从地盘里任意村庄出发,到地盘外任意村庄,所有候选路中哪条最便宜?修它!把新村庄圈进来,继续看下一轮。地盘像滚雪球一样越滚越大,直到 6 个村庄全部被圈进来。

每一步,算法都维护两个集合——已选入生成树的顶点集合 S,和未进入的顶点集合 V-S。它要做的,就是在所有“跨过集合边界”的边里挑一条权重最小的。这就是贪心:每次只看眼前最优,但最终结果却是全局最优。村庄之间候选道路越多,这种“从边界向外圈地”的方式越能发挥威力。

贪心选择的数学表达如下:

实现上,朴素写法用邻接矩阵保存边权,每轮扫描所有顶点找最小边,总复杂度 O(V²);更聪明的做法是用优先队列维护“边界候选边”,每次弹堆得到最小边,复杂度降到O(E log V)。Prim 特别适合边很多的稠密图——顶点虽多,但“从已圈区域向外延伸”的过程非常稳定。

min⁡u∈S, v∉Sw(u,v)\min_{u \in S,\ v \notin S} w(u, v)
每一轮都从“已选集合”与“未选集合”之间的所有边中,挑出权重最小的一条。
  1. 已选集合 SS 中存放的是已经被圈进地盘的村庄顶点,初始时只有起点。
    SS
  2. 未选集合 V \ SV \ S 表示还没被圈进来的顶点集合,V 减掉 S。
    V∖SV \setminus S
  3. 跨边界边 w(u,v)u 在 S 里、v 在 V \ S 里的边,代表一条从地盘通往外界的最短候选连接。
    w(u,v)w(u, v)
  4. 取最小值 min遍历所有这样的边,选出权重最小的那条,并把它对应的新顶点并入 S。
    min⁡u∈S, v∉Sw(u,v)\min_{u \in S,\ v \notin S} w(u, v)

三、Kruskal选边法

Kruskal 则像位精打细算的采购员。他把所有候选路段按造价从低到高排成一条长队,从最便宜的看起:这条路段的两端村庄目前还不连通?修!已经连通?跳过!理由很简单——修了就绕圈,纯属浪费。采购员头也不抬,一路看下去,直到修满 5 条路把 6 个村庄全部连通。

怎么快速判断两端是否连通?答案是并查集。它把每个村庄看作一个集合,初始时各自为政。处理边 (u, v) 时,若 find(u) 不等于 find(v),说明两边还不通,可以修路;修完后把两个集合合并。并查集配合路径压缩,判断一次连通性几乎只要常数时间。

判环与选边的规则可以写成:

采购员从最便宜的慢慢看,修满 n-1 条路就收工。他从不关心连接顺序,只关心“这条边的价签”。所以当图里边的数量远少于点对数量(稀疏图)时,Kruskal 的排序选边法既简单又高效。

选择边(u,v)  ⟺  find(u)≠find(v)\text{选择边}(u,v) \iff \text{find}(u) \neq \text{find}(v)
只有当两个端点分属不同集合(尚未连通)时,这条边才被选入生成树;选入后立即合并两个集合。
  1. find(u)返回 u 所在集合的“代表元素”,也就是 u 的根节点。
    find(u)\text{find}(u)
  2. 不等于 \neq两个端点的代表元素不同,说明它们此刻不在同一个连通块里,连上不会有环。
    find(u)≠find(v)\text{find}(u) \neq \text{find}(v)
  3. 双向等价 \iff这个规则是双向的——不连通才选,选了就马上合并,二者一一对应。
      ⟺  \iff
  4. 选择边 (u,v)满足条件时把这条边放进生成树集合 E_T,并更新总权重。
    选择边(u,v)\text{选择边}(u,v)

四、算法对比与选择

同样是修路,工具要按场景选。如果候选路段密密麻麻,几乎每个村庄之间都有路可选,这叫稠密图;反之如果只有零零散散几条路,叫稀疏图。村长和采购员各有各的强项,关键看地图长什么样。

两种算法的复杂度对比是核心决策依据:

当图稠密时 E 接近 V²,Kruskal 的 E log E 会显著变大,Prim 的邻接矩阵版 O(V²) 反而更稳定;当图稀疏时 E 接近 V,两者差别不大,但 Kruskal 实现简单、只需边集,通常更受工程偏爱。就像搬家:东西少叫小货车,东西多叫大卡车——不是谁好谁坏,而是看你的图是“瘦”还是“胖”。

TPrim=O(Elog⁡V),TKruskal=O(Elog⁡E)T_{\mathrm{Prim}} = O(E \log V), \qquad T_{\mathrm{Kruskal}} = O(E \log E)
Prim 用堆优化后的瓶颈在每条边入堆出堆;Kruskal 的瓶颈在把所有边排一次序。
  1. 边数 E图中的候选边总数,即村庄之间所有可修路段的条数。
    EE
  2. 顶点数 V图中的顶点总数,即村庄数量。
    VV
  3. O(E log V)Prim 每次从堆里弹出最小边、更新邻居权值,总共处理 O(E) 次堆操作,每次 O(log V)。
    O(Elog⁡V)O(E \log V)
  4. O(E log E)Kruskal 需要先对所有边按权重排序,排序的复杂度决定了总复杂度。
    O(Elog⁡E)O(E \log E)

五、实际应用

最小生成树可不只用来给村庄修路。凡是“互相连通 + 总成本最低”的问题,它都能插一脚:通信基站之间拉光纤、数据中心布网线、城市之间铺自来水管,都能套用这棵“省钱的树”。它的应用场景往往藏在我们看不见的基建图纸里。

在 AI 领域,MST 是单链接聚类的核心工具:把数据点当村庄、点间距离当造价,先构建一棵最小生成树,再砍断最长的若干条边,剩下的连通块就是簇。它不像 K-means 那样要求簇是“圆球状”,能识别任意形状的簇,因此在图像分割、社区发现中很有价值。

刻画 MST 与聚类关系的核心性质是切割性质:

切割性质像“围墙选址”的智慧:无论你在地图上怎么画圈,从圈内跨到圈外的最小造价道路,一定值得出现在某个最优方案里。正是这条性质,从根本上保证了贪心算法每一步都不会选错边。

若 e 是割 (S,V∖S) 上的最短跨边,则 e 属于某棵MST\text{若 } e \text{ 是割 } (S, V \setminus S) \text{ 上的最短跨边,则 } e \text{ 属于某棵MST}
无论你怎么把顶点集合分成两拨,跨越两拨之间权重最小的那条边,一定值得出现在某棵最小生成树里。
  1. 割 (S, V \ S)把顶点集合任意分成 S 和 V \ S 两拨,就像在地图上画一道围墙。
    (S,V∖S)(S, V \setminus S)
  2. 跨边一条边两端分别落在两拨里,就称为跨过这道割的边。
    跨边\text{跨边}
  3. 最短跨边所有跨边中权重最小的一条,是贪心算法最想抓的那条。
    最短跨边\text{最短跨边}
  4. 属于某棵MST它不保证出现在每一棵 MST 里,但至少存在一棵最优生成树包含它。
    属于某棵MST\text{属于某棵MST}

六、实现与优化

拿到算法思路后,真刀真枪地写代码,还需要选对“工具箱”。Prim 的关键动作是“快速取出当前最小边”——优先队列(堆)是天然选择;Kruskal 的关键动作是“快速判断两点是否连通”——并查集上场。选对数据结构,代码的简洁度和性能截然不同。

并查集的核心是 find 函数,它返回节点所在集合的代表。朴素实现可能很慢,但配合路径压缩——找到祖先顺便把沿途节点直接挂到祖先下面——之后每次查询几乎都是 O(1)。递推式如下:

工程实现中,Prim 用邻接表存图、用堆存 (distance, vertex) 对;Kruskal 用边集三元组 (u, v, w) 排序,再配合并查集判环。若图本身不连通,两种算法都应在选不满 V-1 条边时返回“不存在最小生成树”。选对了工具,村长和采购员——也就是 Prim 与 Kruskal——都能把修路这件事办得又快又稳。

find(x)={x,if parent[x]=xfind(parent[x]),otherwise\mathrm{find}(x) = \begin{cases} x, & \text{if } parent[x] = x \\ \mathrm{find}(parent[x]), & \text{otherwise} \end{cases}
find 沿着 parent 链向上找根节点,并在回溯时把路径上所有节点直接挂到根上,这就是路径压缩。
  1. parent[x] = x如果 x 的父亲就是它自己,说明 x 是集合的代表(根节点)。
    if parent[x]=x\text{if } parent[x] = x
  2. 否则向上找否则沿着 parent 链递归寻找根节点。
    find(parent[x])\text{find}(parent[x])
  3. 路径压缩递归返回时把 parent[x] 直接更新为根,下次查找一步到位。
    parent[x]=find(x)\text{parent}[x] = \mathrm{find}(x)
  4. 按秩合并union 时把“矮树”挂到“高树”下面,保证树高始终很小。
    union(x,y)\text{union}(x, y)

AI 应用场景

场景一:AI 无监督聚类中的任意形状分簇

阶段:无监督学习 / 数据探索与预处理

解决的问题:K-means 假设簇是凸的、近似球形,面对真实世界中条带状、半月形等任意形状的数据时效果很差。MST 聚类只依赖顶点间距离构建最小生成树,再切断最长边形成簇,无需预设簇数量,就能发现任意形状的自然分组。

具体案例:电商平台对用户行为向量(点击序列、购买间隔、浏览时长)计算相似度构建加权图,用 Kruskal 构建 MST 后切断最长边,自动把用户分成高活跃、价格敏感、沉睡唤醒等自然群组,为后续个性化推荐提供输入。

场景二:AI 训练集群的数据中心布线

阶段:AI 基础设施 / 系统架构设计

解决的问题:大模型训练集群有几十台 GPU 服务器需要高速互联,光纤和交换机端口成本高昂。传统人工布线容易形成冗余环路、浪费端口。MST 给出的拓扑能保证所有节点连通,同时让光纤总成本最低。

具体案例:某大模型团队规划 32 台 GPU 服务器的 InfiniBand 网络,把服务器当顶点、候选光纤链路当边,用 Prim 算法生成最小生成树拓扑,在保证全连通的前提下比人工方案节省约 18% 的布线成本。

场景三:医学图像分割的区域合并

阶段:计算机视觉预处理

解决的问题:像素级分割直接丢给深度学习模型噪声大、计算量高。先用 MST 把相似像素聚成超像素块,再交给模型精分割,可以显著降低计算量并抑制噪声。

具体案例:在肝脏 CT 影像分割中,将相邻像素的灰度差作为边权构建小规模 MST,按切割性质合并相似区域,生成超像素后输入 U-Net 模型,分割精度提升的同时推理时间缩短 30%。

Python 演示

python77 行
import heapq  class UnionFind:    def __init__(self, n):        self.parent = list(range(n))        self.rank = [0] * n     def find(self, x):        if self.parent[x] != x:            self.parent[x] = self.find(self.parent[x])        return self.parent[x]     def union(self, x, y):        rx = self.find(x)        ry = self.find(y)        if rx == ry:            return False        if self.rank[rx] < self.rank[ry]:            self.parent[rx] = ry        elif self.rank[rx] > self.rank[ry]:            self.parent[ry] = rx        else:            self.parent[ry] = rx            self.rank[rx] += 1        return True  def kruskal(vertices, edges):    uf = UnionFind(len(vertices))    mst = []    total = 0    for u, v, w in sorted(edges, key=lambda x: x[2]):        if uf.union(u, v):            mst.append((u, v, w))            total += w    return mst, total  def prim(vertices, adj):    n = len(vertices)    visited = [False] * n    pq = [(0, 0, -1)]    mst = []    total = 0    while pq:        w, u, p = heapq.heappop(pq)        if visited[u]:            continue        visited[u] = True        total += w        if p != -1:            mst.append((p, u, w))        for v, w2 in adj[u]:            if not visited[v]:                heapq.heappush(pq, (w2, v, u))    return mst, total  if __name__ == '__main__':    vertices = [0, 1, 2, 3, 4, 5]    edges = [        (0, 1, 6), (0, 2, 3), (0, 4, 5),        (1, 2, 4), (1, 3, 7), (2, 3, 8),        (2, 4, 2), (2, 5, 5), (3, 5, 9), (4, 5, 4)    ]    mst_k, cost_k = kruskal(vertices, edges)    print('Kruskal 选中的边:', mst_k)    print('Kruskal 总造价:', cost_k)     adj = [[] for _ in vertices]    for u, v, w in edges:        adj[u].append((v, w))        adj[v].append((u, w))    mst_p, cost_p = prim(vertices, adj)    print('Prim 选中的边:', mst_p)    print('Prim 总造价:', cost_p)
目的:在同一份示例数据上分别跑 Kruskal 和 Prim,验证两种算法得到的最小生成树边集与总造价一致。
  1. sorted(edges, key=lambda x: x[2])按边权从小到大排序,这是 Kruskal 的核心第一步。
  2. uf.union(u, v)尝试合并两个端点所在集合,返回 False 说明成环、边被跳过。
  3. heapq.heappop(pq)从优先队列取出当前最小边,Prim 每次扩张都靠它。
  4. if visited[u]: continue跳过已选入生成树的顶点,避免重复处理。
  5. mst.append((p, u, w))记录选中边的两个端点和权重,构成最终生成树。

常见误区

误区我看到贪心算法每次都选最小的边,那是不是任何一条边只要权重小,就一定会被选进最小生成树?图中全局最小的那条边,肯定在 MST 里吧?

正解不一定。Kruskal 跳过的那条 w=5 的边,虽然比后来选中的 w=7 的边更便宜,但它会让已有的路网成环,所以被放弃了。MST 关注的是“全局无环且连通”,单一一条边不论多小,只有在不破坏树结构的前提下才值得保留。全局最小边一定属于某棵 MST(切割性质),但绝不保证出现在每一棵 MST 里。

为何常见:贪心算法“每步选最优”的印象太强,容易让初学者误以为“每一条被选中的边都是全局最小”,从而忽略“不能成环”这个硬约束。

误区两个算法最后都得到同一棵生成树,不都是贪心嘛,那用哪个差别不大吧?我随便选一个就行。

正解差别很大。Prim 是点扩张:从起点出发,维护“已选集合”的边界,每次并入一个新顶点,天然适合稠密图;Kruskal 是边排序:把所有边按权重全局排序后逐条判断,不依赖起点,适合稀疏图。在极端规模下(比如 10 万顶点、500 万条边),选错算法可能让运行时间从几分钟变成几小时。

为何常见:两者结果相同且都是贪心,初学者在小图上跑不出性能差异,容易产生“等价”的错觉。

误区最小生成树让整个网络总代价最小,那树里两个村庄之间的路,应该也是这两点之间最短路吧?毕竟树都最优了。

正解MST 保证的是全树总权重最小,完全不保证任意一对顶点之间的路径最短。比如从 A 到 C 可能走 A-B-C 比 A-D-C 长很多,但为了整体总造价最低,绕一点也值得。最短路径树(如 Dijkstra 生成的树)和 MST 是两种不同的优化目标,不能混为一谈。

为何常见:日常语言里“总成本最低”和“每段路都近”容易被混在一起理解,但在图论里这是两个不同的优化问题。

误区邻接矩阵写起来最简单,二维数组存边权、两层循环扫一遍,不用学并查集也不用排序,那我所有场景都用它不就好了?

正解邻接矩阵的大小是 V²,不管图里实际有多少条边,遍历和初始化都要付出 V² 的代价。对 10 万顶点的稀疏图,V² = 100 亿,完全不可行;此时 Kruskal 只需要对约 10 万条边排序,轻松几个数量级。数据结构选型必须配合图的稀疏程度,否则代码跑不起来。

为何常见:初学者在几十个顶点的玩具图上测试,感受不到任何性能压力,一旦遇到真实工业级数据就立刻崩溃,这是典型的“小图错觉”。

面试题

Q1. 一棵有 10 个顶点的生成树,一共有多少条边?

单选题 · easy

  • A.9← 正确答案
  • B.10
  • C.11
  • D.取决于边的权重

答案:A。生成树是连通所有顶点且无环的子图,边数恒等于顶点数减一。10 个顶点就是 9 条边。这道题直接考察树的边数规律,与权重无关,选 A。

Q2. Prim 算法的基本策略是什么?

单选题 · easy

  • A.按边权排序,从小到大依次选边
  • B.从起点出发,每次选择连接新顶点的最小权值边← 正确答案
  • C.随机选边直到图连通
  • D.先求最短路径再删边

答案:B。Prim 是“圈地扩张”式贪心:维护已选顶点集合,每次从跨越集合边界的所有边中选最小的那条,把新顶点并入。A 是 Kruskal 的策略,C 和 D 与 MST 无关,故选 B。

Q3. Kruskal 算法中用于判断“加入一条边是否会形成环”的数据结构是?

单选题 · mid

  • A.哈希表
  • B.优先队列
  • C.并查集← 正确答案
  • D.树状数组

答案:C。并查集可以快速判断两个端点是否已在同一连通块中,从而决定边是否成环。优先队列用于 Prim 取最小边,哈希表和树状数组不承担连通性判断职责,故选 C。

Q4. 关于 Kruskal 算法,下列说法正确的是?

多选题 · mid

  • A.需要对所有边按权重排序← 正确答案
  • B.每次选择一条不成环的最小边← 正确答案
  • C.时间复杂度与顶点数完全无关
  • D.在稠密图中比 Prim 更有优势

答案:AB。Kruskal 的第一步就是按权重排序,之后用并查集逐条选边,A、B 正确。复杂度 O(E log E) 与边数 E 有关,而 E 在完全图中约等于 V²,不能说与顶点数完全无关,C 错。稠密图中 E 很大,排序开销高,一般更推荐 Prim,D 错。

Q5. 在一个边数接近顶点数平方的稠密图中,更推荐使用哪种算法?

单选题 · mid

  • A.Kruskal
  • B.Prim(邻接矩阵版)← 正确答案
  • C.随机贪心
  • D.动态规划求全源最短路

答案:B。稠密图中 E≈V²,Kruskal 的 O(E log E) 会很大,而 Prim 邻接矩阵版稳定在 O(V²),更合适。Dijkstra 全源最短路解决的是另一类问题,故选 B。

Q6. 关于最小生成树的说法,正确的是?

多选题 · hard

  • A.当图中有等权边时,MST 可能不唯一← 正确答案
  • B.MST 中任意两点的路径一定是最短路径
  • C.同一张图的所有 MST 具有相同的总权重← 正确答案
  • D.全局最小权重的边必定出现在所有 MST 中

答案:AC。存在等权边时,替换某些边可以得到不同的 MST,A 对。所有 MST 都是最优解,总权重必然相同,C 对。MST 保证全树总权重最小,不保证点对路径最短,B 错。全局最小边只保证出现在某棵 MST 中(切割性质),不保证出现在所有 MST 里,D 错。

Q7. 用 Kruskal 算法处理 n 个顶点、m 条边的图,总时间复杂度为?

单选题 · hard

  • A.O(n²)
  • B.O(m log m)← 正确答案
  • C.O(n log m)
  • D.O(m²)

答案:B。Kruskal 的时间瓶颈是“对所有边排序”O(m log m) 加上并查集的近线性操作,总复杂度为 O(m log m)。A 是 Prim 邻接矩阵版的量级,C 和 D 均不符合,故选 B。

Q8. Prim 算法使用优先队列优化后,其时间复杂度为?

单选题 · mid

  • A.O(V²)
  • B.O(E log V)← 正确答案
  • C.O(V log E)
  • D.O(E)

答案:B。优先队列优化的 Prim 中,每条边至多入堆出堆一次,每次堆操作 O(log V),总复杂度 O(E log V)。A 是朴素邻接矩阵版的复杂度,故选 B。

Q9. 以下哪些场景适合用最小生成树求解?

多选题 · extreme

  • A.数据中心光纤布线,要求所有服务器连通且总造价最低← 正确答案
  • B.单链接聚类,根据数据点距离构建自然簇← 正确答案
  • C.PageRank 网页重要性排序
  • D.电路板上连通所有引脚且导线总长度最短← 正确答案

答案:ABD。A 和 D 是经典的“全连通 + 总成本最小”问题,正是 MST 的核心应用。B 利用 MST 切割性质做聚类,是 AI 领域的经典用法。C 的 PageRank 基于图的迭代传播思想,与最小生成树无关,故选除 C 外三项。

Q10. 在最小生成树问题中,贪心算法每一步选择边的依据是什么?

单选题 · easy

  • A.在不产生环的前提下选择当前最优的边← 正确答案
  • B.随机选择一条边
  • C.选择权重最大的边
  • D.选择端点度数最大的边

答案:A。Prim 和 Kruskal 都是贪心算法,核心都是在“不破坏树结构(不成环)”的前提下选择当前权重最小的边。随机选边或选最大边都无法保证总权重最小,故选 A。