
最近在某在线刷题平台上翻到一道名为“牛牛的礼物”的编程题标题看着挺轻松点进去才发现它换了一层经典的外衣考的是动态规划里最基础的背包模型。这道题对刚接触算法竞赛、或者刚开始刷题的朋友来说是很不错的一道入门题它不会涉及太复杂的数学变形但足够把“状态怎么定义、转移方程怎么写、空间怎么优化”这条完整的动规思考链路讲清楚。我前前后后把这道题做了三遍第一遍用暴力递归第二遍用记忆化搜索第三遍才写成最精简的一维动态规划。今天这篇文章就把这三版思路完整拆开讲清楚每一步为什么这样走以及我实际提交时踩过的几个边界坑。无论你是准备面试、备考笔试还是单纯想搞懂背包问题这篇都应该能给你一些踩在实处的经验。1. 题目设定与我读完题的第一反应1.1 题面还原礼物、重量与容量题目名字叫“牛牛的礼物”在我看到的版本里题面大致是这样牛牛面前有 n 件礼物每件礼物有一个重量 w[i] 和一个价值 v[i]牛牛的手提袋最多只能承受总重量 W问牛牛最多能带走价值多少的礼物。不同题库里这个题目的具体数据范围可能有些差别有的把 n 限制在 100 以内有的会放到 1000W 的取值也各不相同。但这道题的内核非常稳定它就是一个典型的 0-1 背包问题每件礼物只有“拿”和“不拿”两种选择不存在“拿一半”或者“拿多次”的说法。明白这一点之后解题方向就立刻清晰了。1.2 我第一眼想用的贪心思路为什么立刻被推翻了很多新手包括我当年拿到这种题第一反应往往是贪心把礼物按“性价比”也就是 v[i] / w[i] 从高到低排序然后依次往包里塞塞不下就跳过。听上去好像很合理但这种局部最优的堆叠并不能保证全局最优。我随手构造了一个反例三件礼物重量分别是 7、5、5价值分别是 9、5、5背包容量 W 10。按性价比排序第一件重量为 7 的礼物性价比最高先装它剩下容量只有 3什么都装不下总价值是 9。可实际上装走两件重量 5、价值 5 的礼物总价值正好是 10。贪心在这里直接输掉了。这个反例让我意识到题目的本质是组合优化要在所有“选/不选”的组合里找总重量不超过 W 且总价值最大的那个组合。暴力枚举所有组合当然可以但礼物个数稍微涨一点组合数量就是 2 的幂次完全不可行。这时候就需要动态规划上场了。1.3 这道题真正想考察的东西“牛牛的礼物”这类题表面上是求一个最大值实际上考察的是你对“决策过程”的建模能力。每一件礼物你都要做一次二选一而这个选择又会影响到后续所有礼物的可装空间所以它是一个天然适合用状态来描述的问题。我自己的体会是看到这种“每个物品选/不选”“容量有限制”的题第一反应就应该是背包看到“数组切成若干段”“相邻不能同时选”的题第一反应就应该是线性 DP。这种条件反射不是天赋是靠一道一道题练出来的。2. 别急着上 DP先把“选/不选”的递归想清楚2.1 决策模型每一件礼物只有两条路面对第 i 件礼物时牛牛其实只有两个选择不拿或者拿前提是当前剩余容量足够。如果我们定义一个递归函数 f(i, remain)表示从第 i 件礼物开始往后挑、当前背包还剩 remain 容量时能获得的最大价值那么状态转移可以写成不拿第 i 件f(i, remain) f(i 1, remain)拿第 i 件remain w[i] 时f(i, remain) f(i 1, remain - w[i]) v[i]最终答案就是 f(0, W)也就是从第 0 件礼物开始考虑背包容量还剩下 W 时的最大价值。2.2 暴力递归的代码实现这段代码非常直接几乎就是把上面的逻辑翻译成程序#include bits/stdc.h using namespace std; int n, W; vectorint w, v; int dfs(int idx, int remain) { if (idx n) return 0; // 所有礼物都考虑完了 int res dfs(idx 1, remain); // 不拿当前礼物 if (remain w[idx]) { res max(res, dfs(idx 1, remain - w[idx]) v[idx]); // 拿当前礼物 } return res; } int main() { cin n W; w.resize(n); v.resize(n); for (int i 0; i n; i) cin w[i] v[i]; cout dfs(0, W) endl; return 0; }我建议第一次写这道题的人先把这段递归跑通。它虽然慢但逻辑结构非常干净几乎不可能写错后续的 DP 优化本质上就是在这个正确版本上做加速。2.3 为什么暴力递归撑不过 n 30每件礼物都要分叉成“拿”和“不拿”两个分支所以递归树的大小是 2^n。n 只有 20 的时候还算能扛30 就已经是十亿级别到 50 基本就是天文数字了。我在本地测试的时候n 给到 35 就已经明显卡顿提交上去只能拿部分分数。更关键的是这棵递归树里大量子问题是重叠的。比如“先拿第 1 件、再拿第 3 件”和“先拿第 3 件、再拿第 1 件”最后都会到达同一个状态已经处理完前 4 件、剩余容量相同。同一个状态被反复计算这就是优化的切入点。3. 记忆化搜索递归太慢时的第一个救星3.1 把算过的状态存下来既然大量子问题被重复计算那我们就用一个表把它们的结果存下来。每次进入 dfs(idx, remain)先查表计算完再写回表。这就是记忆化搜索本质上还是递归但因为去掉了重复计算效率会大幅提升。状态总数是 (n 1) * (W 1)每一个状态最多计算一次所以复杂度从指数级降到了 O(n * W)。这个提升是质的飞跃n 1000、W 1000 的规模完全能扛住。3.2 记忆化搜索的实现细节我通常用一个二维 vector 初始化成 -1-1 表示这个状态还没算过。因为合法的最大价值最小也是 0用 -1 做“未计算”的标记非常安全。#include bits/stdc.h using namespace std; int n, W; vectorint w, v; vectorvectorint memo; int dfs(int idx, int remain) { if (idx n) return 0; if (memo[idx][remain] ! -1) return memo[idx][remain]; int res dfs(idx 1, remain); if (remain w[idx]) { res max(res, dfs(idx 1, remain - w[idx]) v[idx]); } return memo[idx][remain] res; } int main() { cin n W; w.resize(n); v.resize(n); for (int i 0; i n; i) cin w[i] v[i]; memo.assign(n 1, vectorint(W 1, -1)); cout dfs(0, W) endl; return 0; }这里有一个值得注意的地方memo 数组第二维的大小是 W 1只有当 W 不太大时才能这样开。我见过把 W 放到 1e9 的变种题那种情况下这招直接失效。所以拿到题目先看数据范围永远是第一步。3.3 递归转递推的必然性记忆化搜索虽然快但递归仍然有栈开销而且有些在线评测系统的递归深度有限制n 一大可能爆栈。更重要的是面试官或者出题人通常期待的是你写出迭代版本。所以我一般把记忆化搜索当成一个“逻辑验证器”用它来确认状态转移是对的然后再翻译成数组递推。从记忆化搜索到动态规划只需要做一件事把“从前往后递归”改成“从后往前填表”。递归里我们依赖 idx 1 的结果递推时我们就从 idx n 开始倒着往前推或者用“前 i 件”的正向思维从头开始推。这就是下一章要展开的内容。4. 二维 DP 的状态定义与转移方程我为什么习惯从 1 开始编号4.1 状态定义前 i 件、容量 j改成递推之后我习惯用 dp[i][j] 表示“考虑前 i 件礼物背包总容量为 j 时能获得的最大价值”。这个定义和递归里的“从第 i 件开始往后考虑”稍有不同但本质是一回事都包含了“处理到哪一件”和“剩余/可用容量是多少”这两个维度的信息。转移方程也非常整齐不拿第 i 件礼物dp[i][j] dp[i - 1][j]拿第 i 件礼物前提是 j w[i]dp[i][j] max(dp[i][j], dp[i - 1][j - w[i]] v[i])注意这里 dp 数组的行下标 i 表示“前 i 件”而物品的重量和价值数组是 0 下标存储所以写代码时索引要对应好。4.2 一个让我少掉不少头发的小习惯从 1 开始编号很多人写背包题会纠结dp 第 1 维到底要不要从 0 开始我的答案是如果你和我一样容易在边界上出错就让 dp 的 i 从 1 开始把 dp[0][j] 全部初始化为 0表示“前 0 件礼物任何容量下价值都是 0”。这样第 i 件物品实际对应的是 w[i - 1]、v[i - 1]虽然多了一次下标偏移但每一行的逻辑都是统一的边界也只剩下一行初始化不容易错。4.3 二维 DP 的完整代码#include bits/stdc.h using namespace std; int main() { int n, W; cin n W; vectorint w(n), v(n); for (int i 0; i n; i) cin w[i] v[i]; vectorvectorlong long dp(n 1, vectorlong long(W 1, 0)); for (int i 1; i n; i) { for (int j 0; j W; j) { dp[i][j] dp[i - 1][j]; if (j w[i - 1]) { dp[i][j] max(dp[i][j], dp[i - 1][j - w[i - 1]] v[i - 1]); } } } cout dp[n][W] endl; return 0; }写完这段之后我强烈建议像我一样在小数据上手动打印一遍 dp 表。我自己曾经用一个 n 3、W 10 的小样例核对重量依次为 7、5、5价值依次为 9、5、5。最终 dp[3][10] 算出来是 10也就是选了两件重量 5 的礼物。如果表格里某些单元格看起来和直觉不符多半就是重量数组的下标写岔了。4.4 两个容易忽略的初始化细节如果题目求的是“不超过容量 W 的最大价值”dp 全部初始化为 0 即可因为什么都不拿也合法。但如果题目改成了“恰好装满容量 W”初始化就要很小心dp[0][0] 0dp[0][j]j 0设为负无穷表示用前 0 件恰好凑出正容量是不可能的。这个细节在一些变体题里非常致命少了它答案会变成一个看似合理但其实无解的数值。5. 滚动数组优化一维倒着更新的真相5.1 为什么可以把二维压成一维观察二维 DP 的转移你会发现 dp[i][j] 只依赖 dp[i - 1][...]和更早的行没有任何关系。也就是说我们在填第 i 行的时候只需要第 i - 1 行的数据。既然如此不如干脆只保留一行原地更新。这就是滚动数组的思想。空间复杂度从 O(n * W) 降到 O(W)n 很大的时候这个优化非常关键。我见过不少题二维数组直接内存超限一维数组轻松通过。5.2 一维版本为什么内层必须倒着遍历一维版本的代码比二维短了一大截#include bits/stdc.h using namespace std; int main() { int n, W; cin n W; vectorint w(n), v(n); for (int i 0; i n; i) cin w[i] v[i]; vectorlong long dp(W 1, 0); for (int i 0; i n; i) { for (int j W; j w[i]; j--) { dp[j] max(dp[j], dp[j - w[i]] v[i]); } } cout dp[W] endl; return 0; }新手最容易在这里踩坑为什么内层循环的 j 要倒着从 W 往 w[i] 遍历而不是正着从 w[i] 往 W 遍历我用一个极端例子说明。假设只有一件礼物重量 2价值 3背包容量 W 4。如果正着更新j 2 时 dp[2] max(dp[2], dp[0] 3) 3到 j 4 时 dp[4] max(dp[4], dp[2] 3) 6。问题来了明明只有一件礼物却算出了价值 6相当于同一件礼物被选了两次这就不符合 0-1 背包的规则了。如果倒着更新j 4 先更新dp[4] max(dp[4], dp[2] 3) 3此时 dp[2] 还没有被本轮更新用的还是上一轮的旧值然后 j 2 更新dp[2] max(dp[2], dp[0] 3) 3。整个过程 dp[4] 和 dp[2] 都只被当前物品影响一次结果正确。一句话总结正序更新会让当前物品可能被多次使用恰好是“完全背包”的逻辑倒序更新杜绝了这种重复使用保证每件物品最多被拿一次。所以记住一个口诀0-1 背包倒着写完全背包正着写。5.3 顺着“恰好装满”再聊一个初始化细节一维版本同样存在“恰好装满”的初始化问题。如果题目要求恰好装满需要把 dp[0] 设为 0dp[1...W] 设为负无穷例如 -1e18。这样只有从容量 0 出发逐步加出来的状态才是合法状态凑不出来的容量会一直保持负无穷。最终如果 dp[W] 仍然小于 0说明无解。我还见过一些题会把“价值”和“重量”互换比如求“恰好价值 V 的最小重量”这时候就可以用 dp[v] 来存最小重量遍历方向也会相应调整。这种变题在笔试里很常见但核心思路还是那套状态定义清楚初始化合理转移方向正确。6. 提交记录里那些让我改版的边界问题与通解套路6.1 数据范围与类型int 溢出不是玩笑我看到题目第一件事就是看数据范围。如果重量和价值都可能到 1e9那 dp 数组里的中间值可能非常大int 根本存不下。我早期就吃过这个亏本地跑小数据一切正常一提交就 WA后来把数组从 int 全改成 long long 才通过。所以从写第一行代码开始直接上 long long能省去很多无谓的调试时间。另外如果 W 超过 1e7一维 dp 数组都开不下了。这时候要么说明这道题有别的更巧妙的解法比如按价值做 DP、用重量做结果要么说明 n 非常小可以用搜索加剪枝。我在实际刷题里遇到过一道“重量很大但价值很小”的变种做法是反过来定义 dp[v] 为“凑出价值 v 需要的最小重量”这样只要总价值不大数组就能开下。思路比死背模板重要得多。6.2 多组数据的输入坑有些在线评测系统的题目会包含多个测试用例读完 n 和 W 之后紧接着还有下一组直到 EOF。我之前写代码时只处理了一组样例本地样例都过交上去直接超时或者 WA。后来改成这样读取while (cin n W) { // 每组数据记得重新初始化 dp 数组 vectorlong long dp(W 1, 0); // ...处理逻辑 }如果你用二维数组并且没有在每组之间清零上一组的数据会残留到下一组答案自然就错了。用 vector 重新构造或者每次 fill 一下是最稳妥的做法。6.3 举一反三一道题变成一串题把“牛牛的礼物”吃透之后很多兄弟题都可以顺手拿下如果每件礼物可以拿无限次那就是完全背包内层从正序更新改成从 w[i] 到 W 正序。这个结论上面已经解释过理解之后不用额外背。如果每件礼物有一个数量上限那就是多重背包。最常用的套路是二进制拆分把 12 件相同礼物拆成若干组转化成 0-1 背包来做。如果礼物除了重量还占用体积那就是二维费用背包dp 数组多开一维即可。如果要求输出选了哪些礼物还需要维护一个 path 数组在转移时记录下来然后从最终状态倒推。我自己后来再遇到背包问题基本不会再怕因为所有变种的底层都是同一个决策模型面对一个物品要不要把它放进某个受限的容器里。6.4 我的实际调试习惯三版对照互为验证最后分享一个比较笨但很有效的经验。我拿到一道新的动规题通常先写一个暴力递归或 BFS 版本用它跑随机小数据然后写 DP 版本在同一组随机数据上比对两者的答案。只要出现过一次不一致就打印 dp 表定位到底是在哪个容量、哪件物品上出现了偏差。这个方法帮我抓出过无数次“下标偏移一”“w[i] 写成 w[i - 1]”“初始化少了第一列”之类的低级错误。对于“牛牛的礼物”这道题我强烈建议你也这样走一遍第一遍暴力递归第二遍记忆化搜索第三遍二维 DP第四遍一维滚动数组。每一步都能让你对这个模型的理解深一层特别是到最后一版时你会由衷觉得那几行倒序更新写得非常巧妙。最后再分享一点个人体会这道题我大概在三个不同场合重新写过每次都有新收获。第一次只是照着模板把代码敲出来了第二次才真正弄懂为什么内层要倒着遍历第三次才学会“恰好装满”的时候该怎么初始化。动态规划不是靠背板子学会的而是靠弄清楚每个状态代表什么、每种转移从哪里来。等你把“牛牛的礼物”里这一整条链路想通后面再遇到什么“分割等和子集”“零钱兑换”“单词拆分”都会顺很多。