算法(七)最短路径之Bellman-Ford算法

前言

            
前面两篇文章,我们分别学习了Floyed-Warshall和Dijkstra算法。还有印象吗?这篇文章我们就来学习一下另一种最短路径的算法,Bellman-Ford算法和一些邻接表的知识。在上篇文章中学习的Dijkstra算法的时间复杂度是O(N*N),那有没有什么可以优化速度的方法呢?首先,这篇文章就先学习一下 通过邻接表来优化Dijkstra算法,然后再学习一下Bellman-Ford算法。

邻接表

          稀疏图和稠密图
        

           稀疏图:指的是边数M远小于顶点数N * N的图            稠密图:指的是M相对较大的图,和稀疏图相对            
邻接表
           邻接表是用来存储图的数据结构,用它来代替邻接矩阵,使得Dijkstra算法的时间复杂度优化到O(M + N)logN,最坏的情况下就是M等于N * N,这样的话O(M + N)logN 要比N * N还大,但是多数情况下并不会有这么多边,因此 (M + N)logN要比N * N小很多。
           下面就是解释什么叫邻接表,先上一组数据:     
            4    5             1    4    9             2    4    6             1    2    5             4    3    8             1    3    7
            是不是有点茫然了,看不懂这是什么鬼了,我们结合图来详细解释一下             
《算法(七)最短路径之Bellman-Ford算法》
           这下是不是看的有点懂了?其实第一排的两个整数n m 。n 表示顶点个数,m表示边的条数。接下来一共有m行,每一行的3个数x,y,z。表示顶点x到顶点y的边的权值为z,简单理解权值可以理解为路程。
           所以其实第二排的1    4    9其实表示的是,顶点1到顶点4的距离是9
           第三排 2
    4
    6表示的就是顶点2到顶点4的距离是6

           那么我们如何用数组来实现邻接表呢?这里没有使用真正 的指针链表,数组在实际应用中非常容易实现的。首先,我们需要如下几个数组 u[i] v[i]  w[i] ,分别表示第i条边的是从u[i]顶点到v[i]顶点的权值是w[i],简单的说就是u数组表示出发点,v数组表示到达点,w表示这条边的权重。用代码来初始化就是如下:

           

 int[] u = new int[6];//装的是出发点
 int[] v = new int[6];//装的是到达点
 int[] w = new int[6];//装的是边的权重
 u[1] = 1; v[1] = 4; w[1] = 9;
 u[2] = 4; v[2] = 3; w[2] = 8;
 u[3] = 1; v[3] = 2; w[3] = 5;
 u[4] = 2; v[4] = 4; w[4] = 6;
 u[5] = 1; v[5] = 3; w[5] = 7;

           
然后就是非常关键的初始化了,我们需要两个数组first和next,代码如下:

  int[] first = new int[6];
  int[] next = new int[6];
  for (int j = 1; j <= 4 ; j++) {
        first[j] = -1;
  }
  for (int j = 1; j <= 5; j++) {
        next[j] = first[u[j]];
        first[u[j]] = j;
  }

          
