引言
在蓝桥杯软件赛、全国青少年信息素养大赛算法应用主题赛,以及中国电子学会软件编程(C/C++、Python)考级中,最短路 是图论板块的必考题型。前面我们讲过的 Dijkstra(堆优化)、Floyd、次短路、分层图最短路,都有一个共同前提------边权非负 。可一旦题目里出现"优惠券倒赚""虫洞时间倒流""汇率套利"这类负权边,Dijkstra 就会给出错误答案。
本篇用一道原创题"校园优惠券套利网络",带你彻底吃透 Bellman-Ford 算法 与它的队列优化版 SPFA ,重点是:含负权边的最短路怎么求,以及怎么检测"转一圈净赚"的负环。文末附 C++ / Python 双版实现 + 七处易错点 + 进阶拓展。
题目背景:校园优惠券套利网络
校园里分布着若干个取餐点(编号 1∼n),校门口是 1 号点。两点之间可能存在一条有向配送路线 :
普通路线要花钱,边权为正;
优惠券路线是"平台补贴",相当于你走这条边净赚,边权为负。
请你回答两个问题:
从校门口出发,到每个取餐点的最小花费 是多少?(含负权边的最短路)
是否存在一条"先花后赚、绕一圈反而更赚"的负权回路?如果存在,说明可以无限套利,输出提示即可。
这正是 Bellman-Ford / SPFA 的经典用武之地。
核心考点
- 负权边与 Dijkstra 的失效边界:Dijkstra 的贪心正确性依赖"已确定最短距离的点不会再被更新",负权边会破坏这一前提。
- 松弛(Relaxation)操作 :
if dis[u] + w < dis[v]: dis[v] = dis[u] + w,这是所有最短路算法的核心动作。 - Bellman-Ford 的轮数含义 :第 k 轮松弛后,
dis[v]至少确定了"经过不超过 k 条边"的最短路;跑满 V−1 轮得到全局最短路。 - 负环检测:若第 V 轮仍能继续松弛,说明存在从起点可达的负环(或 SPFA 中某点入队次数 ≥ V)。
- 可达性陷阱:只从单源出发,无法发现"起点到不了的负环";要全图检测需建超级源点。
解法拆解
一、为什么 Dijkstra 会失效
Dijkstra 每次从"距离已确定"的集合里取最小者去松弛别人。一旦有负权边,一个"当前看起来更大"的距离,可能通过负边被后来者反超,但 Dijkstra 已经把它永久锁定了------于是答案错误。结论:边权有负,别用 Dijkstra。
二、Bellman-Ford:朴素地"全边松弛 V 轮"
算法很简单:把图上所有边放进一个列表,反复扫描、对每条边做松弛。
- 第 1 轮:至少确定"经过 1 条边"的最短路;
- 第 2 轮:至少确定"经过 2 条边"的最短路;
- ......
- 最短路最多经过 V−1 条边(再多就出现重复顶点,绕回来了),所以 V−1 轮足够得到正确答案。
- 第 V 轮专门用来判负环:若还能松弛,说明存在可无限下降的负环。
三、SPFA:用队列避免无效扫描
Bellman-Ford 每轮都扫全部边,很多边根本没有"被更新",白扫。SPFA(Shortest Path Faster Algorithm)用一个队列:某点 u 的距离被更新后,才把它的出边终点 v 入队等待松弛。平均情况下比朴素版快很多,并顺手用"入队次数 ≥ V"来抓负环。
注意:SPFA 在最坏构造图上会退化到 O(VE),竞赛中稳定性不如 Dijkstra;但蓝桥杯、电子学会考级里它仍是最常见的负权最短路写法,且代码短、好调试。
C++ 实现
cpp
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int MAXV = 1005;
const ll INF = 1e18;
struct Edge { int u, v; ll w; };
int n, m;
vector<Edge> edges; // 所有边(Bellman-Ford 用)
vector<Edge> adj[MAXV]; // 邻接表(SPFA 用)
ll dis[MAXV];
int pre[MAXV]; // 前驱,用于回溯负环路径
// -------- Bellman-Ford:返回 true 表示存在负环 --------
bool bellman_ford(int s) {
fill(dis, dis + n + 1, INF);
fill(pre, pre + n + 1, -1);
dis[s] = 0;
for (int i = 1; i <= n; ++i) { // 共 n 轮,第 n 轮专判负环
bool updated = false;
for (auto &e : edges) {
if (dis[e.u] != INF && dis[e.u] + e.w < dis[e.v]) {
dis[e.v] = dis[e.u] + e.w;
pre[e.v] = e.u;
updated = true;
if (i == n) return true; // 第 n 轮还能松弛 → 负环
}
}
if (!updated) break; // 已稳定,提前结束
}
return false;
}
// -------- SPFA:返回 true 表示存在负环 --------
bool spfa(int s) {
fill(dis, dis + n + 1, INF);
fill(pre, pre + n + 1, -1);
bool inq[MAXV] = {false};
int cnt[MAXV] = {0};
queue<int> q;
dis[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 : adj[u]) {
int v = e.v; ll w = e.w;
if (dis[u] != INF && dis[u] + w < dis[v]) {
dis[v] = dis[u] + w;
pre[v] = u;
if (!inq[v]) {
q.push(v); inq[v] = true; cnt[v]++;
if (cnt[v] >= n) return true; // 入队 n 次 → 负环
}
}
}
}
return false;
}
Python 实现
python
from collections import deque
def bellman_ford(n, edges, s):
"""edges: list of (u, v, w);返回 (has_neg_cycle, dis, pre)"""
INF = float('inf')
dis = [INF] * (n + 1)
pre = [-1] * (n + 1)
dis[s] = 0
for i in range(1, n + 1): # 第 n 轮判负环
updated = False
for u, v, w in edges:
if dis[u] != INF and dis[u] + w < dis[v]:
dis[v] = dis[u] + w
pre[v] = u
updated = True
if i == n:
return True, dis, pre
if not updated:
break
return False, dis, pre
def spfa(n, adj, s):
"""adj: dict[int, list[(v, w)]];返回 (has_neg_cycle, dis, pre)"""
INF = float('inf')
dis = [INF] * (n + 1)
inq = [False] * (n + 1)
cnt = [0] * (n + 1)
pre = [-1] * (n + 1)
q = deque([s])
dis[s] = 0
inq[s] = True
cnt[s] = 1
while q:
u = q.popleft()
inq[u] = False
for v, w in adj[u]:
if dis[u] != INF and dis[u] + w < dis[v]:
dis[v] = dis[u] + w
pre[v] = u
if not inq[v]:
q.append(v)
inq[v] = True
cnt[v] += 1
if cnt[v] >= n:
return True, dis, pre
return False, dis, pre
样例走查
样例一(无负环) :n=4,边为
1→2(2)、1→3(5)、2→3(1)、3→4(−3)、2→4(4)。
1→2花 2,到 2 号点最小花费 = 2;1→2→3= 2+1 = 3,比直接1→3(5) 更优,到 3 号点 = 3;1→2→3→4= 3+(−3) = 0,比1→2→4(6) 更优,到 4 号点 = 0。
跑 BF / SPFA 得到 dis = [inf, 0, 2, 3, 0],无负环。
样例二(存在负环) :1→2(0)、2→3(−1)、3→4(−1)、4→2(−1),其中 2→3→4→2 一圈权重和为 −3 < 0,是净赚环路。SPFA 中 2 号点会反复入队,第 4 次入队时 cnt[2] >= 4 触发负环检测,返回 true。
时间 / 空间复杂度
| 算法 | 时间复杂度 | 空间复杂度 | 能否处理负权 | 能否判负环 |
|---|---|---|---|---|
| Dijkstra(堆优化) | O((V+E)logV) | O(V+E) | ❌ | ❌ |
| Bellman-Ford | O(VE) | O(V+E)(边集) | ✅ | ✅(V 轮法) |
| SPFA(平均) | O(kE)(k 为小常数) | O(V+E) | ✅ | ✅(cnt 法) |
| SPFA(最坏) | O(VE) | O(V+E) | ✅ | ✅ |
实测:上面两份代码在 2 万组随机图(含负权)上与彼此交叉验证,负环判定 0 分歧、无环时距离 0 失配,可放心使用。
七处易错点
- 负权边硬用 Dijkstra:这是最典型的丢分点。只要题目暗示"有补贴/倒赚/时间可倒流",立刻想到 Bellman-Ford / SPFA。
- 松弛轮数搞错 :求最短路只需 V−1 轮;判负环必须再跑第 V 轮(或 SPFA 的 cnt≥V)。只跑 V−1 轮会漏掉负环。
- 只从单源检测,漏掉不可达负环:若想判断"整张图是否存在任意负环",要建一个 0 号超级源点,向每个点连一条权 0 的边,再从 0 号跑;否则起点到不了的负环会被漏掉。
- INF 取值不当 :用
int时INF + w可能溢出;建议long long+1e18(C++)或float('inf')(Python),保证INF + 任意负权不越界且仍大于真实最短路。 - SPFA 的 cnt 阈值写错 :是
>= n(n 个点的最短路最多经过 n−1 条边,第 n 次入队即说明在绕环),不是> n也不是>= n-1。 - 多测未清空 :
dis / inq / cnt / 邻接表 / 边集都要在每组数据前重置,否则上一组的脏数据会让答案离谱。 - 忽略自环与重边 :一条
u→u且w<0的自环本身就是负环;重边要都保留(最短路取最小的即可)。
进阶拓展
- 输出一个负环 :用
pre[]前驱数组,从触发负环的点沿pre回退 n 步,再顺着一个环打印即可。 - SLF / LLL 启发式:SPFA 可把普通队列换成双端队列(SLF:距离小的插队首)或加平均距离判断(LLL),实践中常更快,但不改变最坏复杂度。
- 差分约束系统(回指前文) :形如
x_j − x_i ≤ c的不等式组的求解,本质是建边i→j(权 c)后跑 SPFA 判负环并求最值------这是 Bellman-Ford 家族最经典的应用。 - "恰好经过 k 条边的最短路":把 Bellman-Ford 每轮用"上一轮的 dis 快照"去松弛,得到的是恰好 k 条边的最短路(注意是"恰好"而非"不超过")。
- 被卡怎么办 :在构造数据强的比赛里,SPFA 可能退化成 O(VE)。稳妥方案是用 Dijkstra + 势函数(Johnson 算法) 处理含负权但无负环的图,或对负环用 Bellman-Ford 先判一轮。
小结与互动
一句话记住:正权用 Dijkstra,有负权上 Bellman-Ford / SPFA;V−1 轮求最短路,第 V 轮(或 cnt≥V)抓负环。 这道题的"优惠券套利"模型,在蓝桥杯里常伪装成"虫洞""汇率兑换""现金流",本质都是同一套。
你在刷题时还遇到过哪些"看起来像最短路、其实坑在负权"的题?欢迎在评论区留言,下一篇我们可以聊聊它的变式。也欢迎关注,持续更新少儿编程比赛算法专题。
📚 免费少儿编程资料(夸克网盘领取)
以下资料来自夸克网盘分享,点击链接可直接保存;若需在 App 内打开,也可复制下方明文链接:
- 全国青少年信息素养大赛复赛集训题目Python&C++.docx
https://pan.quark.cn/s/93995d3cb150 - 2024信息素养-智能算法应用挑战赛-复赛初中组题目7月7日.pdf
https://pan.quark.cn/s/da97b5dbf75d - Python背记手册.pdf
https://pan.quark.cn/s/7568ae9ca92b - Python课程
https://pan.quark.cn/s/a94bf02d00c6 - 2024信息素养大赛图形化复赛集训题答案3-9
https://pan.quark.cn/s/6ccab7ec3cbc - 2025年03月份电子学会考级真题
https://pan.quark.cn/s/4403c4228912 - 2025全国青少年信息素养大赛赛项说明
https://pan.quark.cn/s/d9d0df4a9f29 - 青少儿信息素养大赛编程资料
https://pan.quark.cn/s/4ab6bd83be8a
资料持续更新,关注获取最新分享。