登录
OAmaster
图论算法

图论 · 最短路

从 LC 743 出发,看 Dijkstra / Bellman-Ford / Floyd 各自适用的场景。

Medium预计阅读 50 分钟更新于 2026-05-18

1. 核心原理

最短路问题给定一张图 G = (V, E) 与边权函数 w: E → ℝ,要求从源点 s 到其他节点(或所有点对)的最小累积边权。这是图论里最经典的"贪心 + 松弛"问题——边权的取值范围(是否非负、是否带负环)、问题规模(单源 vs 全源、VE 的大小)决定了选哪种算法。

朴素枚举所有路径不可行:从 s 到任一节点的简单路径数随图的稠密程度呈指数级。最短路算法的核心 invariant 是松弛操作的单调性:对边 (u, v, w),若 dist[u] + w < dist[v] 则把 dist[v] 更新为 dist[u] + w。所有最短路算法的差别仅在于"按什么顺序松弛 + 何时确认一个节点的距离已经最优"。

按边权特征选算法

不同的边权特征对应不同的算法:

  • 边权全 1:用 BFS。按"层"扩张,每层 +1 距离,复杂度 O(V + E)
  • 边权 ∈ 1:用 0-1 BFS(双端队列:0 边推队首,1 边推队尾)。复杂度 O(V + E)
  • 边权非负实数:用 Dijkstra 算法 + 优先队列。复杂度 O((V + E) log V)
  • 含负权(但无负环):用 Bellman-Ford 或 SPFA。复杂度 O(V × E)。也能检测负环。
  • 全源最短路:用 Floyd-Warshall。三层循环、O(V³)V 小(≤ 400)时舒适。

LeetCode 上的最短路题,绝大多数边权非负——Dijkstra 是默认选项。只有少数题(如 LC 787 限制中转次数)需要 Bellman-Ford;LC 1334 这种全源 + V 小的场景适合 Floyd。

Dijkstra 的正确性:第一次出堆即最短

Dijkstra 的核心断言是"边权非负时,节点 v 第一次从优先队列弹出时,dist[v] 就是最终最短距离"。反证:若真正最短距离 d' < d,则该最短路径上存在中间节点 uv 之前出堆,且 dist[u] ≤ d'u 出堆时会松弛 (u, v),让 dist[v] 至少降到 dist[u] + w(u, v) ≤ d',与"v 出堆时距离为 d > d'"矛盾。

这条性质只在非负权下成立。出现负边时,uv 之后才出堆也可能让 dist[v] 进一步变小,Dijkstra 直接失效,必须换 Bellman-Ford。

Dijkstra 实现要点

主流实现是"松弛 + 出堆即定型 + 懒删除":

  1. dist[] 初始化为 +∞dist[s] = 0,把 (0, s) 推进最小堆
  2. 循环:弹堆顶 (d, u);若 d > dist[u] 是过期记录,跳过;否则遍历 u 的出边 (u, v, w),能松弛就更新 dist[v] 并把 (dist[v], v) 推堆
  3. 堆空结束。每个节点最多被 push O(E) 次(每次松弛成功 push 一次),总堆操作 O(E log V),整体 O((V + E) log V)

"懒删除"那一行 if d > dist[u]: continue 是关键——Python heapq / Java PriorityQueue 不支持 decrease-key,所以堆里会留下若干个 (过期距离, u) 记录。遇到时直接跳过即可,结果正确,复杂度也正确。漏掉这一行算法仍然返回正确答案,但同一节点会被反复处理,最坏复杂度退化。

负权下 Dijkstra 失效的反例

0 → 1 (w=2), 0 → 2 (w=1), 2 → 1 (w=-3)。从 0 出发,Dijkstra 第一次弹出 1 时 dist[1] = 2,节点 1 被"定型";但真实最短是 0 → 2 → 1 = -2。原因是"边权非负"前提下"第一次出堆即最短"的反证逻辑在负权下不成立——后出堆的节点可能进一步松弛已经出堆的节点。出现负权时必须换 Bellman-Ford。

三者关系速查

把单源最短路三件套放在同一张表对比:

维度DijkstraBellman-FordFloyd
起点单源单源全源
负权不支持支持支持(无负环)
负环检测-跑第 V 轮看能否松弛dist[i][i] < 0
边数限制-k + 1 轮即可-
时间复杂度O((V+E) log V)O(V × E)O(V³)
何时选大图非负权小图含负权 / 边数限制小图全源

