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算法

①将地图上所有边都抹去,只有当到达这个顶点后才把从这个顶点出去的边显现(笔者插话:这个操作在实际编写代码时是自然成立、不需要人为控制的,但是在这单独提出来对理解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算法的伪代码。其实很短,不难,希望读者能够结合上面好好理解一下这个伪代码,然后再往下看:


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

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


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

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

(1)邻接矩阵版

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


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
1int n,G[MAXV][MAXV];   //n为顶点数,MAXV为最大顶点数
2int d[maxv];   //起点到达各点的最短路径长度
3bool vis[MAXV] = {false};  //标记数组,vis[i]==true表示已访问。初始值均为false
4
5void Dijkstra(int s){  //s为起点
6   fill(d,d+MAXV,INF);     //fill函数将整个d数组赋值为INF(慎用memset)
7   d[s] = 0;   //起点s到达自身的距离为0
8   for(int i=0;i<n;i++){    //循环n次
9       int u=-1,MIN=INF;   //u使d[u]最小,MIN存放最小的d[u]
10      for(int j=0;j<n;j++){    //找到未访问的顶点中d[]最小的
11          if(vis[j]==false && d[j]<MIN){
12              u=j;
13              MIN=d[j];
14          }
15      }
16      //找不到小于INF的d[u],说明剩下的顶点和起点s不连通
17      if(u==-1)
18          return;
19      vis[u] = true;  //标记u为已访问
20      for(int v=0;v<n;v++){
21          //如果v未能访问&&u能到达v&&以u为中介点可以使d[v]更优
22          if(vis[v]==false && G[u][v] != INF && d[u]+G[u][v] < d[v]){
23              d[v] = d[u]+G[u][v];    //优化d[v]
24          }
25      }
26  }
27}
28

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

(2)邻接表版


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
1vector<Node> Adj[MAXV];      //图 G,Adj[u]存放从顶点u出发可以到达的所有顶点
2int n;     // n为顶点数,图G使用邻接表实现,MAXV为最大顶点数
3int d[maxv];   //起点到达各点的最短路径长度
4bool vis[MAXV] = {false};  //标记数组,vis[i]==true表示已访问。初始值均为false
5
6void Dijkstra(int s){  //s为起点
7   fill(d,d+MAXV,INF);     //fill函数将整个d数组赋值为INF(慎用memset)
8   d[s] = 0;   //起点s到达自身的距离为0
9   for(int i=0;i<n;i++){    //循环n次
10      int u=-1,MIN=INF;   //u使d[u]最小,MIN存放最小的d[u]
11      for(int j=0;j<n;j++){    //找到未访问的顶点中d[]最小的
12          if(vis[j]==false && d[j]<MIN){
13              u=j;
14              MIN=d[j];
15          }
16      }
17      //找不到小于INF的d[u],说明剩下的顶点和起点s不连通
18      if(u==-1)
19          return;
20      vis[u] = true;  //标记u为已访问
21      for(int j=0;j<Adj[u].size();j++){
22          int v = Adj[u][j].v;    //通过邻接表直接获得u能到达的顶点v
23          if(vis[v]==false && d[u]+Adj[u][j].dis < d[v]){
24              //如果v未能访问&&以u未终结点可以使d[v]更优
25              d[v] = d[u]+G[u][v];    //优化d[v]
26          }
27      }
28  }
29}
30

从复杂度来看,主要是外层循环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)的最短距离,程序代码如下:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
1#include<cstdio>
2#include<algorithm>
3using namespace std;
4const int MAXV = 1000; //最大顶点数
5const int INF = 1000000000;        //设INF为一个很大的数
6
7int n,m,s,G[MAXV][MAXV];   //n为顶点数,m为边数,s为起点
8int d[MAXV];   //起点到达各点的最短路径长度
9bool vis[MAXV] = {false};  //标记数组,vis[i]==true表示已访问。初始值均为false
10
11void Dijkstra(int s){ //s为起点
12  fill(d,d+MAXV,INF);     //fill函数将整个d数组赋值为INF(慎用memset)
13  d[s] = 0;   //起点s到达自身的距离为0
14  for(int i=0;i<n;i++){    //循环n次
15      int u=-1,MIN=INF;   //u使d[u]最小,MIN存放最小的d[u]
16      for(int j=0;j<n;j++){    //找到未访问的顶点中d[]最小的
17          if(vis[j]==false && d[j]<MIN){
18              u=j;
19              MIN=d[j];
20          }
21      }
22      //找不到小于INF的d[u],说明剩下的顶点和起点s不连通
23      if(u==-1)
24          return;
25      vis[u] = true;  //标记u为已访问
26      for(int v=0;v<n;v++){
27          //如果v未能访问&&u能到达v&&以u未终结点可以使d[v]更优
28          if(vis[v]==false && G[u][v] != INF && d[u]+G[u][v] < d[v]){
29              d[v] = d[u]+G[u][v];    //优化d[v]
30          }
31      }
32  }
33}
34
35int main(){
36  int u,v,w;
37  scanf("%d%d%d",&n,&m,&s); //顶点个数,边数,起点编号
38  fill(G[0],G[0]+MAXV*MAXV,INF);  //初始化图G
39  for(int i=0;i<m;i++){
40      scanf("%d%d%d",&u,&v,&w); //输入u,v以及u->v的边权
41      G[u][v] = w;
42  }
43  Dijkstra(s);    // Dijkstra算法入口
44  for(int i=0;i<n;i++){
45      printf("%d ",d[i]);   //输出所有顶点的最短距离
46  }
47  return 0;
48}
49

