1. 从“最短路径”说起:为什么我们需要 Bellman-Ford?

大家好,我是老张,在算法和工程领域摸爬滚打了十几年。今天想和大家聊聊一个听起来有点“古典”,但在关键时刻能救命的算法——Bellman-Ford。很多朋友一听到最短路径,第一反应就是大名鼎鼎的 Dijkstra 算法。确实,Dijkstra 又快又好用,但它有个致命的“洁癖”:它要求图中不能有负权边。什么叫负权边?简单说,就是走这条路不仅不花钱,还能“赚钱”,或者说,能减少你的总成本。想象一下物流运输,有些路段因为补贴或特殊协议,运费是负的;或者金融网络里,某些交易存在套利空间(成本为负)。在这些场景下,Dijkstra 就彻底失灵了,因为它基于一个“当前最短路径就是全局最短”的贪心假设,一旦有负权边,这个假设就不成立了。

这时候,Bellman-Ford 算法就该登场了。它没有 Dijkstra 那么“娇气”,能坦然面对图中存在的负权边。它的核心思想非常朴素,甚至有点“笨”:既然我不知道最优解在哪里,那我就把所有可能的路都反复检查、反复优化,直到再也优化不动为止。这种思想在算法里叫做“动态规划”或者说“松弛”。我刚开始学的时候也觉得它效率不高,但后来在解决实际问题,尤其是在 LeetCode 上刷一些特定题目,以及准备考研 408 时,才发现它的设计精妙和不可替代性。它就像一把万能钥匙,虽然开锁速度可能不如特制的钥匙快,但能开的锁种类最多。

那么,Bellman-Ford 算法最适合谁呢?如果你是正在备战 LeetCode 面试的求职者,那么像“787. K 站中转内最便宜的航班”这类题目就是它的经典战场。如果你是计算机专业考研党,尤其是目标 408 统考的同学,Bellman-Ford 的原理、复杂度分析、与 Dijkstra/Floyd 的对比,都是选择题和大题的高频考点。即便你只是个对算法感兴趣的开发者,理解它也能帮你建立起更完整的图论世界观,知道在什么情况下该用什么工具。接下来,我就带你从最根本的原理开始,一步步拆解这个算法,并用大量实战代码和真题,让你彻底搞懂、会用。

2. 庖丁解牛:Bellman-Ford 算法的核心思想与执行步骤

2.1 核心思想:松弛操作与暴力迭代

Bellman-Ford 算法的全部奥秘,其实就藏在“松弛”这两个字里。我们可以用一个生活中的类比来理解:假设你要规划一个从北京到全国所有城市的最省钱交通方案。一开始,你只知道从北京到北京的成本是0,到其他所有城市的成本你完全不知道,可以认为是无穷大。

“松弛”操作就是,你不断地去核查每一条具体的航线或铁路。比如你查到一条从北京(u)直飞上海(v)的航线,价格是 1000 元(w)。你一看记录本,发现目前“到北京的成本”是0,“到上海的成本”记录是无穷大。显然,0 + 1000 < 无穷大,那么你就更新记录:“到上海的成本”可以优化为 1000。这个过程,就是一次成功的松弛。

Bellman-Ford 算法做的就是:拿着这个记录本,把全国所有的交通线路(图中的所有边)从头到尾彻底检查一遍,尝试进行松弛。检查完一遍,可能更新了一些城市的成本。但这就够了吗?不够。因为可能存在中转更便宜的路线。比如,北京->石家庄(成本200),石家庄->上海(成本300),总成本500,比直飞的1000更便宜。这个信息在第一轮检查北京->上海边时是不知道的,因为那时“到石家庄的成本”可能还没更新出来。所以,我们需要把“检查所有边”这个动作,重复执行多次。

那么,要重复多少次呢?在一个没有“负权回路”的图中,从起点到任意一点的最短路径,最多会经过 V-1 条边(V是顶点数)。想象一下,从一个城市到另一个城市,如果不走回头路,最多经过所有其他城市一次。所以,我们最多只需要进行 V-1 轮全局边的松弛操作,就一定能找到所有可能的最短路径。这就是 Bellman-Ford 算法迭代次数的由来。它不聪明,但很稳妥,通过这种近乎暴力的全局排查,确保不漏掉任何优化的可能。

2.2 标准执行步骤与代码模板

理解了思想,我们来看标准步骤。我会给出一个清晰的、可以当作模板来记忆的流程,并配上详细的代码注释。

