导入
最短路径问题是指在一幅带权图中,找出连接两个顶点之间的所有路径中,边权和最短的那一条。如下图就是一幅带权图,边上的数字就代表该边的权值。解决最短路径问题有多种不同的算法,本文将对它们的基本思想与优化操作一一进行介绍。
Floyd算法
Floyd算法除了存在负环的图外都能适用。它的思想基于动态规划,假设要求得i顶点到j顶点的最短路径,我们引入一个中间顶点k,用F[i][j][k]表示路径中只允许经过顶点1~k的情况下,i到j的最短距离。初始时,F[i][j][0]表示不经过中间顶点的最短路,显然,如果i、j之间是直接连通的,F[i][j][0]就等于连接它们的那条边的权值;否则不借助中间顶点是不可能由i到达j的,F[i][j][0]=正无穷。
对于每个中间顶点k,只存在两种情况:
1、最短路径不经过k:此情况下k顶点对结果没有任何贡献,在1~k的顶点集合中,k是可以忽略的,也就是只用考虑顶点1~k-1,所以F[i][j][k]的值就等于F[i][j][k-1];
为方便演示以有向图为例,如下图,绿色箭头代表最短路:
2、最短路径经过k:此情况下k顶点将对结果产生影响,k作为中间顶点将最短路分成了两部分,i到k的最短路与k到j的最短路,所以F[i][j][k]的值等于F[i][k][k-1]+F[k][j][k-1];
为方便演示以有向图为例,如下图,红色箭头代表i到k的最短路,绿色箭头代表k到j的最短路:
综上所述,最终的最短路径就是上面两种情况中较短的那条,所以得到状态转移方程为:F[i][j][k]=min{F[i][j][k-1],F[i][k][k-1]+F[k][j][k-1]} 。通过该状态转移方程我们知道,程序至少需要三重循环:k i j,时间复杂度为O(),空间复杂度也是O()。
针对空间复杂度我们可以做出一点优化。注意到我们需要的答案只是F[i][j][n],而F[i][j][k]的答案只与F[i][j][k-1]有关。想象一下循环过程中三维数组的构造情况,从最顶层开始,依次构建下一层,而下一层需要的数据都来自于它的上层,最终我们只需要最底层的结果。所以当某一层构建完它的下一层后,它的工作就已经完成了,这一层空间保存的数据将再无作用,那么其实下一层完全不需要新开空间来保存数据,直接存放在这一层原有的空间中,将原来这些无用的数据覆盖掉即可,这样我们仅需要一层的空间就足够了。这里一层的空间为二维数组,通过重复利用已使用的空间我们成功将空间复杂度由 O()缩减到了 O(),这就是滚动数组优化。
所以,原状态状态方程就可直接简化为F[i][j]=min{ F[i][j] , F[i][k]+F[k][j] } ,我们也可以用另外一种思路去理解它:如果从i到k的最小距离+从k到j的最小距离<原来从i到j的最小距离,就用该更小的距离代替原来的距离。
int g[N][Num], f[N][N];
void pretreatment(int n){
for (int i=1; i<=n; ++i)
for (int j=1; j<=n; ++j)
f[i][j]=g[i][j];
}
void floyd(int n){
//k的循环必须在最外层,因为当每k层的dp[i][j]全部求出后才能递推求下一层
for (int k=1; k<=n; ++k)
for (int i=1; i<=n; ++i)
for (int j=1; j<=n; ++j)
//这里的正无穷取了0x3f,其值等于1061109567,并非很大的数,但取其的目的是如果出现了f[i][k]与f[k][j]都为正无穷的时候保证他们相加不会溢出int值,当然也可以直接用longlong存储
if (f[i][k]!=0x3f && f[k][j]!=0x3f && (f[i][k]+f[k][j]<f[i][j]))
f[i][j] = f[i][k]+f[k][j];
}
Dijkstra算法
Dijkstra不能适用于出现负边权的情况。Dijkstra的思想我们可以类比于最小生成树的Prim算法,同样地定义一个dis数组,dis[i]表示从起点到i的最短路径,令起点为s,初始时设置dis[s]=0,其它点的dis值为正无穷。定义vst数组,已经遍历过的顶点的vst值设为1,每次从所有还未遍历的顶点中选出dis值最小的顶点,然后修改与其相连的顶点的dis值,修改规则与Prim算法不同,Prim算法只用考虑当前边,而求最短路还需要考虑到达该点前的最短路径的状态,所以Dijkstra算法的修改规则为:设当前遍历到的顶点为i,与其相连的顶点为j,如果dis[i]+i到j间的边权<dis[j],就将dis[j]修改为dis[i]+i到j间的边权。根据Dijkstra算法的思想可知它求出的是固定起点到达其它顶点的最短路,即Dijkstra算法解决的是单源最短路问题。文字表述不理解的话,可以参考下图:
Prim算法可以利用邻接表+优先队列优化,同样的,Dijkstra算法也可以实现相同的优化:
const int N = 150;
//邻接表
struct Edge{
int to,dis;
};
vector<Edge> head[N];
//添边
void addEdge(int from,int to,int dis){
Edge edge;
edge.to=to;
edge.dis=dis;
head[from].push_back(edge);
}
//辅助数组
int dis[N], vst[N], pre[N];//pre数组记录每个顶点的最短路前驱,可用于打印最短路
priority_queue< pair<int,int> > q;
void dijkstra(int s){
memset(dis,0x7f,sizeof(dis));
dis[s]=0;
q.push(make_pair(-dis[s],s));
while(q.size()){
int i=q.top().second;
q.pop();
if(vst[i]) continue;
vst[i]=1;
for(int j=0;j<head[i].size();++j){
Edge edge=head[i][j];
int x=edge.to;
if(dis[i]+edge.dis<dis[x]){
dis[x]=dis[i]+edge.dis;
pre[x]=i;
q.push(make_pair(-dis[x],x));
}
}
}
}
Bellman-Ford算法
Bellman-Ford算法是Dijkstra算法的一个加强,它能处理负边权的情况,但仍不能处理出现负环的情况,不过我们可以退而求其次,用它来判断一个图中是否存在负环。Bellman-Ford算法解决的也是单源最短路问题。
Bellman-Ford算法的思想是动态规划,定义dis[k][i]表示从起点到i最多经过了k条边的最短路,而n个顶点能连成的最长边数为n-1,所以k的取值范围为0~n-1,即全部顶点连成一行时。除了i=起点的情况之外,dis[0][i]均等于正无穷。
思考下如何求得dis[k][i]:根据k的定义,dis[k][i]最多经过k条边,我们将其拆分为k-1+1条,也就是dis[k][i]可以由k-1条边加上一条连接i的边求得,枚举所有与i相连的顶点,记为j,它们到i的距离记为edge{j,i}.dis,那么经过j顶点到达i的最短路为dis[k-1][j]+edge{j,i}.dis,而dis[k][i]就等于它们中间最短的那条。状态转移方程为:dis[k][i]=min{dis[k-1][j]+edge{j,i}.dis | j是i的前驱}。
这里给出的状态转移方程与大部分博客中给出的不同,一般该状态转移方程的形式为:dis[k][i]=min{dis[k-1][i],min{dis[k-1][j]+edge{j,i}.dis}(j=1~n且!=i) }。
其实仔细分析下这两种形式的状态转移方程会发现它们实际是一样的,首先尽管此处j遍历了处i以外的所有顶点,然而由于i、j不直接相连时edge{j,i}.dis等于正无穷,不可能成为最短路,所以可以不考虑;其次,此处额外考虑了一个dis[k-1][i]的值,事实上也是不用考虑的,无论dis[k-1][i]的值如何,该路径上总之有一个顶点j是与i相连的,而通过这个顶点j用dis[k-1][j]+edge{j,i}.dis算出的值就等于dis[k-1][i]。
但是,在实际编写代码时,我们会使用第三种形式的状态转移方程:dis[k][i]=min{dis[k-1][j]+edge{j,i}.dis | j=1~n}。当j=i时,dis[k-1][j]+edge{j,i}.dis=dis[k-1][j]+0=dis[k-1][i],所有的情况仍然是全部被考虑了的,因此该状态转移方程也是正确的,而且用代码实现起来最为简单,所以最终采用该形式来编写代码,示例如下:
int g[N][N]//邻接矩阵存储
/*因为后面还会优化算法,所以直接省略了不重要的部分,只给出状态转移方程的实现*/
for(int k=1;k<n;++k){
for(int i=1;i<=n;++i){
//dis[k][i]=min{dis[k-1][j]+g[j][i] | j=1~n}
for(int j=1;j<=n;++j){
if(dis[k-1][j]+g[j][i]<dis[k][i])
dis[k][i]=dis[k-1][j]+g[j][i];
}
}
}
//每个顶点i最后的最短路为dis[n-1][i]
for(int i=1;i<=n;++i)
cout<<dis[n-1][i]<<endl;
由于我们只需要k=n-1时数组的值,k=0~n-2的空间完全没有利用上,而且似乎每一层的状态只与它的上层相关,所以我们可以考虑用滚动数组进行优化,将数组减少到一维:dis[i],每次更新的值直接覆盖在原数组上。然而这里有一个小问题,在循环时,更新dis[k-1][i]要用到的dis[k-1][j]的值有可能已经被更新为dis[k][j]了,但幸运的是,我们要求的是最短路,而dis[k-1][j]被更新为dis[k][j]后的值要么不变,要么更小,所以我们得到的dis[k-1][i]尽管在k=1~n-2时得到的答案有可能比正确答案更小,但在k=n-1时一定是正确的,因为最短路本身要求的就是最小值。
算法的优化还没有就此结束,观察代码中的循环部分:for(int i=1;i<=n;++i){ for(int j=1;j<=n;++j) if(dis[k-1][j]+g[j][i]<dis[k][i]) ......}。i,j是顶点,g[j][i]则是它们间的距离,三者共同构成了一条边的信息,也就是说在这个循环中,我们要使用的信息只有边的信息而已,那么我们在存图时可以直接存储图中所有边的信息,即{边的起点,边的终点,边的权值},可以用结构体存储,这样在循环时就不需要再使用二重循环了,只用一次循环所有的边即可。时间复杂度为O(nm),m是边数。
bellman-ford算法可以用来判断负环,负环的特点是每遍历一次负环,最短路会越来越小,一直到负无穷,所以有负环时是无法得到最短路的。要判断图中是否存在负环,只需要在n-1次循环确定最短路后,继续循环一次,若发现最短路还在减小,则说明图中存在负环。最终经过两次优化后并加入了负环的判断的bellman-ford算法的代码如下:
//存边,注意与邻接表还是有区别的
struct Edge{
int from,to,dis;
};
vector<Edge> head;
void addEdge(int from,int to,int dis){
Edge edge;
edge.from=from;
edge.to=to;
edge.dis=dis;
head.push_back(edge);
}
int dis[N];//经滚动数组优化为1维
//n为顶点数,m为边数,s为起点
bool bellman_ford(int n,int m,int s){
for(int i=1;i<=n;++i) dis[i]=0x7f;
dis[s]=0;
for(int k=1;k<n;++k){
//循环所有的边即可
for(int j=0;j<head.size();++j)
if(dis[head[j].from]+head[j].dis<dis[head[j].to])
dis[head[j].to]=dis[head[j].from]+head[j].dis;
}
//判断是否存在负环,按理说循环了n-1之后,所有点的最短路都应该确定了,如果再次循环,发现有点的最短路还可以继续更新,说明图中存在负环
for(int j=0;j<head.size();++j)
if(dis[head[j].from]+head[j].dis<dis[head[j].to])
return false;
return true;
}
SPFA算法
SPFA算法并非一个解决最短路径问题的新算法,而是对Bellman-Ford算法的一种优化,也被称作队列优化的Bellman-Ford算法。
在Bellman-Ford算法中,我们知道一个顶点i是通过它的前驱j来修改的,也就意味着如果j的最短路不变,i的最短路也不会发生变化,所以实际上我们只用在某个顶点被更新后才用它去更新其它的顶点。利用一个队列,当有顶点被更新时,就将其加入队列,如果队列中已经有该顶点了,则不重复入队。每次取出队头的顶点,用它更新与其相连的顶点,同理,有顶点被更新成功就将其入队,重复操作直到队列为空。
需要判负环的话,要新引入一个数组cnt,cnt[i]表示从起点到i的最短路包含的顶点个数,每次更新成功就将该顶点的cnt+1,如果发现cnt值大于了n,就代表图中包含负环。代码如下:
bool spfa(int n,int s){
queue<int> q;
memset(dis,0x3f,sizeof(dis));
memset(vst,0,sizeof(vst));
memset(cnt,0,sizeof(cnt));
dis[s]=0;
q.push(1); vst[s]=0; cnt[s]=1;
while(q.size()){
int i=q.front();
q.pop();
vst[i]=0;
for(int j=0;j<head[i].size();++j){
Edge edge=head[i][j];
int x=edge.to;
if(dis[i]+edge.dis<dis[x]){
dis[x]=dis[i]+edge.dis;
cnt[x]=cnt[i]+1;
if(cnt[x]>n) return false;
if(!vst[x]){
q.push(x);
vst[x]=1;
}
}
}
}
return true;
}
总结
最短路算法 | 解决的最短路问题类型 | 不能求最短路的情况 | 时间复杂度(优化后) |
Floyd算法 | 多源最短路 | 含有负环的图 | O() n为顶点数 |
Dijkstra算法 | 单源最短路 | 含有负边权的图 | O(mlogn) n为顶点数,m为边数 |
Bellman-Ford算法 | 单源最短路 | 含有负环的图 | O(nm) n为顶点数,m为边数 |
SPFA算法 | 单源最短路 | 含有负环的图 | O(km) k为常数,m为边数 |