彻底弄懂最短路径问题(图论)

Posted konjac蒟蒻

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了彻底弄懂最短路径问题(图论)相关的知识,希望对你有一定的参考价值。

来源:彻底弄懂最短路径问题  http://www.cnblogs.com/hxsyl/p/3270401.html

P.S.根据个人需要,我删改了不少

问题引入

        问题:从某顶点出发,沿图的边到达另一顶点所经过的路径中,各边上权值之和最小的一条路径——最短路径。解决最短路的问题有以下算法,Dijkstra算法,Bellman-Ford算法,Floyd算法和SPFA算法,另外还有著名的启发式搜索算法A*,不过A*准备单独出一篇,其中Floyd算法可以求解任意两点间的最短路径的长度。笔者认为任意一个最短路算法都是基于这样一个事实:从任意节点A到任意节点B的最短路径不外乎2种可能,1是直接从A到B,2是从A经过若干个节点到B。

一.Dijkstra算法

        该算法在《数据结构》课本里是以贪心的形式讲解的,不过在《运筹学》教材里被编排在动态规划章节,建议读者两篇都看看。

        (1)   迪杰斯特拉(Dijkstra)算法按路径长度递增次序产生最短路径。先把V分成两组:

  • S:已求出最短路径的顶点的集合
  • V-S=T:尚未确定最短路径的顶点集合

        将T中顶点按最短路径递增的次序加入到S中,依据:可以证明V0到T中顶点Vk的最短路径,或是从V0到Vk的直接路径的权值或是从V0经S中顶点到Vk的路径权值之和(反证法可证)。

        (2)   求最短路径步骤

  1. 初使时令 S={V0},T={其余顶点},T中顶点对应的距离值, 若存在<V0,Vi>,为<V0,Vi>弧上的权值(和SPFA初始化方式不同),若不存在<V0,Vi>,为Inf。
  2. 从T中选取一个其距离值为最小的顶点W(贪心体现在此处),加入S(注意不是直接从S集合中选取,理解这个对于理解vis数组的作用至关重要),对T中顶点的距离值进行修改:若加进W作中间顶点,从V0到Vi的距离值比不加W的路径要短,则修改此距离值(上面两个并列for循环,使用最小点更新)。
  3. 重复上述步骤,直到S中包含所有顶点,即S=V为止(说明最外层是除起点外的遍历)。

        (3)   问题:Dijkstar能否处理负权边?(来自《图论》)

             答案是不能,这与贪心选择性质有关,每次都找一个距源点最近的点(dmin),然后将该距离定为这个点到源点的最短路径;但如果存在负权边,那就有可能先通过并不是距源点最近的一个次优点(dmin\'),再通过这个负权边L(L<0),使得路径之和更小(dmin\'+L<dmin),则dmin\'+L成为最短路径,并不是dmin,这样dijkstra就被囧掉了。比如n=3,邻接矩阵:
0,3,4
3,0,-2
4,-2,0,用dijkstra求得d[1,2]=3,事实上d[1,2]=2,就是通过了1-3-2使得路径减小。不知道讲得清楚不清楚。

二.Floyd算法

        Floyd算法的基本思想如下:从任意节点A到任意节点B的最短路径不外乎2种可能,1是直接从A到B,2是从A经过若干个节点到B,所以,我们假设dist(AB)为节点A到节点B的最短路径的距离,对于每一个节点K,我们检查dist(AK) + dist(KB) < dist(AB)是否成立,如果成立,证明从A到K再到B的路径比A直接到B的路径短,我们便设置 dist(AB) = dist(AK) + dist(KB),这样一来,当我们遍历完所有节点K,dist(AB)中记录的便是A到B的最短路径的距离。

        这里我们要注意循环的嵌套顺序,如果把检查所有节点K放在最内层,那么结果将是不正确的,为什么呢?因为这样便过早的把i到j的最短路径确定下来了,而当后面存在更短的路径时,已经不再会更新了。让我们来看一个例子,看下图:

                                              image

        图中红色的数字代表边的权重。如果我们在最内层检查所有节点K,那么对于A->B,我们只能发现一条路径,就是A->B,路径距离为9,而这显然是不正确的,真实的最短路径是A->D->C->B,路径距离为6。造成错误的原因就是我们把检查所有节点K放在最内层,造成过早的把A到B的最短路径确定下来了,当确定A->B的最短路径时dist(AC)尚未被计算。所以,我们需要改写循环顺序,加上路径保存问题:

Attention 上代码啦~

 1 void floyd() 
 2 {
 3     for(int i=1;i<=n;i++)
 4     {
 5       for(int j=1;j<=n;j++)
 6       {
 7         if(map[i][j]==Inf) path[i][j]=-1;//表示i->j不通 
 8         else path[i][j]=i;//表示i->j前驱为i
 9       }
10     }
11     for(int k=1;k<=n;k++)
12      for(int i=1;i<=n;i++)
13        for(int j=1;j<=n;j++)
14          if(!(dist[i][k]==Inf||dist[k][j]==Inf) && dist[i][j]>dist[i][k]+dist[k][j])
15          {
16            dist[i][j]=dist[i][k]+dist[k][j];
17            path[i][k]=i;
18            path[i][j]=path[k][j];//注意 是path[k][j],而不是k
19          }
20 }
21 void printPath(int from,int to)
22 {//倒序输出;最优子结构性质,即最短路径的子路径仍然是最短路径
23     while(path[from][to]!=from)
24     {
25       System.out.print(path[from][to] +"");
26       to=path[from][to];
27     }
28 }
floyd算法+路径打印

        Floyd算法另一种理解DP,为理论爱好者准备的,上面这个形式的算法其实是Floyd算法的精简版,而真正的Floyd算法是一种基于DP(Dynamic Programming)的最短路径算法。设图G中n 个顶点的编号为1到n。令c [i, j, k]表示从i 到j 的最短路径的长度,其中k 表示该路径中的最大顶点,也就是说c[i,j,k]这条最短路径所通过的中间顶点最大不超过k。因此,如果G中包含边<i, j>,则c[i, j, 0] =边<i, j> 的长度;若i= j ,则c[i,j,0]=0;如果G中不包含边<i, j>,则c (i, j, 0)= +∞。c[i, j, n] 则是从i 到j 的最短路径的长度。对于任意的k>0,通过分析可以得到:中间顶点不超过k 的i 到j 的最短路径有两种可能:该路径含或不含中间顶点k。若不含,则该路径长度应为c[i, j, k-1],否则长度为 c[i, k, k-1] +c [k, j, k-1]。c[i, j, k]可取两者中的最小值。状态转移方程:c[i, j, k]=min{c[i, j, k-1], c [i, k, k-1]+c [k, j, k-1]},k>0。这样,问题便具有了最优子结构性质,可以用动态规划方法来求解。

        看另一个DP

                       image

        说了这么多,相信读者已经跃跃欲试了,咱们看一道例题,以ZOJ 1092为例:给你一组国家和国家间的部分货币汇率兑换表,问你是否存在一种方式,从一种货币出发,经过一系列的货币兑换,最后返回该货币时大于出发时的数值,下面是一组输入。
3    //国家数
USDollar  //国家名
BritishPound
FrenchFranc
   3    //货币兑换数
USDollar 0.5 BritishPound  //部分货币汇率兑换表
BritishPound 10.0 FrenchFranc
FrenchFranc 0.21 USDollar

        月赛做的题,不过当时用的思路是求强连通分量,也没做出来,现在知道了直接floyd算法就ok了。

        思路分析:输入的时候可以采用Map<String,Integer> map = new HashMap<String,Integer>()主要是为了获得再次包含汇率输入时候的下标以建图(感觉自己写的好拗口),或者第一次直接存入String数组str,再次输入的时候每次遍历str数组,若是equals那么就把str的下标赋值给该币种建图。下面就是floyd算法啦,初始化其它点为-1,对角线为1,采用乘法更新求最大值。

 

三.Bellman-Ford算法

        为了能够求解边上带有负值的单源最短路径问题,Bellman(贝尔曼,动态规划提出者)Ford(福特)提出了从源点逐次绕过其他顶点,以缩短到达终点的最短路径长度的方法。 Bellman-ford算法是求含负权图的单源最短路径算法,效率很低,但代码很容易写。即进行不停地松弛,每次松弛把每条边都更新一下,若n-1次松弛后还能更新,则说明图中有负环,无法得出结果,否则就成功完成。Bellman-ford算法有一个小优化:每次松弛先设一个flag,初值为FALSE,若有边更新则赋值为TRUE,最终如果还是FALSE则直接成功退出。

Bellman-ford算法浪费了许多时间做无必要的松弛,所以SPFA算法用队列进行了优化,效果十分显著,高效难以想象。SPFA还有SLF,LLL,滚动数组等优化。

        关于SPFA,请看我这一篇http://www.cnblogs.com/hxsyl/p/3248391.html

        递推公式(求顶点u到源点v的最短路径):

           dist 1 [u] = Edge[v][u]

           dist k [u] = min{ dist k-1 [u], min{ dist k-1 [j] + Edge[j][u] } }, j=0,1,…,n-1,j≠u

         Dijkstra算法和Bellman算法思想有很大的区别:Dijkstra算法在求解过程中,源点到集合S内各顶点的最短路径一旦求出,则之后不变了,修改  的仅仅是源点到T集合中各顶点的最短路径长度。Bellman算法在求解过程中,每次循环都要修改所有顶点的dist[ ],也就是说源点到各顶点最短路径长度一直要到Bellman算法结束才确定下来。

  • 算法适用条件
  • 1.单源最短路径(从源点s到其它所有顶点v)
  • 有向图&无向图(无向图可以看作(u,v),(v,u)同属于边集E的有向图)
  • 边权可正可负(如有负权回路输出错误提示)
  • 差分约束系统

        Bellman-Ford算法描述:

  1. 初始化:将除源点外的所有顶点的最短距离估计值 d[v] ←+∞, d[s] ←0
  2. 迭代求解:反复对边集E中的每条边进行松弛操作,使得顶点集V中的每个顶点v的最短距离估计值逐步逼近其最短距离;(运行|v|-1次,看下面的描述性证明(当做树))
  3. 检验负权回路:判断边集E中的每一条边的两个端点是否收敛。如果存在未收敛的顶点,则算法返回false,表明问题无解;否则算法返回true,并且从源点可达的顶点v的最短距离保存在d[v]中

        描述性证明:(这个解释很好)

        首先指出,图的任意一条最短路径既不能包含负权回路,也不会包含正权回路,因此它最多包含|v|-1条边。

其次,从源点s可达的所有顶点如果 存在最短路径,则这些最短路径构成一个以s为根的最短路径树。Bellman-Ford算法的迭代松弛操作,实际上就是按顶点距离s的层次,逐层生成这棵最短路径树的过程。

在对每条边进行1遍松弛的时候,生成了从s出发,层次至多为1的那些树枝。也就是说,找到了与s至多有1条边相联的那些顶点的最短路径;对每条边进行第2遍松弛的时候,生成了第2层次的树枝,就是说找到了经过2条边相连的那些顶点的最短路径……。因为最短路径最多只包含|v|-1条边,所以,只需要循环|v|-1 次。

每实施一次松弛操作,最短路径树上就会有一层顶点达到其最短距离,此后这层顶点的最短距离值就会一直保持不变,不再受后续松弛操作的影响。(但是,每次还要判断松弛,这里浪费了大量的时间,这就是Bellman-Ford算法效率底下的原因,也正是SPFA优化的所在)。

image,如图,若是B和C的最短路径不更新,那么点D的最短路径肯定也无法更新,这就是优化所在。

如果没有负权回路,由于最短路径树的高度最多只能是|v|-1,所以最多经过|v|-1遍松弛操作后,所有从s可达的顶点必将求出最短距离。如果 d[v]仍保持 +∞,则表明从s到v不可达。

如果有负权回路,那么第 |v|-1 遍松弛操作仍然会成功,这时,负权回路上的顶点不会收敛。

        问题:Bellman-Ford算法是否一定要循环n-1次吗?未必!其实只要在某次循环过程中,考虑每条边后,都没能改变当前源点到所有顶点的最短路径长度,那么Bellman-Ford算法就可以提前结束了(开篇提出的小优化就是这个)。

 Attention 上代码啦~

 1 bool Bellman_Ford()
 2 {
 3     for(int i=1;i<=nodenum;++i) //初始化
 4       dis[i]=(i==original?0:MAX);
 5     for(int i=1;i<=nodenum-1;++i)
 6      for(int j=1;j<=edgenum;++j)
 7        if(dis[edge[j].v]>dis[edge[j].u]+edge[j].cost) //松弛操作
 8        {
 9          dis[edge[j].v]=dis[edge[j].u]+edge[j].cost;
10          pre[edge[j].v]=edge[j].u;
11        }
12     bool flag=1; //判断是否含有负权回路 or 负环
13     for(int i=1;i<=edgenum;++i)
14       if(dis[edge[i].v]>dis[edge[i].u]+edge[i].cost)
15       {
16         flag=0;
17         break;
18       }
19     return flag;
20 }
21 void print_path(int root) //打印最短路的路径(反向)
22 {
23     while(root!=pre[root]) //前驱
24     {
25       printf("%d-->", root);
26       root=pre[root];
27     }
28     if(root==pre[root])
29     printf("%d\\n",root);
30 }
31 
32 int main()
33 {
34     //此处省略无数字......
35     if(Bellman_Ford())
36       for(int i=1;i<=nodenum;++i)//每个点最短路
37       {
38         printf("%d\\n", dis[i]);
39         printf("Path:");
40         print_path(i);
41       }
42     else printf("have negative circle\\n");
43     return 0;
44 }
Bellman-ford算法+打印路径(输出多点结束的最短路长度)

四.SPFA算法

        用一个队列来进行维护。初始时将源加入队列。每次从队列中取出一个元素,并对所有与他相邻的点进行松弛,若某个相邻的点松弛成功,则将其入队。直到队列为空时算法结束;这个算法,简单的说就是队列优化的bellman-ford,利用了每个点不会更新次数太多的特点发明的此算法(看我上面那个图,只有相邻点更新了,该点才有可能更新) 。

         代码参见 : http://www.cnblogs.com/hxsyl/p/3248391.html

以上是关于彻底弄懂最短路径问题(图论)的主要内容,如果未能解决你的问题,请参考以下文章

十二个基本问题帮你彻底弄懂最短路径问题

彻底弄懂最短路径问题

十一个基本问题帮你彻底弄懂与轴对称相关的最短路径问题

(建议收藏)一文多图,彻底搞懂Floyd算法(多源最短路径)

(建议收藏)一文多图,彻底搞懂Floyd算法(多源最短路径)

图论:图的四种最短路径算法