大致一看是不是觉得这几行代码有点懵逼,那么我们来解释一下,就好理解了,1到5,表示的是所有的边的编号,然后first数组,我们将first数组初始化为-1,然后first数组,里面其实装的是顶点u[j]的第一条边的编号,u[j]表示的就是第j条边的初始点,first[u[j]],就表示顶点u[j]的第一条边的编号,而next数组,其实装的就是下一条边的编号,比如next[j],表示的就是编号为j的边的下一条边的编号。           
所有,第二个for循环里面的两行代码的意义如下
          next[j] = first[ u[j] ] ,next[j] 存储的是顶点u[j] 的第一条边的编号,
          first[u[j]] = j ,将顶点u[j] 的第一条边的编号更新为 j
          我们上面说了first装的是顶点u[j] 的第一条边的编号,那么如果一个顶点有多条边的话,如何确定哪一条是第一条边呢?这里就是默认的最后读取的边是顶点 u[j] 的第一条边,这样看是不是还是有点抽象,我们下面通过数据初始化过程,来具体了解下:



          当j = 1 时,表示我们现在开始将编号为j的边的数据,存入first和next数组,next[ 1 ] = first [ u [ 1 ] ],u[ 1 ] = 1,第一条边的出发顶点是 1号顶点,first [ 1 ] ,一号顶点的第一条边的编号是,因为之前我们将first数组都初始化为了 -1 ,所以  next[ 1 ] = first[ 1 ] = -1,然后是first[ u [1 ] ] = 1,first[ 1 ] = 1,1号顶点的第一条边的编号是1,这个时候 next [ 1 ] = -1 ,first[ 1 ] = 1,意思就是1号顶点的第一条边的编号是1,编号为1的边的下一条边编号是 -1 ,因为我们只读入了一条边,所以肯定是 -1 啦。

          当j = 2 时, 表示我们开始将编号为2的边的数据写入数组,next [ 2 ] = first[ u[ 2 ] ],u[ 2 ] = 4,   表示编号为2的边的下一条边的编号是 顶点4的第一条边的编号,因为顶点4还没读入过边,所以next[ 2] = -1 ,first[ u[ 2] ] = 2,表示顶点u[2] =4的第一条边的编号,我们就存入编号2,因为现在我们读取到的4作为开始点的边只有编号为2的边这一条。

          当 j = 3 时,关键点来了,因为现在读取的编号为3的边的顶点也是1,跟我们第一次读入的编号为1的号的顶点是一样的。next[ 3 ] = first[ u[ 3 ] ],u[ 3 ] = 1,表示编号为3的边的下一条边是顶点1的第一条边的编号,因为first[ 1 ] ,我们在上面已经写入过了数据,所以next [ 3 ] = first[ u[ 3 ] ] = first[ 1 ] = 1;编号为3的边的下一条边的编号是1,然后first[ u [ 3] ] = 3,意思就是将顶点1的第一条边的编号更新为 3。

         通过上面的步骤,我们发现顶点的第一条边的编号,其实一直是变化的,然后每一次变化都会把上一次的边的编号作为该边的下一条边的编号,这样其实我们就将边串起来了,如果还不好理解,这里附上原书上的解释图。

《算法(七)最短路径之Bellman-Ford算法》


《算法(七)最短路径之Bellman-Ford算法》


            这就是上面初始化代码的全部过程,通过上述过程,我们就将所有的边通过两个数组关联了起来。那么如何遍历某一个点的所有的边呢?比如顶点1的所有边,从first[ 1 ] 开始,first [ 1 ] = 5。

            所以顶点1的第一条边是编号5的边,编号5的下一条边是 编号 next[ 5 ] = 3的边,编号3的边的下一条边是编号为next[ 3 ] = 1的边,编号为1的边的下一条边是next[ 1 ] = -1,这时候我们发现没有下一条边了,也就是说顶点1的所有的边的编号就是1、3、5。发现是不是和上面我们的数据一模一样。

            同理顶点2的所有边就包括 first[ 2 ] = 4,next[ 4 ] = -1,意思就是顶点2只有一条边编号为4。

            顶点为3的边就包括first[ 3 ] = -1,我们发现顶点3没有边,说明没有以顶点3作为初始点的边。

            顶点为4的边就包括first[ 4 ] = 2,next[ 2 ] = -1,说明顶点4只有编号为2的一条边。

            我们都有所有点的编号了,那是不是从u、v、w数组读取该边的所有数据也变得很简单了。

现在我们来讲讲为什么说用邻接表可以大大缩短Dijkstar算法的时间复杂度???

           
还记得上一部分我们讲解的Dijkstar算法吗?我们的做法是先找出离源点A最近的一个顶点,然后以这个顶点去缩短其他顶点到源点A的距离,然后再从剩下的这些缩短了路程的顶点中找一个离得最近的点,这样依次就确定了源点A到所有的点的最短距离。该算法的时间复杂度是O(N * N) ,而以邻接表的方式存储边的数据,遍历每一条边的时间复杂度其实就是O(M)(想想是不是这样),所有对于一个稀疏图来说M远远小于N * N ,所有用邻接表来存储数据,是可以远远缩短Dijstra算法的时间复杂度的。当然如果是最差的情况M  = N * N ,时间又会远远大于O( N * N )。           
Dijkstra虽然能满足需求,但是它没办法解决“负权边”的问题,因为他基于的原理是,当所有边权值都是正时,由于不会存在一个路程更短的没扩展过的点,所以这个点的路程永远不会再被改变,因而保证了算法的正确性。所以下面我们下面来学习一个无论思想上还是代码实现上都堪称完美的最短路算法:BellMman-Ford。

Bellman-Ford算法(解决负权边)

          上面我们说了Dijkstra算法不能解决权值为负的情况,所以这里我们要学习一个无论思想上海市代码实现上都堪称完美的最短路径算法:Bellman-Ford算法。
           该算法的代码如下

   for (int i = 1; i < n - 1; i++) {
        for (int j = 1; j < m; j++) {
               if (dis[v[j]] > dis[u[j]] + w[j]){
                    dis[v[j]] = dis[u[j]] + w[j];
               }
        }
    }

             
