SPFA算法
题目描述
某国为促进城市间经济交流,决定对货物运输提供补贴。共有 n 个编号为 1 到 n 的城市,通过道路网络连接,网络中的道路仅允许从某个城市单向通行到另一个城市,不能反向通行。
网络中的道路都有各自的运输成本和政府补贴,道路的权值计算方式为:运输成本 - 政府补贴。权值为正表示扣除了政府补贴后运输货物仍需支付的费用;权值为负则表示政府的补贴超过了支出的运输成本,实际表现为运输过程中还能赚取一定的收益。
请找出从城市 1 到城市 n 的所有可能路径中,综合政府补贴后的最低运输成本。如果最低运输成本是一个负数,它表示在遵循最优路径的情况下,运输过程中反而能够实现盈利。
城市 1 到城市 n 之间可能会出现没有路径的情况,同时保证道路网络中不存在任何负权回路。
输入描述
第一行包含两个正整数,第一个正整数 n 表示该国一共有 n 个城市,第二个整数 m 表示这些城市中共有 m 条道路。
接下来为 m 行,每行包括三个整数,s、t 和 v,表示 s 号城市运输货物到达 t 号城市,道路权值为 v (单向图)。
输出描述
如果能够从城市 1 到连通到城市 n, 请输出一个整数,表示运输成本。如果该整数是负数,则表示实现了盈利。如果从城市 1 没有路径可达城市 n,请输出 "unconnected"。
题目链接:https://kamacoder.com/problempage.php?pid=1152
文章讲解:https://programmercarl.com/kamacoder/0094.城市间货物运输I-SPFA.html
思考
这道题是经典的带负权值的单源最短路问题,Bellman_ford可以解决
Bellman_ford 队列优化算法 ,也叫SPFA算法(Shortest Path Faster Algorithm)。
在之前的 Bellman_ford 中,
// 松弛操作
if (minDist[start] != INT32_MAX &&
minDist[end] > minDist[start] + val) {
minDist[end] = minDist[start] + val;
updated = true; // 标记发生了更新
}
这个判断条件意味着无论 start 节点是否被更新过,都尝试松弛。
即:
每轮都要检查所有边,包括那些起点距离没有变化的边
例如:如果节点3的距离在这轮没变,那么检查3→5这条边就是无效工作
那么我们需要做的是什么呢?我们实际上需要松弛的是那些距离有变化的出发节点的边。
也就是说
Bellman_ford 算法每次松弛 都是对所有边进行松弛。但真正有效的松弛,是基于已经计算过的节点在做的松弛。
所以 只需要对 上一次松弛的时候更新过的节点作为出发节点所连接的边 进行松弛就够了。
如何记录上一次松弛的时候更新过的节点呢?
使用队列或栈都是可以的,并没有要求顺序。
如何记录出发节点所连接的边呢?
使用邻接表记录节点的“邻居” 是非常方便的。
注意点
需要注意的是,需要更新的邻居加入队列的时候可能队列里面已经存在这个邻居了,会导致重复添加的问题。因此我们使用一个数组记录已经在队列中的元素即可vector<bool> inQue(n + 1, false);
举例
说明一下Bellman-Ford和SPFA的区别
假设图中有节点1→2→3→4,权重都是1:
Bellman-Ford的执行过程:
第1轮:检查所有边
- 1→2: 松弛成功,minDist[2] = 1
- 2→3: 松弛成功,minDist[3] = 2
- 3→4: 松弛成功,minDist[4] = 3
第2轮:再次检查所有边
- 1→2: 检查但不更新(浪费时间)
- 2→3: 检查但不更新(浪费时间)
- 3→4: 检查但不更新(浪费时间)
SPFA的执行过程:
初始:queue = [1]
处理节点1:
- 松弛1→2,minDist[2] = 1
- 节点2入队:queue = [2]
处理节点2:
- 松弛2→3,minDist[3] = 2
- 节点3入队:queue = [3]
处理节点3:
- 松弛3→4,minDist[4] = 3
- 节点4入队:queue = [4]
处理节点4:
- 没有出边,queue = []
- 算法结束
代码实现
/*
Bellman_ford 队列优化算法 ,也叫SPFA算法(Shortest Path Faster Algorithm)
*/
#include <cstdint>
#include <iostream>
#include <list>
#include <queue>
#include <stdint.h>
#include <vector>
using namespace std;
struct Edge {
int p2;
int val;
};
int main() {
int n, m, s, t, v;
cin >> n >> m;
vector<list<Edge>> grah(n + 1);
for (int i = 0; i < m; i++) {
cin >> s >> t >> v;
grah[s].push_back({t, v});
}
queue<int> que;
int start = 1;
int end = n;
que.push(start);
vector<int> minDist(n + 1, INT32_MAX);
vector<bool> inQue(n + 1, false);
minDist[start] = 0;
inQue[start] = true;
while (!que.empty()) {
int cur = que.front();
que.pop();
inQue[cur] = false;
for (auto edge : grah[cur]) {
int to = edge.p2;
int price = edge.val;
if (minDist[to] > minDist[cur] + price) {
minDist[to] = minDist[cur] + price;
if(inQue[to] == false){
que.push(to);
inQue[to] = true;
}
}
}
}
if (minDist[end] == INT32_MAX)
cout << "unconnected" << endl;
else
cout << minDist[end] << endl;
}
效率分析
从代码上看什么时候节点会加入队列?当它是其他节点的邻居时。所以说对于非常稠密的双向图,如:
一个双向图,且每一个节点和所有其他节点都相连的话,那么该算法的时间复杂度就接近于 Bellman_ford 的 O(N * E) N 为节点数量,E为边的数量。
在这种图中,每一个节点都会重复加入队列 n - 1次。因为它是 n-1个其余节点的邻居。
总结:如果图越稠密,则 SPFA的效率越接近与 Bellman_ford。
反之,图越稀疏,SPFA的效率就越高。
SPFA时间复杂度
例如图是一条线形图且单向的话,每个节点的入度为1,那么只需要加入一次队列,这样时间复杂度就是
所以 SPFA 在最坏的情况下是
以上只是理论上的时间复杂度。
虽然入队列出队列的时间复杂度都是
所以 SPFA(队列优化版Bellman_ford) 在理论上更胜一筹,但如果 图很大且非常稠密的情况下,虽然 SPFA的理论上时间复杂度接近Bellman_ford,但实际时间消耗 可能是 SPFA耗时更多。