Studying-代码随想录训练营day58| 拓扑排序精讲、dijkstra(朴素版)精讲

第58天,拓扑排序和最短路径算法讲解!!💪(ง •_•)ง💪,编程语言:C++

目录

拓扑排序精讲

拓扑排序的背景 

题目:117. 软件构建 (kamacoder.com)

拓扑排序的思路 

模拟过程

有环情况 

写代码

dijkstra(朴素版)精讲

模拟过程

debug方法

如何求路径 

dijkstra与prim算法的区别 

总结 


拓扑排序精讲

文档讲解:代码随想录拓扑排序精讲

拓扑排序的背景 

拓扑排序,虽然称之为排序算法,但是确实经典的图论算法之一。

拓扑排序的应用场景主要是解决存在依赖关系的问题。例如大学排课,先上A课,才能上B课,上了B课才能上C课,上了A课才能上D课,等等一系列这样的依赖顺序。 问给规划出一条完整的上课顺序。又比如我们在做项目安装文件包的时候,经常发现复杂的文件依赖关系, A依赖B,B依赖C,B依赖D,C依赖E等等。

对于简单的依赖关系来说,其实我们一眼就可以看出来。但是对于存在成百上千条依赖关系,甚至存在循环依赖的情况,我们就需要依靠算法来进行解决了。

总结来说:给出一个有向图,把这个有向图转成线性的排序,就叫拓扑排序。

当然拓扑排序也要检测这个有向图是否有环,即存在循环依赖的情况,这种情况是不能做线性排序的。所以拓扑排序也是图论中判断有向无环图的常用方法。

接着我们从题目出发,进行具体分析:

题目:117. 软件构建 (kamacoder.com)

拓扑排序的思路 

拓扑排序更重要的是一个解题的思路,而具体的实现算法,可能是广搜也可能是深搜。但只要能把有向无环图进行线性排序的算法都可以叫做拓扑排序。

我们这里主要讲解卡恩算法(BFS),底层是广度优先搜索的算法。其实现思路以示例为例:

首先我们应该找到的是出发点,显然我们肉眼可以看出出发点是0。但是如果没有图的情况下,我们如何确定出发点呢,这就需要依靠出发点的特征。出发点最重要的特征就是入度为0,也就是没有别的点指向它(在题中也可以理解为,实现节点0不需要任何依赖)。因此我们在拓扑排序的时候,应该优先找入度为0的节点,只有入度为0,它才是出发节点。

接着是拓扑排序的过程,其主要就两步:

  1. 找到入度为0的节点,加入结果集
  2. 将该结点从图中移除。

接着循环以上两步,直至把所有节点移除。(只要没有环,是能够不断找到入度为0的节点的)。

结果集的顺序,就是我们想要的拓扑排序的顺序。(结果集里顺序可能不唯一)

模拟过程

用本题的示例来进行模拟:

1.找到入度为0的节点,加入结果集:

2.将该结点在图中删除:


1.找到入度为0的节点,加入结果集 

这里发现,节点1和节点2入度都是0,选哪个都可以。


1.找到入度为0的节点,加入结果集

节点2和节点3入度都为0,选哪个都行,这里选节点2。

2.将该节点从图中删除

最后3,和4随机选择,并进行入栈即可,答案不为1。

有环情况 

如果这个图是有环的,那么在我们把0加入到结果集里面后,就不再有入度为0的节点了。此时结果集里面也就只有一个元素了。因此如果我们发现结果集里面的元素个数,不等于图中节点的个数,我们就可以认定图中一定有有向环。 这也是拓扑排序判断有向环的方法。

写代码

解题思路理解起来很简单,但代码实现并不容易。

为了每次可以找到所有节点的入度信息,我们要在初始化的时候,把每个节点的入度和每个节点的依赖关系做好统计。

cin >> n >> m;
vector<int> inDegree(n, 0); // 记录每个文件的入度
vector<int> result; // 记录结果
unordered_map<int, vector<int>> umap; // 记录文件依赖关系,这也是邻接表的一种写法

while (m--) {
    cin >> s >> t;
    inDegree[t]++; // t的入度加一
    umap[s].push_back(t); // 记录s指向哪些文件,s->t
}

在遍历入度为0的节点的时候,我们需要用一个队列来存放,因为入度为0的节点不止一个,可能很多节点入度都为0,需要将这些入度为0的节点都放到队列里,依次去处理。

queue<int> que;
for (int i = 0; i < n; i++) {
    // 入度为0的节点,可以作为开头,先加入队列
    if (inDegree[i] == 0) que.push(i);
}

