第 18 章

最短路与最小生成树

第十八章:最短路与最小生成树

图的遍历(BFS/DFS)告诉我们"哪些节点可达",但在现实世界中,我们更关心的是代价:从 A 到 B 的最快路线是什么?连接所有城市的最便宜光纤网络怎么布?这两个问题分别对应图论中最经典的两大优化问题——最短路径(Shortest Path)最小生成树(Minimum Spanning Tree, MST)

这两个问题看起来类似(都是在带权图上找"最优"结构),但本质完全不同:最短路是源到目标的路径优化,MST 是全局连通的边集优化。它们的算法设计思路也截然不同——最短路依赖松弛(relaxation) 操作逐步逼近最优解,MST 依赖贪心(greedy) 策略逐步构建最优解。

这一章我们要彻底掌握五个核心算法:Dijkstra(单源最短路,非负权)、Bellman-Ford(单源最短路,允许负权)、Floyd-Warshall(全源最短路)、Prim(MST,从点出发)、Kruskal(MST,从边出发)。每个算法我们都会回答:它解决什么问题?它为什么正确?它的时间复杂度是多少?什么场景下应该选择它?


Level 1 · 你需要知道的

18.1 问题定义

单源最短路径(Single-Source Shortest Path, SSSP):给定一个带权有向图 G=(V, E) 和源节点 s,求 s 到所有其他节点的最短路径距离。

全源最短路径(All-Pairs Shortest Path, APSP):求所有节点对之间的最短路径距离。

最小生成树(Minimum Spanning Tree, MST):给定一个带权无向连通图 G=(V, E),找到一棵包含所有节点的树,使得边权之和最小。

带权有向图示例(SSSP):

    A --2--> B --3--> D
    |        ^        ^
    4        1        2
    |        |        |
    v        |        |
    C --1--> B'  E ---+
         \            ^
          5           |
           \          |
            +--> E ---+

带权无向连通图示例(MST):

    A ---4--- B
    |  \      |  \
    1    3    6    5
    |      \  |      \
    C ---2--- D ---7--- E

关键区别

18.2 Dijkstra 算法

适用条件:边权非负的图(无向/有向均可)。

核心思想:维护一个"已确定最短距离"的节点集合 S,每次从未确定的节点中选择距离最小的节点 u,将其加入 S,然后用 u 的出边去松弛(relax) 其邻居的距离。

松弛操作:如果 dist[u] + w(u,v) < dist[v],则更新 dist[v] = dist[u] + w(u,v)

import heapq
from typing import List, Tuple, Dict

def dijkstra(graph: Dict[int, List[Tuple[int, int]]], source: int, n: int) -> List[float]:
    """
    Dijkstra 算法 - 优先队列实现
    
    参数:
        graph: 邻接表,graph[u] = [(v, weight), ...]
        source: 源节点
        n: 节点数量 (0 到 n-1)
    
    返回:
        dist: dist[i] 表示 source 到节点 i 的最短距离
    """
    dist = [float('inf')] * n
    dist[source] = 0
    
    # 优先队列: (距离, 节点)
    pq = [(0, source)]
    
    while pq:
        d, u = heapq.heappop(pq)
        
        # 关键优化:如果取出的距离大于已知最短距离,跳过
        if d > dist[u]:
            continue
        
        # 松弛所有邻居
        for v, weight in graph[u]:
            new_dist = dist[u] + weight
            if new_dist < dist[v]:
                dist[v] = new_dist
                heapq.heappush(pq, (new_dist, v))
    
    return dist

使用示例

# 构建图
graph = {
    0: [(1, 2), (2, 4)],  # 0->1 权重2, 0->2 权重4
    1: [(2, 1), (3, 7)],  # 1->2 权重1, 1->3 权重7
    2: [(3, 3)],           # 2->3 权重3
    3: []
}

dist = dijkstra(graph, 0, 4)
print(dist)  # [0, 2, 3, 6]
# 解释: 0->0=0, 0->1=2, 0->1->2=3, 0->1->2->3=6

时间复杂度:O((V + E) log V)。每个节点最多入队 V 次(实际远少于此),每次入队/出队 O(log V)。

常见错误

# 错误1:图中有负权边时使用 Dijkstra
graph = {0: [(1, -3), (2, 2)], 1: [(2, 1)], 2: []}
# Dijkstra 会得到 dist[2] = 2(直接走 0->2)
# 但实际最短路是 0->1->2 = -3+1 = -2
# 因为 Dijkstra 一旦确定节点就不再更新!

# 错误2:忘记跳过过期的队列条目
def dijkstra_wrong(graph, source, n):
    dist = [float('inf')] * n
    dist[source] = 0
    pq = [(0, source)]
    while pq:
        d, u = heapq.heappop(pq)
        # 没有 if d > dist[u]: continue
        # 导致重复处理已确定的节点,虽然结果正确但时间大增
        for v, w in graph[u]:
            if dist[u] + w < dist[v]:
                dist[v] = dist[u] + w
                heapq.heappush(pq, (dist[v], v))
    return dist

