力扣算法解析按专题吃透,不做题海 速查 90 天路线 ⌂ 全部教程

专题 22图论进阶:最短路与最小生成树

专题 12 你学会了在无权图上用 BFS 求最短路——因为每条边「代价相同」,层数天然就是距离。可一旦边带上了权重(比如飞行时间、过路费),BFS 立刻失效: 走两步「近路」可能比走一步「远路」还慢。这一章我们进入带权图的世界,先把最短路的「两大家族」理清,再补上最小生成树。 你会学到:什么时候用 Dijkstra(贪心 + 堆)、什么时候它无能为力而必须上 Bellman-Ford、多源最短路为什么用 Floyd、以及把分散的点连成一张「最省钱网络」的 Kruskal。承接 专题 12 的图遍历基础,本章是面试图论的半壁江山。

学习目标
掌握 Dijkstra / Bellman-Ford / Floyd / Kruskal
前置要求
专题 12 图遍历、专题 20 并查集
本章产出
4 类最短路/生成树模板 + Java 代码
建议学时
9–11 小时

22.1带权图与最短路的两大家族

先建立一张「选择地图」,避免拿到题不知道掏哪个算法。带权最短路主要分三派:

算法思想能处理典型题
Dijkstra贪心 + 小顶堆,每次确定「当前最近」的点边权非负、单源743 网络延迟时间
Bellman-Ford逐边松弛,|V|-1 轮;可限 K 步负权、限中转次数787 K 站中转航班
Floyd三重循环 dp,经中转点 k 会不会更近多源(任意两点)399 除法求值

为什么带权后 BFS 不行了

专题 12 里 BFS 的「层数 = 距离」成立,前提是每条边代价相等(都是 1)。一旦边权不同,走 3 条边未必比走 1 条边远——「先到的不一定更近」。所以必须引入能比较「真实累积代价」的算法,这就是 Dijkstra 和 Bellman-Ford 的出发点。

邻接表怎么建

带权图几乎都用邻接表存:每个节点挂一串「邻居 + 边权」。Java 里最顺手的是 List,其中 int[]{to, weight} 表示「到 to 的边、权重 weight」:

int n = ...;                              // 节点数
List<int[]>[] graph = new List[n];        // 邻接表
for (int i = 0; i < n; i++) graph[i] = new ArrayList<>();
// 加一条有向边 u -> v,权重 w
graph[u].add(new int[]{v, w});
一句话选型:单源 + 无负权 → Dijkstra;要处理负权或限制走几步 → Bellman-Ford;要算所有点对的最短路(且点数小)→ Floyd。最小生成树(把点连起来且总权最小)是另一回事,用 Kruskal / Prim,见 22.5 节。

22.2网络延迟时间:Dijkstra(中等,力扣 743)

中等 核心:小顶堆 · 惰性删除

题意:有 n 个节点(编号 1~n),times[i] = [u, v, w] 表示信号从 u 到 v 需要 w 毫秒(有向)。从节点 k 发出信号,求所有节点都收到信号的最短时间(即「最远的那个节点」收到的时间);若有节点收不到,返回 -1。 例:n=4, k=2, times=[[2,1,1],[2,3,1],[3,4,1]] → 节点 4 最后收到,耗时 2,返回 2。

思路推导

第一步,这本质就是单源最短路。从 k 出发到每个点的最短时间就是「k 到该点的最短路」。因为边权都是正毫秒(非负),用 Dijkstra。

第二步,Dijkstra 的核心直觉——「贪心确定最近点」。维护每个点的「当前已知最短距离」dist[],初始除 k 外都是无穷大。用一个小顶堆(优先队列)存 (距离, 节点),始终弹出「当前距离最小」的点。

关键性质:从堆里 poll 出来的一个点,它此时的距离就是最终最短距离——因为所有还没确定的点距离都更大,不可能再找到一条更短的路绕过它。第一次弹出即确定,这就是 Dijkstra 的正确性基石。

第三步,「惰性删除」为什么安全。一个节点可能被多次入队(每次通过不同邻居松弛出更小的距离)。但我们用一个 visited(或对比 dist)判断:一旦某点已被确定(弹出的距离 ≤ 已知 dist),再遇到它就直接跳过。旧的大距离记录「躺在堆里不用管」,相当于惰性删除,省去手动维护堆的麻烦。

