1 概述
最短路问题是图论中一类经典问题,也是图论中较为基础的算法。本文旨在简要概述解决最短路问题的一些常见算法及其原理。
对于一张带权重的图G,G上一条路径p=<v0,v1,…,vk>的权重w(p)为这条路径各边边权之和:w(p)=∑w(vi-1,vi)。在从u到v的所有路径中,称最小权重为从u到v的最短路径权重,称的权重最小的路径为从u到v的最短路径。显而易见的是,从u到v的最短路径可能不止一条。如果u和v不连通,我们将从u到v的最短路权重记为+∞。
最短路算法通常都依赖于这样一个性质:最短路径的子路径也是最短路径。这一点很好理解:如果一条最短路径的子路径不是最短路径,我们一定能用这条子路径两端点间的最短路径替换这条子路径,从而找到了一条权重更小的路径,因而现在的这条路径并不是最短路径。于是,最短路径是具有最优子结构的,我们便可以使用动态规划来解决这个问题。事实上,本文讨论的这些最短路算法都是基于动态规划的。
一条最短路能包含环路吗?我们首先讨论权重为负值的环路(即负环)对最短路径权重的影响。容易发现,如果一条最短路径包含了一个负环,我们可以重复地经过这个负环,从而使得最短路径权重变为-∞(我们可以使用Bellman-Ford算法来判断一张图是否具有负环)。由此可以发现,讨论包含负环的最短路径意义不大。此外,最短路也不会包含权重为正值的环路,因为只要将环路从最短路径上删除,我们就能得到一条权值更小的路径。类似的,最短路也不会包含权重为0的环路。由此可以发现,在一张图上,从一个起点出发,它到其它结点的最短路径构成了一棵树。这是一条非常有趣的性质。
2 单源最短路径算法
单源最短路径问题是指:给定一张图G,从图中指定的源点(起点),计算它到图中其它所有结点的最短路径。
单源最短路径问题是最短路径的一个基本问题,解决此问题后,我们也可以解决以下几个单源最短路径问题的变体:
单汇最短路径问题:即计算图中每个结点到指定的汇点(目的地)的最短路径。显然,只要我们反向建图,这个问题就被转化成了单源最短路径问题。
单结点对最短路径问题:即计算图中某个结点对间的最短路径。显然,只要解决了单源最短路径问题,这个问题也迎刃而解。
所有结点对最短路径问题:即计算图中所有结点对之间的最短路径。我们可以对每个结点求一次单源最短路径来解决这个问题,不过在更为稠密的图上,我们有另外的算法来解决这个问题。我们将在后续的部分中专门讨论这个问题。
2.1 松弛操作
本文讨论的单源最短路径算法都依赖于松弛操作。
对于每个结点v,我们维护两个属性dist和path,dist用来记录从源点s到v的最短路径权重,path用来记录最短路径上v的前驱结点。在初始状态,所有结点的dist都被赋值为+∞。我们现在要解决的问题就是如何更新dist。这一操作依赖于如下一个性质,不妨称之为“三角形定理”:设x、y为图G上两结点,g[x][y]为从x到y的距离,则应有dist[x]+g[x][y]≥dist[y]。这个定理的正确性是显而易见的。倘若其不成立,则现有的dist[y]并不是从源点s到y的最短路径权重,我们就用dist[x]+g[x][y]更新dist[y],并将path[y]更新为x,这样我们就找到了一条更短的从s到y的路径,也就完成了一次松弛操作。显然,只要松弛的次数足够多,我们便可以算出从s到y的最短路径。
2.2 Dijkstra算法
2.2.1 Dijkstra算法
Dijkstra算法是一种用于解决边权非负的图上的单源最短路径的算法。
Dijkstra算法是基于贪心思想实现的。每一次,我们新拓展一个dist最小的结点,然后以它为中间点进行松弛操作,尝试更新其它结点的dist。由于图中的边权都非负,因而一个结点在被拓展之后其dist一定不会被再度更新。因此,我们只要把所有结点都拓展一遍,就能解决单源最短路径问题。这个算法的复杂度是Θ(n2)的。
1 void dijkstra(int s){ 2 memset(dist,0x3f,sizeof(dist)); 3 dist[s]=0; 4 int x; 5 for(int i=1;i<=n;i++){ 6 int MIN=0x3f3f3f3f; 7 for(int j=1;j<=n;j++) 8 if(!vis[j]&&dist[j]<MIN) x=j,MIN=dist[j]; 9 //在所有未扩展的节点中选取一个dist值最小的点进行更新 10 vis[x]=1;//标记为已拓展 11 for(int j=1;j<=n;j++) 12 if(dist[x]+g[x][j]<dist[j]) 13 dist[j]=dist[x]+g[x][j],path[j]=x; 14 //更新到其他节点的最短路,path数组用于记录路径 15 } 16 }//效率O(n^2)
2.2.2 堆优化Dijkstra算法
前文中提到的Dijkstra算法显然还有很大的提升空间。容易发现,我们在寻找dist最小的结点上浪费了很多时间。注意到这一过程可以被视作以下两个操作的组合:向一个集合添加一个新元素;求出这个集合中的最小元素。于是我们可以引入堆这一数据结构来优化这一过程。此外,在稀疏图上,我们还可以使用前向星来优化枚举其它结点的过程。
堆优化Dijkstra算法的复杂度是O(mlogn)的,在稀疏图上明显更优。这一复杂度是比较稳定的。
1 struct node{ 2 int u,v,w,nxt; 3 //u记录起点,v记录终点,w记录边权,nxt记录该点出发的上一条边 4 }e[M];//边目录 5 int num,f[N];//f表示该点出发的最后一条边(按加边顺序) 6 void add(int u,int v,int w){ 7 e[++num].u=u;e[num].v=v;e[num].w=w; 8 e[num].nxt=f[u];f[u]=num; 9 }//前向星加边 10 typedef pair<int,int> T; 11 void dijkstra(int s){//堆优化dijkstra 12 memset(vis,0,sizeof(vis)); 13 memset(d,0x3f,sizeof(d)); 14 priority_queue< T,vector<T>, greater<T> >q;//小根堆 15 d[s]=0;q.push(make_pair(d[s],s)); 16 //以dist为第一关键字,结点序号为第二关键字 17 //pair的作用在于防止在堆的操作导致结点序号信息的丢失 18 while(!q.empty()){ 19 pair<int,int> t=q.top();q.pop(); 20 int dd=t.first,u=t.second; 21 if(vis[u]) continue;vis[u]=1; 22 for(int i=f[u];i;i=e[i].nxt){ 23 int v=e[i].v,w=e[i].w; 24 if(d[v]>dd+w){ 25 d[v]=dd+w;q.push(make_pair(d[v],v)); 26 } 27 } 28 } 29 }
2.2.3 拓展:如何把负边权转化为非负边权
我们给任意一个结点v赋予一个权值h[v]。对每条边(u,v),我们将其边权w改为w'=w+h[u]-h[v]。于是,一条从结点i到结点j的路径p的权重w(p)就变成了w'(p)=w(p)+h[i]-h[i](裂项相消)。这个值只与原路径权重、起点与终点的边权有关,而与路径上的中间结点无关。因此,如果一条从i到j的路径在不使用这个权重时比另一条路径更短,那么在使用这个权重仍然比另一条短。于是我们只要设计一个合适的权重函数,便能将负边权转化为非负边权,从而能在包含边权为负的边但不包含负环的图上使用Dijkstra算法。
2.3 Bellman-Ford算法与SPFA算法
2.3.1 Bellman-Ford算法
Bellman-Ford算法也可以被用来判断一张图是否具有负环。
Bellman-Ford算法的思路非常简单:初始时,我们将源点s的dist赋值为0,其它结点的dist都赋值为+∞;然后我们对每条边松弛n-1次之后,再没有负环的情况下,就求出了从源点到所有结点的最短路。
为什么n-1次松弛操作就能保证得到正确答案?我们可以这样理解:第i次松弛操作实际上求的是包含了至多i条边的最短路径权重,而由于最短路径不包含环路,因此一条最短路径最多包含n-1条边,因此在没有负环的情况下,n-1次松弛操作后得到的dist一定是从源点到该结点的最短路径权重。倘若这个时候还能进行松弛操作,那么就说明这张图上存在负环。
Bellman-Ford算法的复杂度是O(n*m)的。
1 int Bellman-Ford(int x){ 2 memset(d,0x3f,sizeof(d)); 3 d[x]=0; 4 for(int i=1;i< n;i++)//进行n-1次松弛 5 for(int j=1;j<=m;j++){ 6 int u=e[j].u,v=e[j].v,w=e[j].w; 7 if(d[v]>d[u]+w) d[v]=d[u]+w; 8 //如果是有向图,一次松弛就搞定了 9 if(d[u]>d[v]+w) d[u]=d[v]+w; 10 //无向图还要再反向松弛一次,因为一条无向边相当于两条有向边 11 } 12 for(int i=1;i<=m;i++) 13 if(d[e[i].v]>d[e[i].u]+e[i].w) return 0; 14 //如果还能松弛,说明存在负环 15 return 1; 16 }//效率O(n*m)
2.3.2 SPFA算法
SPFA算法实际上是Bellman-Ford算法的另一种实现方式。
回顾Bellman-Ford算法的实现过程,我们不难发现有些松弛操作是多余的。那么在实现过程中,我们能否通过尽量减少不必要的松弛操作来进行优化呢?答案是肯定的。如果我们用搜索的方式来实现Bellman-Ford算法,那么我们便可以通过剪枝的方式来提高这一算法的效率。这便是SPFA算法。
下文将给出一种基于广度优先搜索实现的SPFA算法。在这种算法中,我们每一次取出队首元素进行松弛操作,将能更新的所有不在队列中的结点加入队列。
这样优化后的SPFA算法虽然时间复杂度仍然是O(n*m)的,但在多数实际问题中,它是远远达不到这个上界的,效率近似于Dijkstra算法。事实上,在某些特殊情况下,SPFA算法甚至快于Dijkstra算法。不过SPFA算法并不稳定,在另一些特殊情况下,比如存在负环的情况下,其运行效率与Bellman-Ford算法没有明显差异。因而为了求稳,在算法竞赛中使用更多的是更稳定的Dijkstra算法。
1 void spfa(int x){ 2 queue<int>q; 3 memset(d,0x3f,sizeof(d)); 4 q.push(x),vst[x]=1,d[x]=0; 5 //vst数组用来标记该结点是否在队列中 6 while(!q.empty()){ 7 int u=q.front();q.pop();vst[u]=0; 8 for(int i=f[u];i;i=e[i].nxt){ 9 int v=e[i].v,w=e[i].w; 10 if(d[v]>d[u]+w){ 11 d[v]=d[u]+w; 12 if(!vst[v]){q.push(v),vst[v]=1;} 13 //如果这个结点已经在队列中,我们显然没有必要再让它进队一次 14 //这实际上就是一种剪枝 15 } 16 //如果这个结点无法被松弛,那么它也不会再松弛相邻的其它结点 17 //因而这个结点也没有再次进队的必要 18 } 19 } 20 }//如果存在负环,效率为O(n*m)
2.3.3 SPFA算法的深度优先搜索实现方式
不难发现,上述用BFS实现的SPFA算法虽然在一定程度上实现了优化,但是并不能很容易地判定负环的存在性。那么我们不妨尝试换一种实现方式,即用DFS来实现SPFA算法。
由前文的讨论,在没有负环的情况下,最短路径上不会出现环路。因此在这一情况下,如果我们在进行DFS时每次搜索一个可以被松弛的结点,那么同一个结点不会在搜索栈中出现两次,否则图中便含有一个负环。因此,我们维护一个初始值为0的vis数组,用来标记该结点是否在搜索栈中。搜索到当前结点u时,我们将vis[u]改为1;当完成可被u松弛的结点的搜索后,再将vis[u]改回0。
1 void spfa(int u){ 2 if(flag) return;vis[u]=1; 3 for(int i=f[u];i;i=e[i].nxt){ 4 int v=e[i].v,w=e[i].w; 5 if(d[u]+w<d[v]){ 6 if(vis[v]){flag=1;return;} 7 //重新经过了一个在当前搜索栈中的节点,说明存在负环 8 d[v]=d[u]+w;spfa(v); 9 } 10 } 11 vis[u]=0;//遍历完毕,出栈 12 }
2.3.4 双端队列优化SPFA
我们不妨利用贪心的思想对SPFA作进一步优化:如果我们先搜索dist较小的结点,应当可以在一定程度上减少一些多余的松弛操作。使用双端队列替换普通的队列便可实现这一点。每当有一个新的结点需要进队时,我们比较它与队首元素的dist,如果它的更小,便将它从队首插入,否则仍然从队尾插入。
1 void spfa(int x){ 2 memset(vis,0,sizeof(vis)); 3 memset(d,0x3f,sizeof(d)); 4 d[x]=0;deque<int>q; 5 q.push_back(x);vis[x]=1;cnt[x]++; 6 while(!q.empty()){ 7 int u=q.front();q.pop_front();vis[u]=0; 8 for(int i=f[u];i;i=e[i].nxt){ 9 int v=e[i].v,w=e[i].w; 10 if(d[v]>d[u]+w){ 11 d[v]=d[u]+w; 12 if(!vis[v]){ 13 if(++cnt[v]>n){flag=1;return;} 14 if(q.empty()||d[v]<d[q.front()]) q.push_front(v); 15 //队空或小于队首,从队首加入 16 //注意,队空后q.front()会RE 17 else q.push_back(v); 18 vis[v]=1; 19 } 20 } 21 } 22 } 23 }//双端队列优化spfa
3 所有结点对最短路径问题:Floyd算法
3.1 原理
如前文所述,所有结点对最短路径问题可以归结为计算每个结点的单源最短路径问题,因此我们可以对每个结点都调用一遍Dijkstra算法或者SPFA算法。对于较为稀疏的图,这种方法当然是可行的;但对于稠密图而言,这种思路似乎还是略显粗暴(这种思路下,效率最高的反而是未经优化的Dijkstra算法,时间复杂度为Θ(n3),与Floyd算法相当)。
给定一张图G=(V,E),我们考虑结点集合V的一个子集{1,2,…,k}(k≤n)。对于任意结点对(i,j),考虑所有中间结点均在这个子集中的从i到j的路径,并记权重最小的一条路径为路径p。现在我们分两种情况讨论:
·k不是路径p的中间结点。那么路径p上的所有结点都属于集合{1,2,…,k-1},从而也属于集合{1,2,…,k}。
·k是路径p的中间结点。我们类似地定义从i到k的路径p1和从k到j的路径p2,于是p1、p2上的点都属于集合{1,2,…,k-1}。
于是我们便得出这样一个时间复杂度为Θ(n3)的算法:依次枚举中间结点k、起点i和终点j,然后计算从i到j的所有中间结点均在集合{1,2,…,k}中的最短路径权重。我们有状态转移方程fij=min{fij,fik+fkj}。这便是Floy算法。由上文的讨论,容易发现这种DP的方式是不具有后效性的,因而可以保证其正确性。
关于f数组的初始化:如果i与j通过G中的一条边直接相连,则f[i][j]=这条边的边权;否则f[i][j]=+∞。
1 void floyd(){ 2 for(int k=1;k<=n;k++)//枚举中间点,注意必须放在最外层 3 for(int i=1;i<=n;i++) 4 for(int j=1;j<=n;j++) 5 f[i][j]=min(f[i][j],f[i][k]+f[k][j]); 6 }
3.2 求解有向图的传递闭包
有向图G=(V,E)的传递闭包为图G*=(V,E*),其中,E*={(i,j)|G中包含一条从i到j的路径}。
显然,如果G中包含一条从i到j的路径,那么一定有一条从i到j的最短路径。因此如果我们给G中每条边附上边权1,便可用Floyd算法解决这一问题。
在高等教育出版社出版的《离散数学及其应用(第2版)》中还提到了一种用关系矩阵来计算传递闭包的Warshall算法,其本质仍是Floyd算法的变体,在此处仅给出伪代码:
1 for k = 1 to n 2 for i = 1 to n 3 for j =1 to n 4 M[i,j]=M[i,j]+M[i,k]M[k,j] 5 //M初始赋值为G对应的矩阵,涉及的加法均为逻辑加
参考文献
(美)Thomas H.Cormen,Charles E.Leiserson,Ronald L.Rivest,Clifford Stein,殷建平,徐云,王刚等译.算法导论[M].北京,机械工业出版社,2021.
屈婉玲,耿素云,张立昂.离散数学及其应用(第2版)[M].北京,高等教育出版社,2018.
浙公网安备 33010602011771号