18.3 Bellman-Ford 算法

适用条件:允许负权边的图。可以检测负环(从源可达的负权环)。

核心思想:对所有边重复执行松弛操作 V-1 次。因为最短路径最多包含 V-1 条边(无环情况下),所以 V-1 轮松弛一定能找到所有最短路径。

def bellman_ford(edges: List[Tuple[int, int, int]], source: int, n: int) -> Tuple[List[float], bool]:
    """
    Bellman-Ford 算法
    
    参数:
        edges: 边列表 [(u, v, weight), ...]
        source: 源节点
        n: 节点数量
    
    返回:
        (dist, has_negative_cycle): 距离数组和是否存在负环
    """
    dist = [float('inf')] * n
    dist[source] = 0
    
    # V-1 轮松弛
    for i in range(n - 1):
        updated = False
        for u, v, w in edges:
            if dist[u] != float('inf') and dist[u] + w < dist[v]:
                dist[v] = dist[u] + w
                updated = True
        # 提前终止优化:如果某轮没有任何更新,已收敛
        if not updated:
            break
    
    # 第 V 轮检测负环
    for u, v, w in edges:
        if dist[u] != float('inf') and dist[u] + w < dist[v]:
            return dist, True  # 存在负环
    
    return dist, False

为什么 V-1 轮就够了? 如果最短路径经过 k 条边,那么第 k 轮松弛时它一定被正确计算出来。一条简单路径最多经过 V-1 条边(经过所有 V 个节点),所以 V-1 轮足够。

时间复杂度:O(VE)。V-1 轮,每轮遍历所有 E 条边。

使用示例

edges = [
    (0, 1, 4),
    (0, 2, 5),
    (1, 2, -3),  # 负权边
    (2, 3, 4),
    (3, 1, -2),  # 注意:1->2->3->1 构成环,权重 = -3+4-2 = -1 < 0,负环!
]

dist, has_neg_cycle = bellman_ford(edges, 0, 4)
if has_neg_cycle:
    print("图中存在负环,最短路径无定义")
else:
    print(dist)

18.4 Floyd-Warshall 算法

适用条件:全源最短路径。允许负权边,但不能有负环。

核心思想:动态规划。定义 dp[k][i][j] = 从 i 到 j 只经过编号 ≤ k 的中间节点的最短路径。转移方程:

dp[k][i][j] = min(dp[k-1][i][j], dp[k-1][i][k] + dp[k-1][k][j])

要么不经过节点 k(继承 dp[k-1][i][j]),要么经过节点 k(i→k 和 k→j 的路径拼接)。

def floyd_warshall(n: int, edges: List[Tuple[int, int, int]]) -> List[List[float]]:
    """
    Floyd-Warshall 全源最短路径
    
    参数:
        n: 节点数量
        edges: 边列表 [(u, v, weight), ...]
    
    返回:
        dist: dist[i][j] 表示 i 到 j 的最短距离
    """
    INF = float('inf')
    dist = [[INF] * n for _ in range(n)]
    
    # 初始化
    for i in range(n):
        dist[i][i] = 0
    for u, v, w in edges:
        dist[u][v] = min(dist[u][v], w)  # 处理重边
    
    # DP: 逐步加入中间节点
    for k in range(n):
        for i in range(n):
            for j in range(n):
                if dist[i][k] + dist[k][j] < dist[i][j]:
                    dist[i][j] = dist[i][k] + dist[k][j]
    
    return dist

时间复杂度:O(V³)。空间优化后为 O(V²)(原地更新 dist 矩阵)。

适用场景

18.5 最小生成树:Prim 算法

适用条件:带权无向连通图。

核心思想:从任意起点开始,每次选择连接"已在树中的节点"和"不在树中的节点"的最小权边,将其加入 MST。这是一种贪心策略。

def prim(graph: Dict[int, List[Tuple[int, int]]], n: int) -> Tuple[int, List[Tuple[int, int, int]]]:
    """
    Prim 算法 - 优先队列实现
    
    参数:
        graph: 无向图邻接表,graph[u] = [(v, weight), ...]
        n: 节点数量
    
    返回:
        (total_weight, mst_edges): MST 总权重和边列表
    """
    visited = [False] * n
    mst_edges = []
    total_weight = 0
    
    # (权重, 目标节点, 来源节点)
    pq = [(0, 0, -1)]  # 从节点 0 开始
    
    while pq and len(mst_edges) < n - 1:
        weight, u, parent = heapq.heappop(pq)
        
        if visited[u]:
            continue
        
        visited[u] = True
        total_weight += weight
        if parent != -1:
            mst_edges.append((parent, u, weight))
        
        for v, w in graph[u]:
            if not visited[v]:
                heapq.heappush(pq, (w, v, u))
    
    return total_weight, mst_edges

