题单 · 习题解析

洛谷 P1616 疯狂的采药

★★★ 题面那句 m × t ≤ 10⁷ 是出题人直接把复杂度写上去了(各自顶格会误算成 10¹¹,差一万倍);而它没说的那句要自己算:答案 10¹¹,int / unsigned 两条溢出线都在题面内

原题:洛谷 P1616出自 第 24 章 完全背包与多重背包 的题单题面本地存档:2026-08-29
⚠ 先自己写一遍,再往下看

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

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

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

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

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

题目背景

此题为纪念 LiYuxiang 而生。

题目描述

LiYuxiang 是个天资聪颖的孩子,他的梦想是成为世界上最伟大的医师。为此,他想拜附近最有威望的医师为师。 医师为了判断他的资质,给他出了一个难题。医师把他带到一个到处都是草药的山洞里对他说: “孩子,这个山洞里有一些不同种类的草药,采每一种都需要一些时间,每一种也有它自身的价值。 我会给你一段时间,在这段时间里,你可以采到一些草药。如果你是一个聪明的孩子, 你应该可以让采到的草药的总价值最大。”

如果你是 LiYuxiang,你能完成这个任务吗?

此题和原题的不同点:

  1. 每种草药可以无限制地疯狂采摘。
  2. 药的种类眼花缭乱,采药时间好长好长啊!师傅等得菊花都谢了!

输入格式

输入第一行有两个整数,分别代表总共能够用来采药的时间 t 和代表山洞里的草药的数目 m

第 2 到第 (m + 1) 行,每行两个整数,第 (i + 1) 行的整数 aᵢ, bᵢ 分别表示采摘第 i 种草药的时间和该草药的价值。

输出格式

输出一行,这一行只包含一个整数,表示在规定的时间内,可以采到的草药的最大总价值。

数据规模与约定

  • 对于 30% 的数据,保证 m ≤ 10³
  • 对于 100% 的数据,保证 1 ≤ m ≤ 10⁴1 ≤ t ≤ 10⁷1 ≤ m × t ≤ 10⁷1 ≤ aᵢ, bᵢ ≤ 10⁴

输入输出样例

输入

70 3
71 100
69 1
1 2

输出

140

时间 70,三种草药 (71,100) (69,1) (1,2)。第一种根本采不起(71 > 70), 最优是把第三种采 70 次:70 × 2 = 140

★ 这一组样例当场挡住了「写成倒序」(那一版只能每种采一次,答案 3,差 46 倍), ⚠ 而放过了另外两个错法(32 位溢出、性价比贪心)。

1★ 和 P1048 只差一个字:内层循环的方向

p1616.cpp★ 这一版就能 AC
// P1616 疯狂的采药 —— ★ 这一版就能 AC
//
// 题意:时间 t,m 种草药,第 i 种采一次要 a[i] 时间、值 b[i];**每种可以无限次采**。
// 求能采到的最大总价值。
//
// ★ 它就是[第 23 章 P1048 采药](/sol/p1048/)的完全背包版 ——
// 两道题的代码只差**一个字**:内层循环的方向。
// 01 背包(每种一件) :for (j = t; j >= a; j--)
// 完全背包(每种无限) :for (j = a; j <= t; j++) ← 就是这一行
// [第 24 章第 ⑧ 步](/ch/24-knapsack-multi/)把这件事讲透了:
// 正序之所以「错」在 01 背包里,正是因为它**恰好在解完全背包**。
//
// ⚠⚠ 而这道题真正咬人的地方在**数据范围那三行**(解析页第 ② ③ 步):
// ① `1 ≤ m × t ≤ 10^7` —— 出题人**直接把复杂度写在题面上了**:`O(mt)` 恒 ≤ 10^7。
// (只看 `t ≤ 10^7` 和 `m ≤ 10^4` 会以为是 10^11,那就不敢写这个 DP 了。)
// ② 答案会**超过 int**:`t = 10^7`、`a = 1`、`b = 10^4` ⇒ 答案 10^11。
// ③ 而 `f` 数组有 `t+1` 格 ⇒ 顶格时 `long long` 要 **76 MB**(限制 128 MB,塞得下)。
#include <bits/stdc++.h>
using namespace std;
int main() {
int t, m;
if (!(cin >> t >> m)) return 0;
vector<long long> f(t + 1, 0); // ⚠ long long:答案能到 10^11
for (int i = 0; i < m; i++) {
int a, b; cin >> a >> b;
for (int j = a; j <= t; j++) // ★ 正序 —— 每种草药可以重复采
f[j] = max(f[j], f[j - a] + b);
}
cout << f[t] << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
    01 背包(每种一件)  :for (j = t; j >= a; j--)      <- [P1048 采药](/sol/p1048/)
    完全背包(每种无限) :for (j = a; j <= t; j++)      <- 这道题
★ 「上一章那个 bug,这一章是正确答案」

第 23 章第 ⑪ 步说过:01 背包的一维写成正序不是随机地错, 它精确地在解完全背包

⇒ 这两道题(P1048 / P1616)就是那句话的实物:同一份代码、同一份数据, 换一个方向就从一道题变成另一道题。 两题对着交一遍,方向的差别一辈子忘不了。

★ 参照物也跟着换了:01 背包的暴力是「每件拿或不拿」, 而这里是「第 i 种采几株」—— 分支数从 2 变成 t/aᵢ + 1

p1616Brute.cpp参照物:枚举每种采几株

2★★★ 数据范围那三行:出题人直接把复杂度写在题面上了

题面的数据范围里有一句很容易滑过去的话:

    1 ≤ m ≤ 10⁴,1 ≤ t ≤ 10⁷,且 1 ≤ m × t ≤ 10⁷
★★★ 「m × t ≤ 10⁷」不是补充说明,它就是这道题的复杂度

DP 是 O(mt)只看前两个上限,会算成 10⁴ × 10⁷ = 10¹¹ —— 那就根本不敢写这个 DP 了。

只看 m ≤ 10⁴t ≤ 10⁷ 各自顶格 10¹¹
加上「m × t ≤ 10⁷」这一句 10⁷
10 000 倍

两个上限的乘积被单独限制了一次 —— 这在题面里是罕见的写法, 而它的意思非常直白:「你想写的那个 O(mt) 的 DP,我保证它跑得完」。

★ 这是本书第 N 次「数据范围那几行是工具」(P1080 的两件工具、 P1060m < 25),而这一次工具的形状是乘积

3★★★ 而题面没说的那一句:答案会溢出,数组还不能随便开大

题面没有给「答案不超过多少」的保证。那就自己算:

    每株价值 ≤ 10⁴,采一株最少花 1 单位时间
    ⇒ 答案 ≤ t × 10⁴ ≤ 10⁷ × 10⁴ = 10¹¹

int 只到 2.1 × 10⁹,差着 47 倍。而溢出的那条线也是算得出来的,还不止一条:

a = 1b = 10⁴ 时答案 = t × 10⁴ 越线要 t ≥ 那时 m 还能到
越过 int2³¹−1 214 749 46
越过 unsigned2³²−1 429 497 23

两条线都在题面允许的范围内m × t ≤ 10⁷ 都满足)。

p1616Int.cpp错法一:状态开成 32 位
⚠ 实测那一组:正好差一个 2³²

t = 429497m = 23m × t = 9 878 431 ≤ 10⁷ ✓)、a = 1b = 10⁴

正解 4 294 970 000
32 位版 2704
4 294 967 296 = 2³²,一分不差

★ 而官方样例放过了它(答案才 140)—— 又一次「样例挡住的都是每组都错的」。

★ 顺带一笔:数组开 long long 会不会 MLE

ft + 1 格,顶格 t = 10⁷

long long(8 字节) 76 MB
内存限制 128 MB

塞得下,不用为了省内存去冒溢出的险。 ★ 这一笔和 P2925 正好相反:那道题是「时间够、空间不够」, 这道题是「空间够、类型不够」。⇒ 每道题都要把两个算术各算一遍。

4★★ 错法二:写成倒序 —— 而它的盲区能一句话证出来

p1616Down.cpp错法二:内层倒序(= 01 背包)
// P1616 错法一:内层写成倒序 —— 它精确地解回了 01 背包
//
// 这是[第 24 章第 ⑧ 步](/ch/24-knapsack-multi/)那句话的反向现场:
// **完全背包写成倒序,输出恒等于同一组数据的 01 背包答案。**
// 解析页第 ④ 步把这句话量成了「300 组逐组相等」。
//
// ★ 官方样例当场挡住它:`t = 70`,三种草药 (71,100)(69,1)(1,2)
// ⇒ 完全背包能把第三种采 70 次 = 140;而 01 背包每种只能采一次,
// 最好是 69 + 1 两株 = 1 + 2 = 3。差了 46 倍。
#include <bits/stdc++.h>
using namespace std;
int main() {
int t, m;
if (!(cin >> t >> m)) return 0;
vector<long long> f(t + 1, 0);
for (int i = 0; i < m; i++) {
int a, b; cin >> a >> b;
for (int j = t; j >= a; j--) // ⚠ 倒序 —— 每种只采得了一次
f[j] = max(f[j], f[j - a] + b);
}
cout << f[t] << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

先说清楚它算了什么:

随机 300 组:倒序的答案 vs 同一组数据的 01 背包答案 300 / 300 逐组相等
★★ 抓获率的旋钮是「t / a 的比值」= 每种最多能采几株

t ≤ 40 固定,只抬 a 的下限a ∈ [下限, 40]),各 300 轮:

a 的下限 1 6 14 21
「存在某种草药能采 ≥ 2 株」的轮数 145 107 32 0
倒序真被抓的轮数 133 89 23 0

⇒ 最后一档那个 0 是结构性的,而且一句话就能证: a ≥ 21 > t/2 ⇒ 每种草药最多只采得起一株 ⇒ 完全背包和 01 背包本来就是同一道题。

★ 而前三档里「触发条件」比「真被抓」多一点点(145 vs 133、107 vs 89、32 vs 23)—— 能采两株不等于采两株更优。⇒ 又一次:这两个数只能量,不能推。

5★★★ 错法三:性价比贪心 —— 同一个贪心,三道题三种命运

p1616Greedy.cpp错法三:一直采性价比最高的
// P1616 错法三:性价比贪心 —— 一直采「单位时间价值最高」的那一种
//
// 完全背包里这个贪心比在 01 背包里**离对更近**:既然可以无限采,
// 那就一直采性价比最高的那种,剩下的零头再拿次高的补 —— 听起来很有道理。
//
// ⚠ 它仍然是错的,而且错的**方式**和 [P1048](/sol/p1048/) 不一样:
// 01 背包里它错在「一件也不能多拿」,这里它错在**凑不满最后那点零头**。
// 解析页第 ⑤ 步把三道题的同一个贪心并排量了一遍:
// [P2240 部分背包](/sol/p2240/)(能切)—— **它是对的**;
// [P1048 01 背包](/sol/p1048/) —— 300 轮错 57 次,错时平均差 10.96%;
// 本题(完全背包) —— 见那一页的表。
//
// ★ 这里如实写成「按性价比排序,从高到低能采就一直采」。
#include <bits/stdc++.h>
using namespace std;
int main() {
int t, m;
if (!(cin >> t >> m)) return 0;
vector<pair<int, int>> h(m); // (a 时间, b 价值)
for (auto& e : h) cin >> e.first >> e.second;
sort(h.begin(), h.end(), [](const pair<int, int>& x, const pair<int, int>& y) {
return (long long)x.second * y.first > (long long)y.second * x.first; // b/a 从大到小
});
long long ans = 0;
int left = t;
for (auto& e : h) {
long long cnt = left / e.first;
ans += cnt * e.second;
left -= (int)(cnt * e.first);
}
cout << ans << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

既然可以无限采,那就一直采「单位时间价值最高」的那种,剩下的零头再补 —— 听着比在 01 背包里靠谱多了。 量一遍m ≤ 5a ≤ 12b ≤ 20,只拧 t,各 300 轮):

t 的上限 20 40 200 2000
贪心被抓 12 12 17 16
错时平均差 6.74% 6.77% 1.21% 0.18%
错时最差 13.33% 13.33% 4.10% 0.64%
★★★ 把三道题的同一个贪心并排放,才看得出它在什么条件下成立
物品能不能拆 性价比贪心 量出来
P2240 部分背包 能切 对的 交换论证证得出来
P1048 采药(01 背包) 每种一件 300 轮错 57 次,错时平均差 10.96%
P1616(完全背包) 每种无限 300 轮错 12~17 次,错时平均差 0.18%~6.77%

「能切」是那条分界线P1048 那页把交换论证断在哪一步说清楚了: 「换 δ 重量」这个动作在不可拆分的背包里根本不存在)。

★★ 而这一页补上的是中间那一档:完全背包里贪心离对更近, 因为「无限次」让它能把大部分时间填满,只剩最后一点零头凑不上。 ⚠⚠ 而这恰恰让它更危险t 越大它越像对的(2000 那档错时只差 0.18%)—— 随手编几组数据一测,你几乎不可能发现它错。

6度量程序和生成器

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

7一页纸

关键的一步 P1048 只差一个字:内层 for (j = a; j <= t; j++)(正序)
哪一版能 AC p1616.cpp —— 一维正序,O(mt)
★★★ 题面那句 m × t ≤ 10⁷ 出题人直接把复杂度写在题面上了(各自顶格会误算成 10¹¹,差 10 000 倍
★★★ 题面没说的那句 答案上界 10¹¹ ⇒ 必须 long long;int 的线是 t ≥ 214 749、unsigned 是 t ≥ 429 497都在题面内
实测溢出 t=429497, m=23 ⇒ 正解 4 294 970 000,32 位打出 2704(正好差一个 2³²
内存 f 顶格开 long long76 MB(限制 128)⇒ 塞得下,不必冒险
错法一 32 位 —— 样例放过
错法二 内层倒序 ⇒ 恒等于 01 背包(300/300 逐组相等);★ 样例当场挡住(3 vs 140)
★ 那个盲区 a > t/2 时倒序是精确的 0 —— 因为那时每种本来就只采得起一株
错法三 性价比贪心 —— ⚠ t 越大越像对的(2000 那档错时只差 0.18%
★★ 三道题一条线 能切 ⇒ 贪心对(P2240)/01 背包错 10.96%(P1048)/完全背包错 0.18%~6.77%