0题目原文(本地存了一份)
原题在洛谷上(页头有链接)。别人的网站不归我们管,打不开、改版、题号调整都可能发生。 所以每个解析页都把题面转录一份存在本地,跟着仓库一起进版本库。
转录自洛谷 P4017,日期见页头。两边不一致时信原站。
题目背景
你知道食物链吗?Delia 生物考试的时候,数食物链条数的题目全都错了,因为她总是重复数了几条或漏掉了几条。 于是她来就来求助你,然而你也不会啊!写一个程序来帮帮她吧。
题目描述
给你一个食物网,你要求出这个食物网中最大食物链的数量。
(这里的「最大食物链」,指的是生物学意义上的食物链,即 最左端是不会捕食其他生物的生产者,最右端是不会被其他生物捕食的消费者。)
Delia 非常急,所以你只有 1 秒的时间。
由于这个结果可能过大,你只需要输出总数模上 80112002 的结果。
输入格式
第一行,两个正整数 n、m,表示生物种类 n 和吃与被吃的关系数 m。
接下来 m 行,每行两个正整数,表示被吃的生物 A 和吃 A 的生物 B。
输出格式
一行一个整数,为最大食物链数量模上 80112002 的结果。
说明/提示
各测试点满足以下约定:
| 测试点编号 | n |
m |
|---|---|---|
| 1, 2 | ≤ 40 | ≤ 400 |
| 3, 4 | ≤ 100 | ≤ 2 × 10³ |
| 5, 6 | ≤ 10³ | ≤ 6 × 10⁴ |
| 7, 8 | ≤ 2 × 10³ | ≤ 2 × 10⁵ |
| 9, 10 | ≤ 5 × 10³ | ≤ 5 × 10⁵ |
对于 100% 的数据,1 ≤ n ≤ 5 × 10³,1 ≤ m ≤ 5 × 10⁵。
【补充说明】数据中不会出现环,满足生物学的要求。
输入输出样例
输入
5 7 1 2 1 3 2 3 3 5 2 5 4 5 3 4
输出
5
五条食物链:1→2→3→4→5、1→2→3→5、1→2→5、1→3→4→5、1→3→5。
⚠ 注意 4 不是生产者(它被 3 吃),所以 4→5 不能单独算一条。
1★ 正解:拓扑序上的计数 DP
f[v] = 从任意一个生产者走到 v 的路径条数
· 生产者(入度为 0) ⇒ f = 1
· 其余 f[v] = Σ f[u](u → v)
答案 = Σ f[v],v 取遍出度为 0 的点
// ★★ P4017 正解:**拓扑序上的计数 DP**。//// f[v] = 从任意一个「生产者」走到 v 的路径条数// · 生产者 = **入度为 0** 的点(不捕食别人)⇒ f = 1;// · 其余点 f[v] = Σ f[u](u → v 的所有前驱);// · 答案 = Σ f[v],v 取遍**出度为 0** 的点(不被别人吃)。//// ⇒ 两句话要一起读:「最左端是生产者、最右端是消费者」——// **初值只给入度为 0 的点、答案只收出度为 0 的点**,两头都不能放宽(本页两个错法各错一头)。//// ⚠ 模数是 **80112002**,不是那个眼熟的 10⁹+7。// ★ 而它顺带把「要不要 long long」这道算术题的答案定了:// 两个模数以下的数相加 < 2 × 8.0112 × 10⁷ < 2³¹ ⇒ **int 就够**(本页第 ⑤ 步量了)。//// 复杂度 O(n + m)。顶格 n = 5 × 10³、m = 5 × 10⁵。// ⚠ 题面【补充说明】保证「数据中不会出现环」—— 所以不用为判环额外写什么。
#include <bits/stdc++.h>using namespace std;const int MOD = 80112002;
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n, m; if (!(cin >> n >> m)) return 0; vector<vector<int>> g(n + 1); vector<int> indeg(n + 1, 0), outdeg(n + 1, 0); for (int i = 0; i < m; i++) { int a, b; cin >> a >> b; // a 被 b 吃 ⇒ 边 a → b g[a].push_back(b); indeg[b]++; outdeg[a]++; }
vector<int> f(n + 1, 0); vector<int> box; for (int i = 1; i <= n; i++) if (!indeg[i]) { box.push_back(i); f[i] = 1; } // ★ 只有生产者的初值是 1 size_t head = 0; while (head < box.size()) { int u = box[head++]; for (int v : g[u]) { f[v] = (f[v] + f[u]) % MOD; if (--indeg[v] == 0) box.push_back(v); } }
long long ans = 0; for (int i = 1; i <= n; i++) if (!outdeg[i]) ans = (ans + f[i]) % MOD; // ★ 只收顶端消费者 printf("%lld\n", ans); return 0;}点「运行 ▶」看结果
「最左端是不会捕食其他生物的生产者,最右端是不会被其他生物捕食的消费者。」
- 左端 ⇒ 只有入度为 0 的点初值是 1;
- 右端 ⇒ 只有出度为 0 的点计入答案。
两头都不能放宽 —— 下一步那两个错法,正好各放宽了一头。
2★ 对拍的参照物:一条一条把路径走出来数
它不排拓扑序、不做 DP,就是照定义 DFS 数路径。
⚠ 而它不取模(long long 直接数)—— 这一点第 ④ 步会派上大用场:
「真实答案有没有越过模数」这个问题,只有它能回答。
3⚠ 两个错法各放宽了一头 —— 而「往哪个方向错」不用跑就能判
// ✗ P4017 错法一:**所有点的初值都设成 1**(不只是生产者)。//// 题面那句「最左端是**不会捕食其他生物的生产者**」被漏掉了半句。// 于是每个点都白得一条「从自己开始」的链 ⇒ 它数的是**所有路径**,// 而不是「从生产者到消费者」的路径。//// ★ 它多算 ⇒ 答案**恒 ≥ 正解**(在不取模的意义上)。解析页第 ③ 步量了这一条。
#include <bits/stdc++.h>using namespace std;const int MOD = 80112002;
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n, m; if (!(cin >> n >> m)) return 0; vector<vector<int>> g(n + 1); vector<int> indeg(n + 1, 0), outdeg(n + 1, 0); for (int i = 0; i < m; i++) { int a, b; cin >> a >> b; // a 被 b 吃 ⇒ 边 a → b g[a].push_back(b); indeg[b]++; outdeg[a]++; }
vector<int> f(n + 1, 0); vector<int> box; for (int i = 1; i <= n; i++) { if (!indeg[i]) box.push_back(i); f[i] = 1; // ★ 每个点都给了 1 } size_t head = 0; while (head < box.size()) { int u = box[head++]; for (int v : g[u]) { f[v] = (f[v] + f[u]) % MOD; if (--indeg[v] == 0) box.push_back(v); } }
long long ans = 0; for (int i = 1; i <= n; i++) if (!outdeg[i]) ans = (ans + f[i]) % MOD; // ★ 只收顶端消费者 printf("%lld\n", ans); return 0;}点「运行 ▶」看结果
// ✗ P4017 错法二:**答案把每个点的 f 都加了进去**(不只出度为 0 的)。//// 漏掉的是题面另外半句:「最右端是**不会被其他生物捕食的消费者**」。// 于是每一条「走到一半」的链也被算成了一条食物链。//// ★ 同样是多算 ⇒ 恒 ≥ 正解。// ⇒ ★★ 两个错法各错一头(一个放宽了左端、一个放宽了右端)——// 这正是[第 26 章那条判据](/sol/p1220/):**解一个放宽了的问题 ⇒ 答案只会偏大。**
#include <bits/stdc++.h>using namespace std;const int MOD = 80112002;
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n, m; if (!(cin >> n >> m)) return 0; vector<vector<int>> g(n + 1); vector<int> indeg(n + 1, 0), outdeg(n + 1, 0); for (int i = 0; i < m; i++) { int a, b; cin >> a >> b; // a 被 b 吃 ⇒ 边 a → b g[a].push_back(b); indeg[b]++; outdeg[a]++; }
vector<int> f(n + 1, 0); vector<int> box; for (int i = 1; i <= n; i++) if (!indeg[i]) { box.push_back(i); f[i] = 1; } // ★ 只有生产者的初值是 1 size_t head = 0; while (head < box.size()) { int u = box[head++]; for (int v : g[u]) { f[v] = (f[v] + f[u]) % MOD; if (--indeg[v] == 0) box.push_back(v); } }
long long ans = 0; for (int i = 1; i <= n; i++) ans = (ans + f[i]) % MOD; // ★ 每个点都收了 printf("%lld\n", ans); return 0;}点「运行 ▶」看结果
| 300 轮 | 被抓 | 不取模时和正解比 |
|---|---|---|
| 所有点初值都给 1(放宽左端) | 300 | ★ 恒 ≥ 正解(300 / 300) |
| 答案收所有点(放宽右端) | 300 | ★ 恒 ≥ 正解(300 / 300) |
⇒ 这就是第 26 章那条判据:解一个放宽了的问题 ⇒ 答案只会偏大。 两个错法各错一头,官方样例(正确答案 5)当场打出 12 和 11,一测就死。
4★★★ 而另外两个错法,官方样例一个都没挡住 —— 因为样例的答案只有 5
// ✗ P4017 错法三:**模数顺手写成了 10⁹+7**。//// 题面写的是 **80112002**(这是个很不常见的模数,正因为不常见才容易被手指头改成 10⁹+7)。//// ★ 它的抓获率有一条精确的等式:**被抓的轮数 ≡ 真实答案越过 80112002 的轮数**// —— 答案没到模数时,两个模数给出的结果一模一样。解析页第 ④ 步量了这一条。// ⇒ 又一次[第 21 章 P1077](/sol/p1077/) 那条:**抓不到它的是「档位」,不是「轮数」。**
#include <bits/stdc++.h>using namespace std;const int MOD = 1000000007; // ★ 抄错了
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n, m; if (!(cin >> n >> m)) return 0; vector<vector<int>> g(n + 1); vector<int> indeg(n + 1, 0), outdeg(n + 1, 0); for (int i = 0; i < m; i++) { int a, b; cin >> a >> b; // a 被 b 吃 ⇒ 边 a → b g[a].push_back(b); indeg[b]++; outdeg[a]++; }
vector<int> f(n + 1, 0); vector<int> box; for (int i = 1; i <= n; i++) if (!indeg[i]) { box.push_back(i); f[i] = 1; } // ★ 只有生产者的初值是 1 size_t head = 0; while (head < box.size()) { int u = box[head++]; for (int v : g[u]) { f[v] = (f[v] + f[u]) % MOD; if (--indeg[v] == 0) box.push_back(v); } }
long long ans = 0; for (int i = 1; i <= n; i++) if (!outdeg[i]) ans = (ans + f[i]) % MOD; // ★ 只收顶端消费者 printf("%lld\n", ans); return 0;}点「运行 ▶」看结果
答案没到模数时,两个模数给出的结果一模一样 —— 所以这个 bug 只在「答案够大」时才现形。
顺手随机的小图 300 轮:被抓 0 次(答案全都小得可怜)。
换一档「答案指数级」的数据 —— 分层完全图(L 层、每层 3 个点、层间全连,答案正好是 3^L):
层数 L |
10 | 16 | ★ 17 | 20 |
|---|---|---|---|---|
点数 n |
30 | 48 | 51 | 60 |
| 真实答案 | 59 049 | 43 046 721 | ★ 129 140 163 | 3 486 784 401 |
| 越过 80112002 了吗 | 否 | 否 | ★ 是 | 是 |
| 模数抄错被抓 | 否 | 否 | ★ 是 | 是 |
⇒ ★★ 一个不差:越线的那一档正好是被抓的那一档。 ⇒ 又一次第 21 章 P1077、第 28 章 P1879 那条: 抓不到它的是「档位」,不是「轮数」——加多少轮随机小图都没用。
★ 而这条线只要 51 个点就能越过。题面 n 可以到 5000。
// ✗ P4017 错法四:**全程不取模** —— 路径条数是指数级的,`int` 顶不住。//// 题面那句「由于这个结果可能过大,你只需要输出总数模上 80112002 的结果」// 已经把答案会有多大写在脸上了。//// ⚠ 这一版故意用 `unsigned int` 累加:溢出在 C++ 里对**无符号**类型是有定义的(绕回去),// 于是这个错**每次跑都一样**、可复现([第 45 章](/ch/45-estimate/)那条规矩)。// 换成 `int` 就是未定义行为,本机跑一百遍可能都「正常」。//// ★ 解析页第 ⑤ 步量了它从多大开始出问题。
#include <bits/stdc++.h>using namespace std;const int MOD = 80112002;
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n, m; if (!(cin >> n >> m)) return 0; vector<vector<int>> g(n + 1); vector<int> indeg(n + 1, 0), outdeg(n + 1, 0); for (int i = 0; i < m; i++) { int a, b; cin >> a >> b; // a 被 b 吃 ⇒ 边 a → b g[a].push_back(b); indeg[b]++; outdeg[a]++; }
vector<unsigned> f(n + 1, 0); vector<int> box; for (int i = 1; i <= n; i++) if (!indeg[i]) { box.push_back(i); f[i] = 1; } // ★ 只有生产者的初值是 1 size_t head = 0; while (head < box.size()) { int u = box[head++]; for (int v : g[u]) { f[v] = f[v] + f[u]; // ★ 不取模 if (--indeg[v] == 0) box.push_back(v); } }
unsigned ans = 0; for (int i = 1; i <= n; i++) if (!outdeg[i]) ans = ans + f[i]; // ★ 也不取模 printf("%u\n", ans % MOD); // 最后才补一次模,已经晚了 return 0;}点「运行 ▶」看结果
同一档分层图往上加层:
| 第一次出错的层数 | 那时的点数 | 那时的真实答案 | |
|---|---|---|---|
| 模数抄成 10⁹+7 | L = 17 | 51 | 129 140 163 > 80 112 002 |
★ 全程不取模(unsigned) |
L = 21 | 63 | 10 460 353 203 > 2³² = 4 294 967 296 |
⇒ 两条线都是一句算术,而且都只要几十个点。
⚠ 而顺手写的生成器(随机稀疏 DAG,n ≤ 8)在这两条线上都是精确的 0 ——
路径条数在随机图上根本长不大。
★ 顺带:这一版故意用 unsigned 而不是 int —— 无符号溢出在 C++ 里有定义(绕回去),
于是这个错每次跑都一样、可复现(第 45 章那条规矩)。
5★ 反过来的那道算术题:正解要不要 long long
| 模数 | 80 112 002 |
| 两个「< 模数」的数相加,最大 | 160 224 002 |
而 int 的上限 |
2 147 483 648 |
⇒ ★ int 就够(余量 13.4 倍)—— 只要每加一次就取一次模。
本页正解用的是 int 存 f,只有累加答案那一步用了 long long(纯属保守)。
⇒ 这和上一步那个「不取模」的错法正好是一体两面:
取模不是为了「答案对」,是为了让中间值待在类型里。
6★ 对拍这一页
300 轮(n 随机 3~8 的随机 DAG,参照物 = 枚举路径) |
|
|---|---|
| 正解 ≡ 暴力 | ★ 不一致 0 轮 |
| 所有点初值都给 1 | 300(恒 ≥ 正解) |
| 答案收所有点 | 300(恒 ≥ 正解) |
| ⚠ 模数抄成 10⁹+7 | ★ 0(换成分层图那一档才现形) |
| ⚠ 全程不取模 | ★ 0(同上) |
顶格 n = 5000、m = 5 × 10⁵:O(n + m) = 505 000,
实测 0.03 秒 / 8 MB(A 机 · WSL2 · 2026-08-30,独占;时限 1 秒、内存 128 MB)。
7度量程序和生成器
8一页纸
| ★ 关键的一步 | 拓扑序上计数:入度 0 的初值 1、出度 0 的收进答案 —— 两头都不能放宽 |
| ★ 两个「放宽」的错法 | 各错一头,恒 ≥ 正解(300/300),官方样例打 12 和 11,一测就死 |
| ★★★ 另外两个错法 | 模数抄错 / 不取模 —— 样例和顺手随机都是精确的 0(答案太小) |
| ★★ 抓它们要造对档 | 分层完全图(答案 = 3^L):模数那条线在 L=17(51 个点)、溢出那条在 L=21(63 个点) |
| ★ 一个不差 | 「模数抄错被抓」≡「真实答案越过 80112002」,四档全对上 |
| ★ 反过来的算术 | 两个 < 模数的数相加 = 1.6 × 10⁸ < 2³¹ ⇒ 正解用 int 就够(每加一次取一次模) |