步骤一:初始化 创建一个距离数组 dist[],长度为顶点数 V。将起点 src 的距离设为 0,即 dist[src] = 0,其他所有顶点的距离初始化为一个很大的数,代表无穷大(在代码中常用 Integer.MAX_VALUE 或一个很大的常数)。

步骤二:迭代松弛 进行 V-1 轮循环。在每一轮循环中,遍历图中的所有边。对于每一条边 (u, v, w)(表示从 u 到 v 有一条权重为 w 的边),尝试进行松弛操作: 如果 dist[u] != INFdist[u] + w < dist[v],那么我们就找到了一个更短的路径,更新 dist[v] = dist[u] + w。 注意,在每一轮中,我们使用的是上一轮结束后的 dist 数组来进行本轮的松弛判断。在代码实现时,有一个常见的优化技巧:使用一个临时数组 tempDist 来保存本轮松弛的结果,避免本轮松弛产生的新距离立即影响同轮中其他边的判断(这属于一种优化,不影响正确性)。

步骤三:检测负权回路 再进行一次(第 V 次)对所有边的遍历。如果这次遍历中,还有任意一条边 (u, v, w) 满足 dist[u] + w < dist[v],那就说明图中存在从源点可达的负权回路。因为理论上经过 V-1 轮松弛后,最短路径应该已经确定,如果还能被松弛,就意味着可以沿着这个回路一直绕圈,总成本无限降低,最短路径也就不存在了。

下面是一个最基础的、用于解决“从源点到所有点最短路径”问题的 Java 模板:

import java.util.Arrays;

public class BellmanFordTemplate {
    class Edge {
        int u, v, w; // 起点,终点,权重
        Edge(int u, int v, int w) { this.u = u; this.v = v; this.w = w; }
    }

    public int[] bellmanFord(int V, Edge[] edges, int src) {
        // 1. 初始化
        int[] dist = new int[V];
        Arrays.fill(dist, Integer.MAX_VALUE);
        dist[src] = 0;

        // 2. 迭代松弛 V-1 轮
        for (int i = 0; i < V - 1; i++) {
            // 这里使用一个临时数组来记录本轮更新,避免“串联更新”,是标准写法之一
            int[] tempDist = Arrays.copyOf(dist, V);
            boolean updated = false; // 可选:用于提前终止,如果本轮没有任何更新,说明已收敛
            for (Edge e : edges) {
                if (dist[e.u] != Integer.MAX_VALUE && dist[e.u] + e.w < tempDist[e.v]) {
                    tempDist[e.v] = dist[e.u] + e.w;
                    updated = true;
                }
            }
            dist = tempDist;
            if (!updated) break; // 提前终止优化
        }

        // 3. 检测负权回路
        for (Edge e : edges) {
            if (dist[e.u] != Integer.MAX_VALUE && dist[e.u] + e.w < dist[e.v]) {
                // 抛出异常或返回特殊值,表明存在从src可达的负权回路
                throw new RuntimeException("图中存在从源点可达的负权回路,无最短路径");
            }
        }
        return dist;
    }
}

这个模板是理解一切的基础。我强烈建议你亲手敲一遍,并用一个小例子(比如4个顶点,5条边,包含正负权)来单步调试,观察 dist 数组在每一轮迭代后的变化。你会发现,最短路径的信息像波浪一样,从源点一层层传播出去,非常直观。

3. LeetCode 高频题实战:当 Bellman-Ford 遇上“限制条件”

纸上得来终觉浅,绝知此事要躬行。理解了模板,我们来看看它在 LeetCode 上如何大显身手。这里我选了两道非常经典,且能体现 Bellman-Ford 算法灵活性的题目。

3.1 经典变形:LeetCode 787. K 站中转内最便宜的航班

这道题是 Bellman-Ford 的“明星应用题”。题目要求:在最多经过 K 次中转(即乘坐 K+1 次航班)的限制下,找到最便宜的票价。这正好契合了 Bellman-Ford 算法“进行 V-1 轮迭代”的特性——每一轮迭代,可以理解为从源点向外扩展一层,找到了所有“最多使用 i 条边”的最短路径。

解题思路关键点:

  1. 迭代次数:常规 Bellman-Ford 迭代 V-1 轮是为了找到全局最短(无限制边数)。这里限制最多 K 次中转,意味着路径最多包含 K+1 条边。因此,我们只需要进行 K+1 轮 迭代即可。
  2. 防止“串联更新”:这是本题实现的核心技巧,也是容易出错的地方。在某一轮迭代中,我们必须使用上一轮结束后的距离数组来更新本轮。如果直接使用正在更新的当前轮数组,就可能出现“用了多条边”的情况,违反了轮次限制。上面模板中使用 tempDist 数组就是为了解决这个问题。