第四步,答案是「最远节点」。所有点都确定后,dist 数组里的最大值就是「最后一个收到信号的时间」。若有任何 dist 仍是无穷大,说明有节点不可达,返回 -1。

参考代码

class Solution {
    public int networkDelayTime(int[][] times, int n, int k) {
        // 建邻接表:graph[u] 存 {v, w}
        List<int[]>[] graph = new List[n + 1];
        for (int i = 1; i <= n; i++) graph[i] = new ArrayList<>();
        for (int[] t : times) graph[t[0]].add(new int[]{t[1], t[2]});

        int[] dist = new int[n + 1];                 // 最短距离
        Arrays.fill(dist, Integer.MAX_VALUE);
        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[] edge : graph[u]) {             // 松弛邻居
                int v = edge[0], w = edge[1];
                if (dist[u] + w < dist[v]) {          // 找到更近的路
                    dist[v] = dist[u] + w;
                    pq.offer(new int[]{dist[v], v});  // 入队(可能重复,靠上面跳过)
                }
            }
        }

        int ans = 0;
        for (int i = 1; i <= n; i++) {
            if (dist[i] == Integer.MAX_VALUE) return -1; // 有节点不可达
            ans = Math.max(ans, dist[i]);                 // 取最远
        }
        return ans;
    }
}

复杂度

O((V+E) log V) 时间(每个点/边最多入堆一次,堆操作 log V),O(V+E) 空间(邻接表 + 堆 + dist)。边权非负时这是最优单源最短路。

边界自查:① 只有 1 个节点 → dist[k]=0,答案 0。② 存在孤立节点(无任何边指向)→ 其 dist 保持 MAX,返回 -1,正确。③ 多个等长路径 → 堆弹出顺序不影响最终 dist 正确性,因为「第一次确定即最短」。

巩固练习(先自己想 15 分钟,卡住再看括号里的提示)

  • 最小体力消耗路径(力扣 1631,中等)——与本题同用 Dijkstra / 优先队列;差异在节点是网格格子、边权是相邻格高度差绝对值,求从 (0,0) 到右下角的「路径上最大高度差」尽量小(提示:把每个格子当点,松弛时取 max(已走最大差, 当前边权) 而非累加)。
  • 阈值距离内邻居最少(力扣 1334,中等)——多次 Dijkstra;差异在对每个节点当源点都跑一遍,统计在阈值内可达的邻居数,取最小(提示:外层枚举源点,内层标准 Dijkstra,计数 ≤threshold 的节点)。
  • 到达目的地的方案数(力扣 1976,中等)——Dijkstra + 计数;差异在松弛时同时维护「最短路条数」(提示:距离相等则方案数相加,更短则覆盖,最后对方案数取模)。

22.3K 站中转内最便宜的航班:Bellman-Ford(中等,力扣 787)

中等 核心:限步松弛 · 备份数组

题意:有 n 个城市,flights[i] = [u, v, w] 表示从 u 到 v 的票价 w。求从 src 到 dst、最多经 K 站中转(即最多坐 K+1 段航班)的最低价格;无法到达返回 -1。 例:n=3, flights=[[0,1,100],[1,2,100],[0,2,500]], src=0, dst=2, k=1 → 经 1 站走 0→1→2 共 200,比直飞 500 便宜,返回 200。

思路推导

第一步,为什么这里 Dijkstra 不合适。本题多了一个硬约束:「最多中转 K 次」。Dijkstra 按距离贪心,可能先选一条「步数多但总价低」的路,而它超过了 K 步限制;或者相反,一条「步数少但贵」的路才是合法的。Dijkstra 本身不区分「走了几步」,硬塞步数维度会把状态搞复杂。限制步数(或处理负权)正是 Bellman-Ford 的强项。

第二步,Bellman-Ford 的本质——「按轮松弛」。标准 Bellman-Ford 做 |V|-1 轮,每轮遍历所有边,尝试用 u→v 把 dist[v] 缩短(松弛)。经过 t 轮松弛后,dist 里装的就是「最多走 t 条边」能到达的最短路——轮数天然对应步数。

第三步,关键的「备份数组」技巧。最容易写错的地方:如果直接在原 dist 上松弛,同一轮里前面更新的结果会被后面「串联」利用,导致一轮就走了多步,破坏了「第 t 轮恰好 t 步」的语义。解决办法:每轮开始前把上一轮的 dist 备份成 prev,这一轮所有松弛都基于 prev 去更新新 dist:

