[数据结构拾遗]图的最短路径算法

前言

本专题旨在快速了解常见的数据结构和算法。html

在须要使用到相应算法时,可以帮助你回忆出经常使用的实现方案而且知晓其优缺点和适用环境。并不涉及十分具体的实现细节描述。java

图的最短路径算法

最短路径问题是图论研究中的一个经典算法问题,旨在寻找图(由结点和路径组成的)中两结点之间的最短路径。git

算法具体的形式包括:github

  • 肯定起点的最短路径问题:即已知起始结点,求最短路径的问题。适合使用Dijkstra算法。
  • 肯定终点的最短路径问题:与肯定起点的问题相反,该问题是已知终结结点,求最短路径的问题。在无向图中该问题与肯定起点的问题彻底等同,在有向图中该问题等同于把全部路径方向反转的肯定起点的问题。
  • 肯定起点终点的最短路径问题:即已知起点和终点,求两结点之间的最短路径。
  • 全局最短路径问题:求图中全部的最短路径。适合使用Floyd-Warshall算法。

主要介绍如下几种算法:算法

  • Dijkstra最短路算法(单源最短路)
  • Bellman–Ford算法(解决负权边问题)
  • SPFA算法(Bellman-Ford算法改进版本)
  • Floyd最短路算法(全局/多源最短路)

经常使用算法

Dijkstra最短路算法(单源最短路)

图片例子和史料来自:blog.51cto.com/ahalei/1387…编程

算法介绍:后端

迪科斯彻算法使用了广度优先搜索解决赋权有向图或者无向图的单源最短路径问题,算法最终获得一个最短路径树。该算法经常使用于路由算法或者做为其余图算法的一个子模块。数组

指定一个起始点(源点)到其他各个顶点的最短路径,也叫作“单源最短路径”。例如求下图中的1号顶点到二、三、四、五、6号顶点的最短路径。安全

使用二维数组e来存储顶点之间边的关系,初始值以下。bash

咱们还须要用一个一维数组dis来存储1号顶点到其他各个顶点的初始路程,以下。

将此时dis数组中的值称为最短路的“估计值”。

既然是求1号顶点到其他各个顶点的最短路程,那就先找一个离1号顶点最近的顶点。经过数组dis可知当前离1号顶点最近是2号顶点。当选择了2号顶点后,dis[2]的值就已经从“估计值”变为了“肯定值”,即1号顶点到2号顶点的最短路程就是当前dis[2]值。

既然选了2号顶点,接下来再来看2号顶点有哪些出边呢。有2->3和2->4这两条边。先讨论经过2->3这条边可否让1号顶点到3号顶点的路程变短。也就是说如今来比较dis[3]和dis[2]+e[2][3]的大小。其中dis[3]表示1号顶点到3号顶点的路程。dis[2]+e[2][3]中dis[2]表示1号顶点到2号顶点的路程,e[2][3]表示2->3这条边。因此dis[2]+e[2][3]就表示从1号顶点先到2号顶点,再经过2->3这条边,到达3号顶点的路程。

这个过程有个专业术语叫作“松弛”。松弛完毕以后dis数组为:

接下来,继续在剩下的三、四、5和6号顶点中, 选出离1号顶点最近的顶点4,变为肯定值,以此类推。

最终dis数组以下,这即是1号顶点到其他各个顶点的最短路径。

核心代码:

//Dijkstra算法核心语句
    for(i=1;i<=n-1;i++)
    {
        //找到离1号顶点最近的顶点
        min=inf;
        for(j=1;j<=n;j++)
        {
            if(book[j]==0 && dis[j]<min)
            {
                min=dis[j];
                u=j;
            }
        }
        book[u]=1;
        for(v=1;v<=n;v++)
        {
            if(e[u][v]<inf)
            {
                if(dis[v]>dis[u]+e[u][v])
                    dis[v]=dis[u]+e[u][v];
            }
        }
    }
复制代码

关于复杂度:

  • M:边的数量
  • N:节点数量

经过上面的代码咱们能够看出,咱们实现的Dijkstra最短路算法的时间复杂度是O(N^2)。其中每次找到离1号顶点最近的顶点的时间复杂度是O(N)

优化:

  • 这里咱们能够用“堆”(之后再说)来优化,使得这一部分的时间复杂度下降到O(logN)

  • 另外对于边数M少于N^2的稀疏图来讲(咱们把M远小于N^2的图称为稀疏图,而M相对较大的图称为稠密图),咱们能够用邻接表来代替邻接矩阵,使得整个时间复杂度优化到O((M+N)logN)

  • 请注意!在最坏的状况下M就是N^2,这样的话MlogN要比N^2还要大。可是大多数状况下并不会有那么多边,所以(M+N)logN要比N^2小不少。

Dijkstra思想总结:

