Bellman-Ford算法
之前我已经介绍过Dijkstra算法,Dijkstra算法是个优秀的算法,但它不能运用到带有负权边的图中。
于是乎,Bellman-Ford算法登场了~
先上一张带有负权边的图(
n
=
5
n=5
n=5)作为例子,依旧是求顶点1
到其他顶点的最短距离:
准备工作
我们用三个一维数组表示所有边,其中u[i],v[i],w[i]
分别表示第i
条边的起点、终点和权值,边给出的顺序如下:
另外,和Dijkstra算法类似,我们还需要一个dis
数组存储顶点1
到其他顶点的距离:
核心步骤
对于每一条边进行一次松弛操作。 对于第i
条边
u
[
i
]
→
w
[
i
]
v
[
i
]
u[i]\xrightarrow{w[i]}v[i]
u[i]w[i]v[i] ,更新dis[v[i]]=min(dis[u[i]]+w[i])
。
例如,根据边给出的顺序,我们先来处理第一条边
2
→
2
3
2\xrightarrow{2}3
223 ,判断dis[2]+2
是否大于dis[3]
,发现dis[3]=∞
,dis[2]+2=∞
,松弛失败。
继续处理第2条边,
1
→
−
3
2
1\xrightarrow{-3}2
1−32,判断dis[1]+(-3)
是否大于dis[2]
,发现dis[2]=∞
,dis[1]+(-3)=-3
,则dis[2]
的值更新为-3
,松弛成功。
用同样的方法处理剩下的边,dis
数组最后如下:
经过一次上述步骤,dis
数组存储的是从顶点1
经过一条边后到达各个顶点的最短距离。实际上,经过k次上述步骤,dis
数组存储的是从顶点1
经过k条边后到达各个顶点的最短距离。
那么问题来了:到底要经过几轮该步骤呢?
最多经过n-1
轮该步骤即可,原因如下:
- 在一个含有n个顶点的图中,任意两点之间的最短路径最多包含n-1条边,因为n条边和n个顶点组成的图一定包含回路。
- 最短路径一定是一个不包含回路的简单路径。回路分为正权回路(回路权值和为正)和负权回路。若最短路径中包含正权回路,那去掉这个回路就能得到更短路径,违背前提;若最短路径中包含负权回路,那么每走一次负权回路,路径的权值和就会减小,也就是说每次都能获得一个更短的路径,那图中就不存在最短路径了,依旧违背前提。
这样一来,Bellman-Ford算法可以概括成一句话: 对 于 每 一 条 边 进 行 n − 1 次 松 弛 操 作 \color{red}{对于每一条边进行n-1次松弛操作} 对于每一条边进行n−1次松弛操作。核心代码三行,这简洁度相比Floyd算法还更胜一筹~
for (int k = 1; k <= n - 1; k++) //n-1轮松弛
for (int i = 1; i <= m; i++) //枚举m条边
dis[v[i]] = min(dis[v[i]], dis[u[i]] + w[i]);//尝试松弛
利用这一性质,我们还可以判断图中是否存在负权回路,要做的就是判断n-1
轮松弛之后dis
数组是否还会变化,若依旧会发生变化,则必然存在负权回路。
优化
我在前面描述到,最多经过n-1
轮的松弛即可,那为什么我用的是最多呢?因为在实际的运用场景中,得出最终的最短路数组dis
往往不需要n-1
轮的松弛,导致剩下几轮的松弛会变成无意义的循环,浪费时间。
所以我们可以对算法进行优化:每一轮松弛后判断dis
是否发生变化,没有变化则已找到最短路径解,结束算法。
这里贴出优化后的完整代码:
#include <iostream>
#include <algorithm>
#include <queue>
#include <stack>
#include <stdio.h>
#include <limits.h>
#include <cmath>
#include <stdlib.h>
#include <string>
#include <vector>
#include <set>
#include <map>
#include <utility>
#include <windows.h>
#include <climits>
#define LL long long
#define INF 999999
using namespace std;
const double pi = atan(1.) * 4.;
const int N = 51;
const int MOD = 1e9 + 7;
int n, m;
int u[N], v[N], w[N];
int pdis[N];//前一轮松弛后dis数组的备份
int dis[N];
bool check = true;//判断算法结束的标志位
int main() {
ios::sync_with_stdio(false);
cin.tie(0);
//准备工作-----------------------------------
//输入顶点数、边数
cin >> n >> m;
for (int i = 1; i <= m; i++) {
// 第i条边的起点、终点、权值
cin >> u[i] >> v[i] >> w[i];
}
//初始化dis数组
for (int i = 1; i <= n; i++) {
dis[i] = INF;
}
dis[1] = 0;
//核心步骤-----------------------------------
for (int k = 1; k <= n - 1; k++) {
for (int i = 1; i <= n; i++) //备份dis数组
pdis[i] = dis[i];
for (int i = 1; i <= m; i++) //枚举m条边
dis[v[i]] = min(dis[v[i]], dis[u[i]] + w[i]);//尝试松弛
for (int i = 1; i <= n; i++) //判断dis数组是否变化
if (pdis[i] != dis[i]) {
check = false;
break;
}
if (check) break;//无变化则结束算法
}
//输出结果-----------------------------------
for (int i = 1; i <= n; i++) cout << dis[i] << ' ';
}
/*
input:
5 5
2 3 2
1 2 -3
1 5 5
4 5 2
3 4 3
output:
0 -3 -1 2 4
*/
优化++
实际上再深入思考一下,我们会发现,在每一次松弛操作之后,有一些顶点已经求得最短路径,此后这些顶点的dis[i]
就再也没有变过,但是每轮都还要判断是否需要松弛,这里也浪费了时间。
所以,我们可以对算法进行进一步的优化:每次仅对最短路发生变化的顶点的所有出边进行松弛操作。
为了方便遍历顶点的出边,将图的存储形式改为邻接表,添加一个队列(这里用一维数组que
来模拟)来维护这些点,最初将顶点1
入队:
此时位于队首的是顶点1
,来看它的第一个出边
1
→
−
3
2
1\xrightarrow{-3}2
1−32,发现dis[2]=∞
,dis[1]+(-3)=-3
,将dis[2]
更新为-2
,顶点2
不在队列中,将它入队:
继续看第二个出边
1
→
5
5
1\xrightarrow{5}5
155,发现dis[5]=∞
,dis[1]+5=5
,将dis[2]
更新为5
,顶点5
不在队列中,将它入队:
顶点1
的出边已经遍历完了,将它出队(将head
往后移):
现在队首的顶点是2
,继续重复刚刚的操作,然后出队,继续遍历下一个队首的出边,反反复复,直到队列为空(head==tail
)即可,最终队列如下:
总结一下步骤:
- 初始化dis数组,将顶点
1
加入队列; - 取队首的顶点
u
,对于u
的每一个出边 u → w v u\xrightarrow{w}v uwv,若有dis[v]>dis[u]+w
,进入步骤3;否则,进入步骤4; - 更新
dis[v]=dis[u]+w
,若v
不在队列中,将v
加入队列。进入步骤4; - 将队首顶点
u
从队列中移除,若此时队列为空,返回步骤2;否则,算法结束。
贴代码:
#include <iostream>
#include <algorithm>
#include <queue>
#include <stack>
#include <stdio.h>
#include <limits.h>
#include <cmath>
#include <stdlib.h>
#include <string>
#include <vector>
#include <set>
#include <map>
#include <utility>
#include <windows.h>
#include <climits>
#define LL long long
#define INF 999999
using namespace std;
const double pi = atan(1.) * 4.;
const int N = 51;
const int MOD = 1e9 + 7;
int n, m;
int a, b, c;
int dis[N];
bool book[N];//判断顶点是否位于队列
int que[N], head, tail;
struct node {
int v;
int w;
node(int vv, int ww) :v(vv), w(ww) {}
};
//邻接表
vector<node> u[N];
int main() {
ios::sync_with_stdio(false);
cin.tie(0);
//准备工作-----------------------------------
cin >> n >> m;
for (int i = 1; i <= m; i++) {
//每条边的起点、终点、权值
cin >> a >> b >> c;
u[a].push_back(node(b, c));
}
for (int i = 1; i <= n; i++) {
dis[i] = INF;
}
dis[1] = 0;
//将顶点1加入队列
que[tail++] = 1;
book[1] = true;
//核心步骤-----------------------------------
//结束条件:队列为空
while (tail != head) {
int tu = que[head];//取出位于队首的顶点
//遍历其出边进行松弛操作
for (int i = 0; i < u[tu].size(); i++) {
int tv = u[tu][i].v;
int tw = u[tu][i].w;
if (dis[tv] > dis[tu] + tw) {
dis[tv] = dis[tu] + tw;
// 这个顶点如果不在队列,加入这个队列
if (book[tv] == false) {
book[tv] = true;
que[tail++] = tv;
}
}
}
head++;//队首顶点出队
}
//输出que和dis检验
cout << "\nque: "; for (int i = 0; i < n; i++) printf("%3d%",que[i]);
cout << "\ndis: "; for (int i = 1; i <= n; i++) printf("%3d%", dis[i]);
}
/*
input:
5 5
2 3 2
1 2 -3
1 5 5
4 5 2
3 4 3
output:
que: 1 2 5 3 4
dis: 0 -3 -1 2 4
*/