zoukankan      html  css  js  c++  java
  • 彻底搞懂最短路算法

    转载自

    彻底弄懂最短路径问题

            只想说:温故而知新,可以为师矣。我大二的《数据结构》是由申老师讲的,那时候不怎么明白,估计太理论化了(ps:或许是因为我睡觉了);今天把老王的2011年课件又看了一遍,给大二的孩子们又讲了一遍,随手谷歌了N多资料,算是彻底搞懂了最短路径问题。请读者尽情享用……

            我坚信:没有不好的学生,只有垃圾的教育。不过没有人理所当然的对你好,所以要学会感恩。

    一.问题引入

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

    二.Dijkstra算法

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

               image

            观察右边表格发现除最后一个节点外其他均已经求出最短路径。

            (1)   迪杰斯特拉(Dijkstra)算法按路径长度(看下面表格的最后一行,就是next点)递增次序产生最短路径。先把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为止(说明最外层是除起点外的遍历)。

            下面是上图的求解过程,按列来看,第一列是初始化过程,最后一行是每次求得的next点。

               image

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

                 答案是不能,这与贪心选择性质有关(ps:貌似还是动态规划啊,晕了),每次都找一个距源点最近的点(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的最短路径的距离。

            很简单吧,代码看起来可能像下面这样:

    for (int i=0; i<n; ++i) {
    
      for (int j=0; j<n; ++j) {
    
        for (int k=0; k<n; ++k) {
    
          if (dist[i][k] + dist[k][j] < dist[i][j] ) {
    
            dist[i][j] = dist[i][k] + dist[k][j];
    
          }
    
        }
    
      }
    
    }

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

            让我们来看一个例子,看下图:

    image

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

            ps:个人觉得,这和银行家算法判断安全状态(每种资源去测试所有线程),树状数组更新(更新所有相关项)一样的思想。

    for (int k=0; k<n; ++k) {
    
      for (int i=0; i<n; ++i) {
    
        for (int j=0; j<n; ++j) {
    
                /*
    
                实际中为防止溢出,往往需要选判断 dist[i][k]和dist[k][j
    
                都不是Inf ,只要一个是Inf,那么就肯定不必更新。 
    
                */
    
          if (dist[i][k] + dist[k][j] < dist[i][j] ) {
    
            dist[i][j] = dist[i][k] + dist[k][j];
    
          }
    
        }
    
      }
    
    }

            如果还是看不懂,那就用草稿纸模拟一遍,之后你就会豁然开朗。半个小时足矣(早知道的话会节省很多个半小时了。。狡猾

           再来看路径保存问题:

    void floyd() {
    
          for(int i=1; i<=n ; i++){
    
            for(int j=1; j<= n; j++){
    
              if(map[i][j]==Inf){
    
                   path[i][j] = -1;//表示  i -> j 不通 
    
              }else{
    
                   path[i][j] = i;// 表示 i -> j 前驱为 i
    
              }
    
            }
    
          }
    
          for(int k=1; k<=n; k++) {
    
            for(int i=1; i<=n; i++) {
    
              for(int j=1; j<=n; j++) {
    
                if(!(dist[i][k]==Inf||dist[k][j]==Inf)&&dist[i][j] > dist[i][k] + dist[k][j]) {
    
                  dist[i][j] = dist[i][k] + dist[k][j];
    
                  //path[i][k] = i;//删掉
    
                  path[i][j] = path[k][j];
    
                }
    
              }
    
            }
    
          }
    
        }
    
        void printPath(int from, int to) {
    
            /*
    
             * 这是倒序输出,若想正序可放入栈中,然后输出。
    
             * 
    
             * 这样的输出为什么正确呢?个人认为用到了最优子结构性质,
    
             * 即最短路径的子路径仍然是最短路径
    
             */
    
            while(path[from][to]!=from) {
    
                System.out.print(path[from][to] +"");
    
                to = path[from][to];
    
            }
    
        }

            《数据结构》课本上的那种方式我现在还是不想看,看着不舒服……

            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为例:给你一组国家和国家间的部分货币汇率兑换表,问你是否存在一种方式,从一种货币出发,经过一系列的货币兑换,最后返回该货币时大于出发时的数值(ps:这就是所谓的投机倒把吧),下面是一组输入。
    3    //国家数
    USDollar  //国家名
    BritishPound
    FrenchFranc
       3    //货币兑换数
    USDollar 0.5 BritishPound  //部分货币汇率兑换表
    BritishPound 10.0 FrenchFranc
    FrenchFranc 0.21 USDollar

            月赛做的题,不过当时用的思路是求强连通分量(ps:明明说的,那时我和华杰感觉好有道理),也没做出来,现在知道了直接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算法就可以提前结束了(开篇提出的小优化就是这个)。

            上代码(参考了牛帅的博客)

    #include<iostream>
    
    #include<cstdio>
    
    using namespace std;
    
    #define MAX 0x3f3f3f3f
    
    #define N 1010
    
    int nodenum, edgenum, original; //点,边,起点
    
    typedef struct Edge //
    
    {
    
      int u, v;
    
      int cost;
    
    }Edge;
    
    Edge edge[N];
    
    int dis[N], pre[N];
    
    bool Bellman_Ford()
    
    {
    
      for(int i = 1; i <= nodenum; ++i) //初始化
    
        dis[i] = (i == original ? 0 : MAX);
    
      for(int i = 1; i <= nodenum - 1; ++i)
    
        for(int j = 1; j <= edgenum; ++j)
    
          if(dis[edge[j].v] > dis[edge[j].u] + edge[j].cost) //松弛(顺序一定不能反~)
    
          {
    
            dis[edge[j].v] = dis[edge[j].u] + edge[j].cost;
    
            pre[edge[j].v] = edge[j].u;
    
          }
    
          bool flag = 1; //判断是否含有负权回路
    
          for(int i = 1; i <= edgenum; ++i)
    
            if(dis[edge[i].v] > dis[edge[i].u] + edge[i].cost)
    
            {
    
              flag = 0;
    
              break;
    
            }
    
            return flag;
    
    }
    
    void print_path(int root) //打印最短路的路径(反向)
    
    {
    
      while(root != pre[root]) //前驱
    
      {
    
        printf("%d-->", root);
    
        root = pre[root];
    
      }
    
      if(root == pre[root])
    
        printf("%d
    ", root);
    
    }
    
    int main()
    
    {
    
      scanf("%d%d%d", &nodenum, &edgenum, &original);
    
      pre[original] = original;
    
      for(int i = 1; i <= edgenum; ++i)
    
      {
    
        scanf("%d%d%d", &edge[i].u, &edge[i].v, &edge[i].cost);
    
      }
    
      if(Bellman_Ford())
    
        for(int i = 1; i <= nodenum; ++i) //每个点最短路
    
        {
    
          printf("%d
    ", dis[i]);
    
          printf("Path:");
    
          print_path(i);
    
        }
    
      else
    
        printf("have negative circle
    ");
    
      return 0;
    
    }

    四.SPFA算法

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

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

     
  • 相关阅读:
    linux内核中GNU C和标准C的区别
    linux内核中GNU C和标准C的区别
    Getting start with dbus in systemd (02)
    Getting start with dbus in systemd (01)
    Getting start with dbus in systemd (03)
    物理内存相关的三个数据结构
    数据类型对应字节数(32位,64位 int 占字节数)
    Linux kernel 内存
    共模电感的原理以及使用情况
    [原创]DC-DC输出端加电压会烧毁
  • 原文地址:https://www.cnblogs.com/five20/p/7782931.html
Copyright © 2011-2022 走看看