第61天,Bellman_ford算法的全方位扩展学习💪💪,编程语言:C++
目录
Bellman_ford 队列优化算法(又名SPFA)
题目:94. 城市间货物运输 I (kamacoder.com)
SPRA算法(Shoertest Path Faster Algorithm)又称Bellman_ford队列优化算法,是对Bellman_ford在遍历过程中进行的一种优化。
我们可以发现Bellman_ford算法每次松弛都是对所有边进行松弛,但事实上在第一轮松弛过程中,有很多边并没有起到效果,真正有效的松弛,是基于已经计算过的节点再做的松弛。例如:
本图中,对所有边进行松弛,真正有效的松弛,只有松弛边节点1 -> 节点2和边节点1->节点3。而松弛边节点4 -> 节点6,边节点5 -> 节点3等等都是无效操作,因为节点4和节点5都是还没有计算过的点。可见一般的Bellman_ford 算法每次都是对所有边进行松弛,其实是存在一些无用功的。
总的来说就是:只需要对上一次松弛的时候更新过的节点作为出发节点所连接的边进行松弛就够了。
基于上述思路,我们可以使用一个队列来保存上次更新过的节点,以此降低时间复杂度。
模拟过程
还是以本题为例,使用minDist数组来表达起点到各个节点的最短距离。初始化起点为节点1,起点到起点的距离为0,所以minDist[1]为0。将节点1加入队列(第一次松弛从节点1开始)
从队列中取出节点1,松弛节点1作为出发点连接的边节点1 -> 节点2和边节点1 -> 节点3。
边:节点1 -> 节点2,权值为1 ,minDist[2] > minDist[1] + 1 ,更新 minDist[2] = minDist[1] + 1 = 0 + 1 = 1 。 边:节点1 -> 节点3,权值为5 ,minDist[3] > minDist[1] + 5,更新 minDist[3] = minDist[1] + 5 = 0 + 5 = 5。将节点2、 节点3加入队列。
从队列里取出节点2,松弛节点2作为出发点连接的边节点2->节点4,节点2->节点5。之后将节点4和节点5加入队列。
从队列里取出节点3,松弛节点3作为出发点连接的边,但由于没有以节点3出发的边,因此不需要进行操作。
接着从队列中取出节点4,节点5,重复上述步骤。这里要注意,只要节点的值发生了改变,就需要重新入队列一次。
最后我们取出节点6,松弛节点6作为出发点连接的边。节点6作为终点,没有可以出发的边。
这样我们就完成了Bellman_ford队列优化算法的解题过程。可以发现相较于Bellman_ford算法确实是减少了很多无用的松弛情况,具有一定的优化效果。
代码:由于我们需要知道节点作为出发点,有哪些连接的边,因此本题我们倾向于使用邻接表来进行图的存储,更便于我们找到一个节点连接的节点。
#include <iostream>
#include <vector>
#include <queue>
#include <list>
#include <climits>
using namespace std;
struct Edge { //这里我们构建一个结构体来增加权值的保存
int to; //目标节点
int val; //权值
Edge(int to, int val): to(to), val(val) {} //构造函数
};
int main() {
int n, m;
cin >> n >> m;
vector<list<Edge>> grid(n + 1); //采用邻接表的方式来保存图
int s,t,k;
while(m--) {
cin >> s >> t >> k;
grid[s].push_back(Edge(t,k));
}
vector<int> minDist(n + 1, INT_MAX); //minDist数组
minDist[1] = 0; //初始化
queue<int> que;
que.push(1); //放入起点
while(!que.empty()) {
int node = que.front();
que.pop();
for(Edge edge : grid[node]) {
int to = edge.to; //取出结构体里面的值
int value = edge.val;
if(minDist[to] > minDist[node] + value) {
minDist[to] = minDist[node] + value;
que.push(to);
}
}
}
if (minDist[n] == INT_MAX) cout << "unconnected" << endl; // 不能到达终点
else cout << minDist[n] << endl; // 到达终点最短路径
return 0;
}
效率分析
队列优化版Bellman_ford的时间复杂度并不稳定,效率高低依赖于图的结构。
Bellman_ford的时间复杂度为O(N * E) N为节点数量,E为边的数量。因为我们要遍历每一条边(n - 1)次。
而SPFA的时间复杂度为(K*N),K为不定值,因为节点会被计入几次队列取决于图的稠密度。如果图是一条线形图且单向的话,每个节点的入度为1,那么只需要加入一次队列,这样时间复杂度就是 O(N)。
总结来说,如果过图越稠密,甚至每个节点都有 n-1 条指向该节点的边,那么SPFA的时间复杂度就和Bellman_ford的时间复杂度一样了。
换句话说图越稀疏的话,SPFA的时间复杂度也会更高。
但是要注意,我们在SPFA中使用到了队列,在时机的使用过程中,出队列和进队列的耗时是很高的,不能仅仅看时间复杂度,例如下面的代码时间复杂度都是O(n)
for (long long i = 0; i < n; i++) {
k++;
}
for (long long i = 0; i < n; i++) {
que.push(i);
que.front();
que.pop();
}
但耗时却不一样:
- n = 10^4,第一段代码的时间消耗:1ms,第二段代码的时间消耗: 4 ms
- n = 10^5,第一段代码的时间消耗:1ms,第二段代码的时间消耗: 13 ms
- n = 10^6,第一段代码的时间消耗:4ms,第二段代码的时间消耗: 59 ms
- n = 10^7,第一段代码的时间消耗: 24ms,第二段代码的时间消耗: 463 ms
- n = 10^8,第一段代码的时间消耗: 135ms,第二段代码的时间消耗: 4268 ms
由此可见,SPFA在理论的时间复杂度上确实好一些,但是实际的时间消耗却未必有Bellman_ford少。
额外注意事项
我们在代码中使用了while(!que.empty()),假如图中有环的话,会不会出现死循环呢?
答案是,如果是正权回路,那每一次循环只会加大距离,在代码中加大距离我们是不会更新的,也不会再进入队列,因此是不会出现死循环的。
但是如果是负权回路,那每一次循环都会减小距离,这样就可能会出现死循环,但本题中明确说了不会出现负权回路,因此我们不需要担心
bellman_ford之判断负权回路
题目:95. 城市间货物运输 II (kamacoder.com)
上述我们使用的Bellman_ford算法和优化版本的SPFA算法都是在没有负权回路的基础上进行求解的。但是如果真的有负权回路呢,我们应该怎么操作。
在存在负权回路的图中求最短路的话,会出现无限循环(负数 + 负数会越来越小),因此无法求出最短路径。
对此我们需要判断是否图中存在负权回路。
首先我们采用Bellman_ford算法, 该算法的核心就是:对所有边进行n - 1次松弛,即可得到答案。但如果松弛n次以上呢,如果是没有负权回路的情况下,松弛n次以上,数值是不会发生改变的,但如果存在负权回路,那么松弛n次以上,结果就会有变化了,每多一次松弛,都会更新一次最短路径,所以结果会一直变化。
例如上图,只要我们在负权回路中多绕一圈,我们的最短路径就小1,也就是更低的运输成本。
总结来说,我们可以通过多松弛一次的方式,来查看minDist数组是否发生改变,如果改变,则说明存在负权回路,我们需要进行判断。
代码:
#include <iostream>
#include <vector>
#include <list>
#include <climits>
using namespace std;
int main() {
int n, m, p1, p2, val;
cin >> n >> m;
vector<vector<int>> grid;
for(int i = 0; i < m; i++){
cin >> p1 >> p2 >> val;
// p1 指向 p2,权值为 val
grid.push_back({p1, p2, val});
}
int start = 1; // 起点
int end = n; // 终点
vector<int> minDist(n + 1 , INT_MAX);
minDist[start] = 0;
bool flag = false; //判断是否存在负权回路
for (int i = 1; i <= n; i++) { // 这里我们松弛n次,最后一次判断负权回路
for (vector<int> &side : grid) {
int from = side[0];
int to = side[1];
int price = side[2];
if (i < n) {
if (minDist[from] != INT_MAX && minDist[to] > minDist[from] + price) {
minDist[to] = minDist[from] + price;
}
} else { // 第n次松弛
if (minDist[from] != INT_MAX && minDist[to] > minDist[from] + price) {
flag = true;
}
}
}
}
if (flag) cout << "circle" << endl; //如果标志发生改变,则说明存在负权回路
else if (minDist[end] == INT_MAX) {
cout << "unconnected" << endl;
} else {
cout << minDist[end] << endl;
}
return 0;
}
SPFA拓展
如果采用SPFA算法,我们该如何判断出现了负权回路呢。我们知道在SPFA算法中,每个节点最多也就进入队列n - 1次,这种情况的时间复杂度是和bellman_ford一样的。
那么我们就可以确定如果一个节点加入队列的次数超过了n - 1次,那么就说明一定存在负权回路。
#include <iostream>
#include <vector>
#include <queue>
#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 n, m, p1, p2, val;
cin >> n >> m;
vector<list<Edge>> grid(n + 1); // 邻接表
// 将所有边保存起来
for(int i = 0; i < m; i++){
cin >> p1 >> p2 >> val;
// p1 指向 p2,权值为 val
grid[p1].push_back(Edge(p2, val));
}
int start = 1; // 起点
int end = n; // 终点
vector<int> minDist(n + 1 , INT_MAX);
minDist[start] = 0;
queue<int> que;
que.push(start); // 队列里放入起点
vector<int> count(n+1, 0); // 记录节点加入队列几次
count[start]++;
bool flag = false;
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);
count[to]++;
if (count[to] == n) {// 如果加入队列次数超过 n-1次 就说明该图与负权回路
flag = true;
while (!que.empty()) que.pop();
break;
}
}
}
}
if (flag) cout << "circle" << endl;
else if (minDist[end] == INT_MAX) {
cout << "unconnected" << endl;
} else {
cout << minDist[end] << endl;
}
return 0;
}
bellman_ford之单源有限最短路
题目:96. 城市间货物运输 III (kamacoder.com)
本题题目类型全称为单源有限最短路问题,同样是城市货物传输I的延伸题目类型。本题与之前的题目不同,本题规定了经过城市的数量k,以及起点城市和终点城市,不再是1和n,而是src和dst。
这里要注意最多经过k个城市,不是指一定要经过k个城市,只需要经过的城市数量小于k个就可以,但要是最短的路径。
本题的解题思路很简单,由于bellman_ford算法中对所有边松弛一次,相当于计算起点到达起点一条边相连的节点的最短距离,节点数量为n,起点到终点,最多是n - 1条边相连。那么对所有边松弛n - 1次就一定能得到起点到终点的最短距离
衍生到本题,要求最多经过k个城市,加上起点和终点,也就是k+1条边。
因此我们只需要从起点出发,对所有边松弛k + 1次,就是求起点到达起点k + 1条边相连的节点的最短距离。
由此我们可以得到代码:
#include <iostream>
#include <vector>
#include <list>
#include <climits>
using namespace std;
int main() {
int src, dst,k ,p1, p2, val ,m , n;
cin >> n >> m;
vector<vector<int>> grid; //保存图
for(int i = 0; i < m; i++){
cin >> p1 >> p2 >> val;
// p1 指向 p2,权值为 val
grid.push_back({p1, p2, val});
}
cin >> src >> dst >> k;
vector<int> minDist(n + 1 , INT_MAX);
minDist[src] = 0;
for (int i = 1; i <= k + 1; i++) { // 对所有边松弛 k + 1次
for (vector<int> &side : grid) {
int from = side[0];
int to = side[1];
int price = side[2];
if (minDist[from] != INT_MAX && minDist[to] > minDist[from] + price) minDist[to] = minDist[from] + price;
}
}
if (minDist[dst] == INT_MAX) cout << "unreachable" << endl; // 不能到达终点
else cout << minDist[dst] << endl; // 到达终点最短路径
return 0;
}
但是以上代码并不能通过所有的案例,因为没有考虑存在负权回路的问题。以一个带有负权回路的图为例。
数据输入为:
4 4
1 2 -1
2 3 1
3 1 -1
3 4 1
1 4 3
正常来说,起点1,终点4,最多经过3个城市,能够得到的最短路径是1 -> 2 -> 3 -> 4,最后输出为1,但是如果用上述代码,得到的输出是-2。
我们可以通过打印minDist数组发现端倪,通过上述代码,我们再经过第一轮松弛之后得到的是
接着后续的三次松弛分别会得到 -2 -2 -1 0 、-3 -3 -2 -1、-4 -4 -3 -2。最后答案就是-2,是不对的。
原因我们可以从一次次变化中看出,每一次松弛,所有边的值都发生了改变。节点3离节点1是两条边的距离,按理来说节点3应该在第二次松弛的时候进行赋值,但是由于节点2发生了改变,到节点3的时候也就能依靠节点2进行改变,
这样就造成了一个情况,即:计算minDist数组的时候,基于了本次松弛的 minDist数值,而不是上一次 松弛时候minDist的数值。所以在每次计算 minDist 时候,要基于 对所有边上一次松弛的 minDist 数值才行,所以我们要记录上一次松弛的minDist。
代码:因此我们需要加入一个minDist_copy数组,来记录上一次松弛后的minDist数值。
//时间复杂度: O(K * E) , K为至多经过K个节点,E为图中边的数量
//空间复杂度: O(N) ,即 minDist 数组所开辟的空间
#include <iostream>
#include <vector>
#include <list>
#include <climits>
using namespace std;
int main() {
int src, dst,k ,p1, p2, val ,m , n;
cin >> n >> m;
vector<vector<int>> grid;
for(int i = 0; i < m; i++){
cin >> p1 >> p2 >> val;
grid.push_back({p1, p2, val});
}
cin >> src >> dst >> k;
vector<int> minDist(n + 1 , INT_MAX);
minDist[src] = 0;
vector<int> minDist_copy(n + 1); // 用来记录上一次遍历的结果
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;
}
}
}
if (minDist[dst] == INT_MAX) cout << "unreachable" << endl; // 不能到达终点
else cout << minDist[dst] << endl; // 到达终点最短路径
return 0;
}
拓展
拓展一(边的顺序的影响)
虽然题目成功解出,但还是有几个细节问题需要注意。为什么节点3的数值会发生改变,本质上是因为节点2的数值在连接节点3的边之前发生了改变,换句话说,如果边的顺序发生变化,结果就会有所不同。
4 4
1 2 -1
2 3 1
3 1 -1
3 4 1
1 4 3
//改为
4 4
3 1 -1
3 4 1
2 3 1
1 2 -1
1 4 3
如果上面边的顺序改为了下面这种,那么图是没有变化的,但是用第一个版本的代码也可以通过此案例,本质就是因为这样节点3在松弛的时候,使用的就是上一轮节点2松弛的结果了。
拓展二(本题的本质)
那么为什么这道题我们需要考虑使用一个额外的copy数组,而前面的两道题不需要呢。这是因为前面两题我们对负权回路并没有给出解决方案,但本题是有解决办法的(因为规定了只能通过k个节点,对松弛次数有限制)。
94. 城市间货物运输 I (kamacoder.com) 本题不存在负权回路,因此松弛多少次对结果都没有影响。
95. 城市间货物运输 II (kamacoder.com) 而本题存在负权回路,在我们松弛n - 1次以后,再进行松弛,minDist数值依旧会发生改变,我们是通过这一点来判断是否有负权回路的。换句话说我们只是判断是否有负权回路,对minDist的数值的正确与否并没有确认,可能此时minDist里面的数值,已然不表示距离起点n - 1条边的最小成本了。
这也就是为什么本题需要有copy数组,因为我们能够求出存在负权回路情况下的答案。其实如果本题没有负权回路测试用例,那么版本一的代码也就可以通过了。
拓展三(SPFA)
本题也可以采用SPFA的算法进行求解,但是我们需要控制松弛次数k次,控制的办法可以采取一个变量que_size 记录每一轮松弛入队列的所有节点数量。下一轮松弛的时候,就把队列里 que_size 个节点都弹出来,就是上一轮松弛入队列的节点。同时我们还可以进行优化,在每一轮松弛的时候,重复节点是可以不用入队列的,因为重复节点入队列,下次从队列里取节点的时候,该节点要取很多次,而且都是重复计算。
代码:
#include <iostream>
#include <vector>
#include <queue>
#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 n, m, p1, p2, val;
cin >> n >> m;
vector<list<Edge>> grid(n + 1); // 邻接表
// 将所有边保存起来
for(int i = 0; i < m; i++){
cin >> p1 >> p2 >> val;
// p1 指向 p2,权值为 val
grid[p1].push_back(Edge(p2, val));
}
int start, end, k;
cin >> start >> end >> k;
k++;
vector<int> minDist(n + 1 , INT_MAX);
vector<int> minDist_copy(n + 1); // 用来记录每一次遍历的结果
minDist[start] = 0;
queue<int> que;
que.push(start); // 队列里放入起点
int que_size;
while (k-- && !que.empty()) {
vector<bool> visited(n + 1, false); // 每一轮松弛中,控制节点不用重复入队列
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;
if(visited[to]) continue; // 不用重复放入队列,但需要重复松弛,所以放在这里位置
visited[to] = true;
que.push(to);
}
}
}
}
if (minDist[end] == INT_MAX) cout << "unreachable" << endl;
else cout << minDist[end] << endl;
return 0;
}
拓展四(dijkstra)
那么能否使用dijkstra算法呢,答案是不可以的,因为dijkstra 是贪心的思路,每一次搜索都只会找距离源点最近的非访问过的节点,它并不会持续的向着终点进发。
例如上图,显然最短路径为1->2->6->7,最多经过2个节点,但是我们遍历了3次后,只是确定了1,2,3,4的值,节点7并没有被更新。
总结
今天我们主要对bellman_ford算法,以及单源有限最短路问题进行全方位扩展。
首先讲解了bellman_ford算法的优化算法SPFA。
其次针对存在负权回路问题,以及规定了松弛次数的情况,进行了详细讲解。
马上就是图论最后一天了,加油加油!!!