Bellman-Ford 的"对每条边做 V - 1 轮松弛"——经过 V - 1 轮后,任何最短路径(最多 V - 1 条边)都被完整松弛过一次;第 V 轮再跑还能松弛就意味着存在负环。SPFA(Shortest Path Faster Algorithm)是它的队列优化版本:只对"上一轮被松弛过的节点"重新松弛邻居,平均更快但最坏仍是 O(V × E)。Floyd 的状态转移逻辑:dist[i][j] 在第 k 轮表示"只允许使用编号 < k 的中间节点时 ij 的最短距离",第 k 轮决定"要不要把 k 加进中间节点池"。


2. 通用模板

下面给三类主算法的最小骨架:Dijkstra 堆优化(最常用)、Bellman-Ford 带边数限制、Floyd 全源最短路。VE 含义统一为节点数与边数;输入 edges 默认为 (u, v, w) 三元组列表,下标从 0 开始。

import heapq
from math import inf

def dijkstra(n: int, edges: list[tuple[int, int, int]], src: int) -> list[float]:
    """Dijkstra 堆优化:单源最短路,要求边权非负"""
    g = [[] for _ in range(n)]
    for u, v, w in edges:
        g[u].append((v, w))
    dist = [inf] * n
    dist[src] = 0
    pq = [(0, src)]                                  # (距离, 节点)
    while pq:
        d, u = heapq.heappop(pq)
        if d > dist[u]:                              # 懒删除:过期记录跳过
            continue
        for v, w in g[u]:
            nd = d + w
            if nd < dist[v]:                         # 松弛成功才入堆
                dist[v] = nd
                heapq.heappush(pq, (nd, v))
    return dist


def bellman_ford(n, edges, src, k):
    """带边数限制:最多走 k 条边时 src 到每点的最短距离"""
    dist = [inf] * n
    dist[src] = 0
    for _ in range(k):                               # 跑 k 轮
        prev = dist[:]                               # 用上一轮副本松弛
        for u, v, w in edges:
            if prev[u] + w < dist[v]:
                dist[v] = prev[u] + w
    return dist


def floyd(n, edges):
    """全源最短路:返回 n × n 距离矩阵"""
    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)              # 处理重边取小
    for k in range(n):                               # k 是中间节点
        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
// Dijkstra 堆优化:单源最短路,要求边权非负
static long[] dijkstra(int n, int[][] edges, int src) {
    List<int[]>[] g = new List[n];
    for (int i = 0; i < n; i++) g[i] = new ArrayList<>();
    for (int[] e : edges) g[e[0]].add(new int[]{e[1], e[2]});

    long[] dist = new long[n];
    Arrays.fill(dist, Long.MAX_VALUE);
    dist[src] = 0;
    PriorityQueue<long[]> pq = new PriorityQueue<>((a, b) -> Long.compare(a[0], b[0]));
    pq.offer(new long[]{0, src});
    while (!pq.isEmpty()) {
        long[] cur = pq.poll();
        long d = cur[0];
        int u = (int) cur[1];
        if (d > dist[u]) continue;                   // 懒删除
        for (int[] nb : g[u]) {
            int v = nb[0], w = nb[1];
            long nd = d + w;
            if (nd < dist[v]) {
                dist[v] = nd;
                pq.offer(new long[]{nd, v});
            }
        }
    }
    return dist;
}

// Bellman-Ford 带边数限制
static int[] bellmanFordK(int n, int[][] edges, int src, int k) {
    int[] dist = new int[n];
    Arrays.fill(dist, Integer.MAX_VALUE);
    dist[src] = 0;
    for (int i = 0; i < k; i++) {
        int[] prev = dist.clone();
        for (int[] e : edges) {
            if (prev[e[0]] != Integer.MAX_VALUE
                && prev[e[0]] + e[2] < dist[e[1]]) {
                dist[e[1]] = prev[e[0]] + e[2];
            }
        }
    }
    return dist;
}

// Floyd-Warshall:全源最短路,返回 n × n 距离矩阵
static int[][] floyd(int n, int[][] edges) {
    int INF = Integer.MAX_VALUE / 2;                 // 避免加法溢出
    int[][] dist = new int[n][n];
    for (int[] row : dist) Arrays.fill(row, INF);
    for (int i = 0; i < n; i++) dist[i][i] = 0;
    for (int[] e : edges) dist[e[0]][e[1]] = Math.min(dist[e[0]][e[1]], e[2]);
    for (int k = 0; k < n; k++) {                    // k 是中间节点
        for (int i = 0; i < n; i++) {
            for (int j = 0; j < n; j++) {
                if (dist[i][k] + dist[k][j] < dist[i][j]) {
                    dist[i][j] = dist[i][k] + dist[k][j];
                }
            }
        }
    }
    return dist;
}
#include <vector>
#include <queue>
#include <climits>
using namespace std;

