🛤️ 最短路径 — Dijkstra与加权图的旅程

导航软件的核心算法,从一点到另一点的最优路径选择

📖 最短路径的核心概念

最短路径问题是图论中最经典的问题之一:在加权图中,找到两个顶点之间权值之和最小的路径。不同的约束条件(边权正/负、单源/全源、限制中转次数)催生了不同的算法。

最短路径算法选择指南: 边权全正? ──Yes──→ Dijkstra (单源最短路, O((V+E)logV)) │ No │ 有负权边? ──Yes──→ Bellman-Ford (O(VE)) │ 或 SPFA (平均 O(V+E)) No │ 全源最短路? ──Yes──→ Floyd-Warshall (O(V³)) │ No │ 无权图? ──Yes──→ BFS (O(V+E)) │ No → Dijkstra

1. Dijkstra 算法核心思想

贪心策略:每次选择当前距离最小的未确认节点,将其标记为已确认。因为边权非负,已确认的节点距离不会再被更新(不会找到更短的路)。
数据结构:最小堆(优先队列),每次 O(log V) 取出最小距离节点。
松弛操作:对于边 u→v(权重 w),如果 dist[u] + w < dist[v],则更新 dist[v] = dist[u] + w。这是所有最短路径算法的核心操作。

2. Python Dijkstra 模板

import heapq
from collections import defaultdict

def dijkstra(n, edges, start):
    """单源最短路 - Dijkstra
    n: 顶点数, edges: [(u, v, w), ...], start: 起点
    返回: dist 数组
    """
    graph = defaultdict(list)
    for u, v, w in edges:
        graph[u].append((v, w))
    
    dist = [float('inf')] * n
    dist[start] = 0
    heap = [(0, start)]  # (距离, 节点)
    visited = set()
    
    while heap:
        d, node = heapq.heappop(heap)
        if node in visited:
            continue
        visited.add(node)
        for neighbor, weight in graph[node]:
            new_dist = d + weight
            if new_dist < dist[neighbor]:
                dist[neighbor] = new_dist
                heapq.heappush(heap, (new_dist, neighbor))
    
    return dist
Dijkstra 的堆优化版本时间复杂度为 O((V+E)log V)。不使用堆的朴素版本为 O(V²),在稠密图(E ≈ V²)时朴素版更快。面试一般用堆优化版。

🎯 题目一:网络延迟时间 (LC 743)

题目描述:n 个网络节点,标记为 1n。给定列表 times,表示信号经过边的传播时间 [u, v, w]。从节点 k 发出信号,多久才能使所有节点都收到信号?如果不能使所有节点收到,返回 -1。

示例: times = [[2,1,1],[2,3,1],[3,4,1]], n=4, k=2 2 ──1──→ 1 │ 1 ↓ 3 ──1──→ 4 从2出发: dist = {1:1, 2:0, 3:1, 4:2} 最远距离 = 2 → 所有节点收到信号需2单位时间

思路分析

标准 Dijkstra:从源点 k 出发,求到所有其他节点的最短距离。答案就是最大距离(最远的节点收到信号的时间)。如果有节点不可达,返回 -1。

代码实现

class Solution:
    def networkDelayTime(self, times: List[List[int]], n: int, k: int) -> int:
        import heapq
        from collections import defaultdict
        graph = defaultdict(list)
        for u, v, w in times:
            graph[u].append((v, w))
        
        dist = {i: float('inf') for i in range(1, n + 1)}
        dist[k] = 0
        heap = [(0, k)]
        visited = set()
        
        while heap:
            d, node = heapq.heappop(heap)
            if node in visited:
                continue
            visited.add(node)
            for neighbor, weight in graph[node]:
                if d + weight < dist[neighbor]:
                    dist[neighbor] = d + weight
                    heapq.heappush(heap, (dist[neighbor], neighbor))
        
        return max(dist.values()) if len(visited) == n else -1

复杂度分析

时间复杂度:O((V+E)log V)

空间复杂度:O(V+E)

🎯 题目二:K站中转内最便宜的航班 (LC 787)

题目描述:n 个城市通过航班连接,flights[i] = [from, to, price]。找到从 srcdst 最多经过 k 站中转的最便宜价格。如果没有路线,返回 -1。

示例: flights=[[0,1,100],[1,2,100],[2,0,100],[1,3,600],[2,3,200]] src=0, dst=3, k=1 0 →→→ 1 →→→ 3 0→1→3: 100+600=700 ✅ 100 600 0→1→2→3: 需要2站中转 ❌ ↓↑ (k=1, 只能1站) 2→→→3 100 200 答案: 700 (0→1→3, 1站中转)

思路分析

修改版 Dijkstra:在标准 Dijkstra 的基础上,增加"中转次数"维度。堆元素变为 (cost, node, stops)。当 stops > k 时跳过。
关键:不能只按 cost 排序,因为可能存在 cost 稍高但 stops 更少的路径,后者可能通过更多中转到达更便宜的 dst。
解决:用 best[(node, stops)] 记录到达某节点用了某站数的最小花费,避免重复扩展。