for (每一轮 t = 1 .. K+1):          // K+1 条边 = K 站中转
    prev = dist 的副本
    for (每条边 u->v 票价 w):
        if (prev[u] != INF)
            dist[v] = min(dist[v], prev[u] + w)   // 必须用上一轮的 prev[u]!

这样每一轮严格只「多走一条边」,走满 K+1 条边即满足「最多 K 站中转」。

参考代码

class Solution {
    public int findCheapestPrice(int n, int[][] flights, int src, int dst, int k) {
        int INF = Integer.MAX_VALUE / 2;
        int[] dist = new int[n];
        Arrays.fill(dist, INF);
        dist[src] = 0;

        // 最多 K 站中转 = 最多坐 K+1 段航班 = 松弛 K+1 轮
        for (int t = 1; t <= k + 1; t++) {
            int[] prev = dist.clone();          // 备份上一轮(关键!)
            for (int[] f : flights) {
                int u = f[0], v = f[1], w = f[2];
                if (prev[u] != INF) {            // 基于 prev 松弛,避免一轮串联
                    dist[v] = Math.min(dist[v], prev[u] + w);
                }
            }
        }
        return dist[dst] == INF ? -1 : dist[dst];
    }
}

复杂度

O(K·E) 时间(K+1 轮 × 边数 E),O(V) 空间。当 K 较小而图大时,比跑完整 |V|-1 轮的标准 Bellman-Ford 更快,也比重载 Dijkstra 干净。

忘备份数组 = 隐形 bug:若直接 dist[v] = min(dist[v], dist[u]+w) 而不用 prev,一轮内 u 刚被更新的值会立刻被后续边用到,相当于一步走了好几条边,结果会「偷偷多中转」,与题意「最多 K 站」冲突,且很难察觉。务必每轮 clone 一份旧的再松弛。

巩固练习(先自己想 15 分钟,卡住再看括号里的提示)

  • 规定时间内到达的最小花费(力扣 1928,中等)——与本题同是「限步 Bellman」;差异在约束是时间上限 maxTime 而非中转次数(提示:dp[t][u] 表示第 t 分钟到达 u 的最小花费,按分钟轮次松弛,t 从 1 到 maxTime)。
  • 使网格图至少有一条有效路径(力扣 1368,困难)——最短路的变体;差异在边权只有 0 或 1,用 0-1 BFS(双端队列)比 Bellman 更高效(提示:边权 0 从队头入、边权 1 从队尾入,保证队内按距离有序)。
  • cheapest flights 的 Dijkstra 版(力扣 787 本身也可)——用 (距离, 节点, 步数) 三元组入堆,步数 ≤K 才松弛(提示:把「已走步数」作为状态维,超出 K 不入堆,是另一种正确写法)。

22.4Floyd:多源最短路

核心:dp 三重循环 · 经 k 会不会更近

它解决什么:前面 Dijkstra / Bellman-Ford 都是单源(从一个点出发)。如果题目要你回答「任意两个点之间的最短距离」(比如「从每个城市到其他所有城市各要多久」),对每个点都跑一遍 Dijkstra 是 O(V·(V+E)logV)。当 V 很小(≤200)时,Floyd 用一段极短的代码一次性算完所有点对,更香。

思路推导

核心想法:设 dp[i][j] 是 i 到 j 的当前最短距离。我们允许路径经过编号为 1…k 的点当中转。那么「允许经过 k」相比「只经过 1…k-1」,唯一可能变短的情况是:绕道走 i→k→j 比原来的 i→j 更近。于是得到著名的转移:

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

外层枚举 k(中转点),内层两重枚举 i, j。注意 k 必须在最外层——它代表「逐步放开可经的中转集合」,内层 i,j 顺序无所谓。三个循环都是 0..n-1,构成 O(n³)。

参考代码(短小精悍)

// 初始化:dp[i][i]=0;有直接边 dp[i][j]=w;其余 INF
for (int k = 0; k < n; k++) {            // 中转点,必须在最外层
    for (int i = 0; i < n; i++) {
        for (int j = 0; j < n; j++) {
            if (dp[i][k] < INF && dp[k][j] < INF) {
                dp[i][j] = Math.min(dp[i][j], dp[i][k] + dp[k][j]);
            }
        }
    }
}

复杂度

O(n³) 时间,O(n²) 空间。只适合点数小的图(n≤200 左右)。它还能顺便判传递闭包(可达性)、做除法求值等,见下方巩固练习。

