漫话最短路径(一)--迪杰斯特拉(dijkstra)算法

最短路径是图论中比较有实际意义的一个问题。它属于多项式可解的,也就是说有非常漂亮的算法。目前,单源最短路径比较好的算法有迪杰斯特拉算法(贪心算法,效率最高,局限:图中不可有负权边),贝尔曼-福特算法(可以判断能否求出最短路径并找出负权环,但速度比迪杰斯特拉和SPFA算法慢),SPFA算法(可以快速求出任何有向图的单源最短路径,并判断是否有负权环,但不能输出负权环)。多源最短路径有弗洛伊德算法(优点:代码简洁,缺点:效率低)。如果是几何最短路径还有A*算法

特别地,如果是有向无环图,那么任意两顶点间最短路径和最长路径一定存在(这是因为有向无环图有最优子结构性质),并且有O(E)(E为边的条数)的解法,那就是拓扑排序+动态规划(存储图需要用邻接表),或者记忆化搜索(存储图需要用逆邻接表)。

我们今天来谈迪杰斯特拉算法。

一、迪杰斯特拉算法介绍

算法要解决的问题,就是一个加权无向图或有向图中,从某一个顶点s出发,找到它到所有顶点的最短路径,比如北京地铁某个站的价目表就是这么制作的。我要求西二旗到所有站的票价,按照图的最短路径来求,到西直门15公里,5元,到北京站25公里,6元。

有向图也可使用,只不过存储图时有些区别。

二、算法的基本思想

算法的基本思想,就是把图的顶点集V分为两个,一个是H:已找到最短路径,一个是V-H:尚未找到最短路径。每次H更新完毕后,找V-H中的最小路径长度对应的(贪心思想)顶点,然后从它出发更新(dp思想)路径长度即可。

三、算法详解

基本思想有点难懂,没错,我们来一步一步分析它。
我们先给一个图,比如这样:
在这里插入图片描述
我们要求B点到所有其它顶点的最短路径。

我们开一个数组dis,长度等于顶点个数+1(因为编号从1开始,A~F分别为1~6),来表示当前已求出的从B点出发的路径长度。注意在算法结束之前,这个路径长度不一定最短。我们要在算法中对这个dis数组不断更新,算法结束后每个点的路径达到最短。

首先H集合中,一定有B顶点(废话,B到自己当然可达咯),而其它顶点在V-H中。dis[2]=0;然后先找到的路径长度当然是和B相连的了。A-6,C-2,D-5。所以dis[1]=6,dis[3]=2,dis[4]=5。其它的点从B不直接可达,所以dis的值设为无穷大。(在实际编程中,可以设为一个很大的数,比如INT_MAX,但要小心超int的问题)。所以dis数组为[6, 0, 2, 5, ∞, ∞]

接下来,就是见证奇迹的时刻 算法的关键,我们分为几步:
1、取V-H中(注意不能取H中的点,已经找到最短路径的点再重新计算是无意义的)的点k,使得dis[k]是这集合中最小的。这一步是贪心思想,每次取最小。
初始在V-H集合中,已求出的dis最小值对应顶点C,dis[3]=2。所以B–C之间的边一定是B和C间的最短路径。反之,假设B到C的最短路径是经由其它点(假设为 j)的,这个dis的最小值就一定不在C处,而是在 j 处取得。因此下一步应该把C加入集合H。

2、把点k加入H中(同时要从V-H中弹出)。假设顶点k和i之间的直接路径长度为 weight[i][k](如果没有直接路径,长度就是无穷大),对每个和点k直接相连的顶点 i,求出 dis[k] + weight[i][k]。如果这个数小于dis[ i ],则dis[i]= dis[k] + weight[i][k],否则不执行操作。这一步叫做更新,运用了动态规划的思想。下面说的“更新”都指这个步骤

刚才已经确认B、C间的dis为最短了。所以,下一步用当前的C来更新,也就是k=3。V-H中,和C连接的点有A、D、E。对于点A,dis[ 3 ] + weight[1][3] = 2+3 = 5 < dis[1] = 6,所以更新dis[1]=5。看图,从B经由C到达A经过的路线,确实比直接到C来的短。以此类推,对D,经由C或从B直接走的距离一样,都是5,dis不变。对于E,直接由B不可达,经由C到达E路程为2+4=6。dis数组变为 [5, 0, 2, 5, 6, ∞]

