cv.cm

编码 · 算法

最小生成树详解:Kruskal 与 Prim 算法

阅读约 6 分钟

简短回答

连通无向带权图的最小生成树(MST)是一组边:它把所有节点连通且不含环,并且总权最小。Kruskal 算法先把所有边排序,再借助并查集依次加入不构成环的最便宜的边。Prim 算法则从起点开始生长出一棵树,借助优先队列每次加入从树内连向树外的最便宜的边。两者的时间复杂度都是 O(E log E)。

最小生成树解决什么问题

假设你要用电缆连接几栋楼,用公路连接几个城镇,或用网络链路连接几台服务器,每条可能的连接都有成本。你希望所有地点(直接或间接)互相连通,且总成本最低。形成回路的多余连接永远不需要,所以答案是一棵树:V 个节点恰好由 V − 1 条边连接。这棵树就是最小生成树。

最小生成树不是最短路径树。它最小化的是所有边的总权,而不是从某个起点到其他节点的距离。

为什么贪心选择是对的:割性质

把节点分成任意两组。在横跨两组的边中,最轻的那条一定属于某棵最小生成树。两种算法都依赖这一事实:

算法贪心规则辅助数据结构
Kruskal(1956)按从轻到重的顺序取边;如果一条边连接了两个不同的连通分量,就保留它。并查集(不相交集合并)
Prim(1957;Jarník 也在 1930 年提出)生长一棵树;反复加入从树内通向树外节点的最轻的边。最小堆优先队列

如果所有边权互不相同,最小生成树就是唯一的,两种算法会返回同一棵树。边权有相同值时,可能存在多棵总权相同的最小生成树。

逐步演示一个例子

无向图含 5 个节点,边为 A–B 4、A–C 3、B–C 1、B–D 2、C–D 4、D–E 2、C–E 5。

Kruskal 算法,边按权重排序:

边权重决定之后的连通分量
B–C1加入{B, C} {A} {D} {E}
B–D2加入{B, C, D} {A} {E}
D–E2加入{B, C, D, E} {A}
A–C3加入{A, B, C, D, E}:已有 4 条边,完成
A–B, C–D, C–E4, 4, 5不需要(每条都会形成环)—

从 A 出发的 Prim 算法:离开 {A} 的最便宜的边是 A–C(3);从 {A, C} 出发是 C–B(1);从 {A, B, C} 出发是 B–D(2);从 {A, B, C, D} 出发是 D–E(2)。得到同一棵树,总权为 3 + 1 + 2 + 2 = 8。

Python 实现 Kruskal 算法

并查集为每个节点保存一个父指针。find 返回某节点所在连通分量的代表元,两个节点当且仅当代表元相同时才连通。

def kruskal(nodes, edges):
    parent = {n: n for n in nodes}

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

    tree, total = [], 0
    for w, u, v in sorted(edges):
        ru, rv = find(u), find(v)
        if ru != rv:                          # different components: no cycle
            parent[ru] = rv
            tree.append((u, v, w))
            total += w
    return tree, total

nodes = "ABCDE"
edges = [(4, "A", "B"), (3, "A", "C"), (1, "B", "C"), (2, "B", "D"),
         (4, "C", "D"), (2, "D", "E"), (5, "C", "E")]
print(kruskal(nodes, edges))
# ([('B', 'C', 1), ('B', 'D', 2), ('D', 'E', 2), ('A', 'C', 3)], 8)

完整的并查集还会使用按秩或按大小合并,以保持树较浅。仅用路径减半在实践中就已经很快。当树已有 V − 1 条边时,可以提前结束循环。

Python 实现 Prim 算法

Prim 算法需要邻接表。它像 Dijkstra 算法一样使用惰性删除:把每条离开树的边都压入堆,并跳过远端节点已在树中的边。

import heapq
from collections import defaultdict

def prim(edges, start):
    graph = defaultdict(list)
    for w, u, v in edges:
        graph[u].append((w, u, v))
        graph[v].append((w, v, u))
    in_tree = {start}
    heap = list(graph[start])
    heapq.heapify(heap)
    tree, total = [], 0
    while heap:
        w, u, v = heapq.heappop(heap)
        if v in in_tree:
            continue
        in_tree.add(v)
        tree.append((u, v, w))
        total += w
        for edge in graph[v]:
            if edge[2] not in in_tree:
                heapq.heappush(heap, edge)
    return tree, total

print(prim(edges, "A"))
# ([('A', 'C', 3), ('C', 'B', 1), ('B', 'D', 2), ('D', 'E', 2)], 8)

时间与空间复杂度

算法时间空间最适合
KruskalO(E log E)O(V + E)稀疏图,或边已经以列表形式给出的情况。排序占主导;并查集的每次操作近乎 O(1)。
Prim(二叉堆)O(E log V)O(V + E)以邻接表给出的图。
Prim(数组,无堆)O(V²)O(V)稠密图,例如所有点对之间都有边。

因为 E 至多为 V²,所以 log E 至多为 2 log V,O(E log E) 和 O(E log V) 是同一量级。

变体与常见错误

  • 非连通图:不存在生成树。此时 Kruskal 返回最小生成森林(每个连通分量一棵树);从单个起点出发的 Prim 只覆盖该起点所在的连通分量。请检查树是否有 V − 1 条边。
  • 最大生成树:把边按降序排序(或对权取负)。两种算法都允许负权。
  • 有向图需要另一种算法(最小树形图,Chu-Liu/Edmonds 算法);Kruskal 和 Prim 不适用。
  • 聚类:运行 Kruskal,剩下 k 个连通分量时停止,结果就是分成 k 组的单链接聚类。
  • Borůvka 算法(1926 年)同时为每个连通分量加入其最便宜的边,非常适合并行化。

何时使用及实际应用

  • 网络设计:只要任何连通方案都可接受,就用它找出用电缆、管道或公路连接各站点的最便宜方式。
  • 近似算法:对度量旅行商问题,最小生成树可给出长度不超过最优解两倍的环游路线。
  • 聚类与图像分割:切掉最小生成树中最重的边。
  • 面试题:连接所有点的最小费用、最低成本联通所有城市、优化水资源分配。

常见问题

Kruskal 算法和 Prim 算法有什么区别?

Kruskal 全局地从最轻到最重处理所有边,并用并查集合并连通分量。Prim 则借助优先队列从一个节点开始生长出一棵树。两者都能找到最小生成树。

最小生成树是唯一的吗?

所有边权都不同时是唯一的。边权有相同值时,可能存在多棵总权同样最小的树。

最小生成树和最短路径树是一回事吗?

不是。最小生成树最小化的是所有边的权之和,而最短路径树(例如 Dijkstra 算法得到的那棵)最小化的是从一个源点到每个节点的距离。这两棵树往往不同。

生成树有多少条边?

对于有 V 个节点的连通图,恰好有 V − 1 条边。边更少无法连通所有节点,边更多则会形成环。

全部编码教程