单源最短路径算法——深入理解Dijkstra与Bellman-Ford的算法过程及相关优化

Dijkstra Algorithm

算法介绍

  • 贪婪算法
  • 按路径长度递增顺序产生最短路径
  • 首先最初产生从源点到它自身的路径,这条路径没有边,其长度为0
  • 产生下一个最短路径的贪婪准则:在目前已产生的每一条最短路径中,考虑加入一条边到达未产生最短路径的目的顶点,再从所有这些新路径中选择最短的。

伪码表示

变量说明: 
a为图的邻接矩阵表示
dis[i]存储s到i的最短路径 
pre[i]记录从s到达i的路径中顶点
 
算法流程: 
(1)初始化
dis[i]=a[s][i](1<=i<=n)
pre[s]=0;
if(邻接于s)
	pre[i]=s;
else
	pre[i]=-1;
创建一个表newReachableVertices,保存所有pre[i]>0的顶点。
(2)当newReachableVertices为空时,算法停止,否则转至(3)
(3)从newReachableVertices中选择并删除dis[i]最小的顶点i
(4)对于所有邻接于顶点i的顶点j
	更新dis[j]=min{dis[j],dis[i]+a[i][j]};
	if(dis[j]的值改变)
		pre[j]=i; 
	if(j没有在newReachableVertices中)
		j加入表newReachableVertices中; 

代码实现

void shortestPaths(int sourceVertex,T* distanceFromSource, int* predecessor)
{
// Find shortest paths from sourceVertex.
// Return shortest distances in distanceFromSource.
// Return predecessor information in predecessor.
	if (sourceVertex < 1 || sourceVertex > n)
		throw illegalParameterValue("Invalid source vertex");

	if (!weighted())
		throw undefinedMethod
		("adjacencyWDigraph::shortestPaths() not defined for unweighted graphs");

	graphChain<int> newReachableVertices;

	// initialize
	for (int i = 1; i <= n; i++)
	{
		distanceFromSource[i] = a[sourceVertex][i];
		if (distanceFromSource[i] == noEdge)
			predecessor[i] = -1;
		else
		{
			predecessor[i] = sourceVertex; 
			newReachableVertices.insert(0, i);
		}
	}
	distanceFromSource[sourceVertex] = 0;
	predecessor[sourceVertex] = 0;  // source vertex has no predecessor

	// update distanceFromSource and predecessor
	while (!newReachableVertices.empty())
	{	// more paths exist
	    // find unreached vertex v with least distanceFromSource
	    chain<int>::iterator iNewReachableVertices
	                         = newReachableVertices.begin();
	    chain<int>::iterator theEnd = newReachableVertices.end();
	    int v = *iNewReachableVertices;
	    iNewReachableVertices++;
	    while (iNewReachableVertices != theEnd)
	    {
	       int w = *iNewReachableVertices;
	       iNewReachableVertices++;
	       if (distanceFromSource[w] < distanceFromSource[v])
	          v = w;
	    }
	
	    // next shortest path is to vertex v, delete v from
	    // newReachableVertices and update distanceFromSource
	    newReachableVertices.eraseElement(v);
	    for (int j = 1; j <= n; j++)
	    {
	       if (a[v][j] != noEdge && (predecessor[j] == -1 ||
	 	   distanceFromSource[j] > distanceFromSource[v] + a[v][j]))
	       {
	            // distanceFromSource[j] decreases
	            distanceFromSource[j] = distanceFromSource[v] + a[v][j];
	            // add j to newReachableVertices
	        	if (predecessor[j] == -1)
	                // not reached before
	            	newReachableVertices.insert(0, j);
	            predecessor[j] = v;
	       }
	    }
	}
}

复杂度分析

  • n e w R e a c h a b l e V e r t i c e s newReachableVertices newReachableVertices 中选择 d i s t a n c e F r o m S o u r c e distanceFromSource distanceFromSource 值最小的顶点 i i i O ( n ) O(n) O(n)
  • 更新邻接自顶点 i i i 的顶点的 d i s t a n c e F r o m S o u r c e distanceFromSource distanceFromSource 值和前继结点的值,若使用邻接表: O ( o u t − d e g r e e ( i ) ) O(out-degree(i)) O(outdegree(i)),若使用邻接矩阵: O ( n ) O(n) O(n).
  • 总的时间复杂度: O ( n 2 ) O(n^2) O(n2)

堆优化

  在上述操作中从 n e w R e a c h a b l e V e r t i c e s newReachableVertices newReachableVertices 中选择 d i s t a n c e F r o m S o u r c e distanceFromSource distanceFromSource 值最小的顶点 i i i 的时间复杂度为 O ( n ) O(n) O(n),因此可以考虑使用小根堆来维护 n e w R e a c h a b l e V e r t i c e s newReachableVertices newReachableVertices ,小根堆的插入与删除操作的时间复杂度均为 O ( l o g v ) O(logv) O(logv),若使用邻接表存储那么更新 d i s t a n c e F r o m S o u r c e distanceFromSource distanceFromSource 值的松弛操作加更新 n e w R e a c h a b l e V e r t i c e s newReachableVertices newReachableVertices 表的时间复杂度为 O ( e l o g v ) O(elogv) O(elogv),图中每个顶点都出队一次,时间复杂度为 O ( v l o g v ) O(vlogv) O(vlogv),故经堆优化后总的时间复杂度降为 O ( ( v + e ) ∗ l o g v ) O((v+e)*logv) O((v+e)logv)

算法流程

  • 初始化并将源点 s s s 压入小根堆
  • 每次从堆顶取出一个顶点 x x x,遍历该顶点的所有邻接边 ( x , y , w ) (x,y,w) (x,y,w)进行松弛操作
  • d i s [ y ] dis[y] dis[y] 的值改变即松弛成功,则将 y y y 压入小根堆
  • 不断执行上述操作,直至堆空,结束算法

代码实现

int head[maxn],dis[maxn],pre[maxn],tot=0;
struct Edge{
	int to,next,w;
}edge[maxm];

struct heap{
	int s,w;
	heap(int ss,int ww):s(ss),w(ww){}
	bool operator<(const heap &h)const{
		return w>h.w;
	}
};

void Init(){
	tot=0;
	for(int i=1;i<=N;i++)
		head[i]=-1;
}

void Add_Edge(int x,int y,int w){
	edge[++tot].to=y;
	edge[tot].next=head[x];
	edge[tot].w=w;
	head[x]=tot;
}

void Dijkstra(int s,int *dis,int *pre){
	for(int i=1;i<=N;i++){
		dis[i]=INT_MAX;
		vis[i]=0;
		pre[i]=-1;
	}
	pre[s]=-1;dis[s]=0;
	priority_queue<heap> q;
	q.push(heap(s,0));
	while(!q.empty()){
		int x=q.top().s;
		q.pop();
		if(vis[x])
			continue;
		vis[x]=1;
		for(int i=head[x];i!=-1;i=edge[i].next){
			int y=edge[i].to,w=edge[i].w;
			if(dis[y]>dis[x]+w){
				dis[y]=dis[x]+w;
				pre[y]=x;
				q.push(heap(y,dis[y]));
			}
		}
	}
}


Bellman-Ford Algorithm

  当图中出现边权小于 0 0 0 的边时,则不能再使用 D i j k s t r a Dijkstra Dijkstra 算法求解单元最短路径了,假定现在我们要研究的图具有边权小于 0 0 0 的边,但是没有负环,当然使用 F l o y d Floyd Floyd 算法(Floyd算法的应用可参见上篇博文)可以求解,但其复杂度为 O ( n 3 ) O(n^3) O(n3),而且对于单源最短路的求解其操作过程略显冗余,为了提高程序性能,就引入了新的算法—— B e l l m a n − F o r d Bellman-Ford BellmanFord

算法介绍

  • 思想:动态规划
  • 整体思路:对所有边进行松弛操作,即估计的最短路径值渐渐地被更加准确的值替代,直至得到最优解。
  • 假设不存在负环,那么所有最短路径最多具有n-1条边,一条路径如果具有n条以上的边,那么一定有环路,因此进行对每一条边松弛 n-1 次即可得最短路径。
  • d ( v , k ) d(v,k) d(v,k) 表示从源点到顶点 v v v 且最多有 k k k 条边的最短路径长度,那么 d ( v , n − 1 ) d(v,n-1) d(v,n1) 便是我们要找的路径长度,动态规划方程为 d ( v , k ) = m i n { d ( v , 0 ) , m i n { d ( u , k − 1 ) + c o s t ( u , v ) } } d(v,k)=min\left\{d(v,0), \quad min\left\{d(u,k-1)+cost(u,v)\right\}\right\} d(v,k)=min{d(v,0),min{d(u,k1)+cost(u,v)}}其中 u u u 是最短路径上恰在 v v v 之前的顶点, ( u , v ) (u,v) (u,v) 是图的一条边,利用这个递推式可以按序计算 d ( ∗ , 1 ) , d ( ∗ , 2 ) , . . . , d ( ∗ , n − 1 ) d(*,1),d(*,2),...,d(*,n-1) d(,1),d(,2),...,d(,n1) 从而得到最终结果。

伪码表示

initialize d(*)=d(*,0);
//计算d(*)=d(*,n-1)
for(int k=1;k<n;k++)
	for(每一条边(u,v))
		d(v)=min{d(v),d(u)+cost(u,v)}; 

优化

n − 1 n-1 n1 其实是松弛操作轮数的最大值,仔细分析上述算法可以发现:

  • 如果对于某个 k k k d ( v ) d(v) d(v) 的值对任意 k k k 都不会变化,那么说明已找到最短路径,此时即可结束算法。
  • 仅当 d ( u ) d(u) d(u) 在外层循环的先前迭代中发生变化时,对边 ( u , v ) (u,v) (u,v)的内层循环才是需要的,即松弛操作仅仅发生在最短路径前导结点已经成功松弛过的结点上。
  • 在第 i i i 轮松弛,最短路上的第 i i i 条边被确定,在之后的 i + 1.... n − 1 i+1....n-1 i+1....n1轮中,只需对这些已确定的边进行松弛操作即可。

伪码表示

initialize d(*) = d(*,0);
把与源点相邻的顶点放入表 list1;

//计算 d(*) = d(*,n-1)
把源点放入list1;
for(int k=1;k<n;k++)
{
	//查看是否有其值d发生变化的顶点
	if(list1为空) break; //没有这样的顶点
	while(list1不空)
	{
		从list1中删除一个顶点u;
		for(每一条边(u,v))
		{
			d(v)=min{d(v),d(u)+cost(u,v)};
			if(d(v)发生变化而且v不在list2中)
				把v加到list2; 
		} 
		list1=list2;
		清空list2; 
	}	
} 

复杂度分析

  设图中有 n n n 个顶点, e e e 条边,上述伪码中,每一轮松弛操作起始,表 l i s t 1 list1 list1 O ( n ) O(n) O(n) 个顶点,内层 w h i l e while while 循环每一轮要迭代 O ( e ) O(e) O(e),因此在使用邻接矩阵描述时,每一轮松弛操作的时间复杂度为 O ( n 2 ) O(n^2) O(n2),使用邻接表描述时,复杂度为 O ( e ) O(e) O(e),因此总的时间复杂度:邻接矩阵 O ( n 3 ) O(n^3) O(n3),邻接表 O ( n e ) O(ne) O(ne)

SPFA队列优化

  • 小前言
      西南交通大学的段凡丁于1994年提出了用队列来优化的SPFA算法,但貌似一直不被承认…因为其对于SPFA的算法复杂度的证明是错误的,而且在此之前已经有了上文中所提到的与SPFA类似的优化思路,外界对SPFA算法褒贬不一,SPFA算法的平均复杂度为 O ( k m ) O(km) O(km),最坏情况下复杂度退化为 O ( n e ) O(ne) O(ne),甚至还有专门生成卡SPFA评测数据的项目,所以慎用!下面简要地说明一下SPFA算法的流程(参考了某百科):
  • 动态逼近法
    • 设立一个先进先出的队列用来保存待优化的结点
    • 优化时每次取出队首结点u,并且用u点当前的最短路径估计值对离开u点所指向的结点v进行松弛操作,如果v点的最短路径估计值有所调整,且v点不在当前的队列中,就将v点放入队尾。
    • 不断从队列中取出结点来进行松弛操作,直至队列空为止。

图中负环的判断

  • B e l l m a n − f o r d Bellman-ford Bellmanford
  • 正如上文中所提及的,假设不存在负环,那么所有最短路径最多具有n-1条边,一条路径如果具有n条以上的边即n-1轮松弛过后还能完成松弛,那么一定存在环路。
struct Edge{
	int from,to,weight;
}edge[maxm]; 

for(int i=1;i<=n;i++){
	dis[i]=inf;
	pre[i]=0;
}
dis[s]=0;
for(int k=1;k<n;k++){
	for(int i=1;i<=m;i++){
		int u=edge[i].from,
			v=edge[i].to,
			w=edge[i].weight;
		if(dis[v]>dis[u]+w){
			dis[v]=dis[u]+w;
			pre[v]=u;
		}
	}
}
for(int i=1;i<=m;i++){
	int u=edge[i].from,
		v=edge[i].to,
		w=edge[i].weight;
	if(dis[v]>dis[u]+w){
		//存在负环 
	}
}
  • S P F A SPFA SPFA
  • 由于SPFA算法是每次将成功松弛的边所对应的点入队出队,所以不容易判断边的松弛次数,这是我们可以判断点的入队次数,如果某一点入队n次则说明存在负环
int head[maxn],dis[maxn],inq[maxn],cnt[maxn];
bool vis[maxn];

struct Edge{
	int to,w,next;	
}edge[maxm];

void add(int u,int v,int w){
	edge[++tot].to=v;
	edge[tot].w=w;
	edge[tot].next=head[u];
	head[u]=tot;
}

void DFS(int u){
	vis[u]=true;
	dis[u]=-1;
	for(int i=head[u];i;i=edge[i].next){
		if(!vis[edge[i].to]){
			DFS(edge[i].to);
		}
	}
}

void SPFA(){
	for(int i=1;i<=N;i++){
		dis[i]=INT_MAX;
		inq[i]=0;
		cnt[i]=0;
		vis[i]=0;
	}	
	dis[1]=0;inq[1]=1;
	queue<int> q;
	q.push(1);
	while(!q.empty()){
		int u=q.front();
		q.pop();
		inq[u]=0;
		if(vis[u])
			continue;
		for(int i=head[u];i;i=edge[i].next){
			int v=edge[i].to;
			if(vis[v])
				continue;
			if(dis[v]>dis[u]+edge[i].w){
				cnt[v]=cnt[u]+1;				
				if(cnt[v]>=N){
					DFS(v);
					continue;
				}
				dis[v]=dis[u]+edge[i].w;
 				if(!inq[v]){
					q.push(v);
					inq[v]=1;
				}
			}
		}			
	}		
}


以上算法在实际应用方面的总结

  • 不存在负权边的话,尽量还是使用Dijkstra+堆优化,因为SPFA算法的时间复杂度没法保证,容易被卡数据,有负权边的话再考虑选用SPFA 。
  • 存在负环的单源最短路问题,如果是无向连通图,那么整个图就不存在最短路了,如果是有向图则通过负环到达不了的点存在最短路径,贴一张朋友画的图:
    在这里插入图片描述
    图中 e 1 e1 e1 存在最短路径, e 2 e2 e2 不存在最短路径。
  • Dijkstra 算法不能简单地直接修改松弛条件的小于去求解加权有向无环图的最长路径,因为最短路问题是有最优子结构的,而最长路径不存在这个子结构。如果是有向无环图,可以先拓扑排序,再用动态规划求解,如果不是DAG那么就是一个 NP-hard problem
  • 总得来说,还是要掌握算法的核心思想,不要只盲敲算法板子,要根据实际情况灵活应用。


参考

  • 数据结构、算法与应用 C++语言描述(Sartaj Sahni)
  • SDU程序设计思维课程内容
  • 某百科
  • 2
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值