Java 代码实现与逐行解析:

class Solution {
    public int findCheapestPrice(int n, int[][] flights, int src, int dst, int k) {
        // 初始化距离数组,dist[i] 表示从 src 到 i 的最小花费
        int[] dist = new int[n];
        Arrays.fill(dist, Integer.MAX_VALUE);
        dist[src] = 0; // 起点花费为0

        // 进行 k+1 轮松弛(k次中转意味着最多k+1条边)
        for (int i = 0; i <= k; i++) {
            // 关键:使用上一轮的结果进行本轮更新,复制数组
            int[] tempDist = Arrays.copyOf(dist, n);
            boolean hasUpdate = false; // 可选优化,记录本轮是否有更新

            // 遍历所有航班(边)
            for (int[] flight : flights) {
                int u = flight[0], v = flight[1], w = flight[2];
                // 如果起点 u 在上轮是可到达的,且通过 u 到 v 的路径更优
                if (dist[u] != Integer.MAX_VALUE && dist[u] + w < tempDist[v]) {
                    tempDist[v] = dist[u] + w;
                    hasUpdate = true;
                }
            }
            // 本轮结束,将临时数组赋值给 dist,作为下一轮的“上一轮结果”
            dist = tempDist;
            // 如果本轮没有任何更新,提前结束,后续轮次也不会再有更新
            if (!hasUpdate) break;
        }
        // 返回结果,如果不可达(值仍为初始最大值)则返回 -1
        return dist[dst] == Integer.MAX_VALUE ? -1 : dist[dst];
    }
}

踩坑提醒: 我最初做这道题时,曾试图用 Dijkstra 的优先队列思路去改造,结果在处理“限制边数”上异常复杂。而 Bellman-Ford 的这种“轮次”概念天然适合这种限制。记住,看到“最多经过 K 条边/个节点”这类限制,优先考虑 Bellman-Ford 的迭代轮次思想

3.2 状态扩展:LeetCode 1345. 跳跃游戏 IV

这道题看起来是个数组跳跃问题,但可以巧妙地转化为图的最短路径问题,用 Bellman-Ford 的思想来解决。

建模思路:

  • 顶点:数组的每个索引 i 就是一个顶点。
  • :从顶点 i 可以跳到顶点 i + nums[i]i - nums[i](如果下标合法)。同时,题目还有一个隐含条件:值相同的元素之间可以任意跳转,成本极低(这需要特殊处理,是本题难点)。
  • 边权:每一次跳跃的代价视为 1。我们的目标是求从起点 start 到终点 goal最少跳跃次数,也就是最短路径长度。

直接用标准的 Bellman-Ford 对每个顶点和它的两条边进行 V-1 轮松弛,在理论上是可行的,但效率可能不高。然而,其核心的“广度优先松弛”思想(即 BFS)是解题关键。我们可以使用队列(BFS)来模拟这个“一轮一轮向外扩展”的过程,这其实是 Bellman-Ford 在边权为 1 时的特化和优化。

BFS 解法(体现 Bellman-Ford 思想):

class Solution {
    public int minJumps(int[] arr) {
        int n = arr.length;
        if (n == 1) return 0;

        // 预处理:将值相同的索引归类,便于实现“值相同任意跳”
        Map<Integer, List<Integer>> valueIndices = new HashMap<>();
        for (int i = 0; i < n; i++) {
            valueIndices.computeIfAbsent(arr[i], k -> new ArrayList<>()).add(i);
        }

        Queue<Integer> queue = new LinkedList<>();
        boolean[] visited = new boolean[n];
        queue.offer(0);
        visited[0] = true;
        int steps = 0;

        while (!queue.isEmpty()) {
            int size = queue.size();
            // 这个 for 循环处理的就是“当前轮次”的所有顶点
            // 类似于 Bellman-Ford 的一轮迭代
            for (int i = 0; i < size; i++) {
                int curIdx = queue.poll();
                if (curIdx == n - 1) return steps; // 到达终点

                // 向 i+1 跳 (Bellman-Ford 中的一条边)
                int next = curIdx + 1;
                if (next < n && !visited[next]) {
                    visited[next] = true;
                    queue.offer(next);
                }
                // 向 i-1 跳 (另一条边)
                next = curIdx - 1;
                if (next >= 0 && !visited[next]) {
                    visited[next] = true;
                    queue.offer(next);
                }
                // 向所有值相同的索引跳 (一组特殊的边)
                List<Integer> sameValues = valueIndices.get(arr[curIdx]);
                if (sameValues != null) {
                    for (int idx : sameValues) {
                        if (!visited[idx]) {
                            visited[idx] = true;
                            queue.offer(idx);
                        }
                    }
                    // 关键:清空这个值的列表,避免后续重复遍历,巨大优化
                    sameValues.clear();
                }
            }
            steps++; // 完成一轮,步数+1
        }
        return -1;
    }
}