时间复杂度:O((V + E) log V)(用二叉堆),O(E + V log V)(用 Fibonacci 堆)。

18.6 最小生成树:Kruskal 算法

核心思想:将所有边按权重排序,从小到大依次考虑每条边。如果加入这条边不会形成环(用并查集判断),就加入 MST。

class UnionFind:
    """并查集 - 路径压缩 + 按秩合并"""
    def __init__(self, n: int):
        self.parent = list(range(n))
        self.rank = [0] * n
    
    def find(self, x: int) -> int:
        if self.parent[x] != x:
            self.parent[x] = self.find(self.parent[x])
        return self.parent[x]
    
    def union(self, x: int, y: int) -> bool:
        rx, ry = self.find(x), self.find(y)
        if rx == ry:
            return False  # 已连通,加入会形成环
        if self.rank[rx] < self.rank[ry]:
            rx, ry = ry, rx
        self.parent[ry] = rx
        if self.rank[rx] == self.rank[ry]:
            self.rank[rx] += 1
        return True


def kruskal(n: int, edges: List[Tuple[int, int, int]]) -> Tuple[int, List[Tuple[int, int, int]]]:
    """
    Kruskal 算法
    
    参数:
        n: 节点数量
        edges: 边列表 [(u, v, weight), ...]
    
    返回:
        (total_weight, mst_edges): MST 总权重和边列表
    """
    # 按权重排序
    edges.sort(key=lambda e: e[2])
    
    uf = UnionFind(n)
    mst_edges = []
    total_weight = 0
    
    for u, v, w in edges:
        if uf.union(u, v):
            mst_edges.append((u, v, w))
            total_weight += w
            if len(mst_edges) == n - 1:
                break
    
    return total_weight, mst_edges

时间复杂度:O(E log E)(排序主导),即 O(E log V)(因为 E ≤ V²,所以 log E = O(log V))。

Prim vs Kruskal 快速选择

场景 推荐算法 原因
稠密图(E 接近 V²) Prim 不需要排序所有边
稀疏图(E 接近 V) Kruskal 排序代价低,并查集简单
已有并查集实现 Kruskal 代码更简洁
需要在线添加节点 Prim 方便从当前树扩展
面试默写 Kruskal 实现更短,不容易出错

18.7 完整示例:从零构建

"""
综合示例:一个城市道路网络
"""

def demo():
    # 城市网络:6 个城市,双向道路
    # 边: (城市A, 城市B, 距离)
    roads = [
        (0, 1, 7), (0, 2, 9), (0, 5, 14),
        (1, 2, 10), (1, 3, 15),
        (2, 3, 11), (2, 5, 2),
        (3, 4, 6),
        (4, 5, 9)
    ]
    n = 6
    
    # 1. 单源最短路 (Dijkstra)
    graph = {i: [] for i in range(n)}
    for u, v, w in roads:
        graph[u].append((v, w))
        graph[v].append((u, w))  # 无向图
    
    dist = dijkstra(graph, 0, n)
    print("从城市0出发的最短距离:", dist)
    # [0, 7, 9, 20, 20, 11]
    
    # 2. 全源最短路 (Floyd-Warshall)
    # 无向图需要添加双向边
    directed_edges = []
    for u, v, w in roads:
        directed_edges.append((u, v, w))
        directed_edges.append((v, u, w))
    
    all_dist = floyd_warshall(n, directed_edges)
    print("城市2到城市4的最短距离:", all_dist[2][4])
    
    # 3. 最小生成树 (Kruskal)
    total, mst = kruskal(n, roads[:])  # 传入副本因为会排序
    print(f"连接所有城市的最小道路总长: {total}")
    print("选择的道路:", mst)

demo()

Level 2 · 它是怎么运行的

18.8 Dijkstra 的正确性证明

定理:对于边权非负的图,Dijkstra 算法正确计算所有从源可达节点的最短距离。

证明(归纳法)

设 S 是已确定最短距离的节点集合。我们证明:每次将节点 u 加入 S 时,dist[u] 确实是 s 到 u 的最短距离。

基础情况:源节点 s 加入 S 时,dist[s] = 0,正确。

归纳步骤:假设 S 中所有节点的距离都已正确计算。设 u 是下一个被选中的节点(dist 值最小的未确定节点)。

反证法:假设存在一条更短的路径 P: s → ... → x → y → ... → u,其中 x ∈ S,y ∉ S。

因此 dist[u] 就是最短距离。

为什么负权边会破坏正确性? 如果存在边权为负的边 (y, ..., u) 上有负权边,那么 "s → ... → x → y → ... → u" 的长度可能小于 "s → ... → x → y"(后面的边减小了总距离),此时 dist[y] ≤ dist[u] 不再成立,归纳假设被破坏。

