Dijkstra算法求解单源最短路径问题

一 前言

  上一次的文章更新还是在5月份,现在都快8月份了,整整三个月没有记录学习的点点滴滴。哎,主要是现在自己面临读研的一系列事情,到现在面试结果也没有出来,如果没中,那真的要给老师,给招生办跪下了,呜~~~~~~, 小菜鸡在大佬面前瑟瑟发抖,给我一个读书的机会吧!!!
   本次记录的是最短路径算法中的单源最短路经Dijkstra算法,也就是确定图中某一个点,求出图中其他所有点到该点的最短距离分别是多少

二 Dijkstra 算法讲解

1. 贪心算法的证明

Dijkstra算法的基本思想是贪心思想,关于贪心算法求解问题时,我们需要进行证明两点:

  1. 最优子结构
  2. 局部最优性(局部最优解可以得到全局最优解)
    关于算法的证明,小编就不多说了,这里直接分享一篇博客:迪杰斯特拉(Dijkstra)算法最通俗易懂的讲解 ,大家要是觉得解释的不是很好,最好还是去算法书上看看 。
2. 算法实现说明

在实现过程中,重要的就是我们引入了两个数组:dis数组和vis数组
dis数组:用来记录每个点到原点的最短路经。
vis数组:用来标记,某个点是否已经扩展过。

我们要做的就是在所有未扩展的点中,选出一个dis值最小的点进行松弛操作,这样n - 1次操作之后,即可知道原点到所有其他点的最短距离。
初始化操作
算法核心操作:
  现在假设我们已知结点A到源点S的最短距离为X,也存在从结点A指向结点B的有向边,该边的权值为val,显然结点B到原点会有一个距离,我们假设为Y,若X + val < Y,换句话说就是,我们现在找到了源点S到结点B的更短的一个路径,此时我们更新dis数组中到结点B到源点的距离值。

现在我们模拟一遍算法的执行流程

  1. 首先初始化dis数组和vis数组,dis数组中源点的值为0,剩余点的dis值为极大值;vis数组中均为0 ,其中0表示没有扩展,1表示该点已被扩展。
  2. 遍历dis数组,找到值最小,且没有扩展(vis值为0)的点,此时为1号结点。观察图可知,1号结点可以到达2, 3号结点,所以计算可知,结点2到源点的距离为6 + 0 = 6 < dis[2],结点3到源点的距离为0 + 1 = 1 < dis[3], 所以2, 3号结点均可以被更新。至此1号结点已经被扩展,标记vis[1] = 1;
    第一轮
  3. 遍历dis数组,找到值最小,且没有扩展(vis值为0)的点,此时为3号结点。观察图可知,3号结点可以到达2,4,6号结点,计算可知:2号结点到源点的距离为3 + dis[3] = 3 + 1 = 4(连接3号结点和2号结点的边的权值为3,3号结点到源点的最小距离为1)这个新的距离比原来2号结点到源点的距离6要小,所以我们需要进行距离的更新。同理,我们更新4和6号结点的距离。
    第二轮
  4. 按照之前的规则,我们相似操作,最终,我们可以得到如下的结果
    第五轮
3. 初版Dijkstra算法代码

我们以洛谷第3371题为例子:P3371 【模板】单源最短路径(弱化版)
解题代码如下:

#include <iostream>
#include <cstdio>
#include <cstdlib>
#include <queue>
#include <stack>
#include <algorithm>
#include <string>
#include <map>
#include <set>
#include <vector>
#include <cstring>
#include <cmath>
using namespace std;

#define MAX_N 10000
#define MAX_M 500000
#define INF 0x3f3f3f3f

struct edge {
    int to, val, next;
} edg[MAX_M + 5];

int head[MAX_N + 5], vis[MAX_N + 5], dis[MAX_N + 5];
int edg_cnt;

// 此处采用链式前向星存储有向图
void add_edge(int a, int b, int c) {
    edg[edg_cnt].to = b;
    edg[edg_cnt].val = c;
    edg[edg_cnt].next = head[a];
    head[a] = edg_cnt++;
}

void dijkstra(int n, int s) {	
	// 源点到自身的距离为0
    dis[s] = 0;
    for (int i = 1; i < n; i++) {
        int ind = -1;
        // 遍历dis数组,找到dis值最小的结点
        for (int j = 1; j <= n; j++) {
            if (vis[j]) continue;
            if (ind == -1 || dis[j] < dis[ind]) ind = j;
        }
        // 对于找到的结点,更新与该点的连的点到源点的值
        for (int j = head[ind]; j != -1; j = edg[j].next) {
            if (dis[edg[j].to] > dis[ind] + edg[j].val) {
                dis[edg[j].to] = dis[ind] + edg[j].val;
            }
        }
        // 标注该结点已扩展
        vis[ind]++;
    }
}

