Bellman-Ford算法
Bellman-Ford算法是由理查德·贝尔曼和莱斯特·福特联合创立提出的算法,用于解决图上指定一点到其他点的最短距离(单源最短路径)问题。在 n n n点 m m m边的图中时间复杂度为 O ( n m ) O(nm) O(nm)。与Dijkstra算法相比,其最大的优点是它可以解决有负权边的图上的最短距离问题。但是其时间复杂度与前者相比较差。
在讲解算法之前,我们先来看一下负权边对于求图上最短距离的影响。
不想听唠叨的可以直接到“Bellman-Ford_算法解析”部分
我们根据边权,将图上的环分为几类:正环、零环和负环。
正环,即沿环走一圈后,权值之和为正数。在最短路径问题中,走正环会让路径边长,因此必定不会沿着正环绕圈。
零环,即沿环走一圈后,权值之和刚好为零。这样的环不会影响最短路径的结果。
负环,即沿环走一圈后,所得的权值之和为负数。在图中连续走负环会让图上路径越来越短。
了解了图上环的三种分类后,就该分析一下它们的作用了。到此应该可以理解为什么Dijkstra算法无法处理有负权边的图。我们再举一个例子来说明。
Dijkstra算法的核心理论之一就是,每一点联通向其邻接点的路线中,最短的一条一定是可以确定的。因为对于直接可以到达的路线最短的的目标点,从旁边走更长的路到达一个中转点再到目标点显然会比直达线路更长,可以直接不用考虑。
而在负权图上,这个理论被推翻了——以上图为例。从点1到点3,走向点2的路线在第一步更长,但在第二步时却通过负权边使得路线总距离变得比从点1直接到点3更短!而如果执行Dijkstra算法,我们认为第一步更长的路线永远无法变得比直达路线更短,因此这样的路线无法发现。
因此,负权边图无法保证能用Dijkstra算法求解最短路径。那么就需要通过其他算法来解决这种图上的问题。
算法解析
让我们回到Bellman-Ford算法上来。该算法的思路可以解决负权边图带来的困扰。
不同于Dijkstra的按点遍历,Bellman-Ford算法是以边为单位遍历的,因此时间复杂度较高。但这也使得路线“每一节”的长度不会影响算法的判断。
算法的核心思路是:遍历每一条边,起点到该边终点的距离为起点到该边终点原距离与起点到该边起点距离加该边长度的最小值。通俗来讲就是起点到点 a a a的距离如果能通过这条边变得更短,就走这条边。
(没了)
此算法还可以指定经过的中转边数量。将上述步骤重复 x x x遍即可求出从起点经过 x x x条边到终点的最短距离。无特殊要求的情况下上述步骤执行 n − 1 n-1 n−1遍( n n n为图中节点数量)得到答案。
SPFA优化
前面我们一直在说Bellman-Ford算法的时间复杂度高。事实上,针对此算法的优化SPFA(队列优化)就可以一定程度上解决这个问题。它使得算法的时间复杂度降为 O ( n + m ) O(n+m) O(n+m)至 O ( n m ) O(nm) O(nm)。
在原始的Bellman-Ford算法中,我们是通过 n − 1 n-1 n−1次循环遍历所有边来逐步更新最短路径的。事实上可以发现,遍历一条边时如果更新了最短路径,一定是因为从起点到该边起点的最短路径发生了变化。因此,起点路径距离未发生变化的边就可以忽略。这就是很大的优化。
SPFA ( Shortest Path Faster Algorithm, 简称短路快跑 ) 算法就使用了这种优化方法。
我们使用一个队列,将更新最短距离的边的终点所联通的边中未加入队列的边都加入到队列中。每次从队头取一边更新最短距离并入队其邻接边。这样一来,不会导致路线变短的边在一轮遍历中不会被遍历到,它的遍历时间就被省下来了。
SPFA算法还可以判断图中是否有负环。因为负环无论走多少圈都能够更新最短距离,而走负环刷距离的路线会让经过的点数持续变多,因此起点到终点的最短路线中经过的节点数如果大于总结点数(说明有些边走了多次),图中就存在负环。
有一些经过特殊构造的图可以抵消掉SPFA的优化效果。例如
但在实际题目中,若不是特意卡SPFA不让你用,基本是不会有这种情况的。
代码解析
下面是分别用Bellman-Ford和SPFA解决单源最短路径问题的代码
Bellman-Ford
在BF算法中,我们一直都是以边为单位遍历的。因此在存储图时,我们可以用一种独特的方式存储。定义一个结构体,把输入的每条边的起点、终点、权值三个信息作为一个元素,顺次存在一个数组里。再定义一个数组存储起点到其余点的最短距离即可。
用BF求经过k条边的单源最短路径
#include<bits/stdc++.h>
using namespace std;
int n,m,k,p[100005],d[100005];//点数、边数、中转边上限,以及存储最短距离的数组和辅助数组
struct node{ int x,y,z; }a[100005];//定义结构体存储图上的边信息
int f(){//Bellman-Ford算法本体
p[1]=0;//设置起点到起点的距离
for(int i=1;i<=k;i++){//重复更新k次即最多经过k条边
//遍历中为防止轮次混乱,更新时需要用到上一轮时的数据
//d[]中存储上一轮保存的数据,p[]中存储此轮数据
for(int j=1;j<=n;j++) d[j]=p[j];//更新辅助数据
for(int j=1;j<=m;j++){//依次遍历每一条边
int l=a[j].x,r=a[j].y,u=a[j].z;
if(p[r]>d[l]+u) p[r]=d[l]+u;//更新到此边终点的距离
}
}
if(p[n]>0x3f3f3f3f/2) return -1;//如果达到极限量级说明无法到达终点
return p[n];//正常情况下返回到点n的最短距离
}
int main(){
cin>>n>>m>>k;
memset(p,0x3f,sizeof(p));
for(int i=1;i<=m;i++) cin>>a[i].x>>a[i].y>>a[i].z;
//直接将边上信息输入到结构体数组中
cout<<f();
return 0;
}
Shortest Path Faster Algorithm
多么有高级感的名字
在SPFA算法中,我们的遍历方式由顺次遍历改为遍历邻接边。在此顺序下,再直接存储每条边就不方便了。因此我们用链式前向星存储。然后建立一个队列,像广搜一样把有用的边都入队,再依次更新。
由于使用了相同的队列思想,所以SPFA的代码神似广搜
用SPFA求单源最短路径
#include<bits/stdc++.h>
using namespace std;
int n,m,k,x,y,z,p[100005],v[100005];//取消了辅助数组,增加了标记数组v[]和用于输入的x,y,z
//以下为链式前向星配套物品
int head[100005],ver[100005],w[100005],ne[100005],tot;
void add(int a,int b,int c){
ver[++tot]=b,w[tot]=c,ne[tot]=head[a],head[a]=tot;
}
int f(){//SPFA本体
queue<int> q;
memset(p,0x3f,sizeof(p));
p[1]=0,v[1]=1;
q.push(1);
//更新起点距离,标记起点并入队
while(!q.empty()){
int t=q.front();
q.pop();v[t]=0;//取出队头并解除标记
for(int i=head[t];~i;i=ne[i]){//遍历队头的所有邻接边
if(p[ver[i]]>p[t]+w[i]){//等同于p[r]>d[l]+u
p[ver[i]]=p[t]+w[i];
if(v[ver[i]]==0) q.push(ver[i]),v[ver[i]]=1;
//如果不在队列中,入队并标记
}
}
}
if(p[n]>0x3f3f3f3f/2) return -1;
else return p[n];
//返回值与前面一样
}
int main(){
memset(head,-1,sizeof(head));//必要步骤
cin>>n>>m;
for(int i=1;i<=m;i++){
cin>>x>>y>>z;
add(x,y,z);
}
cout<<f();
return 0;
}
Floyd算法
弗洛伊德算法全称Floyd-Warshall算法,是1978年的图灵奖获得者罗伯特·弗洛伊德教授提出的,因此也以他的名字命名。
Floyd算法也用于解决最短路径问题,与其他算法不同的是它可以解决的是多源最短路径问题,即不固定起点和终点,求出任意两点间的最短距离。该算法的时间复杂度为 O ( n 3 ) O(n^3) O(n3)。
这个算法的最大优势在于它可以一次性求出任意两点间的最短距离。但由于过高的时间复杂度,所以不经常被使用。用Dijkstra或Bellman-Ford算法执行多次也可以达到此效果
算法解析与代码解析
Floyd算法本质上是用动态规划的思想解决最短路径问题。执行算法时,对于每一个中转点 a a a,用以 a a a为邻接点的路线更新其他所有点间的最短距离。
使用Floyd算法时,我们采用邻接矩阵存储数据。Floyd的核心代码是一个“内裤往外穿”的三层for循环,循环内真正执行的语句只有一行,与前面的算法形成鲜明对比,可以说完美化简了困难的最短路问题。
#include<bits/stdc++.h>
using namespace std;
int n,m,x,y,z,a[1005][1005];
int main(){
cin>>n>>m;
memset(a,0x3f,sizeof(a));//非联通道路用无限值表示
for(int i=1;i<=n;i++) a[i][i]=0;//点自环为0
for(int i=1;i<=m;i++){
cin>>x>>y>>z;
a[x][y]=min(a[x][y],z);//筛选重边
}
for(int k=1;k<=n;k++) for(int i=1;i<=n;i++) for(int j=1;j<=n;j++)
//循环变量k作为中转点放在最外层
a[i][j]=min(a[i][j],a[i][k]+a[k][j]);//更新最短路
while(cin>>x>>y) cout<<a[x][y]<<"\n";//可以任意询问路径
return 0;
z