迪杰斯特拉与贝尔曼-福特算法:Matlab实现最短路径问题详解
2026/8/27 3:33:41 网站建设 项目流程

1. 从地图导航到网络路由:最短路径问题的现实面孔

如果你用过手机地图导航,或者在网上购物时看到过物流的预计送达时间,那么你已经亲身体验过最短路径算法的威力了。这些看似简单的功能背后,是图论这门古老而又充满活力的数学分支在默默支撑。图论把现实世界中错综复杂的关系——比如城市间的公路网、社交网络中的好友关系、电路板上的元器件连接——抽象成由“点”和“边”构成的“图”。而最短路径问题,就是在这个抽象的图上,寻找从一个起点到一个终点,总“代价”最小的那条通路。这个“代价”可以是物理距离、通行时间、经济成本,甚至是网络传输的延迟。

在数学建模竞赛和实际的工程问题中,最短路径问题无处不在。比如,在物流配送中规划最优行车路线以节省燃油和时间;在通信网络中为数据包选择最不拥堵的传输路径;甚至在社交网络分析中,计算两个人之间通过多少层关系可以建立联系(“六度空间”理论)。解决这类问题的核心武器,就是一系列高效的最短路径算法。今天,我们就来深入探讨其中最经典、应用最广的两位“明星”:迪杰斯特拉算法贝尔曼-福特算法。更重要的是,我们不仅会拆解它们的原理,还会手把手带你用Matlab实现它们,让你不仅能懂理论,更能上手实践,解决实际问题。

2. 迪杰斯特拉算法:稳扎稳打的“贪婪”探索者

迪杰斯特拉算法由荷兰计算机科学家艾兹赫尔·迪杰斯特拉于1956年提出,它的核心思想非常直观:每次从未被访问的节点中,选择当前距离起点最短的那个节点,然后通过这个节点去更新它所有邻居节点的距离。这个过程就像一个谨慎的探险家,每一步都选择当前看来最优的路径向前探索,并不断修正对整个地图的认识。

2.1 算法原理与“贪婪”策略的运作逻辑

为什么说它是“贪婪”的?因为它在每一步都只着眼于当前的最优解(距离起点最近的未访问节点),而不考虑这个选择对后续路径的全局影响。幸运的是,对于所有权重都为非负数的图,这种局部最优的选择策略恰恰能导向全局最优解。这是迪杰斯特拉算法成立的前提,也是它最大的局限性。

算法的执行过程可以概括为以下几个步骤,我们用一个简单的带权有向图来演示。假设我们有节点A、B、C、D、E,起点为A,边的权重代表距离或成本。

  1. 初始化:设置一个距离数组dist,记录所有节点到起点A的当前最短距离估计。起点A的距离设为0,其他所有节点设为无穷大(Inf)。同时,创建一个集合visited来记录已经找到最短路径的节点,初始为空。
  2. 迭代寻找:在未访问节点(即不在visited集合中的节点)中,找到dist值最小的节点,记为u。在第一次迭代中,u就是起点A(dist[A]=0)。
  3. 标记与更新:将节点u加入visited集合,表明我们已经找到了从起点到u的最终最短路径。然后,遍历u的所有邻居节点v。对于每个邻居v,检查如果通过u到达v是否更短,即判断dist[u] + weight(u, v) < dist[v]是否成立。如果成立,就更新dist[v]为这个更小的值,同时可以记录下v的前驱节点是u,以便最后回溯路径。
  4. 循环:重复步骤2和3,直到所有节点都被访问(即visited包含所有节点),或者我们只关心到某个特定终点的路径,那么当该终点被访问时即可提前终止。

这个过程保证了每个节点第一次被加入visited集合时,dist中对应的值就是从起点到该节点的最短距离。因为所有边权非负,不可能存在通过后续其他未访问节点绕回来得到更短路径的情况。

2.2 手动演算:一步步看清算法轨迹

让我们用一个具体例子来手动走一遍。考虑下图(用邻接矩阵表示,Inf表示无边直接相连): 起点:1 邻接矩阵(G[i][j]表示从i到j的边权):