18.9 SPFA:Bellman-Ford 的队列优化

SPFA(Shortest Path Faster Algorithm)是 Bellman-Ford 的实用优化。核心观察:如果节点 u 的 dist 值在某轮没有被更新,那么 u 的出边在下一轮不可能产生新的松弛。所以我们只对"刚被更新过的节点"做松弛,用队列维护这些节点。

from collections import deque

def spfa(graph: Dict[int, List[Tuple[int, int]]], source: int, n: int) -> Tuple[List[float], bool]:
    """
    SPFA (Shortest Path Faster Algorithm)
    Bellman-Ford 的队列优化版本
    
    返回:
        (dist, has_negative_cycle)
    """
    dist = [float('inf')] * n
    dist[source] = 0
    
    in_queue = [False] * n
    count = [0] * n  # count[v] = v 入队次数,超过 n-1 次说明有负环
    
    queue = deque([source])
    in_queue[source] = True
    
    while queue:
        u = queue.popleft()
        in_queue[u] = False
        
        for v, w in graph[u]:
            if dist[u] + w < dist[v]:
                dist[v] = dist[u] + w
                if not in_queue[v]:
                    queue.append(v)
                    in_queue[v] = True
                    count[v] += 1
                    if count[v] >= n:
                        return dist, True  # 负环
    
    return dist, False

复杂度分析

SLF(Shortest Label First)优化:将新入队的节点与队首比较,如果 dist 更小就插入队首而非队尾。

# SLF 优化
if not in_queue[v]:
    if queue and dist[v] < dist[queue[0]]:
        queue.appendleft(v)  # 插入队首
    else:
        queue.append(v)      # 插入队尾
    in_queue[v] = True

18.10 负环检测的深入分析

方法一:Bellman-Ford 第 V 轮检测(前面已展示)

方法二:SPFA 入队次数检测 —— 如果某节点入队超过 V-1 次,说明存在负环。

方法三:DFS + 标记(检测图中任意位置的负环)

def detect_negative_cycle_dfs(graph: Dict[int, List[Tuple[int, int]]], n: int) -> bool:
    """
    检测图中是否存在负环(从任意位置出发)
    思路:添加一个虚拟源节点连接所有节点,权重为0
    """
    # 添加虚拟源节点 n,连接到所有节点
    edges = []
    for u in range(n):
        edges.append((n, u, 0))
        for v, w in graph[u]:
            edges.append((u, v, w))
    
    dist, has_neg_cycle = bellman_ford(edges, n, n + 1)
    return has_neg_cycle

实际应用:套汇检测。外汇市场中,汇率可以建模为图的边权(取对数后变为加法)。如果存在负环,意味着存在套利机会:按环路兑换一圈后能获得更多的起始货币。

import math

def detect_arbitrage(currencies: List[str], rates: List[List[float]]) -> bool:
    """
    检测外汇套利机会
    rates[i][j] = 1单位货币i 能兑换多少货币j
    
    取 -log 后,乘法变加法,>1 变 <0
    套利等价于负环
    """
    n = len(currencies)
    edges = []
    
    for i in range(n):
        for j in range(n):
            if i != j and rates[i][j] > 0:
                # -log(rate) 将乘法转为加法,rate>1 的边变成负权
                edges.append((i, j, -math.log(rates[i][j])))
    
    # 用 Bellman-Ford 检测负环
    dist = [0.0] * n  # 从所有节点同时开始(等价于虚拟源)
    
    for _ in range(n - 1):
        for u, v, w in edges:
            if dist[u] + w < dist[v]:
                dist[v] = dist[u] + w
    
    for u, v, w in edges:
        if dist[u] + w < dist[v]:
            return True  # 存在套利机会
    
    return False

18.11 Prim vs Kruskal:深入对比

理论基础——切割性质(Cut Property)

对于图的任意切割(将顶点分成两个非空集合 S 和 V\S),横跨切割的最小权边一定属于某个 MST。

为什么两者都正确? 因为都在利用切割性质。每一步选择的边都必须属于 MST。这是贪心正确性的数学保证。

实现复杂度对比

操作 Prim (二叉堆) Prim (Fibonacci堆) Kruskal
总时间 O((V+E) log V) O(E + V log V) O(E log E)
适用场景 稠密图 理论最优 稀疏图
代码复杂度
空间 O(V) O(V) O(V+E)

关键洞察:对于稠密图(E ≈ V²),Prim 为 O(V² log V) 而 Kruskal 为 O(V² log V),差别不大。但如果用邻接矩阵实现 Prim(不用堆,直接遍历找最小),Prim 是 O(V²),此时 Prim 明显优于 Kruskal。