// Dijkstra 堆优化:单源最短路,要求边权非负
vector<long long> dijkstra(int n, vector<vector<int>>& edges, int src) {
    vector<vector<pair<int,int>>> g(n);
    for (auto& e : edges) g[e[0]].push_back({e[1], e[2]});

    vector<long long> dist(n, LLONG_MAX);
    dist[src] = 0;
    // 最小堆:(距离, 节点)
    priority_queue<pair<long long,int>,
                   vector<pair<long long,int>>,
                   greater<>> pq;
    pq.push({0, src});
    while (!pq.empty()) {
        auto [d, u] = pq.top(); pq.pop();
        if (d > dist[u]) continue;                   // 懒删除
        for (auto& [v, w] : g[u]) {
            long long nd = d + w;
            if (nd < dist[v]) {
                dist[v] = nd;
                pq.push({nd, v});
            }
        }
    }
    return dist;
}

// Bellman-Ford 带边数限制
vector<int> bellmanFordK(int n, vector<vector<int>>& edges, int src, int k) {
    const int INF = INT_MAX;
    vector<int> dist(n, INF);
    dist[src] = 0;
    for (int i = 0; i < k; i++) {
        vector<int> prev = dist;
        for (auto& e : edges) {
            int u = e[0], v = e[1], w = e[2];
            if (prev[u] != INF && prev[u] + w < dist[v]) {
                dist[v] = prev[u] + w;
            }
        }
    }
    return dist;
}

// Floyd-Warshall:全源最短路,返回 n × n 距离矩阵
vector<vector<int>> floyd(int n, vector<vector<int>>& edges) {
    const int INF = INT_MAX / 2;                        // 避免加法溢出
    vector<vector<int>> dist(n, vector<int>(n, INF));
    for (int i = 0; i < n; i++) dist[i][i] = 0;
    for (auto& e : edges) dist[e[0]][e[1]] = min(dist[e[0]][e[1]], e[2]);
    for (int k = 0; k < n; k++) {                       // k 是中间节点
        for (int i = 0; i < n; i++) {
            for (int j = 0; j < n; j++) {
                if (dist[i][k] + dist[k][j] < dist[i][j]) {
                    dist[i][j] = dist[i][k] + dist[k][j];
                }
            }
        }
    }
    return dist;
}

模板里要注意四点:

  • 懒删除不可省略if d > dist[u]: continue 这一行决定了 Dijkstra 的复杂度。漏了算法结果仍正确(过期记录的松弛是无害的),但堆里会反复处理过期副本,复杂度退化。
  • INF 的取值:Dijkstra 用 Long.MAX_VALUE / LLONG_MAX 没问题(不会加法溢出,因为有非负边权和 nd < dist[v] 短路);Floyd 三层循环里 dist[i][k] + dist[k][j] 容易爆 int,建议用 INT_MAX / 2long long
  • Bellman-Ford 拍照:带边数限制时必须用 prev = dist.clone()。否则同一轮内连续松弛会让一个节点"前进多步",相当于一轮走了多条边,违反"k 轮 = 最多 k 条边"的语义。
  • 节点编号:模板按 0..n-1 写。LeetCode 题目里若编号从 1 开始(如 LC 743),要么把数组开成 n + 1 大、要么减一映射,别混用。

3. 母题精讲

LeetCode 743. 网络延迟时间

链接:LeetCode 743

题意:n 个网络节点,编号 1n。给一组有向带权边 times[i] = [u, v, w],表示信号从节点 u 传到 v 需要 w 单位时间。从源点 k 同时向所有可达节点广播信号,问全部节点收到信号需要多久。如果有节点收不到,返回 -1

数据范围:

  • n:1 到 100
  • times.length:1 到 6000
  • 1 ≤ w ≤ 100

示例:

输入:times = [[2,1,1],[2,3,1],[3,4,1]], n = 4, k = 2
输出:2
解释:从 2 到 1 是 1、到 3 是 1、再从 3 到 4 是 2。最远到 4,耗时 2。

这件事换个说法是:从 k 出发,求到每个节点的最短路径长度,取最大值。如果某个节点不可达,答案是 -1

暴力思路:把 BFS 当最短路用

上一章 DFS / BFS 与拓扑排序 里 BFS 按层扩张,"层数"就是到起点的距离。可是 BFS 里每条边的"距离"默认是 1——这道题的边带权 w,不是固定 1。如果硬把 BFS 套上去,能得到什么?

一种最直觉的修法:维护一个 dist[],初始全是无穷大;从 k 出发跑 BFS,每次松弛 dist[v] = min(dist[v], dist[u] + w(u, v))。但这样的 BFS 不再"按层"——一个节点可能被多次入队、每次得到一个更短的距离。

from collections import defaultdict, deque