G = [0, 10, Inf, 30, 100; Inf, 0, 50, Inf, Inf; Inf, Inf, 0, Inf, 10; Inf, Inf, 20, 0, 60; Inf, Inf, Inf, Inf, 0];

节点:1, 2, 3, 4, 5。目标是求从节点1到所有其他节点的最短路径。

初始化dist = [0, Inf, Inf, Inf, Inf]visited = []

第一轮: 未访问节点中dist最小的是节点1(值为0)。访问节点1。 更新邻居:节点2(dist[2] = min(Inf, 0+10)=10),节点4(dist[4] = min(Inf, 0+30)=30),节点5(dist[5] = min(Inf, 0+100)=100)。dist变为[0, 10, Inf, 30, 100]visited = [1]

第二轮: 未访问节点(2,3,4,5)中dist最小的是节点2(值为10)。访问节点2。 更新邻居:节点3(dist[3] = min(Inf, 10+50)=60)。dist变为[0, 10, 60, 30, 100]visited = [1, 2]

第三轮: 未访问节点(3,4,5)中dist最小的是节点4(值为30)。访问节点4。 更新邻居:节点3(dist[3] = min(60, 30+20)=50),节点5(dist[5] = min(100, 30+60)=90)。dist变为[0, 10, 50, 30, 90]visited = [1, 2, 4]

第四轮: 未访问节点(3,5)中dist最小的是节点3(值为50)。访问节点3。 更新邻居:节点5(dist[5] = min(90, 50+10)=60)。dist变为[0, 10, 50, 30, 60]visited = [1, 2, 4, 3]

第五轮: 未访问节点只有节点5(值为60)。访问节点5。它没有出边,无需更新。 最终dist = [0, 10, 50, 30, 60]visited = [1, 2, 4, 3, 5]

所以从节点1到节点5的最短路径长度为60。通过记录前驱节点(节点5的前驱是3,3的前驱是4,4的前驱是1),可以回溯出路径:1 -> 4 -> 3 -> 5。

2.3 复杂度分析与适用场景

迪杰斯特拉算法最直接的实现方式是使用数组来存储dist和查找最小值,其时间复杂度为 O(V²),其中V是节点数。这在节点数量不多时(比如几百个)是完全可行的。但对于大型稀疏图(比如社交网络、网页链接图,节点数V可能上万甚至百万,但每个节点的连接边数有限),O(V²)的复杂度就难以接受了。

因此,在实际应用中,我们几乎总是使用**优先队列(通常用最小堆实现)**来优化“查找未访问节点中dist最小值”这一步。这样,每次提取最小值的操作是O(log V),而每个节点和每条边都会被处理一次,总时间复杂度可以优化到 O((V+E) log V),其中E是边数。对于稀疏图(E ~ O(V)),这比O(V²)要好得多。

注意:在Matlab中,虽然没有内置的堆数据结构,但我们可以通过巧妙利用矩阵操作和排序来模拟,或者使用较新的min函数配合逻辑索引来实现高效查找。但要注意,对于超大规模图,纯Matlab脚本可能效率不如C++等编译型语言结合专用图算法库。不过,对于数学建模竞赛和大多数工程问题中的网络规模,Matlab实现是完全够用的。

迪杰斯特拉算法的核心适用前提是图中所有边的权重必须为非负数。一旦出现负权边,算法基于的“当前最短路径即全局最短路径”的假设就不成立了,因为未来可能通过一条负权边,让一条当前更长的路径变得比当前最短路径更短。这时,我们就需要请出下一位更强大的选手。

3. 贝尔曼-福特算法:能处理负权的“耐力”选手

贝尔曼-福特算法以其发明者理查德·贝尔曼和莱斯特·福特命名。它与迪杰斯特拉的“贪婪”策略不同,采用了一种更为“暴力”但全面的策略:对图中所有边进行反复松弛操作,直到无法再更新任何节点的最短距离估计为止。它的最大优势,就是能够处理权重为负数的边。

3.1 算法原理:松弛操作与动态规划思想

