Bellman-Ford与SPFA算法的正确性证明
预备知识与符号约定
在开始证明之前,我们首先需要建立一套清晰的数学语言与符号体系。
- 图的表示:一个带权有向图被表示为 ,其中 是顶点的集合, 是边的集合。图中顶点的数量记为 ,边的数量为 。每条边 都有一个权重(cost),由函数 定义。
-
路径与路径权重:一条从顶点 到 的路径 是一个顶点序列 ,其中对所有的 ,边 。路径 的权重是构成它所有边的权重之和:
-
最短路径权重:从顶点 到顶点 的最短路径权重 定义如下:
$$\delta(u, v) = \begin{cases} \min\{w(p) \mid u \xrightarrow{p} v\} & \text{如果存在从 } u \text{ 到 } v \text{ 的路径} \\ \infty & \text{否则} \end{cases} $$若图中存在从 可达的负权环路,且该环路位于某条从 到 的路径上,那么 。
-
最短路径估计:在算法执行过程中,我们为每个顶点 维护一个最短路径的估计值,记为 。算法开始时,除了源点 的估计值为 外,所有其他顶点的估计值均为 。
-
松弛操作 (Relaxation):松弛是所有此类算法的核心操作。对于一条边 ,松弛操作试图利用 的当前最短路径估计值 来改善 的估计值 。其过程如下:
if (d[v] > d[u] + w(u, v)) { d[v] = d[u] + w(u, v); // 可能还会更新 v 的前驱节点 pi[v] = u }松弛操作的本质是在问:“经过顶点 到达顶点 是否会比当前已知的到达 的路径更短?”如果是,则更新 。
基于这些定义,我们可以提出几个最短路径所固有的基本性质:
-
上界性质 (Upper-Bound Property):对于所有顶点 ,我们始终有 。这个性质在初始化后成立,并且在每次松弛操作后依然保持。因为 的更新只会在找到一条更短的、实际存在的路径时发生,所以它永远不会低估真正的最短路径。
-
三角不等式 (Triangle Inequality):对于任意边 ,我们有 。这是最短路径的内在属性,即从 到 的最短路径,不可能比“从 到 的最短路径,再沿着边 走到 ”更长。
当算法终止,且所有 都收敛时,如果 对所有 成立,那么算法就是正确的。
Bellman-Ford算法及其正确性证明
Bellman-Ford算法通过对图中的所有边进行 轮迭代松弛来计算单源最短路径。其结构异常简洁,背后却蕴含着深刻的动态规划思想。
算法描述
-
初始化: 对于图中所有顶点 : ( 是源点)
-
迭代松弛: 重复 次: 对于图中每条边 : 执行松弛操作:
if (d[v] > d[u] + w(u, v)) d[v] = d[u] + w(u, v); -
负权环路检测: 对于图中每条边 : 如果
d[v] > d[u] + w(u, v)成立,则报告图中存在负权环路。
正确性证明
Bellman-Ford算法的正确性证明分为两个部分:首先证明在没有负权环路的情况下,算法能正确计算出所有最短路径;其次证明算法能检测出负权环路的存在。
1. 最短路径计算的正确性
我们将通过数学归纳法来证明,在没有负权环路的前提下,经过 轮松弛后,对所有顶点 ,都有 。
我们引入一个关键的辅助定义:令 表示在完成第 轮对所有边的松弛操作后, 的最短路径估计值。
引理:在第 轮松弛之后,对于任意顶点 , 是从源点 到 的、边数至多为 的所有路径中,权重最小的路径的权重。
证明:我们对轮数 进行归纳。
-
基础情况 (Base Case), i = 0: 在第 0 轮(即初始化后),, (对于 )。 从 到 的、边数至多为 0 的路径只有一条空路径,权重为 0。因此 正确。 对于任何 ,不存在从 到 的、边数为 0 的路径,其最短路径权重应为 。因此 也正确。 故引理在 时成立。
-
归纳步骤 (Inductive Step): 假设在第 轮后,引理成立,即 是从 到 的、边数至多为 的最短路径权重。我们需要证明在第 轮后, 是从 到 的、边数至多为 的最短路径权重。
考虑一条从 到 的、边数至多为 的最短路径 。该路径有两种可能:
- 路径 的边数至多为 。在这种情况下,根据归纳假设,其权重已经不小于 。在第 轮松弛中, 的值只会减小或不变,所以 。因此, 仍然是这条路径权重的下界。
- 路径 的边数恰好为 。设路径为 ,其中 子路径的边数为 。这条子路径必定是从 到 的、边数至多为 的最短路径。根据归纳假设, 就是这条子路径的权重。 在第 轮松弛中,算法会遍历到边 并执行松弛操作。此时,会进行比较: 这表明, 的值不会超过这条 路径的权重。
综合以上两点,在第 轮迭代之后, 的值不会超过任何一条从 到 的、边数至多为 的路径的权重。同时,根据上界性质, 也不会低于真正的最短路径值。因此, 恰好等于从 到 的、边数至多为 的最短路径的权重。
归纳假设成立。
定理:若图 不包含从源点 可达的负权环路,则经过 轮松弛后, 对所有 成立。
证明: 在不包含负权环路的图中,任意两个顶点之间的最短路径必然是简单路径(不含重复顶点)。一条简单路径的边数最多为 。 根据我们刚刚证明的引理,在第 轮松弛之后, 的值等于从 到 的、边数至多为 的所有路径中的最短路径权重。 由于任何一条最短路径的边数都不可能超过 ,所以这个值就是真正的最短路径权重 。 即,。算法正确。
2. 负权环路检测的正确性
定理:当且仅当图 中存在从源点 可达的负权环路时,在 轮迭代之后,第 轮迭代仍会发生松弛操作。
证明:
-
"如果" 部分 (Sufficient Condition): 假设图中没有从 可达的负权环路。根据上一个定理,在 轮迭代后,我们已经得到 对于所有 成立。 根据最短路径的三角不等式性质,对于任意边 ,必然有 。 代入 和 ,我们得到 。 这意味着在第 轮迭代中,松弛条件
d[v] > d[u] + w(u, v)永远不会被满足。因此,不会有任何成功的松弛操作。 -
"必要" 部分 (Necessary Condition): 假设图中存在一个从源点 可达的负权环路 ,其中 。该环路权重为 。 我们用反证法。假设在第 轮迭代中没有发生松弛操作。这意味着在第 轮迭代结束后,对于图中所有边 ,都满足 。 将这个不等式应用于环路 上的所有边:
将这 个不等式相加,得到:
$$\sum_{i=1}^{k} d[v_i] \le \sum_{i=1}^{k} d[v_{i-1}] + \sum_{i=1}^{k} w(v_{i-1}, v_i) $$由于 ,不等式左右两边的 项可以精确地消掉(因为 )。 这样我们就得到了:
这与 是一个负权环路(即其权重和小于0)的假设相矛盾。 因此,我们的初始假设(第 轮没有松弛)是错误的。必然至少有一条环上的边的松弛条件会被满足,导致 值继续下降。这意味着只要负权环路存在, 值理论上可以无限下降,而算法通过在第 轮检查是否仍有松弛发生来捕捉到这一现象。
证毕。Bellman-Ford算法的正确性得到了完全的证明。
代码实现
下面是一个 Bellman-Ford 算法的 C++ 实现,用于解决一个典型的最短路问题。
#include<bits/stdc++.h>
using namespace std;
using ll = long long;
const int N = 510, M = 10010;
const ll INF = 1e18; // 使用长整型以防权重和溢出
int n, m, k; // 点数,边数,要求的最短路径边数(可选)
ll d[N]; // d[i]存储从源点到i的最短距离
ll bak[N]; // 备份数组,防止串联更新
struct Edge {
int u, v, w;
} e[M];
void bfd() {
memset(d, 0x3f, sizeof d); // 初始化为无穷大
d[1] = 0; // 源点为1
// 进行 n-1 轮松弛
for (int i = 0; i < n; ++i) {
memcpy(bak, d, sizeof d); // 备份d数组,防止一轮内发生串联更新
for (int j = 0; j < m; ++j) {
int u = e[j].u, v = e[j].v, w = e[j].w;
if (bak[u] != 0x3f3f3f3f3f3f3f3fLL) { // 仅当u可达时才松弛
d[v] = min(d[v], bak[u] + w);
}
}
}
}
// 负权环检测: 在n-1轮后再进行一轮
// 如果d[v] > d[u] + w,则存在负环
bool neg_cyc() {
memcpy(bak, d, sizeof d);
for (int j = 0; j < m; ++j) {
int u = e[j].u, v = e[j].v, w = e[j].w;
if (bak[u] != 0x3f3f3f3f3f3f3f3fLL) {
if (d[v] > bak[u] + w) {
return true;
}
}
}
return false;
}
int main() {
ios::sync_with_stdio(0); cin.tie(0); cout.tie(0);
cin >> n >> m;
for (int i = 0; i < m; ++i) {
cin >> e[i].u >> e[i].v >> e[i].w;
}
bfd();
// 通常如果距离大于某个阈值(比如INF/2),就认为不可达
if (d[n] > INF / 2) {
cout << "impossible" << endl;
} else {
if (neg_cyc()) {
// 检查负环是否影响到目标点,这需要更复杂的判断
// 但一般如果题目问是否存在负环,这样即可
cout << "negative cycle detected" << endl;
} else {
cout << d[n] << endl;
}
}
return 0;
}
- 代码解释:
- 使用结构体
Edge存储所有边。 bfd函数执行 Bellman-Ford 算法的核心逻辑。外层循环执行n次(实际上n-1次就足够计算最短路,多一次用于检测)。bak数组是 Bellman-Ford 算法实现中的一个关键细节。在每一轮松弛中,我们应该使用上一轮迭代结束时的d值来计算新的d值。如果在同一轮中直接更新d数组,可能会导致“串联更新”,即在一个循环内,用刚刚被更新过的d[u]去更新d[v],这相当于在一次迭代中走了多步。为了严格符合我们证明中的 定义,需要一个备份。
- 使用结构体
- 复杂度分析:
- 时间复杂度:。外层循环执行 次(或 次以检测负环),内层循环遍历所有 条边。
- 空间复杂度:。用于存储距离数组和边列表。
SPFA算法及其正确性证明
SPFA(Shortest Path Faster Algorithm)观察到,Bellman-Ford算法在许多轮次中执行了大量“无效”的松弛操作。如果一个顶点 的最短路径估计值 在一整轮中都没有发生变化,那么在下一轮中,从 出发的所有边的松弛操作也必然是无效的。SPFA正是基于这一洞察,使用一个队列来维护那些 值被“刚刚更新过”的顶点,只对这些顶点的出边进行松弛。
算法描述
-
初始化:
- 对于图中所有顶点 :。
- 。
- 创建一个队列
q,将源点 入队。 - 用一个布尔数组
in_q标记顶点是否在队列中,in_q[s] = true。
-
迭代处理:
- 当队列
q不为空时:- 从队首取出一个顶点 ,
in_q[u] = false。 - 遍历所有从 出发的边 :
- 执行松弛操作:
if (d[v] > d[u] + w(u, v))d[v] = d[u] + w(u, v)- 如果顶点 不在队列
q中,则将其入队,并设置in_q[v] = true。
- 执行松弛操作:
- 从队首取出一个顶点 ,
- 当队列
正确性证明
SPFA的正确性可以理解为:它忠实地执行了所有“必要”的松弛操作,最终达到的状态与Bellman-Ford相同。
核心思想:算法维护一个不变式:只要存在某个顶点 ,其最短路径估计值 还能被优化(即存在某条边 使得 ),那么 最终一定会被加入队列,从而边 会被松弛。
证明:我们再次使用反证法来证明,在没有负权环路的情况下,当SPFA算法终止时(队列为空),对所有可达顶点 ,都有 。
-
算法终止性:在没有负权环路的图中,每个顶点的 值是单调递减且有下界的(即真正的最短路 )。一个顶点的 值更新次数是有限的(虽然可能很大)。因此,每个顶点入队的次数也是有限的。所以算法最终会终止。
-
结果正确性:
- 假设算法终止时,存在某个顶点 使得 。
- 在所有这类 值不正确的顶点中,我们选择一个在从 出发的某条真实最短路径上离 最近的顶点,记为 。(这里的“最近”指路径上的边数最少)。
- 设从 到 的一条最短路径为 。
- 由于 是第一个不满足 的顶点,那么它的前驱节点 必然满足 。
- 根据最短路径的性质,我们有:
- 将 代入,得到:
- 结合我们最初的假设 ,可以推导出:
- 这个不等式是松弛边 的条件。这意味着 还能被 更新。
- 现在我们来分析顶点 的状态。在算法的某个时刻, 一定是被更新为其最终值 的。每当一个顶点的 值被更新,它就会被加入队列(如果不在队列中的话)。所以, 在其 值被最后一次更新(更新为 )之后,一定入队了。
- 既然 入队了,那么在未来的某个时间点,它必然会出队。当它出队时,算法会遍历所有从 出发的边,其中就包括边 。届时,算法会检查 是否成立。
- 根据我们上面的推导,这个条件是成立的。因此, 必定会被更新(松弛)。
- 这次松弛会使得 。再结合上界性质 ,我们得到 在这次更新后会等于 。
- 这与我们的假设——“算法终止时 ”——相矛盾。
- 因此,假设不成立。当算法终止时,所有可达顶点的 都等于 。
证毕。
SPFA的负权环路检测
SPFA算法在有负权环路的情况下不会自行终止。因为环上顶点的 值会无限减小,导致它们被反复加入队列。我们可以利用这个特性来检测负权环路。
一个简单而有效的方法是记录每个顶点入队的次数。
- 在一个不含负权环路的图中,一条从 出发的最短路径是简单路径,其长度(边数)最多为 。这意味着一个顶点的 值最多被更新 次(对应于路径长度从1到的情况)。
- 因此,如果某个顶点 的入队次数达到了 次,根据鸽巢原理,这说明从 到 的某条路径上至少有 个顶点,这意味着该路径必然包含一个环。而每次更新都意味着找到了更短的路径,这只有在环是负权环时才可能发生。
- 所以,我们可以设置一个计数器
cnt[v],记录每个顶点 的入队次数。当一个顶点即将第 次入队时,我们就可以断定图中存在负权环路。
代码实现
#include<bits/stdc++.h>
using namespace std;
using ll = long long;
const int N = 100010;
ll d[N];
int h[N], e[N], ne[N], w[N], idx; // 邻接表存图
int cnt[N]; // 记录每个点入队次数
bool in_q[N]; // 标记是否在队列中
void add(int a, int b, int c) {
e[idx] = b, w[idx] = c, ne[idx] = h[a], h[a] = idx++;
}
bool spfa(int s, int n) {
memset(d, 0x3f, sizeof d);
d[s] = 0;
queue<int> q;
q.push(s);
in_q[s] = true;
cnt[s]++;
while (!q.empty()) {
int u = q.front();
q.pop();
in_q[u] = false;
for (int i = h[u]; i != -1; i = ne[i]) {
int v = e[i];
if (d[v] > d[u] + w[i]) {
d[v] = d[u] + w[i];
if (!in_q[v]) {
q.push(v);
in_q[v] = true;
cnt[v]++;
if (cnt[v] >= n) return true; // 发现负环
}
}
}
}
return false; // 没有负环
}
int main() {
ios::sync_with_stdio(0); cin.tie(0); cout.tie(0);
int n, m;
cin >> n >> m;
memset(h, -1, sizeof h);
for (int i = 0; i < m; ++i) {
int u, v, w;
cin >> u >> v >> w;
add(u, v, w);
}
if (spfa(1, n)) {
cout << "Yes" << endl;
} else {
// 通常求最短路时会在这里输出d[n]等结果
cout << "No" << endl;
}
return 0;
}
- 代码解释:
- 使用邻接表存储图,这对于稀疏图更高效。
spfa函数实现了算法核心逻辑。cnt数组用于负权环检测。当cnt[v]达到n时,说明找到了负环,函数返回true。
- 复杂度分析:
- 时间复杂度:
- 最坏情况:。SPFA的性能高度依赖于图的结构。在一些精心构造的图(例如网格图、链状图)上,SPFA的性能会退化,每个点可能被反复入队,总的松弛次数接近Bellman-Ford的水平。
- 平均情况/随机图:通常被认为是 ,其中 是一个小的常数。在大多数情况下,SPFA的效率远高于Bellman-Ford,接近于稀疏图上Dijkstra的效率。
- 空间复杂度:。用于邻接表、距离数组和队列。
- 时间复杂度:
京公网安备11010802045784号