0题目原文(本地存了一份)
原题在洛谷上(页头有链接)。别人的网站不归我们管,打不开、改版、题号调整都可能发生。 所以每个解析页都把题面转录一份存在本地,跟着仓库一起进版本库。
转录自洛谷 P1164,日期见页头。两边不一致时信原站。
题目背景
uim 神犇拿到了 uoi 的 ra(镭牌)后,立刻拉着基友小 A 到了一家……餐馆,很低端的那种。
uim 指着墙上的价目表(太低级了没有菜单),说:“随便点”。
题目描述
不过 uim 由于买了一些书,口袋里只剩 M 元 (0 < M ≤ 10000)。
餐馆虽低端,但是菜品种类不少,有 N 种 (1 ≤ N ≤ 100),第 i 种卖 aᵢ 元 (0 < aᵢ ≤ 1000)。
由于是很低端的餐馆,所以每种菜只有一份。
小 A 奉行“不把钱吃光不罢休”的原则,所以他点单一定刚好把 uim 身上所有钱花完。 他想知道有多少种点菜方法。
由于小 A 肚子太饿,所以最多只能等待 1 秒。
输入格式
第一行两个整数 N 和 M,分别表示菜品种类和 uim 身上的钱数。
第二行 N 个正整数 aᵢ(可能有重复),用空格隔开,分别表示每种菜的价格。
输出格式
一个正整数,表示点菜方案数,保证答案的范围在 [0, 2³¹ − 1] 之内(不超过 C/C++ 的 int 范围)。
说明/提示
2020.8.29,增添一组 hack 数据 by @yummy
输入输出样例
输入
4 4 1 1 2 2
输出
3
四道菜价格 1 1 2 2,要正好花掉 4 元:1+1+2 有两种(两个 2 各算一种)、2+2 有一种 ⇒ 3 种。
★ 这组样例专门在考「价格相同的两道菜是不同的两道菜」 —— 不能去重。
⚠ 而它同时在结构上问不出本页最后一个错法(第 ⑤ 步):N 和 M 都是 4。
1★ 关键的一步:把 max 换成加法
这道题是 01 背包的计数形态。第 23 章第 ⑫ 步 给过这个套路,一句话就是它的全部:
| 求最大价值 | 求方案数(这道题) | |
|---|---|---|
| 状态 | f[j] = 容量 j 时的最大价值 |
f[j] = 恰好花掉 j 元的点法数 |
| 转移 | f[j] = max(f[j], f[j-a] + w) |
f[j] += f[j-a] |
| 初值 | 全 0 | ★ f[0] = 1,其余 0 |
那个 f[0] = 1 不是凑数:「一分钱不花」本身就是一种点法,
而整张表的方案数都是从这一个 1 一层层长出来的。忘了它,全表恒为 0(第 ③ 步)。
// P1164 小 A 点菜 —— ★ 这一版就能 AC//// 题意:N 道菜(每道只有一份),价格 a[i];恰好把 M 元**花光**,问有多少种点法。//// ★ 关键的一步:把 01 背包的 max 换成加法。// [第 23 章第 ⑫ 步](/ch/23-knapsack-01/)讲过这个套路 ——// 问「最大价值」就 max,问「有多少种方案」就把 max 换成 `+`,// 而初始化从「f 全 0」换成 **f[0] = 1,其余 0**。//// f[j] = 恰好花掉 j 元的点法数// f[j] += f[j - a[i]] (倒序,每道菜只有一份)// f[0] = 1 ← 「一分钱不花」也是一种点法(什么都不点)//// ⚠ 那个 f[0] = 1 不是凑数:整张表的方案数都是从它一层层长出来的。// 忘了它,全表恒为 0(见 p1164Zero.cpp)。//// ★ 关于 int 够不够:题面写着「保证答案在 [0, 2^31-1] 之内」——// 这句话**正好是 int 够用的充分条件**,而且能证(解析页第 ⑤ 步)。// ⚠ 但「用得上的格子不溢出」不等于「所有格子都不溢出」,所以这里仍写 long long。
#include <bits/stdc++.h>using namespace std;
int main() { int n, m; if (!(cin >> n >> m)) return 0; // ⚠ 顺序是「菜品种类 N,钱数 M」 vector<long long> f(m + 1, 0); f[0] = 1; // ★ 一分钱不花:一种点法
for (int i = 0; i < n; i++) { int a; cin >> a; for (int j = m; j >= a; j--) // 倒序:每道菜只有一份 f[j] += f[j - a]; } cout << f[m] << "\n"; return 0;}点「运行 ▶」看结果
复杂度 O(NM),顶格 100 × 10000 = 10⁶ —— 时限 1 秒,绰绰有余。
作为对照,二维版长这样(它压根不需要「倒序」这个概念):
二维版右边读的全是 f[i-1][…],根本不可能读到本行刚写的值。
一维压掉那一维之后,「上一行」和「这一行」挤在同一个数组里 ——
倒序就是唯一能保证「读到的还是上一行」的遍历方式。
⇒ 想不起来该正序还是倒序时,回去看二维版:它读的是哪一行,一维就要保证读到哪一行。
第一版的参照物是 DFS 枚举子集(2ᴺ,顶格当然不行,但小数据上它和 DP 一行代码都不共享):
2三个错法,官方样例一口气挡住了三个
官方样例 4 4 / 1 1 2 2 |
输出 | 挡住了吗 |
|---|---|---|
| 正解 | 3 | —— |
| 一维正序 | 14 | ✓ |
忘了 f[0] = 1 |
0 | ✓ |
| 「不超过 M」 | 13 | ✓ 必被挡住 |
★ 读反 N 和 M(第 ⑤ 步) |
3 | ✗ 放过 |
| 随机 300 组:正序的答案 vs 「每道菜可以点任意多份」的方案数 | ★ 300 / 300 逐组相等 |
⇒ 它解的是完全背包的计数版本。样例上 14 > 3,是因为 「点两份 1 元的菜」这种非法方案也被数进去了。
★ 顺带说清「不超过」那个错法为什么一次都逃不掉(对拍 300 / 300):
Σf[j] 里至少还多着一个 f[0] = 1(什么都不点),所以只要 M ≥ 1,
它就恒大于 f[M]。这不是概率,是一句不等式。
3★★ 「忘了 f[0] = 1」:它漏掉的轮数 ≡ 答案本来就是 0 的轮数
这个 bug 有一条白送的推论:它的输出恒等于 0,和输入完全无关。 ⇒ 它唯一能蒙混过关的方式,就是正确答案本来就是 0。
数一数两边(N ≤ 10、价格 ≤ 10 固定,只拧 M,各 300 轮):
M 的上限 |
10 | 30 | 100 |
|---|---|---|---|
| 它被抓的轮数 | 186 | 182 | 75 |
| 它漏掉的轮数 | 114 | 118 | 225 |
| 「正确答案本来就是 0」的轮数 | ★ 114 | ★ 118 | ★ 225 |
「漏掉的轮数 ≡ 答案为 0 的轮数」三档全中 ⇒ 等于证明了 「只要答案不是 0,它一定被抓」。
⚠ 而更有用的是反过来读这张表:M 上限从 10 拉到 100,抓获率从 186 掉到 75 ——
掉下去的那一段全是「两版一起输出 0」(钱多菜少,凑不出来)。
这是第 14 章 P1746 那条「一致有两种:都算对了,和都没算」的第三次现场。
⇒ 生成器该照抄的是题面的比值(M / 价格上限),不是绝对规模 ——
和 P1020 那条同一个道理。这一页默认档 M ≤ 30、价格 ≤ 10(比值 3),
就是为了让「凑得出来」的轮次占多数。
4★★★ 读反 N 和 M —— 而官方样例在结构上问不出这个问题
题面第一行是「N 和 M」=「菜品种类、身上的钱」。
而「先读钱、再读菜数」也很顺手(不少背包题的输入就是那个顺序)。
// P1164 错法四:把两个输入读反了 —— ★★ 而官方样例**在结构上问不出这个问题**//// 题面第一行是「N 和 M」=「菜品种类、身上的钱」。// 而「先读钱、再读个数」也很顺手(很多背包题的输入就是那个顺序),于是有了这一版。//// ★★★ 官方样例是 `4 4` —— **N 和 M 恰好相等**,交换它们是**恒等变换**。// ⇒ 这不是「概率低」,是**精确的 0**:这组样例问不出这个问题。// 和[第 14 章 P1746](/sol/p1746/) 那个「两个点都在主对角线上」是同一件事。//// ⚠ 而读反之后还会再错一次:菜价那一行要读 N 个数,读成 M 个就会读少 / 读多。// 这里如实照抄「读反了」的写法:n 和 m 换个位置,别的一个字不改。
#include <bits/stdc++.h>using namespace std;
int main() { int n, m; if (!(cin >> m >> n)) return 0; // ⚠ 反了:先读的其实是 N vector<long long> f(m + 1, 0); f[0] = 1; for (int i = 0; i < n; i++) { int a = 0; if (!(cin >> a)) break; // 读不到就算了(数量对不上) for (int j = m; j >= a; j--) f[j] += f[j - a]; } cout << f[m] << "\n"; return 0;}点「运行 ▶」看结果
它在官方样例上输出 3,和正解一模一样。
N 和 M 恰好都是 4 ⇒ 交换它们什么也没做。这不是运气,是这组样例的结构:
| 「读反」被抓的轮数 | |
|---|---|
默认档(N ≤ 10,M ≤ 30)300 轮 |
192 / 300 |
★ 专门造的 N = M 那一档 300 轮 |
★ 精确的 0 / 300 |
⇒ 这和第 14 章 P1746 那个「样例的两个点都在主对角线上 ⇒ 交换行列是恒等变换」 是同一件事:「它过了样例」的第三种原因 —— 这组样例在结构上问不出这个问题。
★ 而那个「精确的 0」自带自检:同一份对拍代码在默认档抓了 192 次, 所以它不是空壳(P2240 那天定下的规矩:报「0 次」之前先证明这段代码是活的)。
⚠ 顺带:默认档漏掉的 108 轮里,只有 16 轮是「N 恰好等于 M」的恒等,
另外 92 轮是读反了、但答案碰巧相同。⇒ 「它有时候能对」和「它没错」是两句话。
5★★★ 题面那句「答案 ≤ 2³¹ − 1」到底保证了什么
题面在输出格式那行写着:「保证答案的范围在 [0, 2³¹−1] 之内(不超过 C/C++ 的 int 范围)。」
按第 12 章那套分法,这是一条情报。而它比看上去给得多 —— 它正好是「32 位够用」的充分条件,并且这件事能证:
对任意一道菜 i:把「凑出 M − aᵢ 的每一种点法」配上第 i 道菜,
就得到一种「凑出 M 的点法」,而且不同的原方案给出不同的新方案。
⇒ 答案 = f[M] ≥ f_{i−1}[M − aᵢ] —— 而 f_{i−1}[M − aᵢ] 正是转移时读到的那一格。
⇒ 只要答案 ≤ 2³¹−1,DP 路径上用得上的格子就全都装得下。
实测(N = 100、M = 10000、价格 ∈ [900, 1000]、194 组满足题面保证的顶格数据):
| 「用得上的中间值 > 答案」出现的次数 | ★ 0 |
32 位版 vs long long 版不一致的组数 |
★ 0 |
造一组:100 道菜全是 1 元,M = 10000。
| 正确答案 | 0(一百块钱凑不出一万) |
| 32 位版的输出 | 0(照样对) |
中间超过 2³¹−1 的格子 |
★ 87 个 |
最大的一格 f[50] = C(100,50) |
★ 约 1.01 × 10²⁹(29 位) |
⇒ 溢出真的发生了,只是那些格子谁也没读。
⚠ 而「没读到」并不能让有符号溢出变成合法 —— 它仍然是未定义行为。
所以本页那份演示写的是 unsigned(无符号溢出是有定义的,才能复现),
而正解干脆写 long long:「算完确认够用」和「没算过」是两回事。
想造顶格数据测一测,第一反应是「N = 100、M = 10000、价格在 [1, 1000] 里随机」。
| 那样造出来的 200 组 | |
|---|---|
答案越过 2³¹−1 的组数 |
★ 200 / 200 —— 一组能用的都没有 |
这些输入题目根本不会给。 要造出真能用的顶格数据,得把价格抬到 [900, 1000]
(菜贵了,凑法就少了)—— 那一档 200 组里有 194 组可用,最大答案 2.92 × 10⁷。
⇒ 这是 P1090 那条的又一次现场: 自己造顶格数据时要连「输出侧的保证」一起满足,否则测的是一组题目不会给的输入。
6度量程序和生成器
7一页纸
| 关键的一步 | 01 背包的计数形态:max 换成 +,初值换成 f[0] = 1 |
| 哪一版能 AC | p1164.cpp —— 一维倒序,O(NM) = 10⁶ |
| 想不起正序倒序 | 回去看二维版:它读的是哪一行,一维就要保证读到哪一行 |
| 错法一 | 一维正序 ⇒ 恒等于「每道菜可点任意多份」的方案数(300/300 逐组相等) |
| 错法二 | 忘了 f[0] = 1 ⇒ 恒输出 0;★ 漏掉的轮数 ≡ 答案为 0 的轮数(三档全中) |
| 错法三 | 把「恰好花完」做成「不超过」 |
| ★★★ 错法四 | 读反 N / M —— 官方样例 4 4 让交换成了恒等变换(精确的 0);默认档 192/300 |
| ★★★ 题面那句保证 | 「答案 ≤ 2³¹−1」正好是 32 位够用的充分条件,两行能证(答案 ≥ 任何被用到的中间值) |
| ⚠ 但仍要写 long long | 用不上的格子照样溢出:100 道 1 元菜 / M=10000 ⇒ 87 个格子越界,最大 10²⁹ |
| ⚠ 造顶格数据 | 价格 ∈ [1,1000] 随机 ⇒ 200/200 组的答案越过题面保证,一组都不能用 |