算法的核心操作叫做“松弛”(Relaxation)。对于一条从节点u到节点v、权重为w的边,松弛操作就是检查:如果dist[u] + w < dist[v],那么我们就找到了一个到v的更短路径,于是更新dist[v] = dist[u] + w

贝尔曼-福特算法的基本思想是动态规划。它定义dist[k][v]为从起点出发,最多经过 k 条边,到达节点v的最短路径长度。那么,经过最多 k+1 条边到达v的最短路径,要么是经过最多 k 条边到达v的路径(不经过第 k+1 条边),要么是经过最多 k 条边到达某个前驱节点u,然后再经过边(u, v)。这正好对应了松弛操作。

算法的标准实现非常简洁:

  1. 初始化:与迪杰斯特拉相同,dist[起点] = 0,其他为Inf
  2. 外层循环:进行V-1轮迭代(V为节点数)。在每一轮中,遍历图中的每一条边,并对每条边执行一次松弛操作。
  3. 检测负权环:再进行一轮对所有边的遍历。如果在这一轮中,还能对任何一条边进行成功的松弛操作,那么说明图中存在从起点可达的负权环。因为在一个没有负权环的图中,最短路径最多包含 V-1 条边,经过 V-1 轮松弛后必然已经找到所有最短路径。如果还能继续松弛,说明可以通过无限次绕行负权环来使路径长度无限减小,最短路径不存在。

为什么是 V-1 轮?因为在没有负权环的图中,任意两点间的最短路径最多经过 V-1 条边(否则路径中必然包含环,而如果是正权环或零权环,去掉它会得到更短或等长的路径;如果是负权环,则最短路径不存在)。因此,经过 V-1 轮对所有边的全面松弛,足以让最短路径的信息从起点传播到任何一个节点。

3.2 手动演算:感受负权边的处理过程

考虑一个包含负权边但不含负权环的图。节点:1, 2, 3, 4。起点为1。 边集:(1->2, 4), (1->4, 5), (2->4, -2), (3->2, -10), (4->3, 3)。

初始化dist = [0, Inf, Inf, Inf]

第一轮松弛(遍历所有边)

  • (1->2,4):dist[2] = min(Inf, 0+4)=4
  • (1->4,5):dist[4] = min(Inf, 0+5)=5
  • (2->4,-2):dist[4] = min(5, 4+(-2))=2// 更新!
  • (3->2,-10):dist[2] = min(4, Inf+(-10))=4// 无变化
  • (4->3,3):dist[3] = min(Inf, 2+3)=5第一轮后:dist = [0, 4, 5, 2]

第二轮松弛

  • (1->2,4):dist[2] = min(4, 0+4)=4
  • (1->4,5):dist[4] = min(2, 0+5)=2
  • (2->4,-2):dist[4] = min(2, 4+(-2))=2
  • (3->2,-10):dist[2] = min(4, 5+(-10))=-5// 更新!通过路径1->4->3->2
  • (4->3,3):dist[3] = min(5, 2+3)=5第二轮后:dist = [0, -5, 5, 2]

第三轮松弛(V-1=3轮,这是最后一轮标准迭代)

  • (1->2,4):dist[2] = min(-5, 0+4)=-5
  • (1->4,5):dist[4] = min(2, 0+5)=2
  • (2->4,-2):dist[4] = min(2, -5+(-2))=-7// 更新!
  • (3->2,-10):dist[2] = min(-5, 5+(-10))=-5
  • (4->3,3):dist[3] = min(5, -7+3)=-4// 更新! 第三轮后:dist = [0, -5, -4, -7]

第四轮(检测负权环): 我们再次遍历所有边。检查边(4->3,3):dist[4]+3 = -7+3 = -4,等于当前的dist[3],松弛失败。检查其他边也均无法再松弛。因此,图中不存在从起点1可达的负权环。最终最短路径距离即为[0, -5, -4, -7]

可以看到,由于负权边(3->2, -10)和(2->4, -2)的存在,路径被不断优化。迪杰斯特拉算法无法处理这种情况,它在第一轮访问节点2(距离4)后就会将其标记为已访问,从而错过了通过节点4和3到达节点2的更短路径(1->4->3->2,距离-5)。

