0题目原文(本地存了一份)
原题在洛谷上(页头有链接)。别人的网站不归我们管,打不开、改版、题号调整都可能发生。 所以每个解析页都把题面转录一份存在本地,跟着仓库一起进版本库。
转录自洛谷 P1877,日期见页头。两边不一致时信原站。
题目描述
一个吉他手准备参加一场演出。他不喜欢在演出时始终使用同一个音量, 所以他决定每一首歌之前他都需要改变一次音量。在演出开始之前,他已经做好一个列表, 里面写着每首歌开始之前他想要改变的音量是多少。每一次改变音量,他可以选择调高也可以调低。
音量用一个整数描述。输入文件中整数 beginLevel,代表吉他刚开始的音量,
整数 maxLevel,代表吉他的最大音量。音量不能小于 0 也不能大于 maxLevel。
输入中还给定了 n 个整数 c₁, c₂, c₃, …, cₙ,
表示在第 i 首歌开始之前吉他手想要改变的音量是多少。
吉他手想以最大的音量演奏最后一首歌,你的任务是找到这个最大音量是多少。
输入格式
第一行依次为三个整数 n,beginLevel 和 maxLevel。
第二行依次为 n 个整数 c₁, c₂, c₃, …, cₙ。
输出格式
输出演奏最后一首歌的最大音量。如果吉他手无法避免音量低于 0 或者高于 maxLevel,输出 -1。
说明/提示
1 ≤ n ≤ 50,1 ≤ cᵢ ≤ maxLevel,1 ≤ maxLevel ≤ 1000,0 ≤ beginLevel ≤ maxLevel。
输入输出样例
输入
3 5 10 5 3 7
输出
10
起始音量 5、上限 10,三次调整 5 / 3 / 7:
5 →(−5) 0 →(+3) 3 →(+7) 10,最后一首歌的音量是 10。
⚠⚠ 这一组样例三个错法一个都没挡住 —— 包括本页那个最要命的(第 ③ 步)。 ★ 而同一章题单里的 P1060 正好相反:三个错法一个不剩全挡住。
1★ 状态是「能不能」,不是「最多是多少」
这道题问的是「最后能到达的最大音量」,而每一步都是二选一(加或减)。 所以要记的不是「最优值」,而是可达性:
f[i][j] = 做完前 i 次调整之后,音量能不能正好是 j
f[i][j] = f[i-1][j - c[i]] 或 f[i-1][j + c[i]] (两边都要落在 [0, maxLevel] 里)
答案 = 最大的 j 使 f[n][j] 为真;一个都没有就输出 -1
// P1877 [HAOI2012] 音量调节 —— ★ 这一版就能 AC//// 题意:初始音量 beginLevel,最大音量 maxLevel;第 i 首歌之前必须把音量// **加上或减去** c[i],而且**每一步之后**音量都要落在 [0, maxLevel] 里。// 问最后一首歌的音量最大能是多少;做不到就输出 -1。//// ★ 状态是「能不能」而不是「最多是多少」:// f[i][j] = 唱完第 i 首歌之前的调整后,音量能不能正好是 j// f[i][j] = f[i-1][j - c[i]] 或 f[i-1][j + c[i]]// 答案就是最大的 j 使 f[n][j] 为真。O(n × maxLevel) = 50 × 1001 —— 五万次,眨眼。//// ⚠⚠ 这道题最容易栽的地方是:**照搬 01 背包「压成一维 + 倒序」的招式,当场就错。**// 理由在解析页第 ③ 步:01 背包的转移只从**更小**的 j 来(单向),// 所以倒序能保证「读到的还是上一层」;// 而这道题同时从 j−c(更小)和 j+c(更大)来 —— **双向的转移没有任何一种遍历顺序救得了**。// ⇒ 这里老老实实用两个数组滚动:cur 只读 pre,谁也污染不了谁。//// ★ 顺带:`0 ≤ j ≤ maxLevel` 这个条件要在**每一层**都成立,不是只在最后// (题面写的是「音量不能小于 0 也不能大于 maxLevel」,说的是全程)。
#include <bits/stdc++.h>using namespace std;
int main() { int n, begin_, maxLevel; if (!(cin >> n >> begin_ >> maxLevel)) return 0; vector<int> c(n); for (int& x : c) cin >> x;
vector<char> pre(maxLevel + 1, 0), cur(maxLevel + 1, 0); pre[begin_] = 1; for (int i = 0; i < n; i++) { fill(cur.begin(), cur.end(), 0); for (int j = 0; j <= maxLevel; j++) { if (!pre[j]) continue; if (j - c[i] >= 0) cur[j - c[i]] = 1; // 调低 if (j + c[i] <= maxLevel) cur[j + c[i]] = 1; // 调高 } pre.swap(cur); // ★ 滚动:cur 只读 pre }
int ans = -1; for (int j = maxLevel; j >= 0; j--) if (pre[j]) { ans = j; break; } cout << ans << "\n"; return 0;}点「运行 ▶」看结果
复杂度 O(n × maxLevel) = 50 × 1001 = 50 050 —— 而暴力枚举每首歌加还是减是
2⁵⁰ ≈ 1.13 × 10¹⁵。这两个数摆在一起,就是「为什么要 DP」的全部理由。
2二维版:它把「为什么压不成一维」摆在明面上
盯着转移看一眼:
f[i][j] <- f[i-1][j - c] ← 左边那一格
<- f[i-1][j + c] ← 右边那一格
一个在左,一个在右。 记住这个形状,下一步整页的内容都是从它推出来的。
3★★★ 这道题真正的坑:照搬「压成一维 + 倒序」,当场就错
第 23 章第 ⑧ 步那一节的标题是「倒序是唯一的活路」。 很多人把它背成了一条无条件的规矩,于是这道题一上来就压成一维、倒着写:
// P1877 错法一:照搬 01 背包 —— 压成一维、倒着写//// 「01 背包一维要倒序」这句话背得很熟,于是顺手就写成了这样。//// ⚠⚠ 它在这道题上**不成立**,而理由很干净:// 01 背包的转移是 f[j] ← f[j-w],**只从更小的 j 来**(单向),// 所以倒序能保证「读到的 f[j-w] 还是上一层的」。// 这道题的转移是 f[j] ← f[j-c] **或** f[j+c] —— 一个在左、一个在右。// ⇒ 倒序保住了左边(j-c 还没被本层改),却**保不住右边**(j+c 已经被本层改过了)。//// ⚠⚠ 草稿里我写的是「它认为可达的音量**只多不少**,所以答案恒 ≥ 正解」——// **实测把这句话打回来了**:300 轮里它的可达集包含正解的只有 **248 轮**,// 答案偏大 88 轮、**偏小 2 轮**。// ⇒ 就地覆盖既会**凭空造出**状态(同一首歌被用了不止一次),// 也会**弄丢**合法状态(右边那格在被读到之前已经被本层抹成 0 了)。//// ★ 最小反例只要**一首歌**:`maxLevel = 1`,`begin = 1`,`c = [1]`// ⇒ 正解是 0(调低一格),而它输出 **-1**。// (j=1 先被算成 0,轮到 j=0 去读 f[1] 时,读到的已经是本层刚写的那个 0。)
#include <bits/stdc++.h>using namespace std;
int main() { int n, begin_, maxLevel; if (!(cin >> n >> begin_ >> maxLevel)) return 0; vector<int> c(n); for (int& x : c) cin >> x;
vector<char> f(maxLevel + 1, 0); f[begin_] = 1; for (int i = 0; i < n; i++) for (int j = maxLevel; j >= 0; j--) { // ⚠ 倒序,就地覆盖 char ok = 0; if (j - c[i] >= 0 && f[j - c[i]]) ok = 1; // 左边:倒序时它还是上一层 ✓ if (j + c[i] <= maxLevel && f[j + c[i]]) ok = 1; // ⚠ 右边:本层已经改过了 f[j] = ok; }
int ans = -1; for (int j = maxLevel; j >= 0; j--) if (f[j]) { ans = j; break; } cout << ans << "\n"; return 0;}点「运行 ▶」看结果
| 01 背包 | 这道题 | |
|---|---|---|
| 转移从哪儿读 | f[j−w] —— 只从更小的 j |
f[j−c] 和 f[j+c] —— 两边都读 |
| 倒序能保住 | 左边(j−w 还没被本层改)✓ |
左边 ✓、右边 ✗ |
| 正序能保住 | —— | 左边 ✗、右边 ✓ |
| 结论 | 倒序可行 | ★ 没有任何一种遍历顺序救得了 |
⇒ 「一维 + 某个方向」这一招的适用条件不是「01 背包」,是「转移单向」。 这道题两个方向各坏一半,所以只能老老实实用两个数组滚动。
| 最小反例 | 正解 | 那一版 |
|---|---|---|
一维倒序:maxLevel = 1,begin = 1,c = [1] |
0 | ★ −1 |
一维正序:maxLevel = 1,begin = 0,c = [1] |
1 | ★ −1 |
倒序那个:j=1 先被算成 0,轮到 j=0 去读 f[1] 时,读到的已经是本层刚写的那个 0。
正序那个:j=0 先把起点那格抹掉,j=1 再去读它,什么都没有了。
一首歌就够。 可官方样例(三首歌)两个版本都照样输出 10 —— ⇒ 「这个 bug 很浅」和「样例能不能发现它」是两件毫不相干的事。
草稿上写的是:「就地覆盖只会让更多状态被认为可达,所以它的答案恒 ≥ 正解」。 听着很顺 —— 同一首歌被用了不止一次嘛。实测不是这样:
300 轮(maxLevel ≤ 20) |
倒序 | 正序 |
|---|---|---|
| 被抓 | 132 | 158 |
| 其中答案偏大 | 88 | —— |
| 其中答案偏小 | ★ 2 | —— |
| 可达集包含正解可达集的轮数 | ★ 248 / 300(不是 300) | 242 / 300 |
⇒ 就地覆盖干了两件事:凭空造出状态(同一首歌用了多次), 同时弄丢合法状态(右边那格在被读到之前已经被抹成 0)。 ★ 上面那个最小反例正是「弄丢」的那一种 —— 它输出的是 −1,比正解小。
⇒ 这是本书第 N 次「说清楚一个 bug 算了什么,比说它错了有用得多」, ⚠ 而这一次的教训是反过来的:「说清楚」之前先量一遍,别把一半当成全部。
4★★ 命门:范围是每一步都要满足,不是只在最后
题面那句「音量不能小于 0 也不能大于 maxLevel」,说的是全程。
读成「最后那个音量要合法」就成了这一版:
用第 12 章那套判据称一称 —— 造一档违反它的数据,看行为变不变:
| 档位(各 300 轮) | 被抓 |
|---|---|
默认档(maxLevel ≤ 20) |
200 / 300 |
c 偏大(每个 c ≥ maxLevel/2,一步就顶到边上) |
★ 223 / 300 |
照题面随机(maxLevel ≤ 1000) |
221 / 300 |
⇒ 这是一条命门(不是情报、也不是噪声):违反它,答案当场就变,而且三档都抓得到。
5★ 「忘了 -1」:被抓的轮数 ≡ 正解输出 -1 的轮数
maxLevel ≤ 20 |
maxLevel ≤ 1000 |
|
|---|---|---|
| 它被抓的轮数 | 175 | 188 |
| 「正解输出 −1」的轮数 | ★ 175 | ★ 188 |
这个 bug 只在无解时才现形(有解时它和正解一模一样)。 两边一个不差 ⇒ 等于证明了「只要无解,它一定被抓」。
★ 而这张表还顺手说明了这道题的生成器为什么不难写: 照题面随机时「无解」占了六成 —— 换成一道无解很罕见的题 (比如第 11 章 P1115 那种),同样的 bug 就得靠数而不是靠抓了。
6度量程序和生成器
7一页纸
| 关键的一步 | 状态记「能不能」而不是「最多是多少」:f[i][j] = 音量能否正好是 j |
| 哪一版能 AC | p1877.cpp —— 两个数组滚动,O(n × maxLevel) = 50 050(2⁵⁰ 是 1.1 × 10¹⁵) |
| ★★★ 这道题的坑 | 压成一维在这里必错:转移同时读左边和右边,没有哪种顺序救得了 |
| 那句规矩的前提 | 「倒序是唯一的活路」成立的前提是转移单向 |
| 最小反例 | ★ 一首歌就够:倒序 (1, begin=1, c=1) ⇒ 正解 0 / 它 −1;正序 (1, begin=0, c=1) ⇒ 1 / −1 |
| ⚠ 被打回的草稿 | 「就地覆盖只会多算」是错的:它既多算也少算(可达集只有 248/300 轮包含正解) |
| 命门 | 范围是每一步都要满足 —— 只在最后检查,三档分别被抓 200 / 223 / 221 |
| 错法三 | 无解时忘了 −1 ⇒ ★ 被抓轮数 ≡ 正解为 −1 的轮数(两档一个不差) |
| ⚠ 官方样例 | ★ 三个错法一个都没挡住 —— 和同章的 P1060(三个全挡住)正好是两个极端 |