SPFA 简介

SPFA,全称一般写作 Shortest Path Faster Algorithm
它可以看作是 Bellman-Ford 的队列优化版,用来求解 单源最短路,并且:

  • 可以处理负边
  • 可以判负环
  • 在很多普通数据下跑得比 Bellman-Ford 快
  • 最坏复杂度仍然很差

在 OI 圈里,SPFA 是一个“很经典,但也很危险”的算法:

  • 能写
  • 要会判负环
  • 但不能无脑拿来跑最短路

一、SPFA 是怎么来的

Bellman-Ford 每一轮都要扫一遍所有边,总共扫 n-1 轮,所以复杂度是:

[ O(nm) ]

但实际上,并不是每条边每次都会产生贡献。
只有那些 刚被更新过的点,它们的出边才有可能继续松弛别人。

于是就有了 SPFA 的想法:

  • 如果某个点 u 的最短路刚刚变小
  • 那么把 u 放进队列
  • 只去检查 u 的出边
  • 若更新了相邻点 v,再把 v 入队

也就是说:

只处理“可能继续产生贡献”的点。

这就是 SPFA 的核心优化。


二、核心思想

dist[s] = 0,其余点设为正无穷。

维护一个队列,初始把源点 s 放进去。
每次取出队首点 u,扫描它的所有出边 u -> v (w)

如果

[ dist[v] > dist[u] + w ]

那么更新 dist[v]
如果 v 当前不在队列中,就把 v 加入队列。

这样不断进行,直到队列为空,说明当前已经没有点能继续松弛别人了。


三、为什么它是对的

本质上,SPFA 仍然在做 Bellman-Ford 的“松弛”。

区别仅仅在于:

  • Bellman-Ford:机械地反复扫描所有边
  • SPFA:只扫描那些“刚被更新”的点的出边

所以 SPFA 并没有改变最短路的正确性基础,它只是减少了很多无效松弛。


四、时间复杂度

这是 SPFA 最需要注意的地方。

理论最坏复杂度

[ O(nm) ]

也就是说,最坏情况下和 Bellman-Ford 一个级别
而且在一些专门构造的数据下,它会非常慢,甚至被卡爆。

实际表现

在很多随机图、普通图、部分实际题目中,SPFA 常常比较快。
所以它是一个“平均看着不错,但最坏不稳”的算法。


五、适用场景

SPFA 常见用途:

  1. 单源最短路,图中有负边
  2. 判负环
  3. 某些差分约束建图题
  4. 图规模不太极端,或者题目数据对 SPFA 友好

但要记住:

  • 没有负边时,一般优先用 Dijkstra
  • 只为了最短路而不是负环,SPFA 往往不是第一选择
  • 被卡常/卡复杂度的题,SPFA 可能寄

六、标准 OI 模板

1. 单源最短路模板

#include <bits/stdc++.h>
using namespace std;

const int N = 100005;
const long long INF = 1e18;

struct Edge {
    int to;
    long long w;
};

vector<Edge> g[N];
long long dist[N];
bool inq[N];

void spfa(int s, int n) {
    for (int i = 1; i <= n; i++) {
        dist[i] = INF;
        inq[i] = false;
    }

    queue<int> q;
    dist[s] = 0;
    q.push(s);
    inq[s] = true;

    while (!q.empty()) {
        int u = q.front();
        q.pop();
        inq[u] = false;

        for (auto e : g[u]) {
            int v = e.to;
            long long w = e.w;
            if (dist[u] != INF && dist[v] > dist[u] + w) {
                dist[v] = dist[u] + w;
                if (!inq[v]) {
                    q.push(v);
                    inq[v] = true;
                }
            }
        }
    }
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n, m, s;
    cin >> n >> m >> s;

    for (int i = 1; i <= m; i++) {
        int u, v;
        long long w;
        cin >> u >> v >> w;
        g[u].push_back({v, w});
    }

    spfa(s, n);

    for (int i = 1; i <= n; i++) {
        if (dist[i] == INF) cout << "INF ";
        else cout << dist[i] << " ";
    }
    cout << '\n';

    return 0;
}

七、模板解释

1. inq[] 是干什么的

bool inq[N];

表示某个点当前是否在队列中。

作用是避免一个点被重复入队很多次。
如果没有这个数组,某些点可能连续被塞进队列,常数会很难看。


