第58天,拓扑排序和最短路径算法讲解!!💪(ง •_•)ง💪,编程语言:C++
目录
[题目:117. 软件构建 (kamacoder.com)](#题目:117. 软件构建 (kamacoder.com))
拓扑排序精讲
文档讲解:代码随想录拓扑排序精讲
拓扑排序的背景
拓扑排序,虽然称之为排序算法,但是确实经典的图论算法之一。
**拓扑排序的应用场景主要是解决存在依赖关系的问题。**例如大学排课,先上A课,才能上B课,上了B课才能上C课,上了A课才能上D课,等等一系列这样的依赖顺序。 问给规划出一条完整的上课顺序。又比如我们在做项目安装文件包的时候,经常发现复杂的文件依赖关系, A依赖B,B依赖C,B依赖D,C依赖E等等。
对于简单的依赖关系来说,其实我们一眼就可以看出来。但是对于存在成百上千条依赖关系,甚至存在循环依赖的情况,我们就需要依靠算法来进行解决了。
总结来说:给出一个有向图,把这个有向图转成线性的排序,就叫拓扑排序。
当然拓扑排序也要检测这个有向图是否有环,即存在循环依赖的情况,这种情况是不能做线性排序的。所以拓扑排序也是图论中判断有向无环图的常用方法。
接着我们从题目出发,进行具体分析:
题目:117. 软件构建 (kamacoder.com)
拓扑排序的思路
拓扑排序更重要的是一个解题的思路,而具体的实现算法,可能是广搜也可能是深搜。但只要能把有向无环图进行线性排序的算法都可以叫做拓扑排序。
我们这里主要讲解卡恩算法(BFS),底层是广度优先搜索的算法。其实现思路以示例为例:
首先我们应该找到的是出发点,显然我们肉眼可以看出出发点是0。但是如果没有图的情况下,我们如何确定出发点呢,这就需要依靠出发点的特征。**出发点最重要的特征就是入度为0,也就是没有别的点指向它(在题中也可以理解为,实现节点0不需要任何依赖)。**因此我们在拓扑排序的时候,应该优先找入度为0的节点,只有入度为0,它才是出发节点。
接着是拓扑排序的过程,其主要就两步:
- 找到入度为0的节点,加入结果集
- 将该结点从图中移除。
接着循环以上两步,直至把所有节点移除。(只要没有环,是能够不断找到入度为0的节点的)。
结果集的顺序,就是我们想要的拓扑排序的顺序。(结果集里顺序可能不唯一)
模拟过程
用本题的示例来进行模拟:
1.找到入度为0的节点,加入结果集:
2.将该结点在图中删除:
1.找到入度为0的节点,加入结果集
这里发现,节点1和节点2入度都是0,选哪个都可以。
1.找到入度为0的节点,加入结果集
节点2和节点3入度都为0,选哪个都行,这里选节点2。
2.将该节点从图中删除
最后3,和4随机选择,并进行入栈即可,答案不为1。
有环情况
如果这个图是有环的,那么在我们把0加入到结果集里面后,就不再有入度为0的节点了。此时结果集里面也就只有一个元素了。**因此如果我们发现结果集里面的元素个数,不等于图中节点的个数,我们就可以认定图中一定有有向环。**这也是拓扑排序判断有向环的方法。
写代码
解题思路理解起来很简单,但代码实现并不容易。
为了每次可以找到所有节点的入度信息,我们要在初始化的时候,把每个节点的入度和每个节点的依赖关系做好统计。
cpp
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的节点都放到队列里,依次去处理。
cpp
queue<int> que;
for (int i = 0; i < n; i++) {
// 入度为0的节点,可以作为开头,先加入队列
if (inDegree[i] == 0) que.push(i);
}
之后我们遍历入度为0的节点,将其放入结果集当中:
cpp
while (!que.empty()) {
int cur = que.front(); // 当前选中的节点
que.pop();
result.push_back(cur);
}
接着有一个非常关键的步骤,将入度为0的节点从图中删除。显然我们不仅仅是要把点从图中去掉这么简单,更重要的是要将与该点有关的边都删掉,而删掉这些边带来最直观的就是对应连接的点的入度会减一!
例如上图,把节点0去掉之后,1,2节点就从入度1变为了入度0。这样节点1和节点2才能作为下一轮选取的节点。
所以我们在代码实现的时候,本质是要将该节点作为出发点所连接的节点的入度减一,这样才好更具入度选择一下个节点,而不用真的在图里把这个节点删掉。这个步骤应该放在遍历队列取出节点的后面。
cpp
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]);
}
}
}
最后我们可以得到代码:
cpp
#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三部曲进行分析:
- 第一步,选源点到哪个节点近且该节点未被访问过
- 第二步,该最近节点被标记访问过
- 第三步,更新非访问节点到源点的距离(即更新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。
cpp
#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数组的变化是否符合预期。
代码:可以这么写代码:
cpp
#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数组更新的过程当中即可。
代码:
cpp
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数组的写法:
cpp
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数组的写法:
cpp
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算法是节点到源点的距离。