3.3 复杂度、优化与SPFA算法

贝尔曼-福特算法的标准实现时间复杂度是 O(V*E),因为要进行 V-1 轮,每轮遍历所有 E 条边。对于稠密图(E ~ O(V²)),这复杂度是 O(V³),远高于迪杰斯特拉的 O(V²)。因此,对于没有负权边的图,我们绝不会用贝尔曼-福特算法。

一个常见的优化是:如果在某一轮松弛中,没有任何dist值被更新,那么算法可以提前终止,因为后续的松弛也不可能再产生更新。这个优化在实践中往往能显著减少迭代轮数。

另一个非常重要的衍生算法是SPFA(Shortest Path Faster Algorithm),它被认为是贝尔曼-福特算法的一个队列优化版本。其思想是:只有那些在前一轮松弛中被更新了距离的节点,才可能引起其邻居节点距离的更新。因此,SPFA使用一个队列来存放这些“有更新”的节点。

SPFA的基本步骤:

  1. 将起点入队。
  2. 从队首取出一个节点u
  3. 遍历u的所有出边,对每条边(u, v, w)执行松弛操作。如果v的距离被更新,且v不在当前队列中,则将v入队。
  4. 重复步骤2和3,直到队列为空。

SPFA的平均时间复杂度被认为是 O(kE),其中k是一个常数,通常远小于V。但在最坏情况下(比如精心构造的网格图),它可能退化到 O(VE),与贝尔曼-福特相同。SPFA的一个优点是它可以检测负权环:如果一个节点被入队超过 V 次,那么图中很可能存在负权环(从起点可达)。

实操心得:在数学建模中,如果题目明确说明没有负权边,果断使用迪杰斯特拉(或其堆优化版本)。如果存在负权边,或者你不确定(比如成本可能为负的利润问题),那么应该使用贝尔曼-福特或SPFA。用Matlab实现SPFA比标准的贝尔曼-福特更高效,代码也更简洁。但要注意,Matlab中队列操作效率不如专门的数据结构,对于超大图仍需谨慎评估性能。

4. Matlab实战:从原理到代码的跨越

理论讲得再多,不如亲手实现一遍。Matlab强大的矩阵运算能力和直观的语法,使其成为实现和验证这些算法的绝佳平台。我们将分别实现迪杰斯特拉算法和SPFA算法(作为贝尔曼-福特的高效代表),并处理路径回溯。

4.1 迪杰斯特拉算法的Matlab实现

我们将实现一个函数dijkstra,输入为邻接矩阵G和起点start,输出为最短距离数组dist和前驱节点数组prev,用于回溯路径。这里我们使用数组遍历找最小值的方式,便于理解。

function [dist, prev] = dijkstra(G, start) % DIJKSTRA 使用迪杰斯特拉算法计算单源最短路径 % 输入: % G - n x n 邻接矩阵,G(i,j)表示从节点i到j的边权,无边则为Inf % start - 起点节点编号 % 输出: % dist - 1 x n 向量,dist(i)表示从起点到节点i的最短距离 % prev - 1 x n 向量,prev(i)表示节点i在最短路径上的前驱节点,起点为0 n = size(G, 1); % 节点数 dist = inf(1, n); % 初始化距离为无穷大 prev = zeros(1, n); % 前驱节点初始为0 visited = false(1, n); % 标记节点是否已访问 dist(start) = 0; % 起点到自身距离为0 for i = 1:n % 步骤1:在未访问节点中找到dist最小的节点u % 使用min函数在未访问节点中找最小值 tempDist = dist; tempDist(visited) = inf; % 将已访问节点的距离设为inf,确保不会被选中 [~, u] = min(tempDist); if isinf(dist(u)) % 如果剩下的节点距离都是inf,说明有不连通的节点 break; end visited(u) = true; % 标记节点u为已访问 % 步骤2:松弛节点u的所有邻居 % 遍历所有节点v for v = 1:n % 如果v未访问,且u到v有边 if ~visited(v) && G(u, v) ~= inf alt = dist(u) + G(u, v); % 通过u到v的路径长度 if alt < dist(v) % 如果找到更短路径 dist(v) = alt; prev(v) = u; % 记录前驱 end end end end end