之后我们遍历入度为0的节点,将其放入结果集当中:

while (!que.empty()) {
    int  cur = que.front(); // 当前选中的节点
    que.pop();
    result.push_back(cur);
}

接着有一个非常关键的步骤,将入度为0的节点从图中删除。显然我们不仅仅是要把点从图中去掉这么简单,更重要的是要将与该点有关的边都删掉,而删掉这些边带来最直观的就是对应连接的点的入度会减一!

例如上图,把节点0去掉之后,1,2节点就从入度1变为了入度0。这样节点1和节点2才能作为下一轮选取的节点。

所以我们在代码实现的时候,本质是要将该节点作为出发点所连接的节点的入度减一,这样才好更具入度选择一下个节点,而不用真的在图里把这个节点删掉。这个步骤应该放在遍历队列取出节点的后面。

while (!que.empty()) {
    int  cur = que.front(); // 当前选中的节点
    que.pop();
    result.push_back(cur);
    // 将该节点从图中移除 
    vector<int> files = umap[cur]; //获取cur指向的节点
    if (files.size()) { // 如果cur有指向的节点
        for (int i = 0; i < files.size(); i++) { // 遍历cur指向的节点
            inDegree[files[i]] --; // cur指向的节点入度都做减一操作
            // 如果指向的节点减一之后,入度为0,说明是我们要选取的下一个节点,放入队列。
            // 这是一个不断降低,不断增加入度为0的节点的过程
            if(inDegree[files[i]] == 0) que.push(files[i]); 
        }
    }
}

最后我们可以得到代码:

#include <iostream>
#include <vector>
#include <unordered_map>
#include<queue>
using namespace std;

int main() {
    int n, m;
    cin >> n >> m;
    
    int s, t;
    vector<int> inDegree(n, 0); //保存节点入度,节点从0开始
    unordered_map<int, vector<int>> umap; //使用邻接表的方式,保存依赖关系
    while(m--) {
        cin >> s >> t;
        inDegree[t]++; //是s->t;
        umap[s].push_back(t); //保存路径
    }
    vector<int> result; //保存结果
    //采用广度优先搜索的方法BFS
    queue<int> que;
    //初始化队列,把入度为0的节点,加入到队列当中
    for(int i = 0; i < n; i++) {
        if(inDegree[i] == 0) {
            que.push(i);
        }
    }
    
    while(!que.empty()) {
        //1.取出入度为0的节点,加入结果集中
        int cur = que.front();
        que.pop();
        result.push_back(cur);
        //2.将该点,以及该点的边从图中去掉
        vector<int> files = umap[cur]; //取出cur的连接对象
        for(int i = 0; i < files.size(); i++) {
            inDegree[files[i]]--; //将边删除
            if(inDegree[files[i]] == 0) {
                que.push(files[i]);
            }
        }
    }
    //判断是否有环,就看结果集的个数是不是n
    if(result.size() != n) {
        cout << -1 << endl;
    }
    else {
        for(int i = 0; i < n - 1; i++) {
            cout << result[i] << " ";
        }
        cout << result[n - 1]; //最后一个元素单独打印不留下空格
    }
    return 0;
}

dijkstra(朴素版)精讲

文档讲解:代码随想录dijkstra(朴素版)精讲

题目:47. 参加科学大会(第六期模拟笔试) (kamacoder.com)

本题是标准的求最短路径的问题,理论上来说,我们可以找到所有从起点到终点的路径,然后找到时间花费最短的路径即可,但这样时间复杂度很高。因此我们学习一种求解最短路径的算法Dijkstra算法(迪杰斯特拉算法)。

dijkstra算法的功能:在有权图,且权值非负数,求从起点到其他节点的最短路径。

需要注意两点:

  • dijkstra算法可以同时求起点到所有节点的最短路径
  • 权值不能为负数

以题目为例进行分析:

图中标绿线的部分就是最短路径。事实上dijkstra算法和prim算法的思路非常接近。dijkstra算法同样是贪心的思路,不断寻找距离源点最近的没有访问过的节点。 

我们同样从dijkstra三部曲进行分析:

  1. 第一步,选源点到哪个节点近且该节点未被访问过
  2. 第二步,该最近节点被标记访问过
  3. 第三步,更新非访问节点到源点的距离(即更新minDist数组)

可以发现确实和prim算法非常像,且都有一个同样的数组minDist,这个数组是用来记录每一个节点距离源点的最小距离(源点也即出发点),这是dijkstra算法的核心所在。

