1 算法背景与直觉
前置算法: bellman-ford。
在 Bellman-Ford 算法中,我们每一轮都对图上的所有边进行松弛操作。
回顾松弛操作的定义:
1:if then
2:
3:end if
思考一个问题:什么样的边才可能成功进行松弛?
如果点
定理1:只有当一个点
证明:
设在某一时刻,对于边
因此,我们不需要盲目枚举所有边,只需要维护一个待更新队列:
- 只有
被成功减小的点,才有资格入队去尝试更新它的邻居。 - 用一个队列保存所有“获得了更短路径、需要通知邻居”的点。
- 出队一个点
,扫描它的所有出边 进行松弛;若 被更新了且 当前不在队列中,则把 入队。
这就是 SPFA (Shortest Path Faster Algorithm),本质上就是队列优化的 Bellman-Ford 算法。
2 图解 SPFA 执行过程
为了直观理解“谁变小了谁去通知邻居”以及“重复入队”的过程,我们来看一个包含负权边的示例有向图(源点为
交互动画加载中...
想换一组边权,或者拿一个带往返负环的图自己跑一遍?试试这个动画:
单源最短路:SPFAanima1图中边权如下:
,权值 ,权值 ,权值 ,权值 - 点
为孤立点(不可达)
2.1 过程演进流程图
2.2 详细步骤追踪表
| 步骤 | 出队点 |
考察出边及松弛操作 | 产生影响 | 队列状态(左为队头) | [1..5] |
|---|---|---|---|---|---|
| 初始 | - | 初始化源点 |
点 1 入队 | [1] |
[0, ∞, ∞, ∞, ∞] |
| 第 1 步 | 1 | 点 2, 3 入队 | [2, 3] |
[0, 5, 2, ∞, ∞] |
|
| 第 2 步 | 2 | 点 4 入队 | [3, 4] |
[0, 5, 2, 7, ∞] |
|
| 第 3 步 | 3 | 关键点:点 2 距离被进一步变小( |
[4, 2] |
[0, -2, 2, 7, ∞] |
|
| 第 4 步 | 4 | 无出边 | 无更新 | [2] |
[0, -2, 2, 7, ∞] |
| 第 5 步 | 2 | 点 2 再次通知邻居,点 4 被更新为 |
[4] |
[0, -2, 2, 0, ∞] |
|
| 第 6 步 | 4 | 无出边 | 队列变空,算法终止 | [] |
[0, -2, 2, 0, ∞] |
2.3 图形分析与结论
通过上图可以看到:
- 重复入队的必要性:点
第一次出队时其距离是 ;后来经过 发现了一条更短路径(距离为 )。如果像普通 BFS 那样把访问过的点永久标记为“已访问”,那么点 的距离就会永远停留在次优的 ,而无法得到最优解 。 in_queue标记的物理含义:in_queue[v]仅表示“点当前是否在队列排队”。如果已经排在队列中,再次松弛只需直接更新数值 ,无需放入两个相同的点。
3 负环判定的图形解释
当图中存在负权环(环上所有边的权值之和
在上面的图中,环
- 每在负环上绕一圈,路径总长度就会减少
。 - 这会导致点
和点 的 可以无限减小下去,因而两点会不断互相松弛、无限次入队与出队,使得普通队列陷入死循环。
计数判定法(边数法):
- 在一个无负环的图上,任意两点间的最短简单路最多经过
个点,即最多经过 条边(推论1)。 - 我们维护
表示从源点到点 当前最短路径所包含的边数。 - 当通过边
松弛成功时,令 。 - 一旦发现某个点的
,则说明该路径上至少经过了 个点,根据抽屉原理必然出现了重复经过的点,即检测到了负环,此时应当立即报告并退出。
4 算法核心过程
- 初始化:
,其余所有点 。 - 将源点
加入队列 ,设置标记 in_queue[s] = true。 - 循环直到队列为空:
- 队头元素
出队,标记 in_queue[u] = false。 - 遍历点
的所有出边 : - 若
: - 更新
。 - 记录经过边数:
。 - 负环判定:若
,则说明存在负环,算法直接返回 false。 - 若
不在队列中(即 !in_queue[v]),将入队,并标记 in_queue[v] = true。
- 更新
- 若
- 队头元素
5 模板代码
#include <algorithm>
#include <queue>
#include <vector>
const int maxn = 100000 + 5;
const long long INF = (1LL << 62);
struct edge {
int to;
long long w;
};
std::vector<edge> g[maxn];
long long dis[maxn];
int cnt[maxn]; // 当前 dis 对应的历史路径的边数,不是更新次数
bool in_queue[maxn]; // 当前是否在队列中,出队后必须清除
// 点编号为 1..n;换一张图前调用,避免多组数据残留旧边。
void init_graph(int n) {
for (int u = 1; u <= n; ++u) g[u].clear();
}
void add_edge(int u, int v, long long w) {
g[u].push_back({v, w});
}
// true:得到单源最短路;false:发现从 s 可达的负环。
// 要求 1 <= s <= n < maxn,所有参与运算的距离与边权和均在 (-INF, INF) 内。
bool spfa(int n, int s) {
std::fill(dis + 1, dis + n + 1, INF);
std::fill(cnt + 1, cnt + n + 1, 0);
std::fill(in_queue + 1, in_queue + n + 1, false);
std::queue<int> q; // 局部队列保证重复调用时没有上次留下的点
dis[s] = 0;
q.push(s);
in_queue[s] = true;
while (!q.empty()) {
int u = q.front();
q.pop();
in_queue[u] = false;
for (const auto& e : g[u]) {
int v = e.to;
long long candidate = dis[u] + e.w; // 入队点一定可达
if (candidate >= dis[v]) continue;
dis[v] = candidate;
cnt[v] = cnt[u] + 1;
if (cnt[v] >= n) return false;
// 即使 v 已在队列中,也必须先更新 dis 和 cnt。
if (!in_queue[v]) {
q.push(v);
in_queue[v] = true;
}
}
}
return true;
}
6 负环判定与拓展
关于 SPFA 查找负环的更多原理与证明(边数判定、点更新次数判定、DFS 版 SPFA 对比),见后续章节:负环。
7 相关题解
暂无相关题解