这道题告诉我们,Bellman-Ford 不仅仅是一个具体的代码模板,更是一种“通过多轮迭代逐步逼近最优解”的思想。在很多求“最少步数”、“最短转换次数”的问题中,这种思想(常以 BFS 形式实现)是解题的利器。

4. 考研 408 核心考点深度剖析与备考策略

对于考研 408 的同学来说,Bellman-Ford 算法是数据结构图论部分的重中之重。它不仅仅要求你记住步骤,更要求你理解其内在原理、能进行复杂度分析、并与其他算法进行对比。下面我结合历年考题风格,梳理出几个核心考点和备考方法。

4.1 考点一:算法原理、过程模拟与代码填空

这是最直接的考法。给你一个带权有向图(很可能包含负权边,但无负权回路),让你:

  1. 描述 Bellman-Ford 算法的基本思想。
    • 答题要点:强调“松弛”和“迭代”。说明算法通过对所有边进行 |V|-1 轮松弛操作,逐步求得单源最短路径。能处理负权边,并能检测负权回路。
  2. 模拟算法的执行过程。
    • 备考策略:找 4-5 个顶点的小图,手工模拟。准备一个表格,列出每一轮迭代后,每个顶点的 dist 值。务必注意,在模拟某一轮时,使用的是上一轮结束后的 dist来更新本轮。这是手动模拟最容易出错的地方。
  3. 给出算法伪代码或代码片段,要求填空。
    • 常考空:初始化 dist 数组、循环次数(V-1)、松弛操作的条件判断(if (dist[u] != INF && dist[u] + w < dist[v]))、负权回路检测的循环。

例题模拟: 假设有图 G,顶点集 {A, B, C, D},边集 (A->B: 4), (A->C: 2), (B->C: -3), (C->D: 1), (D->B: 2)。以 A 为源点。

  • 初始化:dist[A]=0, dist[B]=dist[C]=dist[D]=∞。
  • 第一轮(对所有边松弛):
    • A->B: 0+4<∞, dist[B]=4
    • A->C: 0+2<∞, dist[C]=2
    • B->C: 4+(-3)=1 < 2, dist[C]=1 (更新!)
    • C->D: 1+1=2 < ∞, dist[D]=2
    • D->B: 2+2=4 = dist[B], 不更新。
  • 第二轮:基于第一轮结果继续松弛...
    • A->B: 0+4=4 = dist[B], 不更新。
    • A->C: 0+2=2 > dist[C]=1, 不更新。
    • B->C: 4+(-3)=1 = dist[C], 不更新。
    • C->D: 1+1=2 = dist[D], 不更新。
    • D->B: 2+2=4 = dist[B], 不更新。 第二轮无更新,算法提前终止。最终 dist 即为最短路径。

4.2 考点二:时间复杂度、空间复杂度及证明

时间复杂度:O(|V| * |E|)

  • 为什么? 外层循环 |V|-1 轮,内层循环遍历所有 |E| 条边。这是最坏情况。在代码中,我们可以增加一个提前结束的标志(如某一轮无任何松弛),但在复杂度分析时通常按最坏情况考虑。
  • 可能考法:“为什么是 O(|V|*|E|) 而不是 O(|E|)?” 你需要回答,因为需要 |V|-1 轮才能保证在最坏情况下(如图是一条链)信息能从源点传播到最远的顶点。

空间复杂度:O(|V|)

  • 为什么? 主要开销是存储 dist 数组(大小 |V|)和 predecessor 数组(可选,大小 |V|)。边的存储通常不计入算法的空间复杂度,因为图结构是输入。

与 Dijkstra 算法的对比表格(高频考点):

