题单 · 习题解析

洛谷 P1877 [HAOI2012] 音量调节

★★★ 「01 背包一维要倒序」有前提:转移单向。这道题同时读左边和右边 ⇒ 两个方向各坏一半,最小反例只要一首歌;⚠ 而「就地覆盖只会多算」是我的草稿被实测打回来的

原题:洛谷 P1877出自 第 23 章 01 背包 的题单题面本地存档:2026-08-29
⚠ 先自己写一遍,再往下看

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

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

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

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

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

题目描述

一个吉他手准备参加一场演出。他不喜欢在演出时始终使用同一个音量, 所以他决定每一首歌之前他都需要改变一次音量。在演出开始之前,他已经做好一个列表, 里面写着每首歌开始之前他想要改变的音量是多少。每一次改变音量,他可以选择调高也可以调低

音量用一个整数描述。输入文件中整数 beginLevel,代表吉他刚开始的音量, 整数 maxLevel,代表吉他的最大音量。音量不能小于 0 也不能大于 maxLevel。 输入中还给定了 n 个整数 c₁, c₂, c₃, …, cₙ, 表示在第 i 首歌开始之前吉他手想要改变的音量是多少。

吉他手想以最大的音量演奏最后一首歌,你的任务是找到这个最大音量是多少。

输入格式

第一行依次为三个整数 nbeginLevelmaxLevel

第二行依次为 n 个整数 c₁, c₂, c₃, …, cₙ

输出格式

输出演奏最后一首歌的最大音量。如果吉他手无法避免音量低于 0 或者高于 maxLevel,输出 -1

说明/提示

1 ≤ n ≤ 501 ≤ cᵢ ≤ maxLevel1 ≤ maxLevel ≤ 10000 ≤ 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.cpp★ 这一版就能 AC
// 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

复杂度 O(n × maxLevel) = 50 × 1001 = 50 050 —— 而暴力枚举每首歌加还是减是 2⁵⁰ ≈ 1.13 × 10¹⁵。这两个数摆在一起,就是「为什么要 DP」的全部理由。

p1877Dfs.cpp第一版:2^n 枚举加/减(也是对拍参照物)

2二维版:它把「为什么压不成一维」摆在明面上

p1877Dp2.cpp对照:二维

盯着转移看一眼:

    f[i][j]  <-  f[i-1][j - c]        ← 左边那一格
             <-  f[i-1][j + c]        ← 右边那一格

一个在左,一个在右。 记住这个形状,下一步整页的内容都是从它推出来的。

3★★★ 这道题真正的坑:照搬「压成一维 + 倒序」,当场就错

第 23 章第 ⑧ 步那一节的标题是「倒序是唯一的活路」。 很多人把它背成了一条无条件的规矩,于是这道题一上来就压成一维、倒着写:

p1877Down.cpp错法一:一维 + 倒序
// 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
p1877Up.cpp错法一的另一半:一维 + 正序
★★★ 「倒序是唯一的活路」有一个前提:转移是单向的
01 背包 这道题
转移从哪儿读 f[j−w] —— 只从更小的 j f[j−c] f[j+c] —— 两边都读
倒序能保住 左边(j−w 还没被本层改)✓ 左边 ✓、右边 ✗
正序能保住 —— 左边 ✗、右边 ✓
结论 倒序可行 没有任何一种遍历顺序救得了

⇒ 「一维 + 某个方向」这一招的适用条件不是「01 背包」,是「转移单向」。 这道题两个方向各坏一半,所以只能老老实实用两个数组滚动。

★★ 最小反例只要一首歌 —— 而官方样例照样放过它
最小反例 正解 那一版
一维倒序maxLevel = 1begin = 1c = [1] 0 −1
一维正序maxLevel = 1begin = 0c = [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」,说的是全程。 读成「最后那个音量要合法」就成了这一版:

p1877Loose.cpp错法二:只在最后检查范围

第 12 章那套判据称一称 —— 造一档违反它的数据,看行为变不变:

档位(各 300 轮) 被抓
默认档(maxLevel ≤ 20 200 / 300
c 偏大(每个 c ≥ maxLevel/2,一步就顶到边上) 223 / 300
照题面随机(maxLevel ≤ 1000 221 / 300

⇒ 这是一条命门(不是情报、也不是噪声):违反它,答案当场就变,而且三档都抓得到。

5★ 「忘了 -1」:被抓的轮数 ≡ 正解输出 -1 的轮数

p1877NoNeg.cpp错法三:无解时打了 0
maxLevel ≤ 20 maxLevel ≤ 1000
被抓的轮数 175 188
「正解输出 −1」的轮数 175 188
★ 又一次「触发条件 ≡ 抓获数」,两档全中

这个 bug 只在无解时才现形(有解时它和正解一模一样)。 两边一个不差 ⇒ 等于证明了「只要无解,它一定被抓」。

★ 而这张表还顺手说明了这道题的生成器为什么不难写: 照题面随机时「无解」占了六成 —— 换成一道无解很罕见的题 (比如第 11 章 P1115 那种),同样的 bug 就得靠而不是靠了。

6度量程序和生成器

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

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(三个全挡住)正好是两个极端