其中,n表示顶点个数,m表示边的条数,u、v、w数组表示的是第i条路径的信息,也就是顶点u[i]到顶点v[i]的边的权值是w[i]。
             dis数组和前面Dijkstra算法是一样的,用来记录源点到其余各个顶点的最短路径,dis[3]表示源点到3号顶点的最短路径。
             Bellman-Ford算法的核心代码就上面这几行。

   if (dis[v[j]] > dis[u[j]] + w[j]){
          dis[v[j]] = dis[u[j]] + w[j];
   }

            上面两行代码的意思表示的是看看能否通过u[i] 到v[i]的这条边(权值是w[i]),使得源点到顶点v[j]的顶点的距离缩短,也就是松弛。

   for (int j = 1; j < m; j++) {
       if (dis[v[j]] > dis[u[j]] + w[j]){
              dis[v[j]] = dis[u[j]] + w[j];
       }
   }

            那么上述代码,就是将每一条边都松弛一下,也就是所有的m条边都进行一次松弛,那么所有的边都松弛一遍会有什么效果呢?下面我们来举例说明一下

《算法(七)最短路径之Bellman-Ford算法》
             上图就是我们需要求最短路径的图,其中包括了负权边 顶点1到顶点2,值是-3。
             边给出的数据如下:

2  3  2
1  2  -3
1  5  5
4  5  2
3  4  3

             
初始化dis数组就是dis[1]=0 dis[2] = 999999 dis[3] = 999999 dis[4] = 999999 dis[5] = 999999。
             dis表示,1号顶点到其余个点的最短路径

             那么,经过第一次松弛之后有什么结果呢?
             j = 1,u[1] = 2,v[1] = 3,w[1] = 2,dis[ 2 ] =999999,dis[3] = 999999,w[1] = 2

            很明显无法通过1号边,缩短源点到2号顶点的距离,所以结果如下

             j = 1   dis[1]=0  dis[2] = 999999 dis[3] = 999999 dis[4] = 999999 dis[5] = 999999



            当j = 2,u[2] = 1,v[2] = 2,w[2] = 3,dis[1] = 0,dis[2] = 999999,w[2] = -3
            很明显 ,dis[ v[2] ]  > dis[ u[2] ] + -3,所以dis[v[2]] = dis[ 2 ] = -3,松弛结果如下
            j = 2,dis[1]=0  dis[2] = -3  dis[3] = 999999 dis[4] = 999999 dis[5] = 999999



            当j = 3,u[3] = 1,v[3] = 5,w[3] = 5,dis[1] = 0,dis[5] = 999999,w[2] = 5
            dis[v[3]] = dis[ 5] = 999999 > dis[u[3]] + w[2] = 0 + 5 ,所以dis[v[3]] = dis[5] = 5

            松弛成功,结果如下
            j = 3,dis[1]=0  dis[2] = -3 dis[3] = 999999 dis[4] = 999999  dis[5] = 5



            当j = 4,u
[
4
] = 4,v[
4
] = 5,w[
4
] = 2,dis[4] = 999999,dis[5] = 5,w[4] = 2             dis[5] > dis[4] +w[4]    false 松弛失败             j = 4,
dis[1]=0  dis[2] = -3 dis[3] = 999999 dis[4] = 999999 dis[5] = 5


           当j = 5,u
[5
] = 3,v[
5
] = 4,w[
5
] = 3,dis[3] = 999999,dis[4] = 999999,w[
5
] = 2
           dis[4] > dis[3] + w[5]   false  松弛失败,结果如下            j = 5,
dis[1]=0  dis[2] = -3 dis[3] = 999999 dis[4] = 999999 dis[5] = 5


          所以 第一轮的松弛结果就是 dis[1]=0  dis[2] = -3 dis[3] = 999999 dis[4] = 999999 dis[5] = 5

          我们发现,再对所有边进行一次松弛之后,dis[2] 和dis[5] 已经变短了,那么如果进行第二轮的松弛会有什么结果呢?           第二轮松弛结果  