def bfs_relax(n, k, times):
    g = defaultdict(list)
    for u, v, w in times:
        g[u].append((v, w))
    dist = [float('inf')] * (n + 1)
    dist[k] = 0
    q = deque([k])
    while q:
        u = q.popleft()
        for v, w in g[u]:
            if dist[u] + w < dist[v]:
                dist[v] = dist[u] + w           # 松弛成功
                q.append(v)                     # 这里可能重复入队
    return dist

这本质是 Bellman-Ford / SPFA 的思路——每条边可能被反复松弛。在最坏情况(稠密图 + 边权差异大)下,一个节点可能入队 O(V) 次,每次又触发一轮邻居松弛,总复杂度 O(V × E)n = 100, E = 6000 还过得去,但题目上界稍微一抬就崩——LC 787 类似问题里 V × E10⁴ × 10⁵,直接超时。

更关键的浪费在于:这种 BFS 没有"全局最优"的保证。它只是反复松弛、直到不再变化为止。如果用对优先策略——每次拿"目前已知距离最小的未确定节点"扩展——就能保证它的最短距离已经确定了。这就是 Dijkstra。

关键观察:边权非负 ⇒ 第一次出堆即最短

Dijkstra 的核心是一句话:边权非负时,第一次从堆中弹出的距离值就是该节点的最短距离

