你的边比较松弛:最短路的 Bellman-Ford 和 SPFA 方法
Dijkstra 的局限性
在带权图的最短路径问题中,我们的目标是从一个起点出发,找到到达其他所有节点的最短路径。无论是交通导航中的最短耗时路线,还是金融网络中的最小成本路径,这一问题的核心始终是如何在复杂权重关系中寻找最优解。
经典算法Dijkstra凭借其贪心策略和优先队列优化,成为解决非负权图最短路径的标杆:
- 核心思想:每次选择当前距离起点最近的节点,逐步向外“扩散”确认最短路径。
- 时间复杂度:\(O((V+E)\log V)\)(优先队列实现),在稠密图中表现优异。
但这一算法背后隐藏着一个致命前提——所有边的权重必须非负。
Dijkstra的“傲慢”与困境
想象这样一个场景:
起点为\(A\),路径\(A \to B \to C\)的总权重为\(3 + (-4) = -1\),而Dijkstra可能已经提前确认了\(A \to C\)的“最短路径”为权重\(5\)。此时,负权边\(B \to C\)的存在,使得实际更优的路径被彻底忽略。
根本矛盾在于:Dijkstra通过“锁定”已访问节点的最短路径来保证效率,但这种“傲慢”的确定性策略,在面对负权边时反而成为枷锁——已锁定的节点无法被重新修正。
破局之道:松弛(Relaxation)的哲学
“刚不可久,柔不可守”,面对负权边的挑战,我们需要一种柔性动态更新的机制。这便是松弛操作:
\]
- 核心意义:这和 dijstra 的更新操作相同。但是,在负边情况下,要允许节点距离值被反复修正。每一条边 \((u, v)\) 都可能反复用来更新,即“走我能不能让路更短?”
- 动态性:与Dijkstra的“刚性锁定”不同,松弛操作承认路径的不确定性,通过多轮迭代逼近最优解。
最短路径与负权环的矛盾
最短路径的“黑洞”
通常的最短路算法不限制重复经过节点和边,对于正权图来说无所谓,重复过边徒增权重,没有任何好处;但是对于负权图来说,负权环的存在将直接冲击最短路问题的定义。
在带权图中,若存在一个环路,其总权重为负数(即绕行一圈后路径成本反而降低),则称其为负权环(Negative Weight Cycle)。数学表达式为:
\]
例如,图中环路 \(A \to B \to C \to A\) 的权重依次为 \(2, -3, -1\),总权重为 \(-2\),这便是典型的负权环。
一旦起点到某个负权环存在可达路径,最短路径问题将失去意义:每次绕行负权环,总路径权重减少。理论上,路径可以无限次绕环,导致总权重趋向 \(-\infty\)。这张图也就不存在最短路了。
例如下图中,从节点\(S\)出发,经过路径\(S \to A \to B \to C \to A\)后,每绕行一次环路\(A \to B \to C \to A\),总权重减少2。因此,\(S\)到\(C\)的“最短路径”可以无限优化,最终没有最小值。
负环检测
负权环的存在挑战了我们对“最短路径”的直觉认知。在现实世界中,物理距离不可能为负,但抽象问题(如金融清算、能耗优化)中负权重广泛存在。例如:
- 金融网络:A向B转账手续费为-2元(即B实际收到+2元)。
- 能量回收系统:机器人移动时,下坡路段可回收能量,视为负权边。
任何声称支持负权边的算法,都必须具备负权环检测能力,否则可能在遇到负环时陷入死循环,或输出错误结果。这种检测本质上是对算法收敛性的验证:若图中存在负权环,算法需明确报告“无解”,而非尝试计算不存在的“最短路径”。例如网络路由协议中,需避免数据包因路径成本无限降低而在环路中永久循环。
Bellman-Ford算法
Bellman-Ford算法的核心可以用一句话概括:穷举所有可能的路径更新。
- 暴力松弛策略:对图中所有边进行\(V-1\)轮松弛操作(\(V\)为节点数)
- 数学基础:在无负权环的图中,任意两节点间的最短路径最多包含\(V-1\)条边
算法对所有边进行地毯式扫描。尝试用每一条边更新最短路径。只要没有负环,一条最短路径最多长\(V-1\),所以最多执行\(V-1\)轮“地毯式松弛”。
class Graph {
struct Edge {
int to, weight, index;
Edge(int t, int w, int i) : to(t), weight(w), index(i) {}
};
vector<vector<Edge>> adj; // 邻接表
int n;
public:
Graph(int n) : n(n), adj(n) {}
void addEdge(int u, int v, int w, int i) {
adj[u].emplace_back(v, w, i);
}
vector<int> bellmanFord(int s) {
vector<int> dist(n, INT_MAX);
dist[s] = 0;
for (int i = 0; i < n-1; ++i) {
for (int u = 0; u < n; ++u) {
for (auto &e : adj[u]) {
if (dist[u] != INT_MAX && dist[e.to] > dist[u] + e.weight) {
dist[e.to] = dist[u] + e.weight;
}
}
}
}
// 检测负环
for (int u = 0; u < n; ++u) {
for (auto &e : adj[u]) {
if (dist[u] != INT_MAX && dist[e.to] > dist[u] + e.weight) {
return {}; // 存在负环
}
}
} return dist;
}
};
即使有负环,也可能某一些点仍存在最短路,代码里简略起见,存在负环则直接结束算法。另外,该条件为充分不必要条件,检测的是顶点出发可到达的负环。
时间复杂度
\]
- 最坏案例:完全图(\(E=V^2\))时复杂度达\(O(V^3)\)
- 空间复杂度:\(O(V)\)(仅需存储节点距离)
与Dijkstra算法的对比:
| 场景 | Dijkstra(二叉堆) | Bellman-Ford |
|---|---|---|
| 一般 | \(O(E\log V)\) | \(O(VE)\) |
| 稀疏图(树状) | \(O(V\log V)\) | \(O(V^2)\) |
| 稠密图 | \(O(V^2\log V)\) | \(O(V^3)\) |
负环检测:第V轮的启示录
在完成\(V-1\)轮松弛后,算法会进行最终审判:若无负环,所有最短路径应已确定。若第\(V\)轮仍能松弛,说明存在可无限优化的路径,即负权环
SPFA 算法:队列版 Bellman-Ford
Bellman-Ford 的全量松弛策略虽然最终能完成,但过程中浪费了大量无效松弛操作。例如在下图所示的链状结构中:
A → B → C → D → E
每一轮外层循环只能将更新向前推进一个节点,导致大量重复计算。只有上一次被松弛的结点,所连接的边,才有可能引起下一次的松弛操作。我们可以请回从源点逐渐延申的思路,只有某个节点\(u\)的距离被更新后,其邻居\(v\)才有可能需要更新,所以可以用队列动态维护待处理节点,避免全图扫描。当然,因为负权边的存在,一个节点可能会反复入队列。
首先将源点距离标记为 0 并入队,此后总是从队列中取出节点,并松弛周围的边;若松弛成功,则被更新的节点也可能再去优化其他节点,将其也入队,直到队列空。
SPFA(Shortest Path Faster Algorithm)的命名充满戏剧性:1994年由西南交通大学段凡丁提出,原论文命名为“改进的Bellman-Ford算法”。因其在随机数据中的卓越表现,算法社区赋予了这个“昵称”。
// ...
vector<int> spfa(int s) {
vector<int> dist(n, INT_MAX);
vector<bool> inq(n, false);
vector<int> cnt(n, 0);
queue<int> q;
dist[s] = 0;
inq[s] = true;
q.push(s);
while (!q.empty()) {
int u = q.front();
q.pop();
inq[u] = false;
for (auto &e : adj[u]) {
if (dist[u] != INT_MAX && dist[e.to] > dist[u] + e.weight) {
dist[e.to] = dist[u] + e.weight;
if (!inq[e.to]) {
q.push(e.to);
inq[e.to] = true;
if (++cnt[e.to] > n) {
return {}; // 存在负环
}
}
}
}
} return dist;
}
效率
SPFA 反而更加符合人类直觉,并且在随机图上效率非常优秀,接近线性。但最坏情况下仍会退化为 Bellman-Ford
| 场景 | 时间复杂度 | 类比说明 |
|---|---|---|
| 随机稀疏图 | \(O(E)\) | 更新波快速衰减 |
| 最坏情况 | \(O(VE)\) | 节点被反复加入队列\(O(V)\)次 |
| 含负环图 | \(O(VE)\) | 持续绕环无法退出 |
SPFA体现了反应式编程的思想:不预测哪些边需要松弛,不预设更新轮数上限。
负环检测:计数器
SPFA通过节点入队次数检测负环,比如维护计数器cnt[v]记录每个节点入队次数,若cnt[v] > V则判定存在负环(原理和 BellmanFord 相同,在无负环的图中,任意节点最多被松弛 \(V-1\) 次)。该条件为充分不必要条件,检测的是顶点出发可到达的负环。
SPFA 的数据敏感性
队列策略优化
我们知道 SPFA 的性能极度依赖数据,通过设置队列调度策略,工程中可以使用以下技巧尽可能避免极端数据带来的影响:
- SLF(Small Label First):新节点入队时,若其距离值小于队首节点,则插入队首(双端队列实现),否则入队尾。
- LLL(Large Label Last):当前节点距离值大于队列平均值时,将其重新插入队尾,避免“卡在”局部劣质路径
- 将入队次数较多的点从队尾而非队首插入,或者反过来让前几次入队的点从队尾进
- 随机扰动:以概率 \(p\) 将新节点插入随机位置 / 以概率交换队首队尾 / 以概率排序队列
这些优化本质是在队列的FIFO特性与优先队列的贪心特性之间寻找平衡点。让队列尽可能接近优先队列,不陷入次优解。但他们并非复杂度优化,只是针对常用构造数据(菊花图,网格图)的见招拆招,理论上只要还使用队列,就总存在被卡到 \(O(VE)\) 的最坏数据。
队列变体实验:当SPFA不再“队列”
另外一个思路是更换数据结构,会引发有趣的现象:
实验一:优先队列(可重复入队的Dijkstra)
- 实现:用优先队列(堆)代替普通队列,按
distance[v]排序 - 优点:
- 在正权图中等价于Dijkstra,时间复杂度\(O(E \log V)\)
- 对某些特定负权图(如近DAG图)可能更快
- 灾难性后果:
- 遇到负权边时,节点可能非常多次入队(距离值反复被更新)
- 复杂度不再稳定,可以被构造数据卡成指数级复杂度。
实验二:栈(深度优先松弛)
- 实现:用栈代替队列,后进先出(LIFO)
- 优点:
- 可能更快发现某些负环(深度优先穿透环路)
- 对链式更新结构更高效
- 缺点:
- 随机图上的效率降低
- 在无负环图中容易产生“更新震荡”
- 复杂度不再稳定,可以被构造数据卡成指数级复杂度。
队列的FIFO特性是SPFA在泛用性与效率间的最佳平衡选择。任何结构改变都将打破这一精妙平衡,除非数据特殊,还是不动为好。
算法对比
下表展现了Dijkstra与Bellman-Ford/SPFA的本质矛盾与互补关系:
| Dijkstra | Bellman-Ford/SPFA | |
|---|---|---|
| 适用权重 | 严格非负权 | 任意权重(含负权) |
| 检测负环 | 不能 | 能(需显式实现) |
| 时间复杂度 | \(O(E \log V)\) | \(O(E)\) ~ \(O(VE)\) |
| 更新策略 | 贪心锁定 | 动态松弛 |
| 数据结构 | 优先队列(堆) | 队列/双端队列 |
通过三要素决策树选择算法:
- 是否存在负权边?
- 无 → Dijkstra(稳定高效)
- 有 → 进入下一层判断
- 是否需检测负环?
- 需检测 → Bellman-Ford/SPFA
- 不需检测 → 考虑转化为非负权(Johnson算法预处理)
- 图结构特征?
- 随机稀疏图 → SPFA(平均\(O(E)\))
- 稠密规律图 → Bellman-Ford(避免队列抖动)
- 动态频繁更新 → SPFA(增量式处理)
正如《周易》所言:“穷则变,变则通,通则久”,面对最短路径问题的万千变化,唯有理解算法背后的哲学,方能在刚柔之间找到破局之钥。
你的边比较松弛:最短路的 Bellman-Ford 和 SPFA 方法的更多相关文章
- Til the Cows Come Home 最短路Dijkstra+bellman(普通+优化)
Til the Cows Come Home 最短路Dijkstra+bellman(普通+优化) 贝西在田里,想在农夫约翰叫醒她早上挤奶之前回到谷仓尽可能多地睡一觉.贝西需要她的美梦,所以她想尽快回 ...
- [ACM] 最短路算法整理(bellman_ford , SPFA , floyed , dijkstra 思想,步骤及模板)
以杭电2544题目为例 最短路 Problem Description 在每年的校赛里,全部进入决赛的同学都会获得一件非常美丽的t-shirt. 可是每当我们的工作人员把上百件的衣服从商店运回到赛场的 ...
- 最短路(bellman)-hdu1217
Dijkstra算法是处理单源最短路径的有效算法,但它局限于边的权值非负的情况,若图中出现权值为负的边,Dijkstra算法就会失效,求出的最短路径就可能是错的. 这时候,就需要使用其他的算法来求解最 ...
- sdut3562-求字典序最小的最短路 按顶点排序后spfa的反例
首先我们可以这么搞...倒序建图,算出源点s附近的点距离终点的距离,然后判断一下,终点是否能跑到源点 能跑到的话呢,我们就判断s周围的点是否在最短路上,然后我们选编号最小的点就好了 代码 #inclu ...
- 蓝桥杯 algo_5 最短路 (bellman,SPFA)
问题描述 给定一个n个顶点,m条边的有向图(其中某些边权可能为负,但保证没有负环).请你计算从1号点到其他点的最短路(顶点从1到n编号). 输入格式 第一行两个整数n, m. 接下来的m行,每行有三个 ...
- 最短路(Bellman)-Hdu1874畅通工程序
题目链接:http://acm.hdu.edu.cn/showproblem.php?pid=1874 题目: 某省自从实行了很多年的畅通工程计划后,终于修建了很多路.不过路多了也不好,每次要从一个城 ...
- HDOJ 2544 最短路(最短路径 dijkstra算法,SPFA邻接表实现,floyd算法)
最短路 Time Limit: 5000/1000 MS (Java/Others) Memory Limit: 32768/32768 K (Java/Others) Total Submis ...
- hdu1874 畅通project续 最短路 floyd或dijkstra或spfa
Problem Description 某省自从实行了非常多年的畅通project计划后.最终修建了非常多路.只是路多了也不好,每次要从一个城镇到还有一个城镇时,都有很多种道路方案能够选择.而某些方案 ...
- 最短路(bellman)-hdu2066
题目链接:https://vjudge.net/problem/HDU-2066 题目描述: 代码实现: #include <cstdio> #include <cstring> ...
- ACM - 最短路 - AcWing 851 spfa求最短路
AcWing 851 spfa求最短路 题解 以此题为例介绍一下图论中的最短路算法 \(Bellman\)-\(Ford\) 算法.算法的步骤和正确性证明参考文章最短路径(Bellman-Ford算法 ...
随机推荐
- datagridview点击列头对当前列进行排序的功能无效
DataGridView 的默认行为是支持通过单击列头对列进行排序,但在以下情况下可能会取消该功能或无法使用: 1. 绑定的数据源不支持排序 如果 DataGridView 的数据源是绑定到一个不支持 ...
- ABAP开发规范V1.0
1. 概要 1.1目的 该文档定义了在开发与维护ABAP程序过程中必须遵守的规范与标准.该文档应当被视为一个动态的文档,该文档会根据需要进行增补和修订. 开发规范的重要作用在于保持整个开发团队的开发风 ...
- k8s强制删除pod节点
场景 突然get pod的时候,发现一堆的错误,得把它干掉,否则很不爽. 解决方案 正常过期的状态,比如Evicted ,用正常指令 kubectl -n jingu get pods | grep ...
- 【Amadeus原创】docker中修改wordpress上传文件大小
1,进入docker 2,cp php.ini 3, cd到目录 [root@3 ~]# docker exec -it wordpress /bin/bash root@1d8a4fbdaa6b ...
- CentOS7.8安装k8s
1, 安装 docker / kubelet # 在 master 节点和 worker 节点都要执行 \# 最后一个参数 1.20.6 用于指定 kubenetes 版本,支持所有 1.20.x 版 ...
- 【Python】【Flask】【字符串索引】计算人民币与美元的相互计算
目录 简介 Python Code 导包 设置首页 计算的接口 问题0:设置请求方式 问题1:关于接收数据可能存在的问题 问题2:返回结果 启动 完整代码 HTML Code 问题分析 分析:获取下拉 ...
- Spark内存调优
一.概述Spark 作为一个基于内存的分布式计算引擎,其内存管理模块在整个系统中扮演着非常重要的角色.理解 Spark 内存管理的基本原理,有助于更好地开发 Spark 应用程序和进行性能调优.本文旨 ...
- vue3 路由的使用
添加一个router.js 配置文件 import { createRouter, createWebHistory } from 'vue-router' createRouter:用来创建 路由 ...
- Type of the default value for 'data' prop must be a function的解决方法
Type of the default value for 'data' prop must be a function的解决方法 问题现象 在写形如prop: {type: Array; defau ...
- DotNetBar控件中,删除或移除AdvTree上指定名称的Node
废话少说,直接上核心代码: string deleteNodeName = "节点1"; //移除advTree上指定名称的Node Node deleteNode = advTr ...