dijkstra算法本质上算是贪心的思想,每次在剩余节点中找到离起点最近的节点放到队列中,并用来更新剩下的节点的距离,再将它标记上表示已经找到到它的最短路径,之后不用更新它了。这样作的缘由是到一个节点的最短路径必然会通过比它离起点更近的节点,而若是一个节点的当前距离值比任何剩余节点都小,那么当前的距离值必定是最小的。(剩余节点的距离值只能用当前剩余节点来更新,由于求出了最短路的节点以前已经更新过了)

dijkstra就是这样不断从剩余节点中拿出一个能够肯定最短路径的节点最终求得从起点到每一个节点的最短距离。

用邻接表代替邻接矩阵存储

参考:blog.51cto.com/ahalei/1391…

总结以下:

能够发现使用邻接表来存储图的时间空间复杂度是O(M),遍历每一条边的时间复杂度是也是O(M)。若是一个图是稀疏图的话,M要远小于N^2。所以稀疏图选用邻接表来存储要比邻接矩阵来存储要好不少。

Bellman–Ford算法(解决负权边问题)

思想:

bellman-ford算法进行n-1次更新(一次更新是指用全部节点进行一次松弛操做)来找到到全部节点的单源最短路。

bellman-ford算法和dijkstra其实有点类似,该算法可以保证每更新一次都能肯定一个节点的最短路,但与dijkstra不一样的是,并不知道是那个节点的最短路被肯定了,只是知道比上次多肯定一个,这样进行n-1次更新后全部节点的最短路都肯定了(源点的距离原本就是肯定的)。

如今来讲明为何每次更新都能多找到一个能肯定最短路的节点:

1.将全部节点分为两类:已知最短距离的节点和剩余节点。

2.这两类节点知足这样的性质:已知最短距离的节点的最短距离值都比剩余节点的最短路值小。(这一点也和dijkstra同样)

3.有了上面两点说明,易知到剩余节点的路径必定会通过已知节点

4.而从已知节点连到剩余节点的全部边中的最小的那个边,这条边所更新后的剩余节点就必定是肯定的最短距离,从而就多找到了一个能肯定最短距离的节点,不用知道它究竟是哪一个节点。
复制代码

bellman-ford的一个优点是能够用来判断是否存在负环,在不存在负环的状况下,进行了n-1次全部边的更新操做后每一个节点的最短距离都肯定了,再用全部边去更新一次不会改变结果。而若是存在负环,最后再更新一次会改变结果。缘由是以前是假定了起点的最短距离是肯定的而且是最短的,而又负环的状况下这个假设再也不成立。

Bellman-Ford 算法描述:

  • 建立源顶点 v 到图中全部顶点的距离的集合 distSet,为图中的全部顶点指定一个距离值,初始均为 Infinite,源顶点距离为 0;
  • 计算最短路径,执行 V - 1 次遍历;
    • 对于图中的每条边:若是起点 u 的距离 d 加上边的权值 w 小于终点 v 的距离 d,则更新终点 v 的距离值 d;
  • 检测图中是否有负权边造成了环,遍历图中的全部边,计算 u 至 v 的距离,若是对于 v 存在更小的距离,则说明存在环;

伪代码:

BELLMAN-FORD(G, w, s)
  INITIALIZE-SINGLE-SOURCE(G, s)
  for i  1 to |V[G]| - 1
       do for each edge (u, v)  E[G]
            do RELAX(u, v, w)
  for each edge (u, v)  E[G]
       do if d[v] > d[u] + w(u, v)
            then return FALSE
  return TRUE
复制代码

SPFA(Bellman-Ford算法改进版本)

SPFA算法是1994年西安交通大学段凡丁提出

spfa能够当作是bellman-ford的队列优化版本,正如在前面讲到的,bellman每一轮用全部边来进行松弛操做能够多肯定一个点的最短路径,可是用每次都把全部边拿来松弛太浪费了,不难发现,只有那些已经肯定了最短路径的点所连出去的边才是有效的,由于新肯定的点必定要先经过已知(最短路径的)节点。

因此咱们只须要把已知节点连出去的边用来松弛就好了,可是问题是咱们并不知道哪些点是已知节点,不过咱们能够放宽一下条件,找哪些多是已知节点的点,也就是以前松弛后更新的点,已知节点必然在这些点中。 因此spfa的作法就是把每次更新了的点放到队列中记录下来。

伪代码:

ProcedureSPFA;
Begin
    initialize-single-source(G,s);
    initialize-queue(Q);
    enqueue(Q,s);
    while not empty(Q) do begin
        u:=dequeue(Q);
        for each v∈adj[u] do begin
            tmp:=d[v];
            relax(u,v);
            if(tmp<>d[v])and(not v in Q)then enqueue(Q,v);
        end;
    end;
End; 
复制代码

如何看待 SPFA 算法已死这种说法?

来自:www.zhihu.com/question/29…

在非负边权的图中,随手卡 SPFA 已经是业界常识。在负边权的图中,不把 SPFA 卡到最慢就设定时限是很是不负责任的行为,而卡到最慢就意味着 SPFA 和传统 Bellman Ford 算法的时间效率相似,然后者的实现难度远低于前者。

Floyd最短路算法(全局/多源最短路)

