刷信奥题的时候,碰到一道很有意思的题:P4138 [JOISC 2014] 挂饰 / Straps。名字听起来像是一个模拟题,仔细读完才发现,这是一道非常典型的“排序 + 背包 DP”题,而且藏着一个很容易被忽略的状态上限问题。题面本身很生活化:你手上有 N 个挂饰,每个挂饰自带若干挂钩,还有一个美观值;把一个挂饰挂上去,要占用一个挂钩,同时它自带的挂钩又会变成可用的新挂钩,最后还必须至少留一个挂钩。既要保证挂钩数撑得住,又要让美观值尽量大,“有多少挂钩”和“选哪些挂饰”这两个维度缠在一起,正好用来练一类很实用的模型——把可消耗资源当成背包容量做 DP。
这篇文章我会把从读题到 AC 的完整思路写清楚:状态怎么定义、为什么先排序、挂钩数上限到底开多大、一维滚动数组为什么会有坑,以及几个亲自提交时才发现的边界问题。正在刷背包题的选手,应该能从中省下不少弯路。
1. 先把题意翻译成人话
1.1 挂饰到底是怎么挂的
题目可以这样理解:一开始你有一个空挂钩。手上有 N 个挂饰,每个挂饰有两个属性:
a[i]:这个挂饰自带的挂钩数量;b[i]:把它挂上去之后得到的美观值。
挂上去的动作很简单:占用当前的一个空挂钩,然后这个挂饰自带的a[i]个挂钩就变成了新的可用挂钩。所以每个挂饰实际上会造成一个“净挂钩变化”:
d[i] = a[i] - 1举几个直观的例子:
a = 3:挂上去占用 1 个、带来 3 个,净增加 2 个挂钩;a = 1:占用 1 个、带来 1 个,净变化为 0;a = 0:占用 1 个、带来 0 个,净变化为 -1,这种挂饰是纯粹的负担;a = -1:占用 1 个,还额外倒扣挂钩,净变化为 -2,只有当前挂钩非常多的时候才敢碰它。
最后不能把挂钩全用完,至少要剩 1 个,相当于绳子上还得留下一个挂点。目标是从 N 个挂饰里选若干个,在满足上述约束的前提下,让总美观值最大。
1.2 一个直观但错误的想法
很多人第一反应是:挂钩多的先挂,挂钩少或者负的靠后,按a排个序,然后贪心选美观值为正的挂饰。这个思路初看合理,但一推敲就崩。
原因在于,美观值和挂钩资源是相互纠缠的。一个a很大但b = -100的挂饰,单独看是负收益,可它的作用可能是给后面的高收益挂饰“买”到足够的挂钩空间。比如先挂一个自带 100 个挂钩的挂饰,虽然丑,但后面能挂一个需要大量挂钩、美观值很高的挂饰,整体反而是赚的。
反过来,一个美观值为正但挂钩数为 0 的挂饰,挂上去直接消耗一个挂钩,如果当前挂钩数只剩 1,挂完就归零,直接失败。
所以这道题没法用纯贪心解决。我们需要把“当前挂钩数”作为一个维度记录下来,在走每一步时都保证挂钩数不小于 1,同时让美观值最大——这就是一个背包 DP 的天然模型。
2. 破题关键:挂钩变化是本质,顺序决定可行性
2.1 最终挂钩数只和选了哪些有关
如果把选的挂饰集合记为 S,那么最后剩下的挂钩数可以写成:
最终挂钩数 = 1 + Σ (a[i] - 1)注意,这个结果和选择顺序无关。因为每个挂饰的净挂钩变化是一个普通的加法项,先加谁后加谁,总和不会变。
这一点很重要:它说明我们真正要优化的对象是“选哪些挂饰”,而不是“按什么顺序挂”。
那是不是随便什么顺序都行?不是。虽然最终挂钩数一样,但中间过程的挂钩数可能低于 1,导致方案在中途就执行不下去。这就是这道题需要排序的根本原因。
2.2 为什么排序之后挂一定可行
看一个最简单的例子:初始只有 1 个挂钩。如果第一个挂a = 0的挂饰,挂完剩余挂钩数为 0,直接死亡;但如果先挂一个a = 3的挂饰,挂钩数变成 3,再挂a = 0的就没事了。
这说明一个合法的选择集合,可能只在某种特定的顺序下才能走通。我们希望在 DP 之前,把所有物品排成一个“如果最优解存在,就一定能按这个顺序执行”的序列。
排序规则是:按挂钩数a[i]从大到小排。
这里用一个相邻交换论证来解释为什么这样排一定不亏。假设最优方案中两个相邻物品是 x 在前、y 在后,并且a[x] < a[y]。在它们之前,当前剩余挂钩数为 j。
原来的顺序执行到这一步:
- 挂完 x,挂钩数变成
j + a[x] - 1; - 挂完 y,挂钩数变成
j + a[x] + a[y] - 2。
如果交换顺序,先挂 y 再挂 x:
- 挂完 y,挂钩数变成
j + a[y] - 1; - 挂完 x,挂钩数同样是
j + a[x] + a[y] - 2。
对比中间那一步:
j + a[y] - 1 >= j + a[x] - 1因为a[y] > a[x],所以交换之后中间状态的挂钩数只会更大,不会更小,而最终挂钩数完全一样。
也就是说,任何两个相邻的“小挂钩在前、大挂钩在后”都可以安全地交换成“大挂钩在前、小挂钩在后”。反复交换下去,任何一个合法方案都能整理成按a从大到小的顺序,而不会让过程中的任意一步变差。
所以排序后做 DP,不会漏掉最优解。
2.3 状态上限到底开多少
这个点是我觉得全题最容易被坑的地方。
网上有些写法把挂钩数上限压缩成N,理由是“最多 N 个挂饰,N 个挂钩足够了”。这个说法在这种“容量只会减少”的普通背包里是对的,但在这道题里会漏掉合法状态。
来看一个反例。假设 N = 4,有四个挂饰的a分别是:
400 400 -401 0初始 1 个挂钩,先挂第一个a = 400的挂饰,挂钩数变成 400;再挂第二个a = 400的挂饰,挂钩数变成 799;这时候去挂a = -401的挂饰,剩余挂钩数为:
799 - 1 - 401 = 397完全合法。
但如果把挂钩数上限压缩成 4,挂完两个a = 400的挂饰之后,状态最多只能记为 4,然后再想挂a = -401的挂饰,会得到4 - 1 - 401 < 0,一个本来可行的方案就被错误地丢弃了。
正确的做法是,把状态上限设置为“理论上可能达到的最大挂钩数”:
maxHook = 1 + Σ (a[i] - 1), 其中 a[i] > 0也就是假设所有能增加挂钩的挂饰全部挂上去,能达到的挂钩数。负挂钩的挂饰只会让挂钩数下降,所以任何合法方案过程中的挂钩数都不可能超过这个值。
在上面的例子里,maxHook = 1 + 399 + 399 = 799,数组开成maxHook + 1就足够。这个反例同时也说明,不要盲目相信“状态能压到 N”的结论,尤其是资源可以被物品反向增加的背包题。
3. C++实现:二维滚动DP代码详解
3.1 状态定义与转移方程
定义一维数组dp[j]:当前剩余挂钩数为 j 时,能获得的最大美观值。
初始化:
dp[1] = 0 其余 dp[j] = 负无穷因为初始就一个挂钩,美观值为 0。
对每个挂饰,有两种决策:
- 不挂:状态不变;
- 挂:前提是当前挂钩数 j 满足
j + a[i] - 1 >= 1,挂完之后的新挂钩数是j + a[i] - 1,美观值增加b[i]。
转移方程:
dp_next[remain] = max(dp_next[remain], dp[j] + b[i])其中:
remain = j + a[i] - 1最后答案是整个dp数组的最大值,同时要和 0 取 max,因为允许一个挂饰都不挂。
3.2 为什么不能直接在一维数组上倒序更新
这里一定要特别强调。
标准 01 背包倒序枚举,是为了防止同一个物品被用两次,前提是物品会让背包容量变小。但这道题里a[i] - 1可以是正数,挂了之后挂钩数反而变大。
如果你在原来的dp数组上直接做倒序循环,先更新出新的、更大的挂钩数状态,然后循环到那个更大的挂钩数时,又会拿着本轮刚更新的结果再去转移一次。结果就是同一个挂饰被重复挂了好几次。
正序循环也一样,因为新的状态下标可能比旧状态大,也可能比旧状态小,两种情况都会导致本轮更新过的值被再次当作本轮旧值使用。
所以安全的写法是:每轮新开一个数组ndp,先把上一轮的dp整体复制过去,表示“不选当前物品”的默认情况;然后只基于上一轮的旧dp做选择当前物品的转移;最后swap交换两个数组。这样本轮所有更新都来自旧状态,当前物品绝不可能被用两次。
3.3 完整AC代码
下面是可以直接提交的 C++17 写法,注释我写得比较细:
#include <bits/stdc++.h> using namespace std; const long long NEG = -(1LL << 60); struct Item { int a; long long b; }; int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int n; cin >> n; vector<Item> items(n); long long maxHook = 1; // 理论最大挂钩数:把所有会增加挂钩的挂饰全部挂上 for (int i = 0; i < n; i++) { cin >> items[i].a >> items[i].b; if (items[i].a > 0) { maxHook += items[i].a - 1; } } // 关键排序:挂钩数大的在前 sort(items.begin(), items.end(), [](const Item& x, const Item& y) { return x.a > y.a; }); vector<long long> dp(maxHook + 1, NEG), ndp; dp[1] = 0; // 初始一个挂钩 for (const Item& it : items) { ndp = dp; // 不选当前挂饰,先保留所有旧状态 for (int j = 1; j <= maxHook; j++) { if (dp[j] == NEG) continue; long long remain = (long long)j + it.a - 1; // 挂完必须还有至少一个挂钩 if (remain < 1) continue; // 理论上 remain 不会超过 maxHook,这里加一个保护 if (remain > maxHook) remain = maxHook; ndp[remain] = max(ndp[remain], dp[j] + it.b); } dp.swap(ndp); } long long ans = 0; // 一个都不选也是可行方案 for (int j = 1; j <= maxHook; j++) { ans = max(ans, dp[j]); } cout << ans << '\n'; return 0; }3.4 复杂度和读入细节
排序的部分是O(N log N),DP 是O(N * maxHook)。在常见的信奥数据范围里,N 在几百左右,单个a的绝对值也有限,maxHook大致在十几万以内,这个复杂度完全跑得动。
读入时注意b[i]有可能是负数,所以 DP 数组用long long比较稳。NEG我取的是-4e18级别,比任何合法的美观值累加都要小,不会干扰max运算。
还有一个小细节:枚举 j 不需要从 0 开始,因为任何时候挂钩数都不能为 0,合法状态的下标至少是 1。
4. 实战避坑:WA了三次以后我才明白的事
4.1 数组上限开成 N 的教训
这个坑我已经在前面详细讲过,这里再换一个角度补充一下。
如果maxHook设得不够大,WA 并不是那种“答案差一点点”的错,而是某些本来可以走到的高挂钩状态被提前截断,后面所有依赖这些高挂钩的转移全都短路。最麻烦的是,这种错误在随机小数据下不一定能测出来,只有构造出“先攒大量挂钩,再消耗大量挂钩”的数据才会暴露。
所以我的习惯是:做资源增减类背包题时,先手动算一下“资源最多能涨到多少”,不偷懒直接用 N 当上限。
4.2 初始状态设成 dp[0] = 0 的错
有不少人写这种题会把dp[0] = 0,心想“初始 0 个挂钩,最后答案看 dp[1] 到 dp[N] 就行”。但这个题的起点是 1 个挂钩,不是 0 个。
如果从 0 开始,转移时会凭空多出很多本来不存在的合法方案,答案自然就偏大。正确初始化只有一个:
dp[1] = 0同时,所有状态的下标都从 1 开始,dp[0]这一格从头到尾都不参与转移。
4.3 转移前忘记判断挂完后的挂钩数
这个坑很好笑,但确实容易踩。
挂之前判断了j >= 1,觉得万事大吉,结果一个a = -2的挂饰直接把状态打成负数,后面全乱套。
正确的判断是两步:
- 当前挂钩数 j 必须是可达状态;
- 挂完之后
j + a[i] - 1 >= 1。
第二句尤其重要。因为a[i]可以是负数,也可以为 0,这两种情况下挂完之后的挂钩数可能小于 1,这样的状态不能进入下一轮。
4.4 答案直接用某个固定 dp 值
我一开始以为挂钩越多越好,毕竟挂钩多意味着后面选择余地大,所以直接输出dp[maxHook]。交了之后 WA 得很惨。
实际上,美观值最高的方案完全可能停在挂钩数不多不少的位置。比如选了一堆美观值极高、但挂钩数量为 0 的挂饰,最后挂钩数只剩 1,这个时候答案在dp[1],而不在dp[maxHook]。所以正确做法是扫一遍整个 dp 数组取最大值。
另外别忘了,一个挂饰都不选是一种合法方案,所以答案和 0 取max是必要的。如果所有b[i]都是负数,答案就是 0。
4.5 一个可以手工验证的小样例
给一个适合手算的例子:
输入:
3 2 1 -1 10 2 20排序后按a从大到小为:(2,20)、(2,1)、(-1,10)。
初始挂钩数为 1。
- 挂
(2,20),挂钩数变成1 - 1 + 2 = 2,美观值 20; - 挂
(2,1),挂钩数变成2 - 1 + 2 = 3,美观值 21; - 挂
(-1,10),挂钩数变成3 - 1 - 1 = 1,美观值 31。
最终剩余挂钩数为 1,满足“至少留一个挂钩”的要求,答案 31。
这个例子也说明,a = -1的挂饰不是不能挂,而是要放在最后、等挂钩攒够了再挂。
5. 这类题的迁移与个人体会
5.1 资源型背包的一般套路
打完这道题之后,我对一类题的认识清晰了很多。凡是出现“当前有某种可消耗资源 X,每个物品会消耗或产生资源 X,并且带来一定收益,资源不足就做不了后续操作”的模型,基本都可以往背包 DP 上靠:
- 状态设计只留一个维度:
dp[当前资源量]; - 物品转移时同时更新资源和收益;
- 关键问题从“背包容量多大”变成“资源量上限怎么算”;
- 关键顺序问题用相邻交换论证解决。
这类题和普通 01 背包最大的区别是:物品可能反过来增加资源,所以转移方向不是单调向后的,普通的一维滚动写法会翻车。
5.2 什么时候能压缩状态
这道题最大的思维陷阱,是把“挂钩数压缩到 N”当成理所当然。我后来总结了一个判断原则:
如果一个高资源状态被截断后,后续不再需要消耗大量资源,那么压缩是安全的;但如果后续物品可能出现很大的负收益,也就是会大量消耗资源,那么每一个多余的高资源余量都可能成为解题关键,简单压缩就会出问题。
说得更直白一点:压缩状态前先问自己一句,被截掉的那部分余量,后面有没有可能被用到?如果可能,这个压缩就是不安全的。
5.3 我的做题心得
写这题的过程中,我最大的体会是:不要急着写代码,先把“状态上限”和“转移方向”这两个问题在草稿纸上想清楚。尤其是这种资源可以反向增加的背包题,一维滚动数组的坑非常隐蔽,稍不注意就是同一物品被用两次,答案肉眼看着合理,实际早就跑偏。
我现在做这种题的习惯是:
- 先手算一个极端数据,比如几百个挂钩然后挂一个大负数挂钩,验证状态上限够不够;
- 转移时一律使用“上一轮的旧状态”作为依据,不贪图省一次数组复制;
- 如果题目数据范围很大,导致状态数组开不下,就改用
map或unordered_map做稀疏 DP,每次只枚举当前可达的状态。
其实这个做法放到更广的背包问题上也通用。很多 WA 都不是算法思路错,而是状态表达少了半截,或者滚动数组的方向没想清楚。这道挂饰题,算是帮我把这两个教训都补齐了。