3、重复1、2步骤,直到所有的点都加入集合H。
下一步,集合V-H中还有点A、D、E、F,所以再找dis最小的顶点,A或D,我们选D(选A的情况很简单,就两个点,先略过)。加入H。V-H集合中,和D直接相连的有E、F,然后再更新E、F的dis…。

按照这个方法逐点计算,直到所有的顶点都求出来。最终的dis值是
[5, 0, 2, 5, 6, 8],请读者自行手算。

下面用一个图解来回顾一下算法的执行过程。不同颜色的边表示在算法的第k趟查找的边,颜色以这条边被第一次访问时的趟数为准。绿色第一趟,红色2,棕色3,蓝色4,紫色5。用相应颜色的圈表示第k趟运行的源点,括号中的数字表示第k趟被更新的距离(注意只有小于原数才更新,所以未更新的数值不表示出来)。
在这里插入图片描述
注意,即使图不连通(某点从源点不可达),算法也可以结束,只不过结束后仍然有顶点的dis值为无穷大。

有向图上的做法

很简单,因为邻接表是存储以每一点为起点的边,这刚好适用于迪杰斯特拉算法。只不过,建立邻接表时和无向图不同,无向图需要拆成方向相反的两条有向边,有向图只需要存一个方向。比如,无向边A–B,需要拆成A→B和B←A;但有向边A→B则不需要。

四、算法效率分析及优化

我们根据图的存储结构来分析一下算法效率。假设图有V个顶点,E条边。
如果采用邻接矩阵,则每次找最小dis时需要遍历一次顶点,总共需要找V次最小值,所以找最小dis的总时间复杂度为O(V2)。更新的过程,需要对每个顶点找相连的顶点,需要遍历邻接矩阵的一整列(或行,因为你不知道那些边是不存在的),更新V次,更新的时间复杂度为O(V2)。所以采用邻接矩阵存储的图,算法时间复杂度为O(V2)。

如果采用邻接表,则每次找dis时仍然需要遍历一次顶点,时间复杂度还是O(V2)。只不过,找相连接的顶点时,只需要找存在的边,所以最多每条边被访问2次,时间复杂度为O(E)。所以总的时间复杂度为O(V2+kE),k为常数。注意这不意味着它就比邻接矩阵慢,因为大O符号没有考虑常数。相反如果图比较稀疏,平均来说比邻接矩阵快。

邻接表已经优化了一步,也就是不访问不存在的边。还有一个求最小dis,能不能优化呢?当然能。每次取最小,这不正符合优先队列的某个功能吗?而且,优先队列的插入和弹出是O(logn)时间,取最值是O(1)时间。我们来分析这算法在优先队列下的效率。

这个优先队列应该存储的是集合V-H中的顶点,同时有一个数值记录当前的dis值。但是,这个dis值在优先队列中不可更新。我们可以采取一个折中的办法,那就是把更新后的dis值重新插入队列。因为更新后的值一定比原来的值小,所以后续步骤弹出这个顶点时也一定先弹出更新后最小的那个值。所以,虽然可能会重复弹出同一个顶点,但下一次弹出这个顶点时它一定已经求出了最小值,所以对再次弹出的顶点直接弃之不用即可。如何判断一个顶点是不是再次弹出的呢?很简单,开一个flag数组。一旦某点求出最短路径,就把它对应的flag置为true,当再次访问到一个顶点它的flag就为true,此时就要跳过这个顶点。这样虽然对算法的效率有些影响,但是记住优先队列的操作复杂度是对数量级,影响不会太大。我们采用STL里面的priority_queue来实现。

该分析复杂度了。先看时间。最坏的情况,每遍历一条边,就有一个顶点加入优先队列,所以优先队列中最多有E个元素,这样最多需要加入和弹出各E次,时间复杂度就是 O ( E l o g E ) O(ElogE) O(ElogE)。但是,如果是稀疏图,这种最坏情况是不可能出现的。
注意,好多博客上说的用优先队列优化的时间复杂度O((V+E)logV)是错误的。但是这个复杂度的实现方法的确存在,我们稍后再说。

再看空间复杂度。邻接表本身需要O(E)空间。然后还有邻接表的顶点指针、顶点的flag数组等等,所以需要 O ( k V + E ) O(kV+E) O(kV+E)空间,k为常数。

五、参考代码

给出优先队列实现的源代码:(仔细看注释)

