高级 #algorithm#graph#mst

最小生成树(Kruskal, Prim)

最小生成树(MST)是连通无向图中边权和最小的生成树——Kruskal 排序选边(并查集),Prim 从点出发扩展(类似 Dijkstra)

🏗️ 给 7 个城市铺光缆——最少需要多少公里?

假设你的公司要在 7 个城市之间铺设光缆。任意两个城市之间都可以铺设,但成本(距离)不同。你想让所有城市都连通(任意两个城市之间都能通过光缆通信),同时总成本最低

你应该怎么铺?

这个问题就是 最小生成树(Minimum Spanning Tree, MST) 问题:在一个带权无向连通图中,找一个包含所有顶点的树,且所有边的权重之和最小

城市网络(数字是公里数):
   北京
  / | \
1   |5  2
/   |   \
天津─4─上海
 \  |  /
  3 |6
   \| /
   广州

最小生成树(总长度=1+2+3+4=10):
   北京
   |
   1
   |
   天津
   / \
  3   4
 /     \
广州    上海

🧭 类比:一个镇子要通自来水

镇长决定让全镇 10 个村子都通上自来水。主管道是从水厂铺出去的,但不用每个村子都直接从水厂拉管子——你可以在村子之间铺设支管,只要所有村子都连成一张”管道网”就行。

目标:总管道长度最短,同时每个村子都能用上水。

这就是一个 MST 问题——你不需要回路(多了就是浪费),只需要足够让所有点连通的边。


🌲 什么是一棵”生成树”?

生成树(Spanning Tree) = 包含图中所有顶点,且边数 = V-1 的树。一个连通图可以有多个不同的生成树。

最小生成树(Minimum Spanning Tree, MST) = 所有生成树中,边权和最小的那个。

连通图有 4 个顶点:
A ─5─ B
|  \  |
3    2  4
|     \ |
C ─6─ D

可能的生成树:
A─5─B     A─5─B     A─5─B
|         |   4    3    4
3         |   |    |    |
C         C   D    C    D
总长=8    总长=9   总长=12
                     ↑ 最小的是 8

✂️ Kruskal 算法——排序选边

核心思想

从短到长选边,不形成环就保留。

算法步骤

  1. 把所有边按权重从小到大排序
  2. 从最小权重边开始,如果加入这条边不会形成环,就选入 MST
  3. 重复直到选了 V-1 条边

过程演示

图:
A ─5─ B     边:(A,B,5), (A,C,3), (B,D,4), (C,D,6), (A,D,2)
|  \  |        排序:(A,D,2), (A,C,3), (B,D,4), (A,B,5), (C,D,6)
3    2  4
|     \ |
C ─6─ D

选边过程:
① 选 (A,D,2) → 不形成环 ✅  MST: A-D
② 选 (A,C,3) → 和 A-D 不形成环 ✅  MST: A-D, A-C
③ 选 (B,D,4) → 和 A-D 不形成环 ✅  MST: A-D, A-C, B-D
④ 选 (A,B,5) → A-B-D 会形成环 ❌ 跳过
⑤ 选 (C,D,6) → A-C-D 会形成环 ❌ 跳过

选了 3 条边 = V-1 = 3,停止。
MST: A-D(2), A-C(3), B-D(4) 总长=9

代码实现

Kruskal 需要能快速判断”加入一条边会不会形成环”——这用 并查集(Union-Find) 实现:

class UnionFind:
    def __init__(self, n):
        self.parent = list(range(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):
        px, py = self.find(x), self.find(y)
        if px != py:
            self.parent[px] = py
            return True  # 合并成功
        return False  # 已经在同一集合中(会形成环)

def kruskal(edges, n):
    """
    edges: [(u, v, weight), ...]
    n: 顶点数
    返回: MST 的边列表
    """
    edges.sort(key=lambda e: e[2])  # 按权重排序
    uf = UnionFind(n)
    mst = []
    
    for u, v, w in edges:
        if uf.union(u, v):  # 不会形成环
            mst.append((u, v, w))
            if len(mst) == n - 1:
                break
    
    return mst

复杂度

  • 排序:O(E log E)
  • 并查集操作:O(E α(V))(α 是阿克曼函数的反函数,近似常数)
  • 总体:O(E log E)

🌱 Prim 算法——从点出发扩展

核心思想

从任意一个顶点开始,每次选择连接”已在树中”和”不在树中”的最小权重边,把新顶点加入树。

和 Dijkstra 的对比

Prim 和 Dijkstra 非常像——唯一区别是 Prim 的”距离”是到 MST 的距离,不是到源点的距离:

# Dijkstra 更新的是到源点的距离:
new_dist = dist[current] + weight

# Prim 更新的是到 MST 的距离:
new_dist = weight  # 只看边的权重,不累加

代码实现

import heapq

def prim(graph, start=0):
    """
    graph: 邻接表 {v: [(neighbor, weight), ...]}
    返回: MST 的总权重
    """
    n = len(graph)
    visited = [False] * n
    # (权重, 顶点) — 最小堆
    pq = [(0, start)]
    total_weight = 0
    
    while pq:
        w, v = heapq.heappop(pq)
        if visited[v]:
            continue
        visited[v] = True
        total_weight += w
        
        for neighbor, weight in graph[v]:
            if not visited[neighbor]:
                heapq.heappush(pq, (weight, neighbor))
    
    return total_weight

复杂度

  • 用优先队列:O(E log V)
  • 用简单数组(密集图):O(V²)

⚔️ Kruskal vs Prim

维度KruskalPrim
核心操作选边选点
数据结构并查集优先队列
适用图稀疏图(边少)稠密图(边多)
复杂度O(E log E)O(E log V)
实现难度中等简单(类似 Dijkstra)

选择原则

# 稀疏图(E ≈ V) → Kruskal
# 比如:V=10000, E=15000
# Kruskal: 15000 log 15000 ≈ 15000 × 14 = 210k

# 稠密图(E ≈ V²) → Prim
# 比如:V=1000, E=500000
# Prim: 500000 log 1000 ≈ 500000 × 10 = 5M (但可以用 O(V²) 实现)

🔍 验证 MST 的正确性

切分性质(Cut Property)是 MST 的理论基础:

在图中任意切一刀,把顶点分成两个集合。连接两个集合的所有边中,权重最小的那条一定属于某个 MST。

这个性质就是 Prim 和 Kruskal 正确的理论基础:

  • Kruskal 从小到大选边,如果它连接了不同的”连通分量”(跨越了切分),就选它
  • Prim 每次选跨越”已访问”和”未访问”的最短边

📝 小结

概念一句话
最小生成树(MST)包含所有顶点且边权和最小的树
Kruskal排序选边,并查集检查环——适合稀疏图
Prim从点出发扩展,每次选到 MST 最近的点——适合稠密图
切分性质MST 的正确性基础——最短的跨切边一定属于某个 MST
并查集快速判断两个顶点是否已连通

🎯 思考题:如果图中的边权有负数,MST 算法会受影响吗?会不会找到的”最小”生成树变成包含负权边更少的树?

为什么先学这个? 图算法到此结束。接下来回到基础话题——排序。先复习基础排序(插入、选择、冒泡),再学习更高效的高级排序(归并、快排、堆排)