为什么 k 必须在最外层:若把 i 放最外层,意味着「先固定 i,再逐步放开中转」——但此时 dp[i][k] 可能还没用「经过更大中转点」更新过,导致 i→k→j 这条路径没被充分探索。只有 k 最外层,才能保证「放开 k 这一层中转时,i→k 和 k→j 都已是基于 1…k-1 的最优」。

巩固练习(先自己想 15 分钟,卡住再看括号里的提示)

  • 除法求值(力扣 399,中等)——Floyd 做「传递」;差异在边权是比值、运算从加法变乘法(提示:dp[a][b] 存 a/b 的值,转移 dp[i][j] = dp[i][k] * dp[k][j],初始对角为 1)。
  • 课程安排的合法性(力扣 1462,中等)——Floyd 求传递闭包;差异在只关心可达性(布尔)而非距离(提示:reach[i][j] |= reach[i][k] & reach[k][j],判断先修关系是否成环)。
  • 阈值距离内邻居最少(力扣 1334)——先 Floyd 算全源最短路,再数 ≤threshold 的点(提示:和 Dijkstra 版思路互补,图小直接用 Floyd 更省事)。

22.5连接所有点的最小费用:Kruskal(中等,力扣 1584)

中等 核心:边排序 + 并查集判环

题意:平面上有 n 个点,连接点 i 和 j 的代价是它们的曼哈顿距离。求把这些点全部连通(任意两点间有路径)的最小总代价。 例:points = [[0,0],[2,2],[3,10],[5,2],[7,0]] → 最小生成树总代价为 20。

思路推导

第一步,问题转化 = 最小生成树(MST)。「连通所有点 + 总权最小 + 无环」正是 MST 的定义。任意两点都能连(完全图),我们要挑 n-1 条边把 n 个点连成一棵树,且总权最小。

第二步,为什么 Kruskal 直观。贪心思路:能连就尽量连便宜的边。把所有「候选边」按权值从小到大排序,依次考虑:若这条边连接的两个点当前还不连通,就选它(加入生成树);若已经连通(加了会成环),就跳过。选够 n-1 条即停。

第三步,「判环」靠并查集(承接 专题 20)。判断两点是否连通,就是查它们是否同属一个集合——这正是并查集的拿手好戏。选边时 find(u) != find(v) 说明不连通,union 合并;相等则跳过。这样保证最后是一棵无环的树。

第四步,对比 Prim(一句话)。Prim 是「从某个点出发,每次把离已连通集合最近的未连通点拉进来」,用堆维护。Kruskal 是「全局按边排序 + 并查集」。两者都得到 MST,边多(稠密图)用 Prim,边少或想写简单用 Kruskal。

参考代码

class Solution {
    int[] parent;
    int find(int x) {                       // 并查集找根 + 路径压缩
        if (parent[x] != x) parent[x] = find(parent[x]);
        return parent[x];
    }
    void union(int a, int b) { parent[find(a)] = find(b); }

    public int minCostConnectPoints(int[][] points) {
        int n = points.length;
        parent = new int[n];
        for (int i = 0; i < n; i++) parent[i] = i;

        // ① 生成所有边 (u, v, 曼哈顿距离)
        List<int[]> edges = new ArrayList<>();
        for (int i = 0; i < n; i++)
            for (int j = i + 1; j < n; j++) {
                int d = Math.abs(points[i][0] - points[j][0])
                      + Math.abs(points[i][1] - points[j][1]);
                edges.add(new int[]{d, i, j});
            }
        // ② 按权值排序(Kruskal 核心:贪心选最小边)
        edges.sort((a, b) -> a[0] - b[0]);

        int ans = 0, cnt = 0;               // cnt 记录已选边数
        for (int[] e : edges) {
            int d = e[0], u = e[1], v = e[2];
            if (find(u) != find(v)) {       // 不连通才选,避免成环
                union(u, v);
                ans += d;
                cnt++;
                if (cnt == n - 1) break;    // 选够 n-1 条边即连通,停
            }
        }
        return ans;
    }
}

复杂度

建边 O(n²)、排序 O(n² log n²)、并查集近乎 O(1),总 O(n² log n) 时间,O(n²) 空间(边集)。这是 MST 的标准模板,可直接套用到任何「连点最小代价」题。

边界自查:① 只有 1 个点 → 不需要边,返回 0(循环不选边,ans=0)。② 所有点共线且等距 → 排序后依次连通,恰好 n-1 条边。③ 并查集必须初始化 parent[i]=i 且用路径压缩,否则退化为 O(n) 查找会超时。