特性Bellman-Ford 算法Dijkstra 算法
适用图类型带权有向图,允许负权边带权有向/无向图,所有权重非负
核心思想动态规划/松弛,暴力迭代所有边贪心,每次从未确定顶点中选取距离最小的
时间复杂度O(|V| * |E|)普通数组实现 O(|V|²),二叉堆优化 O((|V|+|E|)log|V|)
空间复杂度O(|V|)O(|V|)
能否检测负环(进行第V
经典应用场景存在负权边、限制路径边数(如K次中转)路由算法、地图导航等权值为非负的场景

4.3 考点三:负权回路检测与算法正确性理解

这是 Bellman-Ford 算法区别于其他最短路径算法的精髓,也是 408 可能出简答题或判断题的地方。

负权回路(负环):如果一个图中存在一个回路,其各边权重之和为负数,那么这个回路就是负权回路。如果这个回路可以从源点到达,那么最短路径问题就无解,因为可以沿着这个回路无限绕圈,使总路径长度趋于负无穷。

检测原理:算法进行 |V|-1 轮松弛后,理论上所有最短路径都应该已经被找到。如果再进行第 |V| 轮松弛,仍然有边可以被松弛(即 dist[u] + w < dist[v] 成立),那就说明图中存在从源点可达的负权回路。因为只有负环的存在,才能让路径在超过 |V|-1 条边后继续变短。

备考建议:一定要理解这个证明思路。可以自己画一个包含负环的小图,模拟算法过程,看看第 |V| 轮松弛是如何还能成功的。这不仅能帮你应对考题,更能加深对算法本质的理解。

4.4 备考策略与实战建议

  1. 理解优先于记忆:不要死记硬背代码。理解“松弛”、“迭代轮次”、“负环检测”这三个核心概念。自己动手画图模拟是最高效的学习方法。
  2. 对比学习:将 Bellman-Ford 与 Dijkstra、Floyd-Warshall 算法放在一起对比学习。制作一个对比表格,从思想、适用条件、复杂度、代码实现等方面进行总结。408 非常喜欢考这种对比。
  3. 动手实现:在 IDE 里把标准模板和 LeetCode 787 的代码敲一遍,并用不同的测试用例进行调试。调试是理解算法数据流动的最佳方式。
  4. 刷真题:找历年 408 统考和各大高校考研真题中关于最短路径的题目。重点练习过程模拟和复杂度分析类的题目。
  5. 思考变形:思考如果问题变了,比如求单源最长路径(无正环)、或者限制路径节点数,Bellman-Ford 的思想如何调整?这种举一反三的能力是拿高分的关键。

5. 进阶:从算法到思想——Bellman-Ford 的启示

聊了这么多具体题目和考点,我想最后分享一下 Bellman-Ford 算法给我个人带来的一些更深的启示。这不仅仅是一个算法,更体现了一种解决问题的哲学。

首先,它拥抱不完美,追求稳健。 Dijkstra 算法很快,但它建立在“所有边权非负”这个理想假设上。现实世界充满复杂性,成本可能为负(利润),距离可能为负(引力势能)。Bellman-Ford 放弃了“一步到位”的贪心幻想,选择了更笨拙但更普适的“反复检查、逐步优化”策略。在工程中,这种对边界条件和异常情况的包容性,往往比峰值性能更重要。我记得早年做一个分布式系统的成本优化调度时,网络链路模型里就存在类似“负权”的缓存收益,正是 Bellman-Ford 的这种特性让我们找到了可行的方案。

其次,它的“轮次”概念是处理“阶段限制”问题的利器。 就像 LeetCode 787 题中的“K 次中转”,很多现实问题都有类似的限制:最多经过 N 个中间节点、最多进行 K 次操作、项目最多有 M 个阶段等等。Bellman-Ford 的迭代框架天然地将“阶段”或“步数”作为外层循环,使得我们可以清晰地计算“最多进行 i 步时的最优解”。这种思想可以迁移到许多动态规划问题中。

最后,它揭示了“局部更新”与“全局收敛”的关系。 每一轮,算法只利用当前已知的、可能还不是最优的信息(dist[u])去尝试优化邻居(dist[v])。这种局部操作非常简单。但通过多轮迭代,这些局部优化信息会像涟漪一样传播到整个网络,最终达到全局最优(如果没有负环)。这有点像分布式系统中的一致性协议,每个节点根据邻居的信息调整自己的状态,经过多轮通信后达到全局一致。

所以,当你下次再遇到一个复杂的最优化问题,特别是涉及阶段、存在“负收益”可能性、或者需要稳健解决方案时,不妨想想 Bellman-Ford 这个老朋友。它的代码也许不炫酷,时间复杂度也不低,但它提供的那种扎实、可靠、普适的解决思路,是许多更高级算法的基础。在考研复习中吃透它,你收获的将不止是几道题的分数,更是一种分析、建模和解决图论问题的底层思维。

Logo

北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

更多推荐