为什么?反证。假设节点 v 第一次出堆时 dist[v] = d,但真正最短距离是 d' < d。那 vd' 的路径上必有一个中间节点 u,且 uv 之前出堆(因为最短路径上 uv 离源点更近,且边权非负意味着 dist[u] ≤ d' < d)。但 u 出堆时会松弛它的所有邻居——包括 v——所以 v 在堆里的距离至少会更新成 dist[u] + w(u, v) ≤ d'。这跟假设"v 出堆时距离是 d > d'"矛盾。

所以算法骨架:

  • 维护 dist[],初始全是无穷大,dist[k] = 0
  • 用最小堆维护"待确认"的 (dist, node)
  • 每次从堆顶弹一个,如果是过期记录(dist 比当前 dist[node] 大)就跳过;否则它的 dist[node] 已经是最短的,松弛它的所有出边邻居入堆
  • 重复直到堆空

每个节点最终被"确认"一次。每条边至多被松弛一次(出节点确认后)。堆操作 O(log V)。总复杂度 O((V + E) log V)

下面用示例 times = [[2,1,1],[2,3,1],[3,4,1]], k = 2 走一遍。建邻接表:2 → 1 (w=1), 2 → 3 (w=1), 3 → 4 (w=1)

Dijkstra 在 LC 743 上的执行

节点 1..4,源点 k=2。颜色:白=未访问,金=当前出堆并确认,蓝=已确认最短距离

Step 1 / 6初始:dist[2]=0,其余为 ∞。堆中只有 (0, 2)
index
0123
value
1
2
3
4
dist[2]
heap top
未确认 / dist=∞本步出堆并确认已确认最短距离本步被松弛的邻居
01/06

最终答案 max(dist[1..n]) = 2。如果有节点的 dist 还是无穷大,说明不可达,返回 -1

整个过程节点 1、2、3、4 各出堆一次,边 (2,1), (2,3), (3,4) 各松弛一次。总复杂度 O((V + E) log V)


4. 例题(三道)

按"算法主线递进"排:标准 Dijkstra → 带边数限制的 Bellman-Ford → Dijkstra 包在动态图类里。每道带 follow-up 续到下一题。

4.1 LC 743 网络延迟时间

链接:LeetCode 743

回到本章开头那道题。建邻接表 + 跑一遍 Dijkstra + 取 dist[1..n] 的最大值。如果最大值是无穷大,说明有节点不可达,返回 -1

import heapq
from collections import defaultdict

class Solution:
    def networkDelayTime(self, times: list[list[int]], n: int, k: int) -> int:
        g = defaultdict(list)
        for u, v, w in times:
            g[u].append((v, w))

        INF = float('inf')
        dist = [INF] * (n + 1)
        dist[k] = 0
        pq = [(0, k)]                                # (距离, 节点)
        while pq:
            d, u = heapq.heappop(pq)
            if d > dist[u]:                          # 过期记录跳过
                continue
            for v, w in g[u]:
                nd = d + w
                if nd < dist[v]:
                    dist[v] = nd
                    heapq.heappush(pq, (nd, v))

        ans = max(dist[1:])                          # 节点编号从 1
        return ans if ans < INF else -1
class Solution {
    public int networkDelayTime(int[][] times, int n, int k) {
        List<int[]>[] g = new List[n + 1];
        for (int i = 1; i <= n; i++) g[i] = new ArrayList<>();
        for (int[] t : times) g[t[0]].add(new int[]{t[1], t[2]});

        int INF = Integer.MAX_VALUE;
        int[] dist = new int[n + 1];
        Arrays.fill(dist, INF);
        dist[k] = 0;
        PriorityQueue<int[]> pq = new PriorityQueue<>((a, b) -> a[0] - b[0]);
        pq.offer(new int[]{0, k});
        while (!pq.isEmpty()) {
            int[] cur = pq.poll();
            int d = cur[0], u = cur[1];
            if (d > dist[u]) continue;
            for (int[] nb : g[u]) {
                int v = nb[0], w = nb[1];
                if (d + w < dist[v]) {
                    dist[v] = d + w;
                    pq.offer(new int[]{d + w, v});
                }
            }
        }

        int ans = 0;
        for (int i = 1; i <= n; i++) {
            if (dist[i] == INF) return -1;
            ans = Math.max(ans, dist[i]);
        }
        return ans;
    }
}
class Solution {
public:
    int networkDelayTime(vector<vector<int>>& times, int n, int k) {
        vector<vector<pair<int,int>>> g(n + 1);
        for (auto& t : times) g[t[0]].push_back({t[1], t[2]});

        const int INF = INT_MAX;
        vector<int> dist(n + 1, INF);
        dist[k] = 0;
        // 小顶堆:(距离, 节点)
        priority_queue<pair<int,int>, vector<pair<int,int>>, greater<>> pq;
        pq.push({0, k});
        while (!pq.empty()) {
            auto [d, u] = pq.top(); pq.pop();
            if (d > dist[u]) continue;                  // 过期记录
            for (auto& [v, w] : g[u]) {
                if (d + w < dist[v]) {
                    dist[v] = d + w;
                    pq.push({d + w, v});
                }
            }
        }

        int ans = 0;
        for (int i = 1; i <= n; i++) {
            if (dist[i] == INF) return -1;
            ans = max(ans, dist[i]);
        }
        return ans;
    }
};

复杂度:时间 O((V + E) log V)、空间 O(V + E)V = n + 1E = times.length。堆最坏装 O(E) 条记录(每次松弛成功就 push 一次),每次 log E ≈ log V(因为 E ≤ V²)。

几个细节:

  • 节点编号从 1dist 数组开 n + 1 大小、答案对 dist[1:] 取 max。开 n 长度会漏掉节点 n 本身。
  • 不可达判定。最后一行检查 max(dist[1:]) 是不是 INF,是的话说明有节点没被弹出过——返回 -1。Java 写法是在循环里随时检查。
  • 懒删除而不是真删除。Python heapq 没有 "decrease key" 操作,所以同一个节点可能被 push 多次(每次松弛成功就 push)。出堆时如果 d > dist[u],这就是过期记录,跳过。这是 Python / Java / Go 里 Dijkstra 的标准写法。

Follow-up:LC 743 边权非负,Dijkstra 直接套。如果题目改成"最多经过 k 条边"——比如限制只能转 1 次机——Dijkstra 还能直接用吗?


4.2 LC 787 K 站中转内最便宜的航班

链接:LeetCode 787

题意:n 个城市,编号 0n − 1。给一组有向航班 flights[i] = [from, to, price]。求从 src 出发到 dst 的最便宜价格,但中转站数不能超过 k——也就是说从 src 出发最多经过 k + 1 条边到达 dst。如果不可能,返回 -1

数据范围:

  • n:1 到 100
  • flights.length:0 到 n × (n − 1) / 2
  • 0 ≤ src, dst, k < nsrc ≠ dst
  • 1 ≤ price ≤ 10⁴

示例:

输入:n = 4, flights = [[0,1,100],[1,2,100],[2,0,100],[1,3,600],[2,3,200]],
      src = 0, dst = 3, k = 1
输出:700
解释:0 → 1 → 3 经过 1 次中转、总价 700。
      0 → 1 → 2 → 3 总价 400 但中转 2 次,超出 k=1 不允许。

这道题加了"边数 ≤ k + 1"的硬约束,Dijkstra 直接套会出错——Dijkstra 的"第一次出堆即最短"基于"距离单调递增",但这道题的"最短"被边数限制截断了。

正确做法是 Bellman-Ford 跑 k + 1。第 i 轮的 dist[v] 含义是"最多经过 i 条边时从 src 到 v 的最便宜价格"。每轮要用上一轮的 dist 副本松弛——否则同一轮内可能链式松弛多步、变相多走几条边。

class Solution:
    def findCheapestPrice(self, n: int, flights: list[list[int]],
                          src: int, dst: int, k: int) -> int:
        INF = float('inf')
        dist = [INF] * n
        dist[src] = 0
        for _ in range(k + 1):                       # 最多 k+1 条边
            prev = dist[:]                           # 拍照上一轮
            for u, v, w in flights:
                if prev[u] + w < dist[v]:
                    dist[v] = prev[u] + w
        return dist[dst] if dist[dst] < INF else -1
class Solution {
    public int findCheapestPrice(int n, int[][] flights, int src, int dst, int k) {
        int INF = Integer.MAX_VALUE;
        int[] dist = new int[n];
        Arrays.fill(dist, INF);
        dist[src] = 0;
        for (int i = 0; i <= k; i++) {
            int[] prev = dist.clone();
            for (int[] e : flights) {
                int u = e[0], v = e[1], w = e[2];
                if (prev[u] != INF && prev[u] + w < dist[v]) {
                    dist[v] = prev[u] + w;
                }
            }
        }
        return dist[dst] == INF ? -1 : dist[dst];
    }
}
class Solution {
public:
    int findCheapestPrice(int n, vector<vector<int>>& flights, int src, int dst, int k) {
        const int INF = INT_MAX;
        vector<int> dist(n, INF);
        dist[src] = 0;
        for (int i = 0; i <= k; i++) {                  // 最多 k+1 条边
            vector<int> prev = dist;                    // 拍照上一轮
            for (auto& e : flights) {
                int u = e[0], v = e[1], w = e[2];
                if (prev[u] != INF && prev[u] + w < dist[v]) {
                    dist[v] = prev[u] + w;
                }
            }
        }
        return dist[dst] == INF ? -1 : dist[dst];
    }
};

复杂度:时间 O(k × E)、空间 O(n)。本题 k ≤ n − 1 = 99E ≤ n × (n − 1) / 2 ≈ 5000,总操作量 5 × 10⁵ 级别。

为什么必须用 prev = dist[:] 拍照?看反例:本轮中如果先松弛了边 (src, a)dist[a] 变小,再松弛 (a, b) 又用了本轮刚更新的 dist[a]——这样 b 实际是经过两条边到达的,但算法以为只用了一条。prev 拍照保证一轮内每个节点只"前进一步"。

另一种解法是带"剩余步数"的 Dijkstra:状态扩展成 (cost, node, edges_used),剩余可走边数 < 0 时不继续。但用堆排序意义不大(剩余步数和 cost 的优先级冲突),常退化成 BFS。Bellman-Ford 在这道题里更自然。

Follow-up:上面两题都把"建图 + 跑算法"一次性写完。如果题目让你做一个图类——支持加边、查最短路这两个操作循环调用呢?


4.3 LC 2642 设计可以求最短路径的图类

链接:LeetCode 2642

题意:实现一个 Graph 类,支持:

  • Graph(int n, int[][] edges):初始化 n 个节点的有向带权图,初始边为 edges[i] = [from, to, weight]
  • addEdge(int[] edge):添加一条有向边 edge = [from, to, weight]
  • shortestPath(int node1, int node2):返回从 node1node2 的最短路径长度。如果不可达,返回 -1

数据范围:

  • n:1 到 100
  • 初始边数 + addEdge 调用次数:1 到 100
  • shortestPath 调用次数:1 到 100
  • 1 ≤ weight ≤ 10⁶

示例:

g = Graph(4, [[0,2,5],[0,1,2],[1,2,1],[3,0,3]])
g.shortestPath(3, 2)   // 返回 6:3 → 0 → 1 → 2,3+2+1=6
g.shortestPath(0, 3)   // 返回 -1:从 0 无法到 3
g.addEdge([1, 3, 4])
g.shortestPath(0, 3)   // 返回 6:0 → 1 → 3,2+4=6

关键决策:是预处理 Floyd O(V³) 一次性算好全源最短路,还是每次查询跑一次 Dijkstra?

  • Floyd 预处理 O(V³),每次 shortestPathO(1),但 addEdge 之后要重跑——addEdge 调用 100 次就是 100 × V³ = 100 × 10⁶,刚好能过但常数大。
  • Dijkstra:addEdgeO(1)(只 push 到邻接表),shortestPathO((V + E) log V) ≈ 100 × 8 = 800。总查询代价 100 × 800 = 8 × 10⁴

Dijkstra 显然更优:addEdge 远比 shortestPath 廉价,而题目允许两类调用各 100 次。下面给 Dijkstra 版本。

import heapq

class Graph:
    def __init__(self, n: int, edges: list[list[int]]):
        self.n = n
        self.g = [[] for _ in range(n)]
        for u, v, w in edges:
            self.g[u].append((v, w))

    def addEdge(self, edge: list[int]) -> None:
        u, v, w = edge
        self.g[u].append((v, w))

    def shortestPath(self, node1: int, node2: int) -> int:
        INF = float('inf')
        dist = [INF] * self.n
        dist[node1] = 0
        pq = [(0, node1)]
        while pq:
            d, u = heapq.heappop(pq)
            if u == node2:                           # 提前返回:目标节点出堆
                return d
            if d > dist[u]:
                continue
            for v, w in self.g[u]:
                nd = d + w
                if nd < dist[v]:
                    dist[v] = nd
                    heapq.heappush(pq, (nd, v))
        return -1                                    # 目标从未出堆 = 不可达
class Graph {
    private int n;
    private List<int[]>[] g;

    public Graph(int n, int[][] edges) {
        this.n = n;
        g = new List[n];
        for (int i = 0; i < n; i++) g[i] = new ArrayList<>();
        for (int[] e : edges) g[e[0]].add(new int[]{e[1], e[2]});
    }

    public void addEdge(int[] edge) {
        g[edge[0]].add(new int[]{edge[1], edge[2]});
    }

    public int shortestPath(int node1, int node2) {
        long INF = Long.MAX_VALUE;
        long[] dist = new long[n];
        Arrays.fill(dist, INF);
        dist[node1] = 0;
        PriorityQueue<long[]> pq = new PriorityQueue<>((a, b) -> Long.compare(a[0], b[0]));
        pq.offer(new long[]{0, node1});
        while (!pq.isEmpty()) {
            long[] cur = pq.poll();
            long d = cur[0];
            int u = (int) cur[1];
            if (u == node2) return (int) d;          // 目标出堆即返回
            if (d > dist[u]) continue;
            for (int[] nb : g[u]) {
                int v = nb[0], w = nb[1];
                if (d + w < dist[v]) {
                    dist[v] = d + w;
                    pq.offer(new long[]{d + w, v});
                }
            }
        }
        return -1;
    }
}
class Graph {
    int n;
    vector<vector<pair<int,int>>> g;                    // g[u] = {(v, w)}

public:
    Graph(int n, vector<vector<int>>& edges) : n(n), g(n) {
        for (auto& e : edges) g[e[0]].push_back({e[1], e[2]});
    }

    void addEdge(vector<int> edge) {
        g[edge[0]].push_back({edge[1], edge[2]});
    }

    int shortestPath(int node1, int node2) {
        const int INF = INT_MAX;
        vector<int> dist(n, INF);
        dist[node1] = 0;
        priority_queue<pair<int,int>, vector<pair<int,int>>, greater<>> pq;
        pq.push({0, node1});
        while (!pq.empty()) {
            auto [d, u] = pq.top(); pq.pop();
            if (u == node2) return d;                   // 目标出堆即返回
            if (d > dist[u]) continue;
            for (auto& [v, w] : g[u]) {
                if (d + w < dist[v]) {
                    dist[v] = d + w;
                    pq.push({d + w, v});
                }
            }
        }
        return -1;                                       // 不可达
    }
};

复杂度:构造 O(E)addEdge O(1)、单次 shortestPath O((V + E) log V)

两个收尾优化:

  • 目标出堆即返回。Dijkstra 一旦从堆里弹出 node2d 就是最短距离——不用跑完整个堆。这是 Dijkstra 在"查单点最短距离"场景的标准提前退出。
  • 不可达返回 -1。整个堆跑空 node2 还没出堆过,意味着图里没有路径,返回 -1。注意不要把"dist[node2] == INF"作为最终判据——目标节点出堆前可能已被一条次优路径松弛过 dist[node2],但实际最短就是次优值(这里返回的 d 才对)。

如果题目把 addEdge 改成"修改边权"(包括减小),Dijkstra 就要从头跑——因为减小边权可能让旧的最短路过期。这道题只允许新增边(权值正),所以已有的 dist 不会失效,但每次查询仍然要重跑 Dijkstra,没法增量维护。


5. 边界、易错与复杂度

Dijkstra 的"过期记录"

Python heapq / Java PriorityQueue / Go container/heap 都不支持 "decrease key",所以同一节点会被 push 多次。出堆时必须检查 if d > dist[u]: continue——这是 Dijkstra 写错最常见的地方之一。漏了这一行算法结果仍然对(过期记录的松弛是无害的),但复杂度从 O((V + E) log V) 退化到 O(E log E) 乘以堆里的过期副本数,最坏 O(E² log E) 直接 TLE。

Bellman-Ford 一轮内的串味

带边数限制的 Bellman-Ford 必须用 prev = dist[:] 拍照。LC 787 反例:

flights = [[0,1,1],[1,2,1]], src=0, dst=2, k=0

k = 0 意味着只能直飞、不能中转。直观答案是 -1(没有 0 → 2 的直飞)。

不拍照的版本:第 1 轮里先松弛 (0,1)dist[1] = 1;接着同一轮松弛 (1,2) 用了本轮刚算好的 dist[1],得到 dist[2] = 2。算法以为 0 到 2 经过 1 条边就能到,错。

拍照版本:第 1 轮 prev[1] = INF,松弛 (1,2)prev[1] + w = INFdist[2] 不更新,正确返回 -1

Floyd 的整数溢出

Floyd 三层循环里 dist[i][k] + dist[k][j] 容易溢出 int 上限。两种解决:

  • long 存(Java / Go)
  • INF 设小一点,比如 Integer.MAX_VALUE / 2,让 "INF + INF" 不溢出

第二种更简洁,多数题里 V³ × max_weight ≪ 10⁹ 完全够。

Dijkstra vs Bellman-Ford 的选择

  • 边权全正 → Dijkstra(O((V + E) log V)
  • 边权可能为负 → Bellman-Ford / SPFA(O(V × E)
  • 要检测负环 → 必须 Bellman-Ford
  • 有"边数 ≤ k"约束 → 必须 Bellman-Ford 跑 k 轮(Dijkstra 不行)
  • 全源最短路 + V 小 → Floyd(O(V³)),写起来比 V 遍 Dijkstra 简单

Dijkstra 的"提前返回"

只需要单点最短距离时,目标节点出堆瞬间就能返回。但有些场景不能提前返回

  • 同时要所有节点的距离(如 LC 743)
  • 要数最短路径条数(要等所有等长路径都松弛完,如 LC 1976)

写代码前先想清楚是查单点还是查全表,免得提前 return 漏算其他节点。

复杂度速查

算法时间空间
LC 743 网络延迟Dijkstra 堆O((V + E) log V)O(V + E)
LC 787 K 中转Bellman-Ford 跑 k+1 轮O(k × E)O(V)
LC 2642 图类Dijkstra 堆 / 查询单次 O((V + E) log V)O(V + E)

Pro 会员

解锁完整北美 OA 题库

165 家公司 / 1000+ 道真题 · Python · Java · C++ 三语题解 · 月度更新


7. 按边权特征选算法 + 与其他图算法的关系

按边权特征选算法

最短路算法选型其实就看边权长什么样

边权类型选哪个复杂度
全 1(无权图)普通 BFSO(V + E)
10-1 BFS(双端队列,0 边推前、1 边推后)O(V + E)
非负实数Dijkstra + 堆O((V+E) log V)
含负权(无负环)Bellman-Ford / SPFAO(VE)
全源 + V 小Floyd-WarshallO(V³)
网格 + 启发式估价A* = Dijkstra + h(n)视 heuristic 而定

0-1 BFS 是个常被忽略的好工具——LC 2290 / LC 1368 都是它的典型场景,比上 Dijkstra 常数小一个数量级。

与最小生成树、网络流、A* 的关系

最短路、MST、网络流是同一族"图上贪心 / 松弛"问题的三个不同切面,可以放在一张表对比:

算法族优化目标贪心选择代表算法
最短路Σ w 路径最小dist[v] 最小的未确认节点Dijkstra
最小生成树Σ w 树最小选权重最小的未连接边Prim / Kruskal
最大流Σ flow 流最大选含可行增广路Ford-Fulkerson / Dinic
A* 启发式Σ w + h 最小dist + h 最小(h 是估价)A*

Prim 算法的实现骨架几乎和 Dijkstra 一模一样——把 priority = dist[v] 改成 priority = edge_weight、把 relax(u, v, w) 改成 update(v, w) 就行。A* 进一步把 Dijkstra 的 priority 改成 dist + h(v),在网格寻路(LC 1091 / 1631)里能大幅剪枝。

8. 小结

最短路三件套对应三种场景:

  • Dijkstra(堆优化 O((V + E) log V)):非负权、单源——LC 最短路题里最常考的一类。"过期记录跳过 + 入堆即松弛"是模板。
  • Bellman-Ford / SPFAO(V × E)):允许负权、能检测负环、能限制"边数 ≤ k"。LC 787 是边数限制的代表题。
  • Floyd-WarshallO(V³)):全源最短路、V 小时(通常 ≤ 400)首选。三层循环写起来不容易出错。

跳出最短路这个具体问题,更通用的两条思路:

  • 状态扩展:当原始图的最短路不够用时(比如带 k 步限制),把状态从 (节点) 升到 (节点, 剩余步数)(节点, 边数 mod 2)。LC 787 / LC 864(钥匙状态)都是这套。
  • 最短路 + DP:Dijkstra 算完距离后按 dist 升序做 DP。LC 1786 / LC 1976 都用到。本质是利用"dist 的 DAG 偏序"。

下一节会讲 最小生成树 与并查集——Kruskal / Prim 都和 Dijkstra 共享"堆 + 邻接表"的骨架,跳过来很顺手。


对应灵神题单:图论 · 最短路 / Dijkstra / Bellman-Ford / Floyd

进一步阅读:灵茶山艾府《图论题单》;LeetCode 最短路标签 高频题

On this page