dis[1]=0  dis[2] = -3 dis[3] = -1  dis[4] = 2  dis[5] = 5
          我们发现第二轮松弛使得dis[3] 和dis[4] 变短了,那么为什么会这样了?第一轮这两个点为什么松弛失败,但是第二轮却成功了呢?

          这是因为经过第一轮松弛之后,dis[2] 已经发生了变化,这个时候再通过2  3  2 这条边进行松弛的话,就能够松弛成功,也就是说这个时候的dis[3] 其实是源点1到源点2再到源点3 = -3 +2 = -1,他其实就是借助了1到2的这条边才能松弛成功的。

          也就是说,第一轮其实只是源点到目标点的边的距离,不借助任何其他的边进行中转,而第二轮执行完之后,就是源点 “最多经过两条边”到目标点的最短路径,同理第三轮之后,就是源点“最多经过三条边”到目标点的最短路径。

          那么现在又有一个问题了,我们到底要经过多少轮呢?其实在一个顶点数为n的图中,任意两点之间的最短路径最多包括n-1条边,这是因为,能求最短路径的图里面,肯定是不包括回路的,既不可能有正权回路(权值为正数),也不可能有负权回路(权值为负数)。至于原因,大家认真想想就知道了。
          所以第三轮松弛结果就是 
dis[1]=0  dis[2] = -3 dis[3] = -1 dis[4] = 2 dis[5] = 4           所以第四轮松弛结果就是 
dis[1]=0  dis[2] = -3 dis[3] = -1 dis[4] = 2 dis[5] = 4
          所以呢,Bellman-Ford算法的核心思想只有一句话,对所有边进行n-1次松弛操作。而核心代码就是开头我们写出来的。

         所以我们进行一个实例编写,完整代码如下

   public static void bellmanFord(){
        int n = 5;int m = 5;//图中5个顶点,4条边
        int[] dis = new int[n + 1];//初始化dis数组
        for (int i = 1; i < dis.length; i++) {
            dis[i] = 999999;//初始化源点到其他点的距离是无穷
        }
        dis[1] = 0;//源点到源点的距离是0
        int[] w = new int[m+1];//初始化路径图
        int[] v = new int[m+1];
        int[] u = new int[m+1];
        u[1] = 2; v[1] = 3; w[1] = 2;
        u[2] = 1; v[2] = 2; w[2] = -3;
        u[3] = 1; v[3] = 5; w[3] = 5;
        u[4] = 4; v[4] = 5; w[4] = 2;
        u[5] = 3; v[5] = 4; w[5] = 3;
        /**执行算法*/
        for (int i = 1; i <= n - 1; i++) {
            for (int j = 1; j <= m; j++) {
                if (dis[v[j]] > dis[u[j]] + w[j]){
                    dis[v[j]] = dis[u[j]] + w[j];
                }
            }
            StringBuilder result = new StringBuilder();
            result.append("第"+i+"轮结果-----");
            for (int z = 1; z < dis.length; z++) {
                result.append("  "+dis[z]);
            }
            Log.i("hero",result.toString());
        }
        /**打印结果*/
        StringBuilder result = new StringBuilder();
        result.append("Bellman-Ford算法的结果是-----");
        for (int i = 1; i < dis.length; i++) {
            result.append("  "+dis[i]);
        }
        Log.i("hero",result.toString());
    }

           运行结果
《算法(七)最短路径之Bellman-Ford算法》
          结果可以看出,运行结果和我们的分析过程是一致的。
          
这里我们需要注意的是 ,首先如果进行了n-1轮之后,还能继续进行松弛,也就是说dis数组还能继续变化,说明该路径图存在负权回路。因为根据算法的思想,源点到任意一点的路径最多能借助n-1条路径来缩短路程。所以n-1轮之后还能缩短,说明存在负权回路。
          第二,Bellman-Ford算法的时间复杂度很显然是O(NM),但是其实经常不需要进行n-1轮就已经松弛完毕了,如果在进行到n-2轮时,dis数组和上一轮已经没有变化的话,其实我们已经可以结束掉算法了。所以我们可以通过增加flag来判断是否已经完成了松弛,从而结束掉算法。

          最后,其实在每一轮松弛操作结束后,就会有一些顶点已经求得其最短路径。此后这些顶点的最短路径的估计值就会一直保持不变,但是每一次都还要对其进行判断。这里浪费了时间,这就启发了我们每次仅对最短路估计值发生了变化的顶点的所有出边执行松弛操作。所以,其实我们还可以对该算法进行优化。

总结

       
上面就是这篇文章所有的内容了。主要是两个方面的内容,一个是邻接表的内容,另一个是另一种最短路径算法Bellman-Ford算法。然后 下一篇文章,我们就来学习一下Bellman-Ford算法的队列优化。然后还有这几种最短路径的算法的一个各方面的对比。
          所以,继续加油学习咯。。。。

    原文作者:Bellman - ford算法
    原文地址: https://blog.csdn.net/qqchenjian318/article/details/75208225
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