路径回溯函数

function path = getPath(prev, target) % GETPATH 根据前驱数组prev回溯最短路径 % 输入: % prev - dijkstra函数输出的前驱节点数组 % target - 目标节点编号 % 输出: % path - 从起点到target的节点编号序列 path = []; if prev(target) == 0 && target ~= find(prev==0, 1) % 处理起点不是1的情况,需要额外逻辑,这里简化 fprintf('节点 %d 不可达。\n', target); return; end while target ~= 0 path = [target, path]; % 在头部插入节点 target = prev(target); end end

使用示例

% 定义邻接矩阵(使用之前的例子) G = [0, 10, inf, 30, 100; inf, 0, 50, inf, inf; inf, inf, 0, inf, 10; inf, inf, 20, 0, 60; inf, inf, inf, inf, 0]; start = 1; [dist, prev] = dijkstra(G, start); fprintf('从节点 %d 出发的最短距离:\n', start); disp(dist); target = 5; path = getPath(prev, target); fprintf('到节点 %d 的最短路径:', target); disp(path); fprintf('路径长度:%d\n', dist(target));

这个实现简单直观,但查找最小值的操作是 O(n),因此总复杂度为 O(n²)。对于节点数上千的图,可能会变慢。你可以尝试用min函数配合逻辑索引来优化,或者实现一个简单的优先队列(例如维护一个排序列表)。

4.2 SPFA算法的Matlab实现

接下来实现SPFA算法,它能处理负权边,并且通常比标准贝尔曼-福特更快。

function [dist, prev, hasNegativeCycle] = spfa(G, start) % SPFA 使用SPFA算法计算单源最短路径,可检测负权环 % 输入: % G - n x n 邻接矩阵,G(i,j)表示从节点i到j的边权,无边则为Inf % start - 起点节点编号 % 输出: % dist - 1 x n 向量,最短距离 % prev - 1 x n 向量,前驱节点 % hasNegativeCycle - 布尔值,true表示检测到从起点可达的负权环 n = size(G, 1); dist = inf(1, n); prev = zeros(1, n); inQueue = false(1, n); % 标记节点是否在队列中 count = zeros(1, n); % 记录节点入队次数,用于检测负环 dist(start) = 0; % 使用一个列表模拟队列 queue = start; inQueue(start) = true; count(start) = count(start) + 1; hasNegativeCycle = false; while ~isempty(queue) u = queue(1); % 取队首 queue(1) = []; % 出队 inQueue(u) = false; % 遍历所有可能的邻居v for v = 1:n if G(u, v) ~= inf % 如果u到v有边 alt = dist(u) + G(u, v); if alt < dist(v) dist(v) = alt; prev(v) = u; % 如果v不在队列中,则入队 if ~inQueue(v) queue(end + 1) = v; % 入队尾 inQueue(v) = true; count(v) = count(v) + 1; % 检测负权环:如果节点入队次数超过n次 if count(v) > n hasNegativeCycle = true; fprintf('警告:检测到可能的负权环(节点 %d 入队超限)。\n', v); return; end end end end end end end

使用示例(包含负权边)

% 定义包含负权边的邻接矩阵 G2 = [0, 4, inf, 5; inf, 0, inf, -2; inf, -10, 0, inf; inf, inf, 3, 0]; start = 1; [dist2, prev2, negCycle] = spfa(G2, start); if negCycle fprintf('检测到负权环,部分最短路径可能不存在。\n'); else fprintf('从节点 %d 出发的最短距离(SPFA):\n', start); disp(dist2); % 可以使用相同的getPath函数回溯路径 end

4.3 性能对比与一个综合案例

让我们构造一个稍大的随机图来对比两种算法的运行时间,并解决一个简单的建模问题。