图片例子和史料来自:www.cnblogs.com/ahalei/p/36…

此算法由Robert W. Floyd(罗伯特·弗洛伊德)于1962年发表在“Communications of the ACM”上。同年Stephen Warshall(史蒂芬·沃舍尔)也独立发表了这个算法。Robert W.Floyd这个牛人是朵奇葩,他本来在芝加哥大学读的文学,可是由于当时美国经济不太景气,找工做比较困难,无奈之下到西屋电气公司当了一名计算机操做员,在IBM650机房值夜班,并由此开始了他的计算机生涯。此外他还和J.W.J. Williams(威廉姆斯)于1964年共同发明了著名的堆排序算法HEAPSORT。

算法介绍:

上图中有4个城市8条公路,公路上的数字表示这条公路的长短。请注意这些公路是单向的。咱们如今须要求任意两个城市之间的最短路程,也就是求任意两个点之间的最短路径。这个问题这也被称为“多源最短路径”问题。

如今须要一个数据结构来存储图的信息,咱们仍然能够用一个4*4的矩阵(二维数组e)来存储。

核心代码:

for(k=1;k<=n;k++)
    for(i=1;i<=n;i++)
        for(j=1;j<=n;j++)
            if(e[i][j]>e[i][k]+e[k][j])
                 e[i][j]=e[i][k]+e[k][j];
复制代码

这段代码的基本思想就是:

最开始只容许通过1号顶点进行中转,接下来只容许通过1和2号顶点进行中转……容许通过1~n号全部顶点进行中转,求任意两点之间的最短路程。一旦发现比以前矩阵内存储的距离短,就用它覆盖原来保存的距离。

用一句话归纳就是:从i号顶点到j号顶点只通过前k号点的最短路程。

另外须要注意的是:Floyd-Warshall算法不能解决带有“负权回路”(或者叫“负权环”)的图,由于带有“负权回路”的图没有最短路。例以下面这个图就不存在1号顶点到3号顶点的最短路径。由于1->2->3->1->2->3->…->1->2->3这样路径中,每绕一次1->-2>3这样的环,最短路就会减小1,永远找不到最短路。其实若是一个图中带有“负权回路”那么这个图则没有最短路。

代码实现:

#include <stdio.h>
int main()
{
    int e[10][10],k,i,j,n,m,t1,t2,t3;
    int inf=99999999; //用inf(infinity的缩写)存储一个咱们认为的正无穷值
    //读入n和m,n表示顶点个数,m表示边的条数
    scanf("%d %d",&n,&m);
    
    //初始化
    for(i=1;i<=n;i++)
        for(j=1;j<=n;j++)
            if(i==j) e[i][j]=0;  
              else e[i][j]=inf;

    //读入边
    for(i=1;i<=m;i++)
    {
        scanf("%d %d %d",&t1,&t2,&t3);
        e[t1][t2]=t3;
    }
    
    //Floyd-Warshall算法核心语句
    for(k=1;k<=n;k++)
        for(i=1;i<=n;i++)
            for(j=1;j<=n;j++)
                if(e[i][j]>e[i][k]+e[k][j] ) 
                    e[i][j]=e[i][k]+e[k][j];
    
    //输出最终的结果
    for(i=1;i<=n;i++)
    {
     for(j=1;j<=n;j++)
        {
            printf("%10d",e[i][j]);
        }
        printf("\n");
    }
    
    return 0;
}
复制代码

总结

关于BellmanFord和SPFA再说两句

来自:www.zhihu.com/question/27…

SPFA只是BellmanFord的一种优化,其复杂度是O(kE),SPFA的提出者认为k很小,能够看做是常数,但事实上这一说法十分不严谨(原论文的“证实”居然是靠编程验证,甚至没有说明编程验证使用的数据是如何生成的),如其余答案所说的,在一些数据中,这个k可能会很大。而Dijkstra算法在使用斐波那契堆优化的状况下复杂度是O(E+VlogV)。SPFA,或者说BellmanFord及其各类优化(姜碧野的国家集训队论文就提到了一种栈的优化)的优点更主要体如今可以处理负权和判断负环吧(BellmanFord能够找到负环,但SPFA只能判断负环是否存在)。

补充算法

还有一些最短路算法的优化或者引伸方法,感兴趣能够谷歌一下:

  • Johnson算法
  • Bi-Direction BFS算法
  • ...

参考

关注我

我目前是一名后端开发工程师。技术领域主要关注后端开发,数据安全,爬虫,5G物联网等方向。

微信:yangzd1102

Github:@qqxx6661

我的博客:

原创博客主要内容

  • Java知识点复习全手册
  • Leetcode算法题解析
  • 剑指offer算法题解析
  • SpringCloud菜鸟入门实战系列
  • SpringBoot菜鸟入门实战系列
  • Python爬虫相关技术文章
  • 后端开发相关技术文章

我的公众号:Rude3Knife

我的公众号:Rude3Knife

若是文章对你有帮助,不妨收藏起来并转发给您的朋友们~

相关文章
相关标签/搜索