def prim_dense(adj_matrix: List[List[float]], n: int) -> int:
    """
    Prim 的邻接矩阵实现 - 适用于稠密图
    时间 O(V²),不使用堆
    """
    INF = float('inf')
    in_mst = [False] * n
    min_edge = [INF] * n  # min_edge[v] = v 到 MST 集合的最小边权
    min_edge[0] = 0
    total = 0
    
    for _ in range(n):
        # 找到不在 MST 中的最小 min_edge 节点
        u = -1
        for v in range(n):
            if not in_mst[v] and (u == -1 or min_edge[v] < min_edge[u]):
                u = v
        
        in_mst[u] = True
        total += min_edge[u]
        
        # 更新邻居的 min_edge
        for v in range(n):
            if not in_mst[v] and adj_matrix[u][v] < min_edge[v]:
                min_edge[v] = adj_matrix[u][v]
    
    return total

18.12 路径还原

算法不仅要计算最短距离,还经常需要输出具体路径。方法是记录每个节点的前驱(predecessor)

def dijkstra_with_path(graph: Dict[int, List[Tuple[int, int]]], source: int, n: int):
    """Dijkstra + 路径还原"""
    dist = [float('inf')] * n
    dist[source] = 0
    prev = [-1] * n  # 前驱数组
    
    pq = [(0, source)]
    
    while pq:
        d, u = heapq.heappop(pq)
        if d > dist[u]:
            continue
        for v, w in graph[u]:
            if dist[u] + w < dist[v]:
                dist[v] = dist[u] + w
                prev[v] = u
                heapq.heappush(pq, (dist[v], v))
    
    return dist, prev


def reconstruct_path(prev: List[int], target: int) -> List[int]:
    """从前驱数组还原路径"""
    path = []
    node = target
    while node != -1:
        path.append(node)
        node = prev[node]
    path.reverse()
    return path


# 使用
dist, prev = dijkstra_with_path(graph, 0, 6)
path = reconstruct_path(prev, 5)
print(f"0 到 5 的最短路径: {' -> '.join(map(str, path))}")
print(f"距离: {dist[5]}")

18.13 算法选择决策树

面对最短路问题时,按以下顺序决策:

1. 是否需要所有点对的最短路?
   ├─ 是 → V ≤ 几百?
   │     ├─ 是 → Floyd-Warshall O(V³)
   │     └─ 否 → 运行 V 次 Dijkstra O(VE log V) 或 Johnson 算法
   └─ 否 → 单源最短路
         ├─ 有负权边?
         │   ├─ 是 → Bellman-Ford O(VE) 或 SPFA
         │   └─ 否 → Dijkstra O((V+E) log V)
         └─ 边权全为 1?
               └─ BFS O(V+E) (最简单最快)

Level 3 · 规范怎么定义的

18.14 Dijkstra 1959 年论文

论文:Edsger W. Dijkstra, "A Note on Two Problems in Connexion with Graphs", Numerische Mathematik, 1959.

这篇仅三页的短文解决了两个问题:(1) 最短路径,(2) 最小生成树。Dijkstra 在文中简洁地描述了算法,甚至没有给出正式证明——他认为正确性"显而易见"。

历史背景:Dijkstra 当时在阿姆斯特丹数学中心工作,这个算法的灵感来自于实际问题——为 ARMAC 计算机的演示设计一个程序,计算荷兰64个城市之间的最短铁路路线。

原文描述的算法(用现代术语重述):

  1. 将所有节点标记为"未访问",距离设为 ∞,源节点距离设为 0。
  2. 选择距离最小的未访问节点 u。
  3. 对 u 的所有邻居 v 执行松弛。
  4. 将 u 标记为"已访问"。
  5. 重复直到所有节点已访问。

Dijkstra 原文中没有使用优先队列——他用的是线性扫描找最小值,时间复杂度为 O(V²)。优先队列优化是后来的工作(Johnson 1977,Fredman & Tarjan 1987 的 Fibonacci 堆实现)。

趣事:Dijkstra 后来在一次采访中说:"这个算法花了我大约20分钟。有一天早上我在阿姆斯特丹和我年轻的未婚妻一起逛街,累了,我们就在咖啡厅的露台坐下喝咖啡,然后我就想到了这个算法。"

18.15 Bellman 1958 / Ford 1956

Richard Bellman 在 1958 年的论文 "On a Routing Problem" 中描述了这个算法,作为他发展动态规划理论的一部分。

Lester R. Ford Jr. 在 1956 年的 RAND Corporation 备忘录中独立描述了同样的算法。

实际上,Alfonso Shimbel 在 1955 年就描述了类似算法。所以这个算法有时也被称为 Bellman-Ford-Moore 算法(Edward F. Moore 在 1959 年也独立发现了它)。

Bellman 的动态规划视角:定义 d_k(v) = 从 s 到 v 的最多经过 k 条边的最短路径。递推关系:

d_k(v) = min(d_{k-1}(v), min_{(u,v)∈E} (d_{k-1}(u) + w(u,v)))