%% 性能对比测试 n = 500; % 500个节点 % 生成一个随机稀疏图,边权为正 density = 0.01; % 边密度1% G_large = sprand(n, n, density); % 生成稀疏随机矩阵(0-1之间) G_large = G_large * 10; % 放大权重到0-10 G_large(G_large > 0) = G_large(G_large > 0) + 1; % 确保权重为正且大于0 % 将稀疏矩阵转换为满矩阵,并将无边的位置设为Inf G_full = full(G_large); for i = 1:n G_full(i, i) = 0; % 对角线设为0 end G_full(G_full == 0) = inf; % 将0(无边)设为Inf start = 1; fprintf('测试节点数 n = %d, 边密度 %.2f%%\n', n, density*100); % 测试迪杰斯特拉 O(n^2) 实现 tic; [dist_dij, ~] = dijkstra(G_full, start); time_dij = toc; fprintf('迪杰斯特拉算法耗时:%.4f 秒\n', time_dij); % 测试SPFA tic; [dist_spfa, ~, ~] = spfa(G_full, start); time_spfa = toc; fprintf('SPFA算法耗时:%.4f 秒\n', time_spfa); % 验证结果一致性(由于是正权图,结果应相同) if max(abs(dist_dij - dist_spfa)) < 1e-9 fprintf('结果一致。\n'); else fprintf('结果存在差异!\n'); end

在这个正权图测试中,迪杰斯特拉(O(n²))很可能比SPFA(平均O(kE))要慢,因为SPFA利用了图的稀疏性。对于稠密图,情况可能反过来。

综合案例:简单的物资配送规划假设有5个仓库(节点1-5),需要从中心仓(节点1)向其他仓配送物资。道路网络及其运输成本(可为负,表示有补贴的路线)如下表所示:

路线成本
1 -> 24
1 -> 45
2 -> 4-2
3 -> 2-10
4 -> 33
4 -> 52
3 -> 56

问题:求从中心仓(1)到所有其他仓库的最低运输成本,并指出到仓库5的具体路径。

%% 物资配送案例 % 构建邻接矩阵 n = 5; G_case = inf(n); G_case(1,2)=4; G_case(1,4)=5; G_case(2,4)=-2; G_case(3,2)=-10; G_case(4,3)=3; G_case(4,5)=2; G_case(3,5)=6; for i=1:n G_case(i,i)=0; end start = 1; [dist_case, prev_case, negCycle] = spfa(G_case, start); if negCycle fprintf('运输网络中存在负权循环,成本可能无限降低,请检查路线数据。\n'); else fprintf('从中心仓到各仓库的最低成本:\n'); for i = 1:n fprintf(' 仓库 %d: %d\n', i, dist_case(i)); end target = 5; path_case = getPath(prev_case, target); fprintf('到仓库 %d 的最低成本路径:', target); disp(path_case); end

运行这段代码,你会得到到仓库5的成本是1,路径是1 -> 4 -> 5。注意,虽然存在更长的路径 1->4->3->5(成本5+3+6=14),但直接路径1->4->5成本仅为5+2=7,而算法找到了成本更低的路径1->2->4->5(成本4+(-2)+2=4)吗?等等,我们手动计算一下:1->2 (4), 2->4 (-2), 4->5 (2),总成本确实是4。但我们的SPFA结果给出的是1。检查一下,我们是否漏掉了路径1->4->3->2->4->5?这形成了环(2->4->3->2),其中2->4 (-2), 4->3 (3), 3->2 (-10),环的总成本是-9,是一个负权环!从起点1可以到达这个环(1->2或1->4->3->2)。因此,这个网络中存在从起点可达的负权环,最短路径问题实际上无解(成本可以无限降低),我们的SPFA算法应该检测到这一点。

踩坑实录:这个案例生动地说明了负权环的检测多么重要。在实际物流中,如果存在这样的“补贴”环路,理论上运输公司可以无限绕行来获取无限补贴,这显然不符合实际。算法检测出负权环,提示我们需要重新审视数据模型或约束条件。在数学建模中,如果遇到成本可能为负的情况,必须优先使用能检测负环的算法,并对结果进行合理性判断。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询