0题目原文(本地存了一份)
原题在洛谷上(页头有链接)。别人的网站不归我们管,打不开、改版、题号调整都可能发生。 所以每个解析页都把题面转录一份存在本地,跟着仓库一起进版本库。
转录自洛谷 P3385,日期见页头。两边不一致时信原站。
题目描述
给定一个 n 个点的有向图,请求出图中是否存在从顶点 1 出发能到达的负环。
负环的定义是:一条边权之和为负数的回路。
输入格式
本题单测试点有多组测试数据。
输入的第一行是一个整数 T,表示测试数据的组数。对于每组数据的格式如下:
第一行有两个整数,分别表示图的点数 n 和接下来给出边信息的条数 m。
接下来 m 行,每行三个整数 u, v, w。
- 若
w ≥ 0,则表示存在一条从u至v边权为w的边,还存在一条从v至u边权为w的边。 - 若
w < 0,则只表示存在一条从u至v边权为w的边。
输出格式
对于每组数据,输出一行一个字符串,若所求负环存在,则输出 YES,否则输出 NO。
说明/提示
数据规模与约定
对于全部的测试点,保证:
1 ≤ n ≤ 2 × 10³,1 ≤ m ≤ 3 × 10³。1 ≤ u, v ≤ n,−10⁴ ≤ w ≤ 10⁴。1 ≤ T ≤ 10。
提示
请注意,m 不是图的边数。
时限 2 秒,内存 250 MB。
输入输出样例
输入
2 3 4 1 2 2 1 3 4 2 3 1 3 1 -3 3 3 1 2 3 2 3 4 3 1 -8
输出
NO YES
第一组:3 → 1 那条 -3 的边是单向的,1 → 2 → 3 → 1 绕一圈是 2 + 1 − 3 = 0,不负 ⇒ NO。
第二组:1 → 2 → 3 → 1 是 3 + 4 − 8 = −1 ⇒ YES。
★ 注意两组数据的边数 m 都是「行数」,而真实边数比它多 —— 见第 ⑤ 步。
1★ 关键的一步:这道题问的不是「图里有没有负环」
图的角落里躺着一个负环,可 1 号点根本过不去 —— 那这道题的答案是 NO。
| 「只算 1 号点能走到的」这件事 | |
|---|---|
| Floyd | 要手动补一句 d[1][k] < INF(它算的是全图,天生不知道 1 号是谁) |
| Bellman-Ford / SPFA | ★ 白送 —— dist 从 1 号点初始化,走不到的点永远是 INF |
⇒ 这道题天生适合 SPFA / Bellman-Ford,而它俩判负环也是白送的:
- Bellman-Ford:跑完 n−1 轮,第 n 轮还能松弛成功就有负环;
- SPFA:
cnt[v]记「v 的最短路用了几条边」,cnt[v] ≥ n就有负环 (n 个点的简单路最多 n−1 条边,用到 n 条必然重复经过某个点、也就是绕了个环, 而绕它让距离变小 —— 那就是负环)。
// 洛谷 P3385 【模板】负环 —— ★ 这一版就能 AC//// 问的是「**从顶点 1 出发能到达**的负环」——正是本章第 1 步那件事,// 而 SPFA / Bellman-Ford **天生只报告 s 能走到的负环**(dist 从 s 初始化)。//// 三处这道题独有的细节:// ① 读边:w ≥ 0 存双向,w < 0 只存 u → v —— **正负号说了算**// ② cnt[v] 记的是「最短路用了几条边」,≥ n 就有负环(n 个点的简单路最多 n−1 条边)// ③ 多组数据,每组都要把 g / dist / cnt / inq 清干净
#include <bits/stdc++.h>using namespace std;
int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int T; cin >> T; while (T--) { int n, m; cin >> n >> m; vector<vector<pair<int, int>>> g(n + 1); for (int e = 0; e < m; e++) { int u, v, w; cin >> u >> v >> w; g[u].push_back({v, w}); if (w >= 0) g[v].push_back({u, w}); // ★ 正负号说了算 }
const int INF = 0x3f3f3f3f; vector<int> dist(n + 1, INF), cnt(n + 1, 0); vector<char> inq(n + 1, 0); queue<int> q; dist[1] = 0; inq[1] = 1; q.push(1); bool neg = false; while (!q.empty() && !neg) { int u = q.front(); q.pop(); inq[u] = 0; // ★ 出队就清(本章第 7 步) for (auto [v, w] : g[u]) if (dist[u] + w < dist[v]) { dist[v] = dist[u] + w; cnt[v] = cnt[u] + 1; // ★ 记的是「几条边」,不是「入队几次」 if (cnt[v] >= n) { neg = true; break; } if (!inq[v]) { inq[v] = 1; q.push(v); } } } cout << (neg ? "YES" : "NO") << "\n"; } return 0;}点「运行 ▶」看结果
2★★★ 第一个错法:读边时不看正负号
// ✗ 错法①:边一律存两遍 —— ★★★ 这一页的主线//// 「无向图存两遍」是练了二十道题练出来的手感,而这道题的题面把规则改了:// 若 w ≥ 0,存 u→v 和 v→u;// 若 w < 0,**只存 u→v**。//// 一律存双向的后果是灾难性的:一条 w < 0 的边被存成来回两条,// 那 u → v → u 绕一圈就是 2w < 0 —— **它自己就是一个负环**。// ⇒ 只要输入里有一条从 1 号点走得到的负权边,它就无脑输出 YES。
#include <bits/stdc++.h>using namespace std;
int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int T; cin >> T; while (T--) { int n, m; cin >> n >> m; vector<vector<pair<int, int>>> g(n + 1); for (int e = 0; e < m; e++) { int u, v, w; cin >> u >> v >> w; g[u].push_back({v, w}); g[v].push_back({u, w}); // ✗ 不看正负号,一律两遍 } const int INF = 0x3f3f3f3f; vector<int> dist(n + 1, INF), cnt(n + 1, 0); vector<char> inq(n + 1, 0); queue<int> q; dist[1] = 0; inq[1] = 1; q.push(1); bool neg = false; while (!q.empty() && !neg) { int u = q.front(); q.pop(); inq[u] = 0; for (auto [v, w] : g[u]) if (dist[u] + w < dist[v]) { dist[v] = dist[u] + w; cnt[v] = cnt[u] + 1; if (cnt[v] >= n) { neg = true; break; } if (!inq[v]) { inq[v] = 1; q.push(v); } } } cout << (neg ? "YES" : "NO") << "\n"; } return 0;}点「运行 ▶」看结果
题面把规则写得明明白白:
w >= 0 -> 存 u->v 和 v->u
w < 0 -> 只存 u->v一律存两遍的后果是灾难性的:一条 w < 0 的边被存成来回两条,
那 u → v → u 绕一圈就是 2w < 0 —— 它自己就是一个负环。
⇒ 只要输入里有一条 1 号点走得到的负权边,它就无脑输出 YES。
| 题 | 谁说了算 | 那一行 |
|---|---|---|
| P1339 / P1462 / B3647 | 题面一句话 | 必须写 |
| P1629 | 题面一句话 | 写了当场就错 |
| P1073 | ★ 同一张输入里,第三个数 z 说了算 |
一半写一半不写 |
| P3385(这道题) | ★★ 同一张输入里,边权自己的正负号说了算 | 一半写一半不写 |
⇒ 第 52 章那条「上一章的正确写法就是这一章的 bug」的第六次现场 —— 它从来不是一个能背的习惯。
| 300 个种子(每个 2~3 组数据) | 默认档 | ★ 全非负边档 | ★ 负环只在走不到的地方 |
|---|---|---|---|
| 「一律存双向」被抓 | 180 | ★ 精确的 0 | 300 |
★ 「全非负边」那一档是这个 0 的自检:所有边本来就该存双向 ⇒ 这个错法在那一档什么都没改。 ⇒ 又一次「造一档违反 / 抽掉那个条件的数据,同时给 0 做自检、又称出那句话的分量」。
3★★★ 第二个错法:从所有点出发 —— 它是下一道题的正解
// ✗ 错法②:从**所有点**出发(等价于加一个超级源点)//// 这是判「图里有没有负环」的标准写法 —— 而这道题问的是// 「**从顶点 1 出发能到达**的负环」。// ⇒ 图的角落里躺着一个 1 号点过不去的负环时,它会多报一个 YES。//// ★★★ 而下一道 [P2850] 问的恰恰是「不管从哪出发」——// 同一段代码,在那道题上是正解,在这道题上是错的。
#include <bits/stdc++.h>using namespace std;
int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int T; cin >> T; while (T--) { int n, m; cin >> n >> m; vector<vector<pair<int, int>>> g(n + 1); for (int e = 0; e < m; e++) { int u, v, w; cin >> u >> v >> w; g[u].push_back({v, w}); if (w >= 0) g[v].push_back({u, w}); } vector<int> dist(n + 1, 0), cnt(n + 1, 0); // ✗ 所有点初值 0、全部入队 vector<char> inq(n + 1, 1); queue<int> q; for (int i = 1; i <= n; i++) q.push(i); bool neg = false; while (!q.empty() && !neg) { int u = q.front(); q.pop(); inq[u] = 0; for (auto [v, w] : g[u]) if (dist[u] + w < dist[v]) { dist[v] = dist[u] + w; cnt[v] = cnt[u] + 1; if (cnt[v] >= n) { neg = true; break; } if (!inq[v]) { inq[v] = 1; q.push(v); } } } cout << (neg ? "YES" : "NO") << "\n"; } return 0;}点「运行 ▶」看结果
这是判「图里有没有负环」的标准写法(等价于加一个连向所有点、边长 0 的超级源点)。 而这道题问的是「从顶点 1 出发能到达的」。
| 问的是什么 | 该怎么初始化 | |
|---|---|---|
| P3385(这道题) | 从 1 出发能到达的负环 | dist[1] = 0,其余 INF |
| P2850(同一张题单) | 「从某块田地出发」⇒ 全图有没有负环 | 所有 dist = 0,全部入队 |
⇒ 这就是本章自测最后一条那道思考题的考场版: 「如果题目问的是图里有没有负环(不管从哪出发),三份代码各要改哪里」。 答案是只改初始化那一行,而两道题把这一行的两个取值都考了一遍。
| 300 个种子 | 默认档 | ★★★ 负环只注入「1 走不到的那一块」 |
|---|---|---|
| 「从所有点出发」被抓 | ⚠ 精确的 0 | ★ 300 / 300 |
⚠⚠ 默认档那个 0 是「结构性」的:随机小图上,负环差不多总是 1 号点走得到的 (那一档 776 组里 422 组答案就是 YES)—— 它不是抓不到,是问不出这个问题。 ⇒ 又一次「抓不到时别加轮数,去想那条线在哪儿、然后照着它造」。
本章第 9 步造「走不到的负环」用的是把点分成两块:R(含起点)和 U,
只允许 R→R、U→U、U→R,永远不连 R→U。
我照抄了,然后档位 2 的 776 组答案全是 YES —— 一个都没造出来。
真因是这道题的读边规则:一条 w ≥ 0 的 U→R 边要存两遍,
那第二遍就是一条 R→U —— 1 号点当场走进了 U。
⇒ 修法两条:R 块内只用非负权(怎么存都不会有负环)、
跨块的 U→R 强制取负(负边只存单向,堵死回头路)。改完这一档就是 300 / 300。
★★ 教训不是「那一招没用」,是 「生成器的招式也有前提」 —— 本章那一招的前提是「边都是有向的」,而这道题把这个前提拿掉了。
4⚠ 第三个错法:多组数据没清干净
// ✗ 错法③:多组数据没清干净//// 题面第一句就是「**本题单测试点有多组测试数据**」。// 这一版把 dist / cnt / inq 提到了循环外面(很多人写惯了全局数组就是这样),// 于是上一组的 dist 会当成这一组的初值 —— 而上一组可能刚跑出一堆负数。
#include <bits/stdc++.h>using namespace std;const int INF = 0x3f3f3f3f;const int MAXN = 2005;int dist_[MAXN], cnt[MAXN];char inq[MAXN];
int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int T; cin >> T; for (int i = 0; i < MAXN; i++) dist_[i] = INF; // ✗ 只在最外面初始化了一次 while (T--) { int n, m; cin >> n >> m; vector<vector<pair<int, int>>> g(n + 1); for (int e = 0; e < m; e++) { int u, v, w; cin >> u >> v >> w; g[u].push_back({v, w}); if (w >= 0) g[v].push_back({u, w}); } queue<int> q; dist_[1] = 0; inq[1] = 1; q.push(1); bool neg = false; while (!q.empty() && !neg) { int u = q.front(); q.pop(); inq[u] = 0; for (auto [v, w] : g[u]) if (dist_[u] + w < dist_[v]) { dist_[v] = dist_[u] + w; cnt[v] = cnt[u] + 1; if (cnt[v] >= n) { neg = true; break; } if (!inq[v]) { inq[v] = 1; q.push(v); } } } cout << (neg ? "YES" : "NO") << "\n"; } return 0;}点「运行 ▶」看结果
题面第一句就是「本题单测试点有多组测试数据」。 写惯了全局数组的人很容易把初始化也留在最外面,于是上一组跑出来的一堆负 dist 成了这一组的初值。
| 300 个种子 | 默认档 | ★ 「第一组必有负环」档 | 全非负边档 |
|---|---|---|---|
| 「没清干净」被抓 | 136 | ⚠ 66 | 0 |
⚠⚠ 这一档是我造反了的:专门让第一组带负环,本以为残留会更狠,实测反而只有一半。 道理事后很清楚:默认档里后面几组也常常带负环,一路残留下去污染得更彻底; 而「只有第一组有」意味着后面几组是干净的正权图,残留的负 dist 反而常常被正权边盖回去。
★ 这是「拧一个旋钮之前,先量一下它到底控制着什么」的又一次 —— 「专门为这个 bug 造的档」不一定比默认档强,这句话只能量。
5⚠ 题面「提示」那一节只有一句话,而它是一句算术
请注意,
m不是图的边数。
// ✗ 错法⑤:链式前向星的边数组**按 m 开**//// 题面「提示」那一节只有一句话:**「请注意,m 不是图的边数。」**// 它说的就是这件事:w ≥ 0 的那些行**各要存两条**边 ⇒ 真实边数最多 2m = 6000。//// ⚠ 数组开小是未定义行为(越界写别人的内存),演示不出可复现的结果 ——// 所以这一版**加了一个出口**:一旦要写的下标越过数组,就打 `OVERFLOW` 退出。// 真交上去的话,那儿是一个安静的越界写。
#include <bits/stdc++.h>using namespace std;const int MAXM = 3005; // ✗ 按题面的 m ≤ 3000 开int head_[2005], nxt[MAXM], to_[MAXM], wt[MAXM], tot;
int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int T; cin >> T; while (T--) { int n, m; cin >> n >> m; tot = 0; for (int i = 0; i <= n; i++) head_[i] = -1; auto add = [&](int u, int v, int w) { if (tot >= MAXM) { cout << "OVERFLOW\n"; exit(0); } to_[tot] = v; wt[tot] = w; nxt[tot] = head_[u]; head_[u] = tot++; }; for (int e = 0; e < m; e++) { int u, v, w; cin >> u >> v >> w; add(u, v, w); if (w >= 0) add(v, u, w); } const int INF = 0x3f3f3f3f; vector<int> dist(n + 1, INF), cnt(n + 1, 0); vector<char> inq(n + 1, 0); queue<int> q; dist[1] = 0; inq[1] = 1; q.push(1); bool neg = false; while (!q.empty() && !neg) { int u = q.front(); q.pop(); inq[u] = 0; for (int i = head_[u]; i != -1; i = nxt[i]) { int v = to_[i], w = wt[i]; if (dist[u] + w < dist[v]) { dist[v] = dist[u] + w; cnt[v] = cnt[u] + 1; if (cnt[v] >= n) { neg = true; break; } if (!inq[v]) { inq[v] = 1; q.push(v); } } } } cout << (neg ? "YES" : "NO") << "\n"; } return 0;}点「运行 ▶」看结果
题面给的 m |
3 000(行数) |
| 真实边数上界 | 2m = ★ 6 000(w ≥ 0 的每一行都要存两条) |
⚠ 数组开小是未定义行为(安静地越界写别人的内存),演示不出可复现的结果 ——
所以这一版加了一个出口:要写的下标越过数组就打 OVERFLOW 退出。
真交上去的话,那儿是一个不会报错的越界写。
(这一招第 30 章 B3625 用过一次:演示会死循环的写法,得先给它一个出口。)
★ 顺带把「要不要 long long」也算完:dist 最低跌到
−(n−1) × 10⁴ = −19 990 000(发现负环就立刻停,不会一路跌下去),
int 的上限是 2 147 483 647 ⇒ 余量 107 倍,不用 long long。
6★ 两种 cnt 判据 —— 而这一个「看着像 bug」的其实是对的
// ?错法④:cnt 记「入队几次」而不是「最短路用了几条边」//// 这是流传很广的另一种写法。它到底对不对,这一页量了一遍 —— 结论写在页面上。// (本书那条老规矩:**流传很广的提醒,量完之后可能要反过来说。**)
#include <bits/stdc++.h>using namespace std;
int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int T; cin >> T; while (T--) { int n, m; cin >> n >> m; vector<vector<pair<int, int>>> g(n + 1); for (int e = 0; e < m; e++) { int u, v, w; cin >> u >> v >> w; g[u].push_back({v, w}); if (w >= 0) g[v].push_back({u, w}); } const int INF = 0x3f3f3f3f; vector<int> dist(n + 1, INF), cnt(n + 1, 0); vector<char> inq(n + 1, 0); queue<int> q; dist[1] = 0; inq[1] = 1; q.push(1); cnt[1] = 1; bool neg = false; while (!q.empty() && !neg) { int u = q.front(); q.pop(); inq[u] = 0; for (auto [v, w] : g[u]) if (dist[u] + w < dist[v]) { dist[v] = dist[u] + w; if (!inq[v]) { inq[v] = 1; q.push(v); if (++cnt[v] >= n) { neg = true; break; } // ?数的是入队次数 } } } cout << (neg ? "YES" : "NO") << "\n"; } return 0;}点「运行 ▶」看结果
流传的写法有两种:cnt[v] 记「v 的最短路用了几条边」,或者记「v 入过几次队」。
草稿里我把后者当成错法,实测五个档位、1500 组数据,一次都没和正解不同。
没有负环时,每次成功松弛都让 dist[v] 严格变小,而 dist[v] 只能取「最多走 k 条边」
这 n 个值里的一个 ⇒ 一个点最多入队 n−1 次。
所以「入队 ≥ n 次」同样是负环的充分必要条件。
| 200 张图(一半带负环) | 记「几条边」 | 记「入队几次」 |
|---|---|---|
| 结论一致 | ★ 200 / 200 | —— |
| 松弛尝试次数 | 191 024 | ★ 315 726(1.65 倍) |
⇒ 两个都对,但记边数的那个发现得更早:cnt[v] = cnt[u] + 1 是顺着路径一步一步累加的,
一条负环转不了几圈就越过 n;而入队计数要那个点真的被反复弹进弹出。
★ 这是本书那条老规矩的又一次:流传很广的说法,量完之后可能要改的是「它错在哪」, 而不是「它错没错」。(第 10 章 P1068 那条的同款。)
⚠ 而「1500 组一次没错」这句话是配了自检才敢写的:这一版在官方样例第二组、 以及默认档 422 组答案为 YES 的数据上都真的打出了 YES ⇒ 这段判负环的代码是活的, 不是一个从不触发的空壳(第 19 章 P2240 立的那条规矩)。
7★ 对拍这一页:参照物走的是 Floyd
Floyd 和 SPFA 一个字都不共享(没有队列、没有 inq、没有 cnt),所以它是最合适的参照物。
⚠ 而它只能当参照物,不能当解法:n = 2000 ⇒ 三重循环 8 × 10⁹ 次,
本机一组数据就要 2.21 秒,而题面 T 可以到 10、时限只有 2 秒。
(P1332 那条:参照物和解法本来就是两回事。)
| 300 个种子 | 默认档 | ★ 恒 YES 档 | ★★★ 负环只在走不到处 | 全非负档 | 第一组必有负环 |
|---|---|---|---|---|---|
| SPFA ≡ Floyd | ★ 0 | ★ 0 | ★ 0 | ★ 0 | ★ 0 |
| Bellman-Ford ≡ Floyd | ★ 0 | ★ 0 | ★ 0 | ★ 0 | ★ 0 |
| 记「入队几次」≡ Floyd | ★ 0 | ★ 0 | ★ 0 | ★ 0 | ★ 0 |
| 一律存双向 | 180 | ⚠ 0 | 300 | ★ 0(自检) | 227 |
| 从所有点出发 | ⚠ 0 | ⚠ 0 | ★ 300 | 0 | 2 |
| 多组没清干净 | 136 | 286 | 0 | 0 | ⚠ 66 |
| ⚠ 这一档答案为 YES 的组数 | 422 / 776 | 776 / 776 | 0 / 776 | 0 / 776 | 337 / 776 |
「负环必在 1 可达处」那一档 776 组全是 YES,于是三个错法里两个一起变成 0 —— 它们全都是「多报 YES」型的,在一个答案本来就是 YES 的档位上,永远露不了馅。
⇒ 这就是那条老规矩的第 N 次:「一致」有两种 —— 都算对了,和都没算。 报抓获率之前先看最后一行:这一档到底有多少组问得出你想问的问题。 (顺带:「全非负边」那一档 0 / 776 全是 NO,它只配当自检档,不能拿来比抓获率。)
8度量程序和生成器
9一页纸
| ★ 关键的一步 | 问的是「从 1 出发能到达的负环」⇒ SPFA / Bellman-Ford 白送,Floyd 要手动补 |
| ★★★ 读边 | 正负号说了算(w ≥ 0 两遍、w < 0 一遍)—— 一律双向 ⇒ 一条负边自己就是负环,抓 180 |
| ★★★ 从所有点出发 | 默认档精确的 0(问不出),负环注进「走不到那一块」就是 300 / 300;★ 而它是下一道题的正解 |
| ⚠⚠ 生成器 | 本章那招「分两块」不能照抄 —— 非负的 U→R 边会被存成 R→U,第一版 776 组全废 |
| ⚠ 多组数据 | 没清干净抓 136;⚠ 而「第一组必有负环」那个专门档反而只有 66 |
| ⚠ 「m 不是边数」 | 真实边数上界 2m = 6000;★ dist 最低 −19 990 000 ⇒ 不用 long long |
| ★ 两种 cnt | 都对(1500 组 0 不同,能证);记边数的发现更早,松弛少 1.65 倍 |