这正是 Bellman-Ford 算法的本质——一个动态规划的"按阶段松弛"过程。

最优性原理(Bellman, 1957):"最优策略的任何子策略也是最优的。" 应用到最短路:如果 s→...→u→v 是 s 到 v 的最短路径,那么 s→...→u 必然是 s 到 u 的最短路径。这是所有最短路算法正确性的基础。

18.16 Floyd 1962

论文:Robert W. Floyd, "Algorithm 97: Shortest Path", Communications of the ACM, 1962.

Floyd 的论文极其简短(不到一页),只给出了算法的三重循环实现。但其背后的思想——逐步放松"允许经过哪些中间节点"——是动态规划在图论中的经典应用。

与 Warshall 的关系:Stephen Warshall 在 1962 年发表的 "A Theorem on Boolean Matrices" 解决了传递闭包问题(判断节点间是否可达),使用了完全相同的三重循环结构。Floyd 将 Warshall 的布尔矩阵扩展为带权矩阵,将"可达性"推广为"最短距离"。

与 Kleene 算法的关系:更深层地看,Floyd-Warshall 本质上是 Kleene 在正则表达式/有限自动机理论中的算法在半环(semiring)上的推广。加法半环 (min, +) 上的 Kleene 算法就是 Floyd-Warshall;布尔半环 (∨, ∧) 上的就是 Warshall 的传递闭包。

18.17 Kruskal 1956 与 Prim 1957

Joseph B. Kruskal, "On the Shortest Spanning Subtree of a Graph and the Traveling Salesman Problem", Proceedings of the American Mathematical Society, 1956.

Kruskal 的论文不仅提出了 MST 算法,还讨论了与旅行商问题的关系。他证明了贪心策略的正确性:按边权从小到大选择不形成环的边。

Robert C. Prim, "Shortest Connection Networks And Some Generalizations", Bell System Technical Journal, 1957.

Prim 在 Bell 实验室工作时独立发现了这个算法。有趣的是,捷克数学家 Vojtěch Jarník 早在 1930 年就在论文 "O jistém problému minimálním" 中描述了本质相同的算法。因此这个算法有时也被称为 Jarník-Prim 算法或 DJP 算法(Dijkstra-Jarník-Prim,因为 Dijkstra 1959 年的论文也独立描述了它)。

切割性质的形式化证明(MST 正确性的基石):

定理(Cut Property):设 (S, V\S) 是图 G 的任意切割,e 是横跨该切割的唯一最小权边。则 e 属于 G 的每一个 MST。

证明:反证法。设 T 是一个 MST 且 e = (u,v) ∉ T。在 T 中,u 和 v 之间有一条路径 P(因为 T 是连通的)。P 必须横跨切割(因为 u ∈ S, v ∈ V\S),所以 P 上有一条横跨边 e' ≠ e。由于 e 是横跨切割的唯一最小权边,w(e) < w(e')。将 e' 从 T 中删除并加入 e,得到新的生成树 T'。w(T') = w(T) - w(e') + w(e) < w(T),与 T 是 MST 矛盾。■

推广:如果最小横跨边不唯一(有并列),则至少存在一个包含该边的 MST。

18.18 复杂度下界与最优算法

最短路径的复杂度下界

MST 的复杂度下界


Level 4 · 边界与陷阱

18.19 LeetCode 743:网络延迟时间

题目:有 n 个网络节点,给定有向边 times[i] = [u, v, w](从 u 到 v 传输时间为 w),从节点 k 发送信号,返回所有节点收到信号的最短时间。如果有节点不可达,返回 -1。

分析:典型的单源最短路问题。所有边权为正(传输时间),直接用 Dijkstra。

def networkDelayTime(times: List[List[int]], n: int, k: int) -> int:
    """
    LeetCode 743: Network Delay Time
    
    直接的 Dijkstra 应用
    """
    # 建图(注意节点编号从 1 开始)
    graph = {i: [] for i in range(1, n + 1)}
    for u, v, w in times:
        graph[u].append((v, w))
    
    # Dijkstra
    dist = {i: float('inf') for i in range(1, n + 1)}
    dist[k] = 0
    pq = [(0, k)]
    
    while pq:
        d, u = heapq.heappop(pq)
        if d > dist[u]:
            continue
        for v, w in graph[u]:
            if dist[u] + w < dist[v]:
                dist[v] = dist[u] + w
                heapq.heappush(pq, (dist[v], v))
    
    result = max(dist.values())
    return result if result < float('inf') else -1


# 测试
times = [[2,1,1],[2,3,1],[3,4,1]]
print(networkDelayTime(times, 4, 2))  # 2
# 从节点2出发: 2->1(1), 2->3(1), 2->3->4(2)
# 所有节点最远距离 = 2

复杂度:O((V + E) log V),其中 V = n, E = len(times)。

