题单 · 习题解析

洛谷 P4017 最大食物链计数

★ 拓扑序上计数 DP,两头都不能放宽(两个错法恒 ≥ 正解,样例一测就死);★★★ 而「模数抄成 10⁹+7」和「不取模」**样例和顺手随机都是精确的 0** —— 要造分层完全图(答案 = 3^L):模数那条线在 **51 个点**、溢出那条在 **63 个点**;★ 反过来:两个 < 模数的数相加 < 2³¹ ⇒ 正解用 int 就够

原题:洛谷 P4017出自 第 31 章 拓扑排序 的题单题面本地存档:2026-08-30
⚠ 先自己写一遍,再往下看

这一页不是标准答案,是一条阶梯:把大多数人真实会写出的第一版摆出来, 看它错在哪、慢在哪,再一步一步改。所以它对「已经自己动手撞过一次墙」的人最有用。
还没写过的话,先去写 —— 写出来的哪怕是错的,也比直接看这一页收获大。

0题目原文(本地存了一份)

为什么要在这儿抄一份题面

原题在洛谷上(页头有链接)。别人的网站不归我们管,打不开、改版、题号调整都可能发生。 所以每个解析页都把题面转录一份存在本地,跟着仓库一起进版本库。

转录自洛谷 P4017,日期见页头。两边不一致时信原站。

题目背景

你知道食物链吗?Delia 生物考试的时候,数食物链条数的题目全都错了,因为她总是重复数了几条或漏掉了几条。 于是她来就来求助你,然而你也不会啊!写一个程序来帮帮她吧。

题目描述

给你一个食物网,你要求出这个食物网中最大食物链的数量。

(这里的「最大食物链」,指的是生物学意义上的食物链,即 最左端是不会捕食其他生物的生产者,最右端是不会被其他生物捕食的消费者。)

Delia 非常急,所以你只有 1 秒的时间。

由于这个结果可能过大,你只需要输出总数模上 80112002 的结果。

输入格式

第一行,两个正整数 nm,表示生物种类 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→51→2→3→51→2→51→3→4→51→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.cpp★ 这一版就能 AC
// ★★ 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
★ 题面那句话的两头,必须一起读

「最左端是不会捕食其他生物的生产者,最右端是不会被其他生物捕食的消费者。」

  • 左端 ⇒ 只有入度为 0 的点初值是 1
  • 右端 ⇒ 只有出度为 0 的点计入答案

两头都不能放宽 —— 下一步那两个错法,正好各放宽了一头。

2★ 对拍的参照物:一条一条把路径走出来数

p4017Brute.cpp参照物:枚举路径(300 轮不一致 0 轮)

它不排拓扑序、不做 DP,就是照定义 DFS 数路径。 ⚠ 而它不取模long long 直接数)—— 这一点第 ④ 步会派上大用场: 「真实答案有没有越过模数」这个问题,只有它能回答。

3⚠ 两个错法各放宽了一头 —— 而「往哪个方向错」不用跑就能判

p4017AllOne.cpp✗ 所有点初值都给 1(样例打 12)
// ✗ 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
p4017AllSum.cpp✗ 答案收了所有点(样例打 11)
// ✗ 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
300 轮 被抓 不取模时和正解比
所有点初值都给 1(放宽左端) 300 恒 ≥ 正解(300 / 300)
答案收所有点(放宽右端) 300 恒 ≥ 正解(300 / 300)

⇒ 这就是第 26 章那条判据解一个放宽了的问题 ⇒ 答案只会偏大。 两个错法各错一头,官方样例(正确答案 5)当场打出 1211,一测就死。

4★★★ 而另外两个错法,官方样例一个都没挡住 —— 因为样例的答案只有 5

p4017Mod7.cpp✗ 模数抄成 10⁹+7(样例照样打 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
★★ 「模数抄错」被抓的轮数 ≡ 真实答案越过 80112002 的轮数

答案没到模数时,两个模数给出的结果一模一样 —— 所以这个 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

p4017NoMod.cpp✗ 全程不取模(样例照样打 5)
// ✗ 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
★ 第二条阈值线:63 个点就撑破 32 位

同一档分层图往上加层:

第一次出错的层数 那时的点数 那时的真实答案
模数抄成 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 倍)—— 只要每加一次就取一次模。 本页正解用的是 intf,只有累加答案那一步用了 long long(纯属保守)。 ⇒ 这和上一步那个「不取模」的错法正好是一体两面: 取模不是为了「答案对」,是为了让中间值待在类型里。

6★ 对拍这一页

300 轮(n 随机 3~8 的随机 DAG,参照物 = 枚举路径)
正解 ≡ 暴力 不一致 0 轮
所有点初值都给 1 300(恒 ≥ 正解)
答案收所有点 300(恒 ≥ 正解)
⚠ 模数抄成 10⁹+7 0(换成分层图那一档才现形)
⚠ 全程不取模 0(同上)

顶格 n = 5000m = 5 × 10⁵O(n + m) = 505 000, 实测 0.03 秒 / 8 MBA 机 · WSL2 · 2026-08-30,独占;时限 1 秒、内存 128 MB)。

7度量程序和生成器

p4017Count.cpp度量程序(本页所有数字都出自它)
p4017Gen.cpp数据生成器

8一页纸

★ 关键的一步 拓扑序上计数:入度 0 的初值 1、出度 0 的收进答案 —— 两头都不能放宽
★ 两个「放宽」的错法 各错一头,恒 ≥ 正解(300/300),官方样例打 12 和 11,一测就死
★★★ 另外两个错法 模数抄错 / 不取模 —— 样例和顺手随机都是精确的 0(答案太小)
★★ 抓它们要造对档 分层完全图(答案 = 3^L):模数那条线在 L=17(51 个点)、溢出那条在 L=21(63 个点)
★ 一个不差 「模数抄错被抓」≡「真实答案越过 80112002」,四档全对上
★ 反过来的算术 两个 < 模数的数相加 = 1.6 × 10⁸ < 2³¹ ⇒ 正解用 int 就够(每加一次取一次模)