2. 为什么出队时要 inq[u] = false

int u = q.front(); q.pop();
inq[u] = false;

因为这个点已经不在队列里了。
这样之后若它再次被更新,还能重新入队。


3. 为什么不用 vis[]

Dijkstra 里常见“确定最短路后不再变化”的贪心性质。
但 SPFA / Bellman-Ford 没有这个性质,所以不能写:

  • “某点处理过一次就永远不管了”

它可能被后续负边继续更新。


八、负环判定

这是 SPFA 很重要的用途。

判定原理

如果一个点的最短路被更新了太多次,就说明可能经过了负环。

更准确地说:

  • 若从源点出发,某个点被松弛次数达到 n
  • 说明存在一条至少含 n 条边的更优路径
  • 根据抽屉原理,这条路径中必然有环
  • 还能继续变优,因此这个环是负环

判负环模板(从源点可达)

#include <bits/stdc++.h>
using namespace std;

const int N = 100005;
const long long INF = 1e18;

struct Edge {
    int to;
    long long w;
};

vector<Edge> g[N];
long long dist[N];
bool inq[N];
int cnt[N]; // 记录每个点被松弛的次数

bool spfa(int s, int n) {
    for (int i = 1; i <= n; i++) {
        dist[i] = INF;
        inq[i] = false;
        cnt[i] = 0;
    }

    queue<int> q;
    dist[s] = 0;
    q.push(s);
    inq[s] = true;
    cnt[s] = 1;

    while (!q.empty()) {
        int u = q.front();
        q.pop();
        inq[u] = false;

        for (auto e : g[u]) {
            int v = e.to;
            long long w = e.w;
            if (dist[u] != INF && dist[v] > dist[u] + w) {
                dist[v] = dist[u] + w;
                cnt[v] = cnt[u] + 1;
                if (cnt[v] >= n) return false; // 存在负环
                if (!inq[v]) {
                    q.push(v);
                    inq[v] = true;
                }
            }
        }
    }
    return true; // 无负环
}

不过上面这种写法里 cnt[v] = cnt[u] + 1 更像“路径边数估计法”,竞赛中更常见的写法其实是“入队次数”统计:


更常用的负环判定模板

#include <bits/stdc++.h>
using namespace std;

const int N = 100005;
const long long INF = 1e18;

struct Edge {
    int to;
    long long w;
};

vector<Edge> g[N];
long long dist[N];
bool inq[N];
int cnt[N]; // 记录每个点入队次数

bool spfa(int s, int n) {
    for (int i = 1; i <= n; i++) {
        dist[i] = INF;
        inq[i] = false;
        cnt[i] = 0;
    }

    queue<int> q;
    dist[s] = 0;
    q.push(s);
    inq[s] = true;
    cnt[s]++;

    while (!q.empty()) {
        int u = q.front();
        q.pop();
        inq[u] = false;

        for (auto e : g[u]) {
            int v = e.to;
            long long w = e.w;
            if (dist[u] != INF && dist[v] > dist[u] + w) {
                dist[v] = dist[u] + w;
                if (!inq[v]) {
                    q.push(v);
                    inq[v] = true;
                    cnt[v]++;
                    if (cnt[v] >= n) return false; // 有负环
                }
            }
        }
    }

    return true;
}

这个版本在题目里最常见。


九、如果要判“图中任意负环”

上面模板判的是:

从源点 s 可达的负环

如果题目要求判整张图是否存在任意负环,常用做法有两个。

做法一:超级源点

建一个新点 0,向所有点连一条权值 0 的边,然后从 0 跑 SPFA。

这样整张图所有点都可达,就能检测整图负环。

做法二:所有点初始入队

更常见的写法是:

  • 把所有点都放进队列
  • dist[i] = 0
  • 直接开始跑

模板如下:

bool detect_negative_cycle(int n) {
    queue<int> q;
    for (int i = 1; i <= n; i++) {
        dist[i] = 0;
        inq[i] = true;
        cnt[i] = 1;
        q.push(i);
    }

    while (!q.empty()) {
        int u = q.front();
        q.pop();
        inq[u] = false;

        for (auto e : g[u]) {
            int v = e.to;
            long long w = e.w;
            if (dist[v] > dist[u] + w) {
                dist[v] = dist[u] + w;
                if (!inq[v]) {
                    q.push(v);
                    inq[v] = true;
                    cnt[v]++;
                    if (cnt[v] >= n) return true;
                }
            }
        }
    }
    return false;
}