面试追问

18.20 LeetCode 1631:最小体力消耗路径

题目:给定一个 rows×columns 的高度矩阵 heights,从左上角 (0,0) 到右下角 (rows-1, columns-1),路径的"体力消耗"定义为路径上相邻格子高度差的绝对值的最大值。求最小体力消耗。

分析:这不是传统的"路径距离和最小"问题,而是"路径上最大边权最小"(minimax path)。但核心思路仍然可以用 Dijkstra 的变体解决!

关键洞察:将 dist[v] 的含义从"路径权重之和"改为"路径上最大边权",松弛操作从 dist[u] + w 改为 max(dist[u], w)。Dijkstra 的贪心策略仍然成立——因为从优先队列中取出的最小 dist 值节点,不可能通过其他路径获得更小的"最大边权"。

def minimumEffortPath(heights: List[List[int]]) -> int:
    """
    LeetCode 1631: Path With Minimum Effort
    
    变体 Dijkstra:dist 定义为路径上最大边权
    """
    rows, cols = len(heights), len(heights[0])
    
    # dist[r][c] = 从 (0,0) 到 (r,c) 的最小体力消耗
    dist = [[float('inf')] * cols for _ in range(rows)]
    dist[0][0] = 0
    
    # (体力消耗, 行, 列)
    pq = [(0, 0, 0)]
    
    directions = [(0, 1), (0, -1), (1, 0), (-1, 0)]
    
    while pq:
        effort, r, c = heapq.heappop(pq)
        
        if r == rows - 1 and c == cols - 1:
            return effort
        
        if effort > dist[r][c]:
            continue
        
        for dr, dc in directions:
            nr, nc = r + dr, c + dc
            if 0 <= nr < rows and 0 <= nc < cols:
                # 边权 = 高度差的绝对值
                edge_weight = abs(heights[nr][nc] - heights[r][c])
                # 新的 dist = max(当前路径最大边权, 这条新边)
                new_effort = max(effort, edge_weight)
                if new_effort < dist[nr][nc]:
                    dist[nr][nc] = new_effort
                    heapq.heappush(pq, (new_effort, nr, nc))
    
    return 0  # 只有一个格子


# 测试
heights = [[1,2,2],[3,8,2],[5,3,5]]
print(minimumEffortPath(heights))  # 2
# 路径 (0,0)->(0,1)->(0,2)->(1,2)->(2,2),最大差值 = |5-3| 或 |2-2|... 
# 实际最优路径最大差值为 2

替代解法——二分答案 + BFS

def minimumEffortPath_binary_search(heights: List[List[int]]) -> int:
    """二分答案:猜测最大体力消耗 mid,用 BFS 验证是否存在可行路径"""
    rows, cols = len(heights), len(heights[0])
    
    def can_reach(max_effort: int) -> bool:
        """判断是否存在一条路径,使得最大边权 ≤ max_effort"""
        visited = [[False] * cols for _ in range(rows)]
        queue = deque([(0, 0)])
        visited[0][0] = True
        
        while queue:
            r, c = queue.popleft()
            if r == rows - 1 and c == cols - 1:
                return True
            for dr, dc in [(0,1),(0,-1),(1,0),(-1,0)]:
                nr, nc = r + dr, c + dc
                if 0 <= nr < rows and 0 <= nc < cols and not visited[nr][nc]:
                    if abs(heights[nr][nc] - heights[r][c]) <= max_effort:
                        visited[nr][nc] = True
                        queue.append((nr, nc))
        return False
    
    lo, hi = 0, 10**6
    while lo < hi:
        mid = (lo + hi) // 2
        if can_reach(mid):
            hi = mid
        else:
            lo = mid + 1
    return lo

替代解法——并查集 + 边排序(本质上是 Kruskal 思路):

def minimumEffortPath_kruskal(heights: List[List[int]]) -> int:
    """
    Kruskal 思路:将所有边按权重排序,依次加入直到起点和终点连通
    """
    rows, cols = len(heights), len(heights[0])
    
    # 生成所有边
    edges = []
    for r in range(rows):
        for c in range(cols):
            idx = r * cols + c
            if c + 1 < cols:
                w = abs(heights[r][c+1] - heights[r][c])
                edges.append((w, idx, idx + 1))
            if r + 1 < rows:
                w = abs(heights[r+1][c] - heights[r][c])
                edges.append((w, idx, idx + cols))
    
    edges.sort()
    uf = UnionFind(rows * cols)
    
    source = 0
    target = rows * cols - 1
    
    for w, u, v in edges:
        uf.union(u, v)
        if uf.find(source) == uf.find(target):
            return w
    
    return 0

这道题完美展示了三种不同的算法范式如何解决同一个问题:Dijkstra 变体、二分答案 + BFS、Kruskal 变体。面试中能给出多种解法并分析优劣是加分项。

18.21 LeetCode 1584:连接所有点的最小费用

题目:给定 n 个点 points[i] = [xi, yi],两点间连接费用为曼哈顿距离 |xi-xj| + |yi-yj|。返回使所有点连通的最小费用。

分析:经典的最小生成树问题。完全图(任意两点可连边),n 个点有 n(n-1)/2 条边。

def minCostConnectPoints(points: List[List[int]]) -> int:
    """
    LeetCode 1584: Min Cost to Connect All Points
    
    方法1: Kruskal(所有边排序)
    """
    n = len(points)
    
    # 生成所有边
    edges = []
    for i in range(n):
        for j in range(i + 1, n):
            dist = abs(points[i][0] - points[j][0]) + abs(points[i][1] - points[j][1])
            edges.append((dist, i, j))
    
    edges.sort()
    
    uf = UnionFind(n)
    total = 0
    count = 0
    
    for w, u, v in edges:
        if uf.union(u, v):
            total += w
            count += 1
            if count == n - 1:
                break
    
    return total


def minCostConnectPoints_prim(points: List[List[int]]) -> int:
    """
    方法2: Prim(优先队列),对完全图更高效
    
    Kruskal 需要 O(n²) 条边全部排序: O(n² log n)
    Prim 每次只需要处理当前节点的边: O(n² log n) 但常数更小
    
    实际上对完全图,不用堆的 Prim O(n²) 最优
    """
    n = len(points)
    
    # 不用堆的 Prim,O(n²)
    in_mst = [False] * n
    min_dist = [float('inf')] * n  # 到 MST 集合的最小距离
    min_dist[0] = 0
    total = 0
    
    for _ in range(n):
        # 找最小 min_dist 的非 MST 节点
        u = -1
        for v in range(n):
            if not in_mst[v] and (u == -1 or min_dist[v] < min_dist[u]):
                u = v
        
        in_mst[u] = True
        total += min_dist[u]
        
        # 更新其他节点到 MST 的距离
        for v in range(n):
            if not in_mst[v]:
                d = abs(points[u][0] - points[v][0]) + abs(points[u][1] - points[v][1])
                min_dist[v] = min(min_dist[v], d)
    
    return total


# 测试
points = [[0,0],[2,2],[3,10],[5,2],[7,0]]
print(minCostConnectPoints(points))  # 20
print(minCostConnectPoints_prim(points))  # 20

性能对比

18.22 常见陷阱与面试技巧总结

陷阱 1:Dijkstra 遇到负权边

# 经典反例
# 0 -> 1 (权重 1)
# 0 -> 2 (权重 5)
# 1 -> 2 (权重 -10)
# Dijkstra: 先确定 dist[1]=1, 然后 dist[2]=5 已确定
# 实际最短: 0->1->2 = 1+(-10) = -9

面试中如果题目保证边权非负,用 Dijkstra;否则必须用 Bellman-Ford。

陷阱 2:Floyd-Warshall 的循环顺序

# 正确:k 在最外层
for k in range(n):
    for i in range(n):
        for j in range(n):
            ...

# 错误:k 不在最外层会导致结果错误
# 因为 dp[k] 依赖 dp[k-1],k 必须是外层循环

陷阱 3:MST 不等于最短路径树

    A --1-- B --1-- C
    |               |
    3               3
    |               |
    D ------1------ E

MST 包含 A-B, B-C, D-E, 和 A-D 或 C-E (总权重 6)
但从 A 到 E 的最短路径是 A-B-C-E (权重 5),不走 MST 的边

陷阱 4:图不连通时 MST 不存在

def kruskal_safe(n, edges):
    total, mst = kruskal(n, edges)
    if len(mst) < n - 1:
        return -1  # 图不连通,MST 不存在
    return total

面试模板总结

问题类型 算法 关键代码
单源最短路(正权) Dijkstra heapq + dist 数组
单源最短路(有负权) Bellman-Ford/SPFA V-1 轮松弛
全源最短路 Floyd-Warshall 三重循环,k 最外层
最小生成树 Kruskal 排序 + 并查集
最小生成树(稠密图) Prim min_dist 数组
minimax 路径 Dijkstra 变体 max 代替 +
判定连通性 并查集/BFS union + find

时间复杂度速查

算法 时间 空间 适用条件
Dijkstra (堆) O((V+E) log V) O(V+E) 非负权
Bellman-Ford O(VE) O(V) 任意权(可检测负环)
SPFA 平均 O(kE) 最坏 O(VE) O(V+E) 任意权
Floyd-Warshall O(V³) O(V²) 全源,V 小
Prim (堆) O((V+E) log V) O(V+E) 无向连通图
Prim (无堆) O(V²) O(V) 稠密图
Kruskal O(E log E) O(V+E) 无向连通图
本章评分
4.7  / 5  (13 评分)

💬 留言讨论