Dijkstra算法

最短路径是图论中一个很经典的问题:给定图G(V,E),求一条从起点到终点的路径,使得这条路径上经过的所有边的边权之和最小。

对任意给出的图G(V,E)和起点S、终点T,如何求从S到T的最短路径。解决最短路径问题的常用算法有Dijkstra算法、Bellman-Ford算法、SPEA算法和Floyd算法。

 

1.Dijkstra算法

Dijkstra算法(读者可以将其读作“迪杰斯特拉算法”)用来解决单源最短路问题,即给定图G和起点s,通过算法得到S到达其他每个顶点的最短距离。Dijkstra的基本思想是对图G(V,B)设置集合S,存放已被访问的顶点,然后每次从集合V-S中选择与起点s的最短距离最小的一个顶点(记为u),访问并加入集合S。之后,令顶点u为中介点,优化起点s与所有从u能到达的顶点v之间的最短距离。这样的操作执行n次(n为顶点个数),直到集合S已包含所有顶点。

单源最短路径:从起点V出发,每个顶点之间的路程尽可能短,即从起点到达其他所有顶点都必须是最短距离。

①将地图上所有边都抹去,只有当到达这个顶点后才把从这个顶点出去的边显现(笔者插话:这个操作在实际编写代码时是自然成立、不需要人为控制的,但是在这单独提出来对理解Dijkstra操作的过程很有帮助)。

②在地图中的顶点V(0≤i≤5)上记录从起点V0到达该城市所需要的最短距离。由于在①中所有边都被抹去了,因此在初始状态下除了V0到达V0的距离是0之外,从V到达其他顶点的距离都是无穷大(记为INF)。为了方便叙述,在下文中某几处出现的最短距离都是指从起点V0到达顶点V的最短距离。

下面是行动策略:

①由于要攻占六个顶点,因此将步骤②③执行六次,每次攻占一个顶点(如果是n个顶点,那么就执行n次)。

