5.图论.题目3
题目
15.软件构造/BFS拓扑搜索
题目连接
概括来说,给出一个 有向图,把这个有向图转成线性的排序 就叫拓扑排序。当然拓扑排序也要检测这个有向图 是否有环,即存在循环依赖的情况,因为这种情况是不能做线性排序的。
实现拓扑排序的算法有两种:卡恩算法(BFS)和DFS;这题要使用卡恩算法(BFS)实现,这就类型广度优先搜索。
BFS算法代码:
#include <iostream>
#include "vector"
#include "queue"
#include "unordered_map"
using namespace std;
int main(){
int m,n,s,t;
cin>> n>> m;
vector<int> indegree(n,0);
unordered_map<int, vector<int>> graph;
for(int i=0;i<m;i++){
cin>> s>> t;
graph[s].push_back(t);
indegree[t]++;
}
queue<int> q;
for(int i=0; i<n; i++){
if(indegree[i]==0) q.push(i);
}
vector<int> res;
// BFS
while (!q.empty()){
int cur = q.front();
q.pop();
res.push_back(cur);
vector<int> files = graph[cur];
if(!files.empty()){
// cur有被依赖的文件
for(int file : files) {
indegree[file]--;
if (indegree[file] == 0) q.push(file); // 将新的入度为0的节点放入que中
}
}
}
if(res.size() == n){
for(int i=0;i<n-1;i++){
cout<< res[i]<<" ";
}
cout<< res[n-1];
}
else cout<< -1<< endl;
return 0;
}
16.参加科学大会/dijkstra 算法
题目链接
本题就是求最短路,最短路是图论中的经典问题即:给出一个有向图,一个起点,一个终点,问起点到终点的最短路径。这题可以使用dijkstra 算法解决。
dijkstra算法:在有权图(权值非负数)中求从起点到其他节点的最短路径算法,需要注意两点:
- dijkstra算法可以同时求 起点到所有节点的最短路径
- 权值不能为负数
17.城市货物运输1/bellman_ford 算法
题目链接
解决经典的带负权值的单源最短路问题,此时就轮到Bellman_ford登场了。
共有两个关键点。
- “松弛”究竟是个啥?
- 为什么要对所有边松弛 n - 1 次 (n为节点个数) ?
B节点的mindist值可以由A,C节点值推导出来,状态一: minDist[A] + value 可以推出 minDist[B] 状态二: minDist[B]本身就有权值 (可能是其他边链接的节点B 例如节点C,以至于 minDist[B]记录了其他边到minDist[B]的权值)
如果 通过 A 到 B 这条边可以获得更短的到达B节点的路径,这就是松弛的概念;而在路径搜索中这成为剪枝操作;也是采用了动态规划的思想,即:将一个问题分解成多个决策阶段,通过状态之间的递归关系最后计算出全局最优解。
if (minDist[B] > minDist[A] + value) minDist[B] = minDist[A] + value
对所有边松弛一次,相当于计算 起点到达 与起点一条边相连的节点 的最短距离;
节点数量为n,那么起点到终点,最多是 n-1 条边相连。那么无论图是什么样的,边是什么样的顺序,我们对所有边松弛 n-1 次 就一定能得到 起点到达 终点的最短距离。
普通版Bellman_ford算法
#include <iostream>
#include "vector"
#include "list"
#include "climits"
using namespace std;
struct Edge {
int to; // 链接的节点
int val; // 边的权重
Edge(int t, int w): to(t), val(w) {} // 构造函数
};
int main(){
int m,n,p1,p2,val;
cin>> n>> m;
vector<vector<Edge>> graph(n+1); // 邻接表,但其实可以使用邻接矩阵进行存储图
for(int i=0; i<m; i++){
cin>> p1>> p2>> val;
graph[p1].emplace_back(p2, val);
}
vector<int> mindist(n+1, INT_MAX);
mindist[1]=0;
// n-1次松弛
for(int j=1; j<n; j++){
// 遍历邻接表所有的边
for(int i=1; i<=n; i++){
for(auto &e: graph[i]){
int from = i;
int to = e.to;
int price = e.val;
if(mindist[from]!=INT_MAX && mindist[to]>mindist[from]+price) mindist[to] = mindist[from]+price;
}
}
cout<< "松弛第 "<< j<< "次"<< endl;
for(int k=1; k<=n; k++){
cout<< mindist[k]<< " ";
}
cout<< endl;
}
if(mindist[n]==INT_MAX) cout<< "unconnected"<< endl;
else cout<< mindist[n]<< endl;
return 0;
}
队列优化版Bellman_ford算法
Bellman_ford 队列优化算法(Queue improved Bellman-Ford) ,也叫SPFA算法(Shortest Path Faster Algorithm)。大家可以发现 Bellman_ford 算法每次松弛 都是对所有边进行松弛。但真正有效的松弛,是基于已经计算过的节点在做的松弛。
基于以上思路,==如何记录上次松弛的时候更新过的节点呢?==用队列来记录。(其实用栈也行,对元素顺序没有要求)
基于队列优化的算法,要比bellman_ford 算法 减少很多无用的松弛情况,特别是对于边数众多的大图 优化效果明显
while (!que.empty()) {
int node = que.front(); que.pop();
for (Edge edge : grid[node]) {
int from = node;
int to = edge.to;
int value = edge.val;
if (minDist[to] > minDist[from] + value) { // 开始松弛
minDist[to] = minDist[from] + value;
que.push(to);
}
}
}
Bellman_ford队列优化版 的时间复杂度 并不稳定,效率高低依赖于图的结构;
例如 如果是一个双向图,且每一个节点和所有其他节点都相连的话,那么该算法的时间复杂度就接近于 Bellman_ford 的 O(N * E)
N 为节点数量,E为边的数量。
所以如果图越稠密,则 SPFA的效率越接近与 Bellman_ford。反之,图越稀疏,SPFA的效率就越高。
18.城市运输货物2/bellman_ford 算法
题目链接
在17题的基础上,条件在评估从城市 1 到城市 n 的所有可能路径中综合政府补贴后的最低运输成本时,存在一种情况:图中可能出现负权回路。
负权回路是指一系列道路的总权值为负,这样的回路使得通过反复经过回路中的道路,理论上可以无限地减少总成本或无限地增加总收益。
为了避免在使用Bellman_ford算法遇到该情况时,出现这种异常情况,需要插入一个判断环节,返回这种异常情况。
在 bellman_ford 算法中,松弛 n-1 次所有的边 就可以求得 起点到任何节点的最短路径,松弛 n 次以上,minDist数组(记录起到到其他节点的最短距离)中的结果也不会有改变;而在有负权回路的情况下,一直都会有更短的最短路,所以 松弛 第n次,minDist数组也会发生改变。
那么很自然,我们在基础版的还是队列优化版的bellman_ford算法松弛n-1次的基础上,再额外松弛一次,判断mindist数组中是否发生改变,若有发生改变,则图中必定存在负权回路。
- 基础版bellman_ford:n个节点,最多n-1条边即可保证起点1至n连通,所以bellman_ford松弛n-1次即可得到到达n节点的距离最小路径。。在顶层for循环中增加一次for循环,在最后的循环中检查mindist数组是否满足松弛条件
- 队列优化版bellman_ford:在极端情况下,即:所有节点都与其他节点相连,每个节点的入度为 n-1 (n为节点数量),所以每个节点最多加入 n-1 次队列。在while循环下,检查松弛条件生效时,全局计数器数组对应节点下标加1,如果存在某节点加入队列次数超过 n-1次 就说明该图与负权回路。
以队列优化版Bellman_ford算法为例子:
#include <iostream>
#include "vector"
#include "queue"
#include "climits"
using namespace std;
struct Edge {
int to; // 链接的节点
int val; // 边的权重
Edge(int t, int w): to(t), val(w) {} // 构造函数
};
int main(){
int m,n,p1,p2,val;
cin>> n>> m;
vector<vector<Edge>> graph(n+1); // 邻接表
for(int i=0; i<m; i++){
cin>> p1>> p2>> val;
graph[p1].emplace_back(p2, val);
}
vector<int> mindist(n+1, INT_MAX);
mindist[1]=0;
queue<int> q; // 队列存放待遍历的节点
q.push(1);
vector<int> count(n+1, 0); // 记录每个节点的入队次数
count[1]++;
bool flag = false;
while(!q.empty()){
int node = q.front(); q.pop();
for(auto edge: graph[node]){
int from = node;
int to = edge.to;
int value = edge.val;
if(mindist[to]>mindist[from]+value){
mindist[to] = mindist[from]+value;
q.push(to);
count[to]++;
if(count[to]==n) {
flag = true;
while(!q.empty()) q.pop(); //释放
break;
}
}
}
}
if(flag) cout<< "circle"<< endl;
else if(mindist[n]==INT_MAX) cout<< "unconnected"<< endl;
else cout<< mindist[n]<< endl;
return 0;
}
19.城市运输货物3/bellman_ford 算法
题目链接
在17题的基础上,解决经典的带负权值的单源最短路问题,考虑最多经过 k 个城市的条件下,而不是一定经过k个城市,也可以经过的城市数量比k小,但要最短的路径。
最多经过k个节点,即最多经过k+1个节点到达终点的最短距离。此时在使用普通版的bellman_ford 算法时,处理带负权值回路的情况。
根据普通版代码每次顶层for循环遍历所有边可知,以上mindist是普通版bellman_ford算法得到第一次松弛的结果;所有边进行的第二次松弛,minDist数组为 : -2 -2 -1 0 所有边进行的第三次松弛,minDist数组为 : -3 -3 -2 -1等。
理论上来说,对所有边松弛一次,相当于计算 起点到达 与起点一条边相连的节点 的最短距离。但我们在实际代码运行发现不仅仅 与起点一条边相连的节点更新了,所有节点都更新了。这说明至多经过k个节点这个限制没有起作用。因为理论上节点三最快在对所有边第二次松弛时,才会更新,而在第一次松弛时,当时是基于已经计算好的 节点2(minDist[2])来做计算了。
所以在每次计算 minDist 时候,要基于 对所有边上一次松弛的 minDist 数值才行,所以我们要记录上一次松弛的minDist(直观的想法是每次顶层遍历时先拷贝上一次计算的结果)
for (int i = 1; i <= k + 1; i++) {
minDist_copy = minDist; // 获取上一次计算的结果
for (vector<int> &side : grid) {
int from = side[0];
int to = side[1];
int price = side[2];
// 注意使用 minDist_copy 来计算 minDist
if (minDist_copy[from] != INT_MAX && minDist[to] > minDist_copy[from] + price) {
minDist[to] = minDist_copy[from] + price;
}
}
}
时间复杂度: O(K * E) , K为至多经过K个节点,E为图中边的数量;空间复杂度: O(N) ,即 minDist 数组所开辟的空间
本题本质
在之前17,18题没有使用mindist_copy拷贝上一次最小节点距离数组怎么会没有影。在17题,是没有负权回路的,那么无论比n-1多松弛多少次,mindist结果不会发生改变。因此在对所有边进行第一次松弛的时候,如果基于 本次计算的 minDist 来计算 minDist (相当于多做松弛了),也是对最终结果没影响。在18题,是判断是否有 负权回路,一旦有负权回路,则异常返回。
因此本题的关键区别是:
1.可以有负权回路,说明只要多做松弛,结果是会变的。
2.要求最多经过k个节点,对松弛次数是有限制的。
SPFA
要求最多经过k个节点,对松弛次数是有限制的。使用技巧,可以用一个变量 que_size 记录每一轮松弛入队列的所有节点数量;下一轮松弛的时候,就把队列里 que_size 个节点都弹出来,就是上一轮松弛入队列的节点。
int que_size;
while (k-- && !que.empty()) {
minDist_copy = minDist; // 获取上一次计算的结果
que_size = que.size(); // 记录上次入队列的节点个数
while (que_size--) { // 上一轮松弛入队列的节点,这次对应的边都要做松弛
int node = que.front(); que.pop();
for (Edge edge : grid[node]) {
int from = node;
int to = edge.to;
int price = edge.val;
if (minDist[to] > minDist_copy[from] + price) {
minDist[to] = minDist_copy[from] + price;
que.push(to);
}
}
}
}
理论上,SPFA的时间复杂度不是要比 bellman_ford 更优吗?因为在该过程中queue的进出元素操纵,耗时很大,所以同的时间复杂度的情况下,SPFA 实际上更耗时了。
能否使用dijkstra算法?
此时最多经过2个节点的搜索就完毕了,但结果中minDist[7] (即节点7的结果)并没有被更;使用dijkstra算法所遍历的节点是1-->2-->3-->4
,dijkstra每一步都采取贪心的策略,以最小的代价值到达下一个没访问的节点,因此没办法找到1-->2-->6-->7
这条路径。
最短路径问题总结
至此已经讲解了四大最短路算法,分别是Dijkstra、Bellman_ford、SPFA 和 Floyd
注意:因为A * 属于启发式搜索,和上面最短路算法并不是一类,不适合一起对比,所以没有放在一起
大体使用场景的分析:
- 如果遇到单源且边为正数的情况,直接Dijkstra 算法。至于 使用朴素版还是 堆优化版 还是取决于图的稠密度, 多少节点多少边算是稠密图,多少算是稀疏图,一般可以直接用堆优化版本。
- 如果遇到单源边可为负数,直接 Bellman-Ford 算法,使用普通版还是队列优化版取决于图的稠密度
- 如果是遇到多源点且边为正求最短路,直接 Floyd 算法。
- 对于A * ,由于其高效性,所以在实际工程应用中使用最为广泛 ,由于其 结果的不唯一性,也就是可能是次短路的特性,一般不适合作为算法题