int main() {
    memset(head, -1, sizeof(head));
    memset(dis, 0x3f, sizeof(dis));
    int n, m, s;
    cin >> n >> m >> s;
    for (int i = 0; i < m; i++) {
        int a, b, c;
        cin >> a >> b >> c;
        add_edge(a, b, c);
    }
    dijkstra(n, s);
    for (int i = 1; i <= n; i++) {
    	// 采用或运算,当i == 1时,此时表达式为真,cout << " "将不会执行
        i == 1 || cout << " ";
        if (dis[i] == INF) {
            cout << int(pow(2, 31) - 1);
        } else {
            cout << dis[i];
        } 
    } 
    cout << endl;
    return 0;
}

  观察代码可知:算法的时间复杂度为O(n^2)。因为我们要扩展n - 1次,同时每一次扩展,我们都要遍历一遍dis数组,找到dis值最小的结点 (后续遍历以该结点为起点的边所连接的另一端的结点)
   虽然使用上述代码可以Accept上面的这道题,但是对于:P4779 【模板】单源最短路径(标准版),由于数据量较大,所有的测试用例都会超时,所以我们需要对算法进行优化改进,降低算法的时间复杂度。

三 时间复杂度优化

1. 优化策略

  第二部分我们说到,O(n^2)的算法时间复杂度过大,故需要进行优化。
  其实很简单,我们可以借助 优先队列 来优化我们的算法,首先最外面的for循环是不可必避免的,所以我们只能将遍历dis数组这一操作进行优化,我们在优先队列中存储这样的一个二元组<now, dis> 分别表示:当前是几号结点,以及该结点到源点的距离 ,对于优先队列中的元素,我们根据dis的值进行优先级排比,dis越小的二元组在优先队列的头部,这样,我们每次弹出的结点就是距离源点最近的结点,我们也就不再需要线性的遍历dis数组,所以从线性的遍历dis数组O(N),变成了使用有优先队列O(logN) 这里插一句话,优先队列的底层实现是采用二叉树。
  所以Dijkstra算法的时间复杂度由之前的O(N^2) 优化为 O(N * logN)

2. 优化后的代码

  这里以洛谷4479题为例,题目相关链接上面有。

#include <iostream>
#include <cstdio>
#include <cstdlib>
#include <queue>
#include <stack>
#include <algorithm>
#include <string>
#include <map>
#include <cstring>
#include <set>
#include <vector>
using namespace std;

#define MAX_N 100000
#define MAX_M 200000

/*关于Data类的说明
	now 表示结点编号,dis表示该结点到源点的距离
	重载 < ,dis越小,优先级越高
*/
struct Data {
    int now, dis;
    Data(int x, int y) : now(x), dis(y) {}
    bool operator< (const Data &b) const {
        return this->dis > b.dis;
    }
};

struct edge {
    int to, val, next;
} edg[MAX_M + 5];

int head[MAX_N + 5], vis[MAX_N + 5], dis[MAX_N + 5];
int edg_cnt;

void add_edge(int a, int b, int c) {
    edg[edg_cnt].to = b;
    edg[edg_cnt].val = c;
    edg[edg_cnt].next = head[a];
    head[a] = edg_cnt++;
}

void dijkstra(int n, int s) {
    dis[s] = 0;
    priority_queue<Data> que;
    que.push(Data(s, 0));
    for (int i = 1; i < n && !que.empty(); i++) {
        int ind = que.top().now;
        que.pop();
        while (vis[ind] && !que.empty()) {
            ind = que.top().now;
            que.pop();
        }
        for (int j = head[ind]; j != -1; j = edg[j].next) {
            if (dis[edg[j].to] > dis[ind] + edg[j].val) {
                dis[edg[j].to] = dis[ind] + edg[j].val;
                // vis[edg[j].to] = 0;
                que.push(Data(edg[j].to, dis[edg[j].to]));
            }
        }
        vis[ind]++;
    }
}

int main() {
    memset(head, -1, sizeof(head));
    memset(dis, 0x3f, sizeof(dis));
    int n, m, s;
    cin >> n >> m >> s;
    for (int i = 0; i < m; i++) {
        int a, b, c;
        cin >> a >> b >> c;
        add_edge(a, b, c);
    }
    dijkstra(n, s);
    for (int i = 1; i <= n; i++) {
        i == 1 || cout << " ";
        cout << dis[i];
    }
    cout << endl;
    return 0;
}

  大家肯能会询问,在使用优先队列时,为什么获取队首元素之后,还要使用 while 循环,不断进行弹出操作呢?
  这是因为此次弹出的点,有可能是之前已经扩展过的点,所以我们舍弃,直接看队列中的下一个结点。
  我们现在假设,之前某一时刻以X结点为边的起点进行扩展,假设扩展了2号结点,对应边的权值依次为2,所以向优先队列中压入了<2, 2>,而之后又有一次,以Y结点为边的起点进行扩展,假设也扩展了2号结点,对应边的权值依次为1,所以向优先队列中压入了<2, 1>,显然队列中<2, 1>排在<2, 2>的前面,然后我们假设队列中的其他二元组<a, b>的b都比2大,即优先队列中的前两项为<2, 1>, <2, 2>,弹出<2, 1>后,我们将2号结点标记为1,也就是已经扩展过了的,所以之后的<2, 2>就是没有意义的,我们可以直接从队列中弹出,不做其他操作。

四 结语

  图论是数据结构和算法中难度较大的一部分,自己虽然当年数据结构考试拿了一个不错的分数,但是这并不代表自己可以熟练编程实现代码,所以,加油吧!路漫漫其修远兮,吾将上下而求索!
  另外,明天好像就出面试结果了。拜托拜托,无量天尊、如来佛祖、还有上帝、真主,保佑我过呀!Wu~~~~~

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值