巩固练习(先自己想 15 分钟,卡住再看括号里的提示)

  • 最低成本联通所有城市(力扣 1135,中等)——与本题几乎同一个 Kruskal 模板;差异在图已给定、部分边已免费(connections 里有)(提示:把给定边按权排序,免费边权当 0 优先选,最后若边数不足 n-1 则无法连通返回 -1)。
  • 水资源分配(力扣 1168,困难)——Kruskal 的「虚拟节点」技巧;差异在每个城市有独立的井造价,引入一个虚拟水源点,把「打井」当成连到水源的边(提示:建虚拟节点 0,城市 i 连 0 的权 = 井造价,再和管道边一起排序跑 Kruskal)。
  • 以图判树(力扣 261,简单)——并查集判「连通且无环」;差异在不求最小权、只判断给定 n-1 条边能否构成树(提示:边数为 n-1 且用并查集 union 时不出现同集合,即连通无环 → 是树)。

22.6本专题常见坑

  • 坑一:Dijkstra 用于负权边。Dijkstra 的「贪心确定最近点」依赖「边权非负」——若有负权,一个已弹出的点可能之后被更负的边拉得更近,第一次弹出就不再是最短,算法正确性崩塌。负权一律用 Bellman-Ford(或 SPFA,但面试少用)。
  • 坑二:Bellman-Ford 忘备份数组。直接在原 dist 上松弛会让一轮内更新「串联」,一步多走多步,破坏「第 t 轮恰 t 步」语义,导致中转次数约束失效。务必每轮 clone 成 prev 再基于它松弛(见 22.3 节)。
  • 坑三:Kruskal 忘用并查集直接扫边。若只按权排序、不加「是否连通」判断就全选,会连出环、且选超过 n-1 条边。必须用并查集 find 判同集合,union 合并,选够 n-1 即停。
  • 坑四:INF 取值导致加法溢出。用 Integer.MAX_VALUE 当 INF,再做 dist[u] + w 会整数溢出变负,松弛判断全乱。习惯用 MAX_VALUE / 2 或先判 dist[u] != INF 再相加。
  • 坑五:Floyd 的 k 循环放错层。k 必须在最外层,否则中转点没被充分放开,结果漏算部分路径。三循环顺序记住「k, i, j」。

22.7自测题

Q1.【动手题】给定 n=4 的带权有向图,边权均非负。请口述 Dijkstra 从节点 1 出发的前三步(堆里依次弹出什么、dist 如何更新),并说明为什么「第一次弹出的距离就是最终最短距离」。

初始 dist[1]=0、其余 INF,堆里 {(0,1)}。第一步 poll (0,1),确定节点 1 最短距离 0,松弛其邻居更新 dist 并入堆。第二步 poll 当前堆中距离最小者,其距离即最终值(因为剩余未确定点距离都更大,不可能再找到更短路绕过它)。第三步同理。核心:贪心 + 「已确定点不会再被更优更新」(依赖非负边权)。

Q2. 为什么「K 站中转内最便宜航班」不适合直接套 Dijkstra,而用 Bellman-Ford 的限步松弛?

因为约束是「最多走 K 段边(K 站中转)」,属于步数限制。Dijkstra 按距离贪心、不区分走了几步,可能先采纳一条步数超标的低价路或漏掉合法的少步高价路。Bellman-Ford 每轮严格多走一条边,轮数 = 边数,天然匹配「步数」维度,用备份数组保证每轮只增一步。

Q3. 最小生成树的 Kruskal 和单源最短路的 Dijkstra,都用到「贪心选最小」,区别在哪?

Dijkstra 选的是「从源点出发、当前距离最小的点」(基于点到源的距离,且需非负权 + 堆);Kruskal 选的是「全局权值最小的、且不造成环的边」(基于边权排序 + 并查集)。一个服务于「单源到各点最近」,一个服务于「连通所有点总权最小」。
学完这章,你应该能回答:① 拿到带权图最短路题,如何根据「单源/多源、有无负权、是否限步」选 Dijkstra / Bellman-Ford / Floyd?② 手写 Dijkstra 的小顶堆 + 惰性删除?③ 用备份数组实现限 K 步松弛?④ 用 Kruskal + 并查集求最小生成树?⑤ 说出本专题五个常见坑的应对。