算法学习笔记(27)——SPFA算法(单源最短路)
SPFA 算法
SPFA算法实际上是对Bellman-Ford算法的队列优化,也是用于在存在负权边的图上,求单源点最短路,一般情况下时间复杂度可以看作 \(O(m)\) ,最坏情况下时间复杂度是 \(O(nm)\) 。
虽然SPFA算法是对Bellman-Ford算法的优化,但是不是所有用Bellman-Ford算法的问题都能用SPFA来代替。例如,对最短路经过的边数做一个限制,要求经过的边数 \(\le k\) 的最短路,这个时候能用Bellman-Ford算法,但是不能用SPFA算法来做。
SPFA算法是单源最短路问题中限制最少的算法。在求单源点最短路的时候,只要图中没有负环就可以用SPFA算法,而且一般要求单源点最短路的问题都是没有负环的。只是如果没有负权边的话,用Dijkstra会更快,都是在有负权边的时候才用SPFA。
回顾Bellman-Ford算法,我们在 \(n\) 次循环中,每次都要遍历所有的 \(m\) 条边来做松弛操作更新dist[]
数组,但实际上并不是每条边都会产生更新,SPFA正是对此进行了优化。在Bellam-Ford的松弛操作中,dist[b] = min(dist[b], last[a] + w)
,只有当last[a]
,即上一轮的dist[a]
变小了,才会更新这一轮的dist[b]
,而b
是a
的后继节点,所以只有前驱节点变小了,后继节点才会更新。
SPFA运用宽搜做优化,维护一个队列,最开始将起点加入到队列中,作为更新的前驱。队列里存的始终是"变小了"的节点,只要队列不空,就取出队头节点,并利用此节点的边做更新,把这些边对应的后继节点入队。可以用伪代码表示:
queue <= 1
while queue不空
t <= q.front
q.pop
更新t的所有出边 t =w=> b
queue <= b
另外在加入队列之前可以再优化一下,如果队列里已经有 \(b\) 了,就不用再加入了,因为在未来的某个时刻一定会把 \(b\) 拿出来更新,这就足够了,不需要在意是因为结点 \(t_1\) 还是 \(t_2\) 的更新导致了 \(b\) 节点的变小。
SPFA 求最短路
注意SPFA相比Bellman-Ford算法的优化之后,每次是要拿出一个结点的所有后继边来尝试更新的,所以这个时候“结点后继”这个语义是需要的了,因此不能像Bellman-Ford算法那样直接把所有的边存到数组里去,这里直接存到邻接表里。
用STL的队列,不好判断一个结点是不是已经存在队列里了,这里的实现方式是用一个st数组来同步记录这个消息:如果一个结点t出队列了,就st[t] = false
;如果一个结点b进入队列了,就st[b] = true
。
#include <iostream>
#include <queue>
#include <cstring>
using namespace std;
const int N = 1e5 + 10;
int n, m;
int h[N], e[N], ne[N], w[N], idx; //带权的邻接表存图
bool st[N]; // 用于入队优化,标记当前节点是否在队列中
int dist[N]; // 距离数组
// 邻接表加边
void add(int a, int b, int c)
{
e[idx] = b, ne[idx] = h[a], w[idx] = c, h[a] = idx ++;
}
void spfa()
{
// 初始化距离数组
memset(dist, 0x3f, sizeof dist);
dist[1] = 0;
queue<int> q;
// 初始阶段将起点入队
q.push(1);
st[1] = true;
// 宽搜
while (q.size()) {
int t = q.front();
q.pop();
st[t] = false;
// 枚举每一条节点t的出边
for (int i = h[t]; i != -1; i = ne[i]) {
int j = e[i];
// 看看先到t再到j是不是更短,更短的话再更新
if (dist[j] > dist[t] + w[i]) {
dist[j] = dist[t] + w[i];
// 如果j不在队列中,再入队,避免重复入队
if (!st[j]) {
q.push(j);
st[j] = true;
}
}
}
}
}
int main()
{
// 初始化邻接表
memset(h, -1, sizeof h);
cin >> n >> m;
for (int i = 0; i < m; i ++ ) {
int x, y, z;
cin >> x >> y >> z;
add(x, y, z);
}
spfa();
if (dist[n] > 0x3f3f3f3f / 2) puts("impossible");
else cout << dist[n] << endl;
return 0;
}
SPFA 判断负环
如果图里面存在负环,那么求单源最短路的过程会一直进行下去,每次走一圈负环路径长度都会变小。
SPFA算法中dist[j]
记录了从起点到j
的最短距离,我们再维护一个cnt[]
数组,cnt[j]
表示到j
经过了多少步,如果达到了n
步以上,这个路径一定有负环。
注意点:负环可能从任意一个点出发,所以初始化队列时将所有点入队。
#include <iostream>
#include <cstring>
#include <queue>
using namespace std;
const int N = 2010, M = 10010;
int n, m;
int h[N], e[M], ne[M], w[M], idx; // 邻接表存储最多 N个点 M条边的图
int dist[N];
bool st[N];
int cnt[N]; // 记录到某一点经过了多少步
void add(int a, int b, int c)
{
e[idx] = b, ne[idx] = h[a], w[idx] = c, h[a] = idx ++;
}
bool spfa()
{
// 由于所有点都要入队,所以dist的语义已经不是单源点的最短路长度了
// 而且求的不再是距离的绝对值,这里就默认全是0也一样能判断负环
// memset(dist, 0x3f, sizeof dist);
// dist[1] = 0;
// 初始化队列,将所有点都入队
queue<int> q;
for (int i = 1; i <= n; i ++ ) {
q.push(i);
st[i] = true;
}
// 宽搜套路
while (q.size()) {
int t = q.front();
q.pop();
st[t] = false;
for (int i = h[t]; i != -1; i = ne[i]) {
int j = e[i];
if (dist[j] > dist[t] + w[i]) {
dist[j] = dist[t] + w[i];
// 更新了到j的最短路径 = 到t的最短路径加1
cnt[j] = cnt[t] + 1;
// 如果路径长度达到n(n个点不重复最多n-1条边),代表一定有负环
if (cnt[j] == n) return true;
if (!st[j]) {
q.push(j);
st[j] = true;
}
}
}
}
return false;
}
int main()
{
memset(h, -1, sizeof h);
cin >> n >> m;
while (m -- ) {
int x, y, z;
cin >> x >> y >> z;
add(x, y, z);
}
if (spfa()) puts("Yes");
else puts("No");
return 0;
}
特别注意在这种求任意点出发的负环里,dist[]
已经只是作为一个“比较和更新”的工具来用了,它的值是多少不关心,它的语义也不再是从单源点到某个点的最短路长度了。由于不关心距离的绝对值,所以也没必要初始化为正无穷,只要保证里面的数初始都是一样的,这样就能正常的根据w
来更新就行了。