输入数据:


1
2
3
4
5
6
7
8
9
10
16 8 0
20 1 1
30 3 4
40 4 4
51 3 2
62 5 1
73 2 2
83 4 3
94 5 3
10

输出结果:


1
2
10 1 5 3 4 6
2

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

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


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

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


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

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


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
1int n,G[MAXV][MAXV];   //n为顶点数,MAXV为最大顶点数
2int d[maxv];   //起点到达各点的最短路径长度
3int pre[MAXV];     //pre[v]表示从起点到顶点v的最短路径v的前一个顶点
4bool vis[MAXV] = {false};  //标记数组,vis[i]==true表示已访问。初始值均为false
5
6void Dijkstra(int s){  //s为起点
7   fill(d,d+MAXV,INF);     //fill函数将整个d数组赋值为INF(慎用memset)
8   for(int i=0;i<n;i++)
9       pre[i] = i; //初始状态设每一个点的前驱为自身
10  d[s] = 0;   //起点s到达自身的距离为0
11  for(int i=0;i<n;i++){    //循环n次
12      int u=-1,MIN=INF;   //u使d[u]最小,MIN存放最小的d[u]
13      for(int j=0;j<n;j++){    //找到未访问的顶点中d[]最小的
14          if(vis[j]==false && d[j]<MIN){
15              u=j;
16              MIN=d[j];
17          }
18      }
19      //找不到小于INF的d[u],说明剩下的顶点和起点s不连通
20      if(u==-1)
21          return;
22      vis[u] = true;  //标记u为已访问
23      for(int v=0;v<n;v++){
24          //如果v未能访问&&u能到达v&&以u未终结点可以使d[v]更优
25          if(vis[v]==false && G[u][v] != INF && d[u]+G[u][v] < d[v]){
26              d[v] = d[u]+G[u][v];    //优化d[v]
27              pre[v] = u;     //记录v的前驱顶点是u  
28          }
29      }
30  }
31}
32

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

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

Dijkstra算法


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

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

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

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


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

至此,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]。代码如下:


1
2
3
4
5
6
7
8
9
10
11
12
1for(int v=0;v&lt;n;v++){  
2   // 如果v未访问&amp;&amp;u能到达v
3   if(vis[v]==false G[u][v] != INF){
4       if(d[u] + G[u][v] &lt; d[v]){   //以u未终结点可以是d[v]更优
5           d[v] = d[u] + G[u][v];
6           c[v] = c[u] + cost[u][v];
7       } else if(d[u] + G[u][v] ==d[v] &amp;&amp; c[u] + cost[u][v] &lt; c[v]){
8           c[v] = c[u] + cost[u][v];   //最短距离相同时看能否使c[v]更优
9       }
10  }
11}
12

②新增点权。以新增的点权代表顶点中能得到的权重为例,用 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]。代码如下:


1
2
3
4
5
6
7
8
9
10
11
12
13
1for(int v=0;v&lt;n;v++){  
2   // 如果v未访问&amp;&amp;u能到达v
3   if(vis[v]==false G[u][v] != INF){
4       if(d[u] + G[u][v] &lt; d[v]){   //以u未终结点可以是d[v]更优
5           d[v] = d[u] + G[u][v];
6           w[v] = w[u] + weight[v];
7       } else if(d[u] + G[u][v] ==d[v] &amp;&amp; w[u] + weight[v] &gt; w[v]){
8           w[v] = w[u] + weight[v];    //最短距离相同时看能否使w[v]更优
9       }
10  }
11}
12
13

③)求最短路径条数。只需要增加一个数组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]上。代码如下:


1
2
3
4
5
6
7
8
9
10
11
12
1for(int v=0;v&lt;n;v++){  
2   // 如果v未访问&amp;&amp;u能到达v
3   if(vis[v]==false G[u][v] != INF){
4       if(d[u] + G[u][v] &lt; d[v]){   //以u未终结点可以是d[v]更优
5           d[v] = d[u] + G[u][v];
6           num[v] = num[u];
7       } else if(d[u] + G[u][v] ==d[v]){
8           num[v] += num[u];   //最短距离相同时累加num
9       }
10  }
11}
12

以上就是Dikjstra的全部内容。

给TA打赏
共{{data.count}}人
人已打赏
安全经验

Google Adsense(Google网站联盟)广告申请指南

2021-10-11 16:36:11

安全经验

安全咨询服务

2022-1-12 14:11:49

个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索