简述
最短路径问题是图论研究中的一个经典算法问题,旨在寻找图(由结点和路径组成的)中两结点之间的最短路径。
解决图中最短路径问题又分为两种——单源最短路径和多源最短路径,单源最短路径的经典算法包括Dijkstra(戴克斯特拉)算法,Bellman-Ford(贝尔曼-福特)算法以及SPFA(Bellman-Ford算法的改进版本)等,多源最短路径有Floyd-Warshall(弗洛伊德)算法等。
Floyd-Warshall(弗洛伊德算法)
弗洛伊德算法的主要思想是通过第三点来缩短两点间的路径,例如顶点a和顶点b之间的路径为dis[a][b],如果想使a,b之间的路径缩短,只有引入第三点k,并通过k点来中转,即a->k->b,才可能缩短a,b之间的路径,那么这个k点是哪个点呢,其实并不唯一,k点也有可能有多个,但可以确定的是,在经过k点中转后,所得到的dis[a][k]+dis[k][b]一定小于中转前的dis[a][b],所以就可以总结为从a点到b点只经过前k个点的最短路径,是不是感觉这句话有点熟悉呢[滑稽],没错,这就是动态规划的思想。
下面给出算法完整代码:
注:Floyd-Warshall算法可以处理带有负权边的图,但不能处理带有“负权回路”的图
#include<bits/stdc++.h>
#define rep(i,a,b) for(i=a;i<=b;i++)
using namespace std;
const int Max=1e3+7;
int dis[Max][Max];
int n,m;
int main()
{
memset(dis,0x3f,sizeof(dis));
int i,j,k,a,b,v;
cin>>n>>m;
rep(i,1,n)dis[i][i]=0;
rep(i,1,m){
cin>>a>>b>>v;
dis[a][b]=v;
}
rep(k,1,n){ //Floyd核心代码
rep(i,1,n){
rep(j,1,n){
if(dis[i][k]+dis[k][j]<dis[i][j]) //判断是否满足中转条件
dis[i][j]=dis[i][k]+dis[k][j];
}
}
}
rep(i,1,n){
rep(j,1,n){
printf("%5d",dis[i][j]);
}cout<<endl;
}
return 0;
}
Dijkstra(戴克斯特拉)算法
戴克斯特拉算法是解决单个点到其余各点最短路径的算法,其主要思想为通过其他边来松弛单个点到其余各点的路程。这里我们还需要定义一个dis数组,表示该点到其余各点的未确定最短距离。然后从dis数组中选取一个最小值(离始点最近的点),将它的距离确定为最短,这里是因为每条边都是正数,那么肯定不可能通过第三个点来使得此时距始点最短的距离更短了,因为始点到其他点的距离都比到这个点的距离要大呀<(^-^)>
然后我们就可以通过这个点的边来对始点到其他点的边进行“松弛”,比如设始点为1号点,刚刚确定为最短路径的点为2号点,然后此时的dis[3]=10,dis[2]=1,e[2][3]=4(始点为2,终点为3的边),这时我们就可以通过e[2][3]这条边来对dis[3]进行松弛,因为dis[3]>dis[2]+e[2][3],所以此时的dis[3]应更新为5。这便是戴克斯特拉算法的主要思想:通过其他边来松弛单个点到其余各点的路程。
这样我们就可以总结出算法的基本步骤了:每次找到离始点最近的一个顶点,通过这个点的边来对始点到其余各点的边进行松弛,最终得到始点到其余各点的最短路径。
具体算法代码如下:
注:这里的Dijkstra无法解决带有负权边及负权回路的图,因为此算法是一种基于贪心策略的算法,每次新拓展一个路径最短的点,更新与其相邻的点的路程。当所有边权为正的时候,由于不会存在一个路程更短的没拓展过的点,所以我们可以保证这个点的路程永远不会再被改变。但是如果出现了负权边,当拓展到负权边的时候会产生更短的路径,就有可能破坏了之前已经确定过的最短路径。
#include<bits/stdc++.h>
#define rep(i,a,b) for(i=a;i<=b;i++)
using namespace std;
const int Max=1e3+7;
const int inf=2147483647;
struct Node
{
int to,val;
Node(int _to=0,int _val=0){to=_to;val=_val;}
}node;
vector<vector<Node> > e(Max); //这里用的二维vector数组做的邻接表来储存的点边信息
int book[Max];
int dis[Max];
int n,m,i,j,a,b,v,u;
int main()
{
freopen("in.htm","r",stdin);
cin>>n>>m;
memset(book,0,sizeof(book));
memset(dis,0x3f,sizeof(dis));
dis[1]=0;
rep(i,1,m){
cin>>a>>b>>v;
if(a==1)dis[b]=v; //这里设始点为 1
e[a].push_back(Node(b,v));
}
book[1]=1;
rep(i,1,n-1){ //Dijkstra算法核心代码
int mins=inf;
rep(j,2,n){
if(book[j]==0&&dis[j]<mins){ //找到目前距始点最近的点u
mins=dis[j];
u=j;
}
}
book[u]=1;
rep(j,0,(int)e[u].size()-1){ //通过点u的边来对始点到其他点的边进行松弛
if(dis[u]+e[u][j].val<dis[e[u][j].to])
dis[e[u][j].to]=dis[u]+e[u][j].val;
}
}
rep(i,1,n)cout<<dis[i]<<" ";
cout<<endl;
return 0;
}
Bellman-Ford(贝尔曼-福特)算法——解决负权边
在以上介绍的两种算法中虽然很好的解决了最短路径问题,但多多少少都有一些缺陷,比如无法解决带有负权回路的图。现在我们介绍的这种Bellman-Ford算法就能够解决负权边问题啦O(∩_∩)O~
Bellman-Ford算法其实操作方法与Dijkstra算法如出一辙,只不过它没有进行最短路径的猜测,而是对每一条边都进行了n-1次松弛,这样就确保了每一条边最后都能到达它的最短路径。
代码如下:
#include<bits/stdc++.h>
#define rep(i,a,b) for(i=a;i<=b;i++)
using namespace std;
const int Max=1e3+7;
const int inf=2147483647;
struct Node
{
int to,val;
Node(int _to=0,int _val=0){to=_to;val=_val;}
}node;
vector<vector<Node> > e(Max);
int dis[Max];
int n,m,i,j,a,b,v,u;
int main()
{
freopen("in.htm","r",stdin);
cin>>n>>m;
memset(dis,0x3f,sizeof(dis));
dis[1]=0;
rep(i,1,m){
cin>>a>>b>>v;
e[a].push_back(Node(b,v));
}
rep(u,1,n-1)
rep(i,1,n)
rep(j,0,(int)e[i].size()-1)
if(dis[i]+e[i][j].val<dis[e[i][j].to])
dis[e[i][j].to]=dis[i]+e[i][j].val;
rep(i,1,n)cout<<dis[i]<<" ";
cout<<endl;
return 0;
}
有些爱动脑的同学可能会问了,那这样我们会不会做了很多不必要的操作呀,的确,我们在对边进行松弛的过程中,有些边经过少于n-1次的松弛便得到了最短路程,但是我们依旧对它进行了后续没有必要的松弛,这显然是不合适的。所以在这里我们引进了Bellman-Ford算法的队列优化(SPFA)
SPFA(Bellman-Ford算法的队列优化)
在刚刚的算法中我们提到,对每一条边都进行n-1次的松弛,可能会进行很多不必要的操作,浪费掉很多时间,顺便说一下,不进行优化的Bellman-Ford算法的时间复杂度为O(n*m),而经过队列优化的Bellman-Ford算法的时间复杂度最坏情况下也是O(n*m),但是一般是达不到的。
那么我们应该如何利用队列进行优化呢?我们可以将始点u放入队列,然后对u的所有出边进行松弛。例如有一条u->v的边,如果通过u->v这条边使得始点到顶点v的路程变短(dis[u]+e[u][v]<dis[v]),且顶点v不在队列中,就将点v放入队尾,点u松弛完毕后,就将点u出队,继续进行下一点的出边松弛,直到队列为空。
#include<bits/stdc++.h>
#define rep(i,a,b) for(i=a;i<=b;i++)
using namespace std;
const int Max=1e3+7;
const int inf=2147483647;
struct Node
{
int to,val;
Node(int _to=0,int _val=0){to=_to;val=_val;}
}node;
vector<vector<Node> > e(Max);
queue<int> q;
int book[Max]; //判断队列中是否已有该点
int dis[Max];
int n,m,i,j,a,b,v,u;
int main()
{
freopen("in.htm","r",stdin);
cin>>n>>m;
memset(dis,0x3f,sizeof(dis));
memset(book,0,sizeof(book));
dis[1]=0;
q.push(1);
book[1]=1;
rep(i,1,m){
cin>>a>>b>>v;
e[a].push_back(Node(b,v));
}
while(!q.empty()){
i=q.front();
q.pop();
rep(j,0,(int)e[i].size()-1)
if(dis[i]+e[i][j].val<dis[e[i][j].to]){
dis[e[i][j].to]=dis[i]+e[i][j].val;
q.push(e[i][j].to);
book[e[i][j].to]=1;
}
book[i]=0;
}
rep(i,1,n)cout<<dis[i]<<" ";
cout<<endl;
return 0;
}
看到这里爱思考的同学可能会发现了,使用队列优化的Bellman-Ford算法看起来与BFS十分相似,但是不同的是BFS的队列元素出队后一般就不会再入队了,但是这里一个顶点出队后很有可能再入队,因为如果找到了一条到点v的比之前所找到的路程更短的路径,那么也会对经过点v的路径产生影响,所以需要对点v的所有出边再进行一次松弛,这样才能够保证相邻顶点的最短路程同步更新。