接下来我们进行dijkstra算法的解题过程,我们首先讲的是朴素版的dijkstra算法

模拟过程

初始化:首先我们需要初始化两个数组,一个minDist数组,一个visited数组。

minDist数组初始化为int的最大值,因为它记录的是所有节点到源点的最短路径,因此初始化为最大值,才便于后续出现最短路径的时候,进行更新。

visited数组初始化为false,表示是否访问过。

接着我们需要把原点的距离设为0,意味着原点到自己的距离为0,minDist[1] = 0(我们默认节点是从1开始的,节点0没有意义,不做处理)

然后我们进行dijkstra三部曲:

1、选源点到哪个节点最近且该节点未被访问过:当前应选取节点1,距离为0,且未被访问过。

2、标记该节点为访问过:把节点1标记为访问过,visited[1] = true;

3、更新非访问节点到源点的距离(即更新minDist数组):依据当前遍历的节点进行更新。

此次更新了两个距离:

  • 源点到节点2的最短距离为1,小于原minDist[2]的数值max,更新minDist[2] = 1
  • 源点到节点3的最短距离为4,小于原minDist[3]的数值max,更新minDist[4] = 4

这里我们要注意,不能少了比较原先数值的步骤。因为这个值是会发生改变的,它表示源点到当前点的距离,随着我们遍历的过程中,可能会出现距离更小的路径进行覆盖。

然后进行下一轮dijkstra三部曲:

1、选源点到哪个节点近且该节点未被访问过:未访问过的节点中,源点到节点2距离最近,选节点2。

2、该最近节点被标记访问过:节点2被标记访问过

3、更新非访问节点到源点的距离(即更新minDist数组):依据当前遍历的节点进行更新。

这个过程可以理解为 源点(节点1)通过 已经计算过的节点(节点2)可以链接到的节点有节点3,节点4和节点6。这个地方我们对节点3的值进行了覆盖,也是这个原因,因为还有一条路径是能够到达节点3的。

  • 源点到节点6的最短距离为minDist[2] + 4 = 5,小于原minDist[6]的数值max,更新minDist[6] = 5
  • 源点到节点3的最短距离为minDist[2] + 2 = 3,小于原minDist[3]的数值4,更新minDist[3] = 3
  • 源点到节点4的最短距离为minDist[2] + 5 = 6,小于原minDist[4]的数值max,更新minDist[4] = 6

注意我们是依靠节点2的距离来进行更新的,因为节点2的距离我们已经确定了。

最后不断的重复上述过程:

将所有节点都加入,在本题中我们最后加入节点7,就不用更新minDist数组了,因为所有的visited都标记为true了(节点0不作考虑)。

最后我们得到答案12。

#include <iostream>
#include <vector>
#include <climits> //包含INT_MAX等类型最大值最小值
using namespace std;
int main() {
    int n, m, p1, p2, val;
    cin >> n >> m;
    vector<vector<int>> grid(n + 1, vector<int>(n + 1, INT_MAX));
    for(int i = 0; i < m; i++){
        cin >> p1 >> p2 >> val;
        grid[p1][p2] = val;
    }
    int start = 1;
    int end = n;
    // 存储从源点到每个节点的最短距离
    std::vector<int> minDist(n + 1, INT_MAX);
    // 记录顶点是否被访问过
    std::vector<bool> visited(n + 1, false);
    minDist[start] = 0;  // 起始点到自身的距离为0

    for (int i = 1; i <= n; i++) { // 遍历所有节点

        int minVal = INT_MAX;
        int cur = 1;
        // 1、选距离源点最近且未访问过的节点
        for (int v = 1; v <= n; ++v) {
            if (!visited[v] && minDist[v] < minVal) {
                minVal = minDist[v];
                cur = v;
            }
        }
        visited[cur] = true;  // 2、标记该节点已被访问
        // 3、第三步,更新非访问节点到源点的距离(即更新minDist数组)
        for (int v = 1; v <= n; v++) {
            if (!visited[v] && grid[cur][v] != INT_MAX && minDist[cur] + grid[cur][v] < minDist[v]) {
                minDist[v] = minDist[cur] + grid[cur][v];
            }
        }
    }
    if (minDist[end] == INT_MAX) cout << -1 << endl; // 不能到达终点
    else cout << minDist[end] << endl; // 到达终点最短路径
    return 0;
}

debug方法

一般程序debug的方法就是打印日志,对于本题来说就是打印minDist数组,来查看哪里出了问题,minDist数组的变化是否符合预期。

代码:可以这么写代码:

#include <iostream>
#include <vector>
#include <climits>
using namespace std;
int main() {
    int n, m, p1, p2, val;
    cin >> n >> m;
    vector<vector<int>> grid(n + 1, vector<int>(n + 1, INT_MAX));
    for(int i = 0; i < m; i++){
        cin >> p1 >> p2 >> val;
        grid[p1][p2] = val;
    }
    int start = 1;
    int end = n;
    std::vector<int> minDist(n + 1, INT_MAX);
    std::vector<bool> visited(n + 1, false);
    minDist[start] = 0;
    for (int i = 1; i <= n; i++) {
        int minVal = INT_MAX;
        int cur = 1;
        for (int v = 1; v <= n; ++v) {
            if (!visited[v] && minDist[v] < minVal) {
                minVal = minDist[v];
                cur = v;
            }
        }
        visited[cur] = true;
        for (int v = 1; v <= n; v++) {
            if (!visited[v] && grid[cur][v] != INT_MAX && minDist[cur] + grid[cur][v] < minDist[v]) {
                minDist[v] = minDist[cur] + grid[cur][v];
            }
        }
        // 打印日志:
        cout << "select:" << cur << endl;
        for (int v = 1; v <= n; v++) cout <<  v << ":" << minDist[v] << " ";
        cout << endl << endl;;
    }
    if (minDist[end] == INT_MAX) cout << -1 << endl;
    else cout << minDist[end] << endl;
    return 0;
}

//运行结果:
select:1
1:0 2:1 3:4 4:2147483647 5:2147483647 6:2147483647 7:2147483647

select:2
1:0 2:1 3:3 4:6 5:2147483647 6:5 7:2147483647

select:3
1:0 2:1 3:3 4:5 5:2147483647 6:5 7:2147483647

select:4
1:0 2:1 3:3 4:5 5:8 6:5 7:2147483647

select:6
1:0 2:1 3:3 4:5 5:8 6:5 7:14

select:5
1:0 2:1 3:3 4:5 5:8 6:5 7:12

select:7
1:0 2:1 3:3 4:5 5:8 6:5 7:12

12

如何求路径 

本题打印路径的方式和prim算法中的方式是一样的,同样是加入在minDist数组更新的过程当中即可。

代码:

vector<int> parent(n + 1, -1);

for (int v = 1; v <= n; v++) {
    if (!visited[v] && grid[cur][v] != INT_MAX && minDist[cur] + grid[cur][v] < minDist[v]) {
        minDist[v] = minDist[cur] + grid[cur][v];
        parent[v] = cur; // 记录边
    }
}

dijkstra与prim算法的区别 

可以发现dijkstra算法和prim算法十分相似,唯一的区别在于三部曲的第三步,也就是更新minDist数组。

prim是求 非访问节点到最小生成树的最小距离,而 dijkstra是求非访问节点到源点的最小距离

prim 更新 minDist数组的写法:

for (int j = 1; j <= v; j++) {
    if (!isInTree[j] && grid[cur][j] < minDist[j]) {
        minDist[j] = grid[cur][j];
    }
}

因为 minDist表示节点到最小生成树的最小距离,所以 新节点cur的加入,只需要使用grid[cur][j] ,grid[cur][j] 就表示 cur 加入生成树后,生成树到节点j 的距离。

dijkstra 更新 minDist数组的写法: 

for (int v = 1; v <= n; v++) {
    if (!visited[v] && grid[cur][v] != INT_MAX && minDist[cur] + grid[cur][v] < minDist[v]) {
        minDist[v] = minDist[cur] + grid[cur][v];
    }
}

因为 minDist表示 节点到源点的最小距离,所以新节点cur的加入,需要使用源点到cur的距离 (minDist[cur]) + cur 到节点v的距离(grid[cur][v]) 才是 源点到节点v的距离。

由于这个特点,prim算法是可以有负权值的,因为prim算法只需要将节点以最小权值和链接到一起,不涉及到单一路径。但是dijkstra算法是不可以的。


总结 

今天又了解了两个算法:拓扑排序算法和dijkstra(朴素版)算法

拓扑排序算法解决的是:有向无环图转换为线性排序的方法,它还能用于解决判断有向图是否有环的情况。

dijkstra算法则是解决:最短路径的问题,要理解它的核心minDist数组,同时该算法是不能够解决存在负权值问题的。还要理解它与prim算法的区别,在于minDist数组的更新的不同之处,关键在于prim算法要的是节点到生成树的最小距离,而dijkstra算法是节点到源点的距离。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值