/*
 * 输入:顶点个数v,边条数e,源点s,以及各条边的情况
 * 输出:s到所有顶点的最短路径长度
 */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unordered_map>
#include <queue>

using namespace std;

unordered_map<int, long long> graph[100001];
// 我们使用STL中的哈希映射表来存储邻接表,避免手撸链表,同时还能解决重边问题
long long len[100001];
bool flag[100001]; // 指示某一点是(true)否(false)已求出最短路径

inline void add_edge(int v1, int v2, long long weight)
{ // 这是添加一条有向边v1→v2
    if (!graph[v1].count(v2) || graph[v1][v2] > weight)
        graph[v1][v2] = weight;
        // 这一步就是解决重边,因为最短路径的话,只需要重边中最短的那一个
}

class vvv
{
public:
    int id;
    long long dis;

    vvv(int id, long long dis)
    {
        this->id = id;
        this->dis = dis;
    }

    bool operator<(const vvv& v) const
    {
        return dis > v.dis; // 重载运算符,用于构造小顶堆
    }
};

int main() {
    int v, e, s, i, v1, v2, cnt;
    long long w;
    while (scanf("%d %d %d", &v, &e, &s) != EOF)
    {
        while (e--)
        {
            scanf("%d %d %lld", &v1, &v2, &w);
            add_edge(v1, v2, w); // 这是无向图的解法,拆成两条有向边
            add_edge(v2, v1, w); // 有向图不需要add_edge(v2, v1, w)
        }
        priority_queue<vvv> qv;
        for (i = 1; i <= v; i++) len[i] = (long long) INT_MAX;
        cnt = 0;
        qv.push(vvv(s, 0));
        len[s] = 0;
        do
        {
            vvv tmp = qv.top();
            s = tmp.id;
            qv.pop();
            if (flag[s]) continue; // 已求出的,则忽略
            flag[s] = true;
            for (unordered_map<int, long long>::iterator it = graph[s].begin(); it != graph[s].end(); it++)
            {
                if (len[s] + it->second < len[it->first])
                { // 如果可更新
                    len[it->first] = len[s] + it->second; // 更新长度
                    qv.push(vvv(it->first, len[it->first])); // 入队
                }
            }
            cnt++;
        } while (cnt < v && !qv.empty());
        for (i = 1; i <= v; i++)
        {
            if (!flag[i]) printf("%d ", INT_MAX); // 不可达的情况,输出INT_MAX
            else printf("%lld ", len[i]);
            graph[i].clear();
        }
        printf("\n");
        memset(flag, false, sizeof(flag));
        while (!qv.empty()) qv.pop();
    }
    return 0;
}

六、补充说明

刚才说的 O ( ( V + E ) l o g V ) O((V+E)logV) O((V+E)logV)的实现方法,其实不是基于STL的优先队列。我们刚才看到,用优先队列实现时,可能会有重复加入同一个顶点的情况,这就造成一定的效率损失

优先队列是用二叉堆实现的。如果我们考虑自己维护一个小顶堆的话,就可以不加入更新的顶点,而是修改被更新的顶点dis值,然后调整堆。需要开一个索引数组,表示顶点在堆中的位置。这样,堆中的元素最多有V个,需要调整最多E次,弹出堆顶元素(弹出堆顶元素也是堆调整的过程)V次,所以时间复杂度为 O ( ( V + E ) l o g V ) O((V+E)logV) O((V+E)logV)

还有一种理论上更高效的堆,叫做斐波那契堆,这个堆可以保证平摊性能,查找、插入时间复杂度均为O(1),而修改和删除的时间复杂度为O(logn)。所以,如果用斐波那契堆来实现迪杰斯特拉算法,需要插入E次,弹出V次,时间复杂度可以降低到 O ( k E + V l o g V ) O(kE+VlogV) O(kE+VlogV)。但是,由于斐波那契堆的时间常数因子太大,往往在实际编程中不能运行得更快,只有理论价值。而且,斐波那契堆的操作过于复杂,编码难度高,不宜使用

七、回顾总结

OK。今天讲了迪杰斯特拉算法,回顾一下:

1、算法要解决的问题:无负权边的无向图(或有向图)的单源最短路径

2、算法的思想:贪心+动态规划

3、算法使用的数据结构:邻接表,优先队列

4、算法的时间复杂度:最坏 O ( E l o g E ) O(ElogE) O(ElogE)

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值