②每次都从还未攻占的城市中选择当前距离起点V0最近的城市(即地图的顶点上记录的最短距离最小的未到达的顶点,记为Vk(0≤k≤5)。

③到达顶点Vk后,开放地图上从Vk出发的所有边,并査看以Vk为中介点的情况下,能否使从起点V0到达某些还未到达的顶点的最短距离变小。如果能,则将那个最短距离覆盖到对应的城市上(因为开放了从Vk出发的边,因此有了改善某些顶点最短距离的可能,且当前只有Vk能够优化这个最短距离)。

首先,Dijkstra算法解决的是单源最短路问题,即给定图G(V,E)和起点s(起点又称为源点),求从起点s到达其它顶点的最短距离。Dijkstra算法的策略是:设置集合S存放已被访问的顶点(即已到达的顶点),然后执行n次下面的两个步骤(n为顶点个数)。

①每次从集合V-S(即未到达的顶点)中选择与起点s的最短距离最小的一个顶点(记为u),访问并加入集合S(即令其已被访问)。

②之后,令顶点u为中介点,优化起点s与所有从u能到达的顶点v之间的最短距离。

Dijkstra算法的具体实现:由于Dijkstra算法的策略比较偏重于理论化,因此为了方便编写代码,需要想办法来实现策略中两个较为关键的东西,即集合S的实现、起点s到达顶点Vi(0≤i≤n-1)的最短距离的实现。

①集合S可以用一个bool型数组vis[]来实现,即当vis[i]=true时表示顶点Vi已被访问,当vis[i]=false时表示顶点Vi未被访问。

②令int型数组表示起点s到达顶点V的最短距离,初始时除了起点s的的d[s]赋为0,其余顶点都赋为一个很大的数(10^9)来表示INF,即不可达。

接下来看看实现Dijkstra算法的伪代码。其实很短,不难,希望读者能够结合上面好好理解一下这个伪代码,然后再往下看:

//G为图,一般设成全局变量;数组d为源点到达各点的最短路径长度,s为起点 
Dijkstra(G,d[],s){
	初始化
	for(循环n次){
	u = 使d[u]最小的还未访问的顶点的标号;
	记u已被访问;
	for(从u出发能到达的所有顶点v){
		if(v未被访问&&以u为中介点使s到顶点v的最短距离d[v]更优){
		 优化d[v]; 
			} 
		} 
	}
}

由于图可以使用邻接矩阵或者邻接表来实现,因此也就会有两种写法,但是这两种写法都是以上面的伪代码为基础的,区别主要集中在枚举从u出发能到达的顶点v上面:邻接矩阵需要枚举所有顶点来查看v是否可由u到达;邻接表则可以直接得到u能到达的顶点v。在写出具体函数之前,需要先定义MAXV为最大顶点数、INF为一个很大的数字:

const int MAXV = 1000;	//最大顶点数
const int INF = 1000000000;		//设INF为一个很大的数

下面来看邻接矩阵和邻接表对Dijkstra算法的具体实现代码。

(1)邻接矩阵版

适用于点数不大(例如V不超过1000)的情况,相对好写。代码如下:

int n,G[MAXV][MAXV];	//n为顶点数,MAXV为最大顶点数
int d[maxv];	//起点到达各点的最短路径长度
bool vis[MAXV] = {false};	//标记数组,vis[i]==true表示已访问。初始值均为false 

void Dijkstra(int s){	//s为起点 
	fill(d,d+MAXV,INF);		//fill函数将整个d数组赋值为INF(慎用memset)
	d[s] = 0;	//起点s到达自身的距离为0
	for(int i=0;i<n;i++){	//循环n次
		int u=-1,MIN=INF;	//u使d[u]最小,MIN存放最小的d[u]
		for(int j=0;j<n;j++){	//找到未访问的顶点中d[]最小的 
			if(vis[j]==false && d[j]<MIN){
				u=j;
				MIN=d[j];
			} 
		} 
		//找不到小于INF的d[u],说明剩下的顶点和起点s不连通
		if(u==-1)
			return;
		vis[u] = true;	//标记u为已访问 
		for(int v=0;v<n;v++){
			//如果v未能访问&&u能到达v&&以u为中介点可以使d[v]更优
			if(vis[v]==false && G[u][v] != INF && d[u]+G[u][v] < d[v]){
				d[v] = d[u]+G[u][v];	//优化d[v] 
			} 
		} 
	} 
} 

从复杂度来看,主要是外层循环O(V)(V就是顶点个数n)与内层循环(寻找最小的d[u]需要O(V)、枚举v需要O(V))产生的,总复杂度为O(V*(V+V))=O(V^2)。

(2)邻接表版

vector<Node> Adj[MAXV];		//图 G,Adj[u]存放从顶点u出发可以到达的所有顶点
int n;		// n为顶点数,图G使用邻接表实现,MAXV为最大顶点数
int d[maxv];	//起点到达各点的最短路径长度
bool vis[MAXV] = {false};	//标记数组,vis[i]==true表示已访问。初始值均为false 

void Dijkstra(int s){	//s为起点 
	fill(d,d+MAXV,INF);		//fill函数将整个d数组赋值为INF(慎用memset)
	d[s] = 0;	//起点s到达自身的距离为0
	for(int i=0;i<n;i++){	//循环n次
		int u=-1,MIN=INF;	//u使d[u]最小,MIN存放最小的d[u]
		for(int j=0;j<n;j++){	//找到未访问的顶点中d[]最小的 
			if(vis[j]==false && d[j]<MIN){
				u=j;
				MIN=d[j];
			} 
		} 
		//找不到小于INF的d[u],说明剩下的顶点和起点s不连通
		if(u==-1)
			return;
		vis[u] = true;	//标记u为已访问 
		for(int j=0;j<Adj[u].size();j++){
			int v = Adj[u][j].v;	//通过邻接表直接获得u能到达的顶点v 
			if(vis[v]==false && d[u]+Adj[u][j].dis < d[v]){
				//如果v未能访问&&以u未终结点可以使d[v]更优
				d[v] = d[u]+G[u][v];	//优化d[v] 
			} 
		} 
	} 
} 

从复杂度来看,主要是外层循环O(V)与内层循环(寻找最小的d[u]需要O(V)、枚举V需要O(adj[u].size))产生的。又由于对整个程序来说,枚举v的次数总共为O(E),因此总复杂度为O(V^2+E)。

可以注意到,上面的做法都是复杂度O(V)级别的,其中由于必须把每个顶点都标记为已访问,因此外层循环的O(V)时间是无法避免的,但是寻找最小d[u]的过程却可以不必达到O(V)的复杂度,而可以使用堆优化来降低复杂度。最简洁的写法是直接使用STL中的优先队列priority_ queue,这样使用邻接表实现的Dijkstra算法的时间复杂度可以降为O(VlogV+E)。此外,Dijkstra算法只能应对所有边权都是非负数的情况,如果边权出现负数,那么 Dijkstra算法很可能会出错,这时最好使用SPFA算法。

下面以上面的例图为例编写代码,并最终输出从起点V0到达所有顶点(包括V0)的最短距离,程序代码如下:

#include<cstdio>
#include<algorithm>
using namespace std;
const int MAXV = 1000;	//最大顶点数
const int INF = 1000000000;		//设INF为一个很大的数

int n,m,s,G[MAXV][MAXV];	//n为顶点数,m为边数,s为起点 
int d[MAXV];	//起点到达各点的最短路径长度
bool vis[MAXV] = {false};	//标记数组,vis[i]==true表示已访问。初始值均为false 

void Dijkstra(int s){	//s为起点 
	fill(d,d+MAXV,INF);		//fill函数将整个d数组赋值为INF(慎用memset)
	d[s] = 0;	//起点s到达自身的距离为0
	for(int i=0;i<n;i++){	//循环n次
		int u=-1,MIN=INF;	//u使d[u]最小,MIN存放最小的d[u]
		for(int j=0;j<n;j++){	//找到未访问的顶点中d[]最小的 
			if(vis[j]==false && d[j]<MIN){
				u=j;
				MIN=d[j];
			} 
		} 
		//找不到小于INF的d[u],说明剩下的顶点和起点s不连通
		if(u==-1)
			return;
		vis[u] = true;	//标记u为已访问 
		for(int v=0;v<n;v++){
			//如果v未能访问&&u能到达v&&以u未终结点可以使d[v]更优
			if(vis[v]==false && G[u][v] != INF && d[u]+G[u][v] < d[v]){
				d[v] = d[u]+G[u][v];	//优化d[v] 
			} 
		} 
	} 
} 

int main(){
	int u,v,w;
	scanf("%d%d%d",&n,&m,&s);	//顶点个数,边数,起点编号
	fill(G[0],G[0]+MAXV*MAXV,INF);	//初始化图G
	for(int i=0;i<m;i++){
		scanf("%d%d%d",&u,&v,&w);	//输入u,v以及u->v的边权 
		G[u][v] = w;
	} 
	Dijkstra(s);	// Dijkstra算法入口
	for(int i=0;i<n;i++){
		printf("%d ",d[i]);	//输出所有顶点的最短距离 
	} 
	return 0; 
} 

输入数据:

6 8 0
0 1 1
0 3 4
0 4 4
1 3 2
2 5 1
3 2 2
3 4 3
4 5 3

输出结果:

0 1 5 3 4 6

有的读者会有疑问,如果题目给出的是无向边(即双同边)而不是有向边,又该如解决呢?这其实很容易,只需要把无向边当成两条指向相反的有向边即可。对邻接矩阵来说,一条u与v之间的无向边在输入时可以分别对G[u][v]和G[v][u]赋以相同的边权;而对邻接表来说,只需要在u的邻接表Adj[u]末尾添加上v,并在v的邻接表Adj[v]末尾添加上u即可。

之前一直在讲最短距离的求解,但是还没有讲到最短路径本身怎么求解。那么接下来学习一下最短路径的求法。在Dijkstra算法的伪代码部分,有这么一段:

if(v未被访问&&以u为中介点使s到顶点v的最短距离d[v]更优){
	优化d[v]; 
} 

这个地方提到的条件“以u为中介点可以使起点s到顶点v的最短距离d[v]更优”隐含了这样一层意思:使d[v]变得更小的方案是让u作为从s到v最短路径上v的前一个结点(即s→…→u→v)。这就给我们一个启发:不妨把这个信息记录下来。于是可以设置数组pre[]令pre[v]表示从起点s到顶点v的最短路径上v的前一个顶点(即前驱结点)的编号。这样当伪代码中的条件成立时,就可以将u赋给pre[v],最终就能把最短路径上每一个顶点的前驱结点记录下来。而在伪代码部分只需要在if内增加一行:

if(v未被访问&&以u为中介点使s到顶点v的最短距离d[v]更优){
    优化d[v]; 
    令v的前驱为u;
} 

具体实现中,以邻接矩阵为例:

int n,G[MAXV][MAXV];	//n为顶点数,MAXV为最大顶点数
int d[maxv];	//起点到达各点的最短路径长度
int pre[MAXV];		//pre[v]表示从起点到顶点v的最短路径v的前一个顶点 
bool vis[MAXV] = {false};	//标记数组,vis[i]==true表示已访问。初始值均为false 

void Dijkstra(int s){	//s为起点 
	fill(d,d+MAXV,INF);		//fill函数将整个d数组赋值为INF(慎用memset)
	for(int i=0;i<n;i++)
		pre[i] = i; //初始状态设每一个点的前驱为自身 
	d[s] = 0;	//起点s到达自身的距离为0
	for(int i=0;i<n;i++){	//循环n次
		int u=-1,MIN=INF;	//u使d[u]最小,MIN存放最小的d[u]
		for(int j=0;j<n;j++){	//找到未访问的顶点中d[]最小的 
			if(vis[j]==false && d[j]<MIN){
				u=j;
				MIN=d[j];
			} 
		} 
		//找不到小于INF的d[u],说明剩下的顶点和起点s不连通
		if(u==-1)
			return;
		vis[u] = true;	//标记u为已访问 
		for(int v=0;v<n;v++){
			//如果v未能访问&&u能到达v&&以u未终结点可以使d[v]更优
			if(vis[v]==false && G[u][v] != INF && d[u]+G[u][v] < d[v]){
				d[v] = d[u]+G[u][v];	//优化d[v] 
				pre[v] = u;		//记录v的前驱顶点是u  
			} 
		} 
	} 
} 

新增的只有3、8~9、27行。

到这一步,只是求出了最短路径上每个点的前驱,那么如何求整条路径呢?以下图这个很简单的路径来说明。从算法中已经可以得到了每个顶点的前驱:

pre[4] = 3;
pre[3] = 2;
pre[2] = 1;
pre[3] = 1;

那么,当想要知道从起点V1到达V4的最短路径,就需要先从pre[4]得到V4的前驱顶点是V3,然后从pre[3]得到V3的前驱顶点是V2,再从pre[2]得到V2的前驱顶点是V1。

这听起来有点像递归?没错,就是用递归不断利用pre[]的信息寻找前驱,直至到达起点V1后从递归深处开始输出。

这个递归写起来很简洁,读者可以好好体会一下:

void DFS(int s, int v){		//s为起点编号,v为当前访问的顶点编号(从终点开始递归) 
	if(v ==s){		//如果当前以及到达起点s,则输出起点并返回 
		printf("%d\n",s);
		return;
	}
	DFS(s,pre[v]);		//递归访问v的前驱顶点pre[v]
	printf("%d\n",v);		//从最深处return回来之后,输出每一层的顶点号 
}

至此,Dijkstra算法的基本用法大家都应该已经掌握。但是题目肯定不会考得这么“裸”,更多时候会出现这样一种情况,即从起点到终点的最短距离最小的路径不止一条。例如上面的例子中如果把V0→V3的距离改为3,那么从V0→V3就会有两条最短路径,即V0→V1→V3与V0→V3,它们都可以达到最短路径3。

于是,碰到这种有两条及以上可以达到最短距离的路径,题目就会给出一个第二标尺(第一标尺是距离),要求在所有最短路径中选择第二标尺最优的一条路径。而第二标尺常见的是以下三种出题方法或其组合:

①给每条边再增加一个边权(比如说花费),然后要求在最短路径有多条时要求路径上的花费之和最小(如果边权是其他含义,也可以是最大)。

②给每个点增加一个点权(例如每个顶点能得到的权重),然后在最短路径有多条时要求路径上的点权之和最大(如果点权是其他含义的话也可以是最小)。

③直接问有多少条最短路径。

对这三种出题方法,都只需要增加一个数组来存放新增的边权或点权或最短路径条数,然后在Dijkstra算法中修改优化d的那个步骤即可,其他部分不需要改动。下面对这三种出题方法对代码的修改给出解释:

①新增边权。以新增的边权代表花费为例,用 cost[u][v]表示u→v的花费(由题目输入),并增加一个数组c[],令从起点s到达顶点u的最少花费为c[u],初始化时只有c[]为0、其余c[u]均为INF。这样就可以在d[u]+G[u][v]<d[v](即可以使s到v的最短距离更优)时更新d[v]和c[v],而当d[u]+G[u][v]=d[v](即最短距离相同)且c[u]+cost[u][v]<c[v](即可以使s到v的最少花费更优)时更新c[v]。代码如下:

for(int v=0;v<n;v++){	
	// 如果v未访问&&u能到达v 
	if(vis[v]==false G[u][v] != INF){
		if(d[u] + G[u][v] < d[v]){	//以u未终结点可以是d[v]更优 
			d[v] = d[u] + G[u][v];
			c[v] = c[u] + cost[u][v];
		} else if(d[u] + G[u][v] ==d[v] && c[u] + cost[u][v] < c[v]){
			c[v] = c[u] + cost[u][v];	//最短距离相同时看能否使c[v]更优 
		}
	} 
} 

②新增点权。以新增的点权代表顶点中能得到的权重为例,用 weight[u]表示城市u中的物资数目(由题目输入),并增加一个数组w[],令从起点s到达顶点u可以收集到的最大物资为w[u],初始化时只有w[s]为 weight[s]、其余w[u]均为0。这样就可以在d[u]+G[u][v]<d[v](即可以使s到v的最短距离dv更优)时更新d[v]和c[v],而当d[u]+G[u][v]=d[v](即最短距离相同)且w[u]+weight[v]>w[v](即可以使s到v的最大物资数目更优)时更新w[v]。代码如下:

for(int v=0;v<n;v++){	
	// 如果v未访问&&u能到达v 
	if(vis[v]==false G[u][v] != INF){
		if(d[u] + G[u][v] < d[v]){	//以u未终结点可以是d[v]更优 
			d[v] = d[u] + G[u][v];
			w[v] = w[u] + weight[v];
		} else if(d[u] + G[u][v] ==d[v] && w[u] + weight[v] > w[v]){
			w[v] = w[u] + weight[v];	//最短距离相同时看能否使w[v]更优 
		}
	} 
} 

③)求最短路径条数。只需要增加一个数组num[],令从起点s到达顶点u的最短路径条数为num[u],初始化时只有num[u]为1、其余num[u]均为0。这样就可以在d[u]+G[u][v]<d[v](即可以使s到v的最短距离d[v]更优)时更新d[v],并让num[v]继承num[u],而当d[u]+G[u][v]=d[v](即最短距离相同)时将 num[u]加到num[v]上。代码如下:

for(int v=0;v<n;v++){	
	// 如果v未访问&&u能到达v 
	if(vis[v]==false G[u][v] != INF){
		if(d[u] + G[u][v] < d[v]){	//以u未终结点可以是d[v]更优 
			d[v] = d[u] + G[u][v];
			num[v] = num[u];
		} else if(d[u] + G[u][v] ==d[v]){
			num[v] += num[u];	//最短距离相同时累加num 
		}
	} 
} 

以上就是Dikjstra的全部内容。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值