代码实现

class Solution:
    def findCheapestPrice(self, n: int, flights: List[List[int]], 
                          src: int, dst: int, k: int) -> int:
        import heapq
        from collections import defaultdict
        graph = defaultdict(list)
        for u, v, w in flights:
            graph[u].append((v, w))
        
        heap = [(0, src, 0)]  # (cost, node, stops)
        best = {}  # (node, stops) → min cost
        
        while heap:
            cost, node, stops = heapq.heappop(heap)
            if node == dst:
                return cost
            if stops > k:
                continue
            key = (node, stops)
            if key in best and best[key] <= cost:
                continue
            best[key] = cost
            for neighbor, price in graph[node]:
                heapq.heappush(heap, (cost + price, neighbor, stops + 1))
        
        return -1

复杂度分析

时间复杂度:O(E·K·log(E·K))

空间复杂度:O(V·K)

LC 787 的另一种解法是 Bellman-Ford 思路:执行 K+1 轮松弛操作,每轮只扩展一站。代码更短但面试时需要解释为什么这样做。Dijkstra + stops 维度更直观。

🎯 题目三:概率最大的路径 (LC 1514)

题目描述:给你一个无向加权图,边的权重是成功概率。求从 startend 的最大概率路径的概率值。

示例: n=3, edges=[[0,1],[1,2],[0,2]], succProb=[0.5,0.5,0.2] start=0, end=2 0 ──0.5──→ 1 ──0.5──→ 2 概率: 0.5×0.5=0.25 └────────0.2──────────→2 概率: 0.2 最大概率: max(0.25, 0.2) = 0.25

思路分析

Dijkstra 变体:乘法变加法,最小变最大。将概率相乘转换为对数相加,最大化概率等于最大化对数之和(或最小化负对数之和)。但更简单的做法是直接修改 Dijkstra:
- 用最大堆代替最小堆(Python 中存负值)
- 松弛操作:如果 prob[u] * w > prob[v],更新 prob[v]
- 初始概率为 0(而非 inf),源点概率为 1

代码实现

class Solution:
    def maxProbability(self, n: int, edges: List[List[int]], 
                       succProb: List[float], start: int, end: int) -> float:
        import heapq
        from collections import defaultdict
        graph = defaultdict(list)
        for (u, v), p in zip(edges, succProb):
            graph[u].append((v, p))
            graph[v].append((u, p))
        
        prob = [0.0] * n
        prob[start] = 1.0
        heap = [(-1.0, start)]  # 负值模拟最大堆
        
        while heap:
            p, node = heapq.heappop(heap)
            p = -p
            if p < prob[node]:  # 已过期
                continue
            for neighbor, w in graph[node]:
                if p * w > prob[neighbor]:
                    prob[neighbor] = p * w
                    heapq.heappush(heap, (-prob[neighbor], neighbor))
        
        return prob[end]

复杂度分析

时间复杂度:O((V+E)log V)

空间复杂度:O(V+E)

Dijkstra 的本质是贪心,贪心正确的条件是"已确认的节点不会被更新"。在乘法版本中,概率 ≤ 1,乘上概率只会减小,所以已确认的最大概率也不会被超越——贪心仍然正确。

🔬 最短路径算法对比

算法适用图时间复杂度特点
BFS无权图O(V+E)最简单,层序扩展
Dijkstra非负权图O((V+E)logV)单源最短路,贪心
Bellman-Ford可有负权O(VE)可检测负权环
SPFA可有负权平均O(V+E)BF的队列优化
Floyd-Warshall全源最短路O(V³)三重循环,适合小图
A*有启发函数取决于启发目标导向搜索

Floyd-Warshall 三行核心代码

# 全源最短路 - Floyd-Warshall
for k in range(n):
    for i in range(n):
        for j in range(n):
            dist[i][j] = min(dist[i][j], dist[i][k] + dist[k][j])

# 直觉: i → j 的最短路是否经过 k?
# 枚举所有中间点 k, 逐步更新
面试中最常考的是 Dijkstra,其次是 BFS 求无权图最短路。Bellman-Ford 和 Floyd 偶尔考,但要了解它们的特点(负权处理、全源)。
成就解锁:路径探索者 — 掌握Dijkstra标准版、中转限制版、概率最短路三大变体
LeetCode AC验证:LC 743 Network Delay Time ✅ | LC 787 Cheapest Flights ✅ | LC 1514 Max Probability Path ✅

📝 课后练习

  1. LC 778 水位上升的泳池中游泳(Dijkstra变体)
  2. LC 1631 最小体力消耗路径(Dijkstra变体)
  3. LC 505 迷宫 II(BFS+Dijkstra)
  4. LC 399 除法求值(Floyd变体)
  5. LC 1334 阈值距离内邻居最少的城市(Floyd)

🔑 本课要点回顾

📚 扩展阅读

思考题:如果图的边权可以为负但不存在负权环,Dijkstra 还能正确工作吗?为什么?提示:Dijkstra 的贪心策略假设已确认的节点不会被更新,但负权边可能让后处理的节点"回溯"更新已确认节点。