十、SPFA 和 Bellman-Ford 的关系

可以把它们理解成:

  • Bellman-Ford:暴力扫所有边
  • SPFA:只扫“最近被更新的点”的出边

所以 SPFA 是 Bellman-Ford 的优化实现,而不是另一个完全不同的理论体系。


十一、SPFA 和 Dijkstra 的对比

Dijkstra

  • 只能处理 非负边
  • 复杂度优秀
  • 最短路首选

SPFA

  • 可以处理 负边
  • 能判 负环
  • 最坏复杂度不稳

所以竞赛里通常是:

  • 无负边:Dijkstra
  • 有负边/判负环:Bellman-Ford 或 SPFA
  • 卡 SPFA:要小心题目是否故意针对它

十二、常见优化

SPFA 有两个很经典的启发式优化。

1. SLF(Small Label First)

如果新入队点的 dist 比队首还小,就插到队首。
这样更小的点优先处理,有时会快很多。

一般用 deque 实现。

模板

void spfa(int s, int n) {
    for (int i = 1; i <= n; i++) {
        dist[i] = INF;
        inq[i] = false;
    }

    deque<int> q;
    dist[s] = 0;
    q.push_back(s);
    inq[s] = true;

    while (!q.empty()) {
        int u = q.front();
        q.pop_front();
        inq[u] = false;

        for (auto e : g[u]) {
            int v = e.to;
            long long w = e.w;
            if (dist[u] != INF && dist[v] > dist[u] + w) {
                dist[v] = dist[u] + w;
                if (!inq[v]) {
                    if (!q.empty() && dist[v] < dist[q.front()]) q.push_front(v);
                    else q.push_back(v);
                    inq[v] = true;
                }
            }
        }
    }
}

2. LLL(Large Label Last)

如果队首点的距离过大,就把它扔到队尾。
这个优化也比较经典,但实现和收益常常不如 SLF 直观。

OI 里最常见的是:

  • 普通队列版
  • deque + SLF

十三、典型应用:差分约束

SPFA 在差分约束里非常常见。

例如约束:

[ x_v - x_u \le w ]

可以转化为一条边:

[ u \to v,\ w ]

然后:

  • 求可行解
  • 判是否无解(有负环)

这类题里 SPFA 经常出现,因为:

  • 图里可能有负边
  • 需要判负环
  • 不一定是单纯的最短路问题

十四、竞赛里常见坑

1. 别把 SPFA 当万能最短路

很多新手会觉得:

“SPFA 能负边,也能正边,那我以后全用它。”

这是不对的。
因为在无负边图上,Dijkstra 通常更稳、更快,也更不容易被卡。


2. 最坏复杂度真的可能炸

不是说“理论最坏”,而是 真的有人专门卡 SPFA
尤其在一些老题、模板题、出题人熟悉数据构造的题里,SPFA 很危险。


3. 判负环时计数别写错

最常见两种计数:

  • 记录入队次数 cnt[v]
  • 记录路径边数估计

一般推荐用 入队次数,更稳、更常见。


4. INF 要够大

const long long INF = 1e18;

图上边权可能很大,距离也可能累加很大,别用太小的无穷。


5. 判整图负环和判源点可达负环不是一回事

  • 从源点出发跑:只能判源点可达部分
  • 整图判负环:要超级源点或所有点初始化入队

这个在题目里非常容易看漏。


十五、一句话理解 SPFA

SPFA 的本质就是:

把 Bellman-Ford 中“无脑扫描所有边”改成“只从最近被更新的点继续扩展”,从而减少很多无效松弛。


十六、总结

SPFA 的关键词可以概括为:

  • 单源最短路
  • 可处理负边
  • 可判负环
  • Bellman-Ford 队列优化
  • 最坏复杂度不稳

你可以把它记成下面这张口袋版:

什么时候用

  • 有负边
  • 需要判负环
  • 差分约束

什么时候别乱用

  • 普通非负边最短路
  • 题目可能卡复杂度
  • 大图高强度数据

最常用模板

  • 普通队列 SPFA
  • 判负环版 SPFA
  • deque + SLF 优化版

Updated: