Bellman-Ford 与 SPFA:单源最短路径
介绍 Bellman-Ford 的松弛过程、路径恢复、边数限制,以及 SPFA 的队列优化和负环检测。
Bellman-Ford 用于求带权有向图中,从一个源点出发到其他顶点的最短距离。它可以处理负权边,这是 Dijkstra 算法无法直接处理的情况。
本文默认顶点编号为 [0, n),边表示为 from -> to,边权为 weight。
一、Bellman-Ford 算法
1.1 松弛操作
设 dist[v] 表示当前已知的从源点到顶点 v 的最短距离。对于一条边 u -> v,权重为 w,如果:
dist[u] + w < dist[v]
就可以通过 u 更新 v:
dist[v] = dist[u] + w
这一步称为松弛。
1.2 为什么最多遍历 n - 1 次
如果不存在从源点可达的负权环,任意一条简单路径最多包含 n - 1 条边。第 i 轮松弛后,可以保证已经考虑了最多 i 条边组成的路径。
因此,最多进行 n - 1 轮即可得到结果。如果某一轮没有任何距离更新,可以提前结束。
1.3 求最短距离
#include <limits>
#include <vector>
struct Edge {
int from;
int to;
long long weight;
};
struct BellmanFordResult {
std::vector<long long> distance;
std::vector<int> previous;
bool has_negative_cycle = false;
};
BellmanFordResult BellmanFord(
int vertex_count, int source, const std::vector<Edge>& edges) {
constexpr long long inf = std::numeric_limits<long long>::max() / 4;
BellmanFordResult result;
result.distance.assign(vertex_count, inf);
result.previous.assign(vertex_count, -1);
result.distance[source] = 0;
for (int round = 0; round < vertex_count - 1; ++round) {
bool changed = false;
for (const Edge& edge : edges) {
if (result.distance[edge.from] == inf) {
continue;
}
const long long candidate = result.distance[edge.from] + edge.weight;
if (candidate < result.distance[edge.to]) {
result.distance[edge.to] = candidate;
result.previous[edge.to] = edge.from;
changed = true;
}
}
if (!changed) {
break;
}
}
// 第 n 轮仍能松弛,说明存在从 source 可达的负权环。
for (const Edge& edge : edges) {
if (result.distance[edge.from] == inf) {
continue;
}
if (result.distance[edge.from] + edge.weight < result.distance[edge.to]) {
result.has_negative_cycle = true;
break;
}
}
return result;
}
distance[v] 为 inf 时,表示源点无法到达 v。使用一个足够小的有限值而不是直接使用 LLONG_MAX,是为了避免执行 LLONG_MAX + weight 时发生溢出。
时间复杂度为 O(VE),空间复杂度为 O(V),其中 V 是顶点数,E 是边数。
二、恢复最短路径
每次更新 dist[to] 时记录前驱顶点,就可以从终点反向恢复路径:
#include <algorithm>
#include <vector>
std::vector<int> RestorePath(
int source, int target, const std::vector<int>& previous) {
std::vector<int> path;
for (int current = target; current != -1;
current = previous[current]) {
path.push_back(current);
if (current == source) {
break;
}
}
if (path.empty() || path.back() != source) {
return {}; // target 不可达
}
std::reverse(path.begin(), path.end());
return path;
}
如果图中存在源点可达的负权环,最短距离没有有限值,此时不应使用 previous 恢复所谓的最短路径。
三、限制路径的边数
有些问题要求路径最多经过 k 条边。不能在同一轮中直接使用本轮刚更新的 dist,否则一轮可能沿着多条边连续传播,实际使用的边数会超过限制。
正确做法是使用两个数组:
previous:上一轮最多使用step - 1条边的结果。current:这一轮最多使用step条边的结果。
#include <limits>
#include <vector>
std::vector<long long> ShortestWithAtMostKEdges(
int vertex_count, int source, int k, const std::vector<Edge>& edges) {
constexpr long long inf = std::numeric_limits<long long>::max() / 4;
std::vector<long long> previous(vertex_count, inf);
previous[source] = 0;
for (int step = 1; step <= k; ++step) {
std::vector<long long> current = previous;
for (const Edge& edge : edges) {
if (previous[edge.from] == inf) {
continue;
}
current[edge.to] = std::min(
current[edge.to], previous[edge.from] + edge.weight);
}
previous.swap(current);
}
return previous;
}
如果题目限制的是最多 k 个顶点,通常需要转换为最多 k - 1 条边,具体要以题目对起点是否计数的定义为准。
四、SPFA
SPFA 可以看作 Bellman-Ford 的队列实现。它不再每轮遍历所有边,而是只把距离发生变化的顶点加入队列,并从这些顶点继续尝试松弛邻接边。
4.1 算法流程
- 将源点加入队列。
- 取出一个顶点,尝试松弛它的所有出边。
- 如果某个顶点的距离被更新,且它当前不在队列中,则将它加入队列。
- 如果某个顶点被成功松弛至少
n次,说明存在从源点可达的负权环。
#include <limits>
#include <queue>
#include <utility>
#include <vector>
using WeightedGraph = std::vector<std::vector<std::pair<int, long long>>>;
bool SPFA(int source, const WeightedGraph& graph,
std::vector<long long>& distance) {
constexpr long long inf = std::numeric_limits<long long>::max() / 4;
const int vertex_count = static_cast<int>(graph.size());
distance.assign(vertex_count, inf);
std::vector<bool> in_queue(vertex_count, false);
std::vector<int> relax_count(vertex_count, 0);
std::queue<int> pending;
distance[source] = 0;
pending.push(source);
in_queue[source] = true;
while (!pending.empty()) {
const int from = pending.front();
pending.pop();
in_queue[from] = false;
for (const auto& [to, weight] : graph[from]) {
if (distance[from] + weight >= distance[to]) {
continue;
}
distance[to] = distance[from] + weight;
if (++relax_count[to] >= vertex_count) {
return false; // 存在从 source 可达的负权环
}
if (!in_queue[to]) {
pending.push(to);
in_queue[to] = true;
}
}
}
return true;
}
SPFA 在很多普通数据上速度较快,但它没有优于 Bellman-Ford 的稳定最坏复杂度,最坏情况下仍可能达到 O(VE)。对于没有负权边的图,通常优先考虑 Dijkstra;对于边数较少或需要检测负权环的场景,可以考虑 Bellman-Ford。
五、总结
- Bellman-Ford 通过反复松弛所有边求解单源最短路径。
- 不存在可达负权环时,最多需要
n - 1轮松弛。 - 第
n轮仍然可以松弛,说明存在从源点可达的负权环。 - 限制边数时必须使用上一轮的距离数组,避免同一轮连续使用多条边。
- 通过记录前驱顶点可以恢复最短路径。
- SPFA 使用队列减少无效遍历,但最坏复杂度仍可能达到
O(VE)。