【算法】动态规划第三篇:背包第一课------贪心之死与正序倒序开关
摘要
DP 系列第三篇,第 3 级背包开篇。一道「分割等和子集」(LC416),走完了四个版本 的完整弧线:贪心版(自己造反例亲手击毙)→ 回溯版(正确,但 25 个元素实测 3300 万次调用)→ 二维 bool 背包(target 错、种子缺、返回格子错,三 bug 一堂课)→ 一维滚动(正序陷阱 + 赋值覆盖,两个坑一次踩完)。沉淀出背包世界的四块拼图:转化 (target = sum/2)、种子 (dp[i][0] = true)、或族合并 (max/min/|| 三族集齐)、以及最值钱的一块------正序倒序开关 (倒序保旧 = 0-1 背包,正序取新 = 完全背包)。所有坑照例有实测数据:一个用例的 max 恰好等于 sum/2 掩盖了致命错误、一个 1 被重复使用三次凑出 1/2/3、一组 dp 状态里 true 神秘倒退成 false。
前置阅读:动态规划第一篇:从打家劫舍到状态设计的三堂课、动态规划第二篇:双序列 DP 三课------继承、计步与断链。配套代码仓库(按题号分目录):https://github.com/a18792721831/studyleetCode
【算法】动态规划第三篇:背包第一课------贪心之死与正序倒序开关
- 【算法】动态规划第三篇:背包第一课------贪心之死与正序倒序开关
-
- 摘要
- [1. 起点:贪心之死------自己造反例击毙自己](#1. 起点:贪心之死——自己造反例击毙自己)
- [2. 桥:从自己的回溯代码翻译出 DP](#2. 桥:从自己的回溯代码翻译出 DP)
- [3. 二维 bool 背包:三个 bug 一堂课](#3. 二维 bool 背包:三个 bug 一堂课)
- [4. 一维滚动:两个坑一次踩完](#4. 一维滚动:两个坑一次踩完)
-
- [坑一:正序 → 元素被重复使用](#坑一:正序 → 元素被重复使用)
- [坑二:赋值覆盖 → "不选"分支消失 → 可行性倒退](#坑二:赋值覆盖 → “不选”分支消失 → 可行性倒退)
- 为什么正序会重复使用:新旧混居原理
- 修复:两处改动
- [5. 方向开关:本课最值钱的沉淀](#5. 方向开关:本课最值钱的沉淀)
- [6. 背包第一课拼图总表](#6. 背包第一课拼图总表)
- 总结
- 参考资料
1. 起点:贪心之死------自己造反例击毙自己
LC416 分割等和子集:判断能否把正整数数组分成两个元素和相等的子集。[1,5,11,5] → true([1,5,5] 和 [11])。
拿到题,我先用回溯的视角做了正确的分析(这段注释后来被证明是宝藏):
选不选:选 → 当前元素进入第一个数组;不选 → 进入第二个数组
最终结果是 bool → 可以快速短路返回
只判断和 → 不需要数组,只要 sum1 和 sum2
但回溯会超时
然后 DP 定义跑偏了------我写的是:
dp[i] = 前 i 个元素的差值最小
每来一个数,放到让差值变小的一边
这是贪心,不是 DP。跑实测:
[1,5,11,5]: true ✓ ← 碰巧对
[3,3,3,4,5]: false ✗ ← 期望 true:3+3+3 | 4+5,但贪心逐个放小边永远走不出这条分法
死因两条:
- 放差值小的一边 = 每步只走一条路 。DP 的本质是两个分支都算(不选它怎样、选它怎样),贪心只算了"看起来更优"的一条。可行性问题里,"当前差值最小"不保证"最后能归零"------
3+3+3 | 4+5这条路径在贪心的决策树上根本不存在。 - 这是"发明局部规则回避枚举"的第四次登场:博弈题的"选最小"、139 的漫游、300 的 maxV 摘要、这次的"放小边"。同一个元模式,四件马甲。
值得记录的是这次我自己用 [3,3,3,4,5] 击毙了自己------反例是自己造的,诊断("这个 case 不成立,说明 dp 定义错误")是自己下的。比被题解纠正前进了半步。
顺带把注释里的回溯版解出来实测:三个用例全对,但 25 个元素的无解用例------
纯回溯: 67,108,863 次调用 (2^26 - 1,超时预判的数据实锤)
这段回溯代码没删------因为接下来写的 DP,就是从它逐行翻译出来的。
2. 桥:从自己的回溯代码翻译出 DP
第一篇打家劫舍的演化链(回溯 → 记忆化 → DP 数组),在这里原样重演:
我的回溯状态:(idx, sum1, sum2)
↓ sum1 + sum2 = 前 idx 个数之和(定值)→ sum2 是冗余信息,删
压缩为:(idx, sum1)
↓ "backtrack(idx, sum1) 的答案是确定的 bool,与怎么到达无关"(无后效性)
dp[i][j] = 前 i 个数里选一些,能否恰好凑出和 j ← bool 型,j 是"目标和"
↓ 回溯里的两个分支 = 转移方程的两个分支
不选第 i 个 → dp[i-1][j]
选第 i 个 → dp[i-1][j-nums[i-1]](j ≥ nums[i-1] 才有这条分支)
dp[i][j] = 两分支"或"起来
注意维度切换:双序列 DP 的 j 是"另一个序列的位置",背包的 j 是**"容量/目标和"**------这是两个世界的分水岭。还要注意合并方式集齐第三族:LCS 用 max、编辑距离用 min、可行性用 ||。
3. 二维 bool 背包:三个 bug 一堂课
第一版 bool 实现的作业问答里,一问致命:
我答:
target = max(nums)
转化没完成。正确的逻辑链只有三步:
分割成两个和相等的子集
→ 每个子集的和 = sum / 2
→ 问:能否选出一些数,和恰好 = sum/2?
(选出的凑出 sum/2,剩下的自动也是 sum/2)
和 max 毫无关系。而 [1,5,11,5]:sum/2 = 11 = max------恰好相等,用例巧合家族第五案 (这个用例永远测不出 target 错误;[3,3,3,4,5] 的 sum/2 = 9 > max = 5,立刻分家)。
三个 bug 的实测现场:
[1,5,11,5]: false ✗ [3,3,3,4,5]: false ✗ [1,2,3,5]: false ✓(碰巧,期望本来就是 false)
空数组: panic: slices.Max: empty list ✗
三个用例全部输出 false 的直接原因:整张 dp 表全是 false ------make 零值全 false,从头到尾没有一格被置 true。种子没埋,庄稼死光:
| bug | 内容 | 旧病新病 |
|---|---|---|
| target 错 | max(nums) → 应为 sum/2 |
新病(转化未完成)+ 奇数剪枝答题答了代码没写 |
| 种子缺 | dp[0][0]=true、dp[i][0]=true 没设 |
边界锚点公式答了两个半,漏的恰好是种子 |
| 返回格子 | dp[m-1][n-1] |
LCS 同款原样复发 (开 (m+1)×(n+1) 却读 m-1,n-1) |
| 守卫顺序 | m==0 判断在 slices.Max 之后 |
空数组先 panic 才轮到守卫 |
手推表也暴露了同一个洞:j=0 行全写了 f------手推和代码精确地犯了同一个错,说明这是认知空洞不是手滑(也说明没做互证,做了的话两边一对,洞双向确认)。
修复后的种子三件套(边界锚点公式的完整版):
go
dp[0][0] = true // 空集凑 0
for i := 0; i <= m; i++ {
dp[i][0] = true // 前 i 个数凑 0:什么都不选
} // dp[0][j>0] = false 由零值白送
修复版六用例全绿(含我加的三个针对性边界:[1,1]、[2,2,3,5] 偶数无解、[100] 单元素),打印 dp 表与手推 12 行逐格一致。
一个互信时刻 :上一版手推表 j=10 行我批"全对",其实漏了一格(dp[3][10]:{1,5,11} 凑 10 凑不出,应 f 写了 t)。下一版他自己修正了这格------我的漏批 + 他的自纠。互证闭环这次是他自己走完的,而且抓到了我没抓到的格子。
4. 一维滚动:两个坑一次踩完
二维验收后的加时赛:dp[i][j] 的两个来源(dp[i-1][j]、dp[i-1][j-nums])全在上一行 ------按第一篇的滚动判据(依赖只碰上一行 → 行维可滚),压成一维 dp[n+1]。
两个问题(凭直觉答):①凭什么能滚?②内层循环从大到小还是从小到大?我答"从小到大"------踩进了 0-1 背包最著名的陷阱,而且实现里还藏着第二个 bug:
go
for j := 1; j <= n; j++ { // ← 坑一:正序
if j >= nums[i-1] {
dpn[j] = dpn[j-nums[i-1]] // ← 坑二:赋值覆盖
}
}
三个常规用例全对(又碰巧),两个针对性反例双向翻车:
[4,1,1]: true ✗ 误报(数组里只有一个 1,凑 3 需要三个 1)
[3,5,2]: false ✗ 漏报({5} 或 {3,2} 明明能凑 5)
坑一:正序 → 元素被重复使用
[4,1,1] 的逐轮状态:
处理元素 4 后 dpn = t f f f
处理元素 1 后 dpn = t t t t ← 一个 1 同时凑出了 1、2、3:被用了三次!
坑二:赋值覆盖 → "不选"分支消失 → 可行性倒退
[3,5,2] 的逐轮状态:
处理元素 3 后 dpn = t f f t f f ← dpn[3]=t:{3} 凑 3 ✓
处理元素 5 后 dpn = t f f t f t ← dpn[5]=t:{5} 凑 5 ✓
处理元素 2 后 dpn = t f t f t f ← dpn[3]、dpn[5] 双双倒退成 f!
数组多了个可选的 2,"凑 5"反而凑不出了 ------物理上荒谬。行间单调性(元素只能多不能少,凑得出的永远凑得出)被违反,凶手是赋值:二维版里"不选"= dp[i-1][j];滚成一维后,上一行正上方就是 dpn[j] 自己还没被写的当前值------所以"不选 = 保持不动"是隐式的,合并必须写全:
go
dpn[j] = dpn[j] || dpn[j-nums[i-1]] // 不选(保持) || 选(左上)
为什么正序会重复使用:新旧混居原理
一维滚动后,上一行和本行混居在同一个数组里,靠遍历方向切分新旧 。处理第 i 行的全程,dpn[] 里躺的就是第 i-1 行(旧值);每格读旧写新。冲突点:写 dpn[j] 要读 dpn[j-nums](更小的下标)------
倒序(j 从大到小):写 dpn[9] 时 dpn[6] 还没被本行碰过 = 纯旧值 ✓
读到"没算元素 i 时能不能凑 6" → 元素 i 只被用这一次
正序(j 从小到大):写 dpn[9] 时 dpn[6] 已是本行新值 ✗
读到"已经用了元素 i 之后凑 6" → 元素 i 被用了第二次
当时我对"为什么能压缩"的两个理解偏差,一并纠偏:
- "因为返回 bool 所以能压缩" ✗------能否压缩只看依赖形状:53 的 Kadane 是 int 照样滚成两个变量;300 的 LIS 要回读所有历史(任意步回看)就滚不了。判据自始至终一条:依赖只碰上一行(或常数行)→ 行维可滚。
- "新值写入后上一层信息丢失了" ✗------不是丢失,是用完即弃 。处理第 i 行期间,i-1 行的值随时可读(还在数组里);行处理完,它所有的读者都已读完。信息在最后一次读取之后被覆盖 ≠ 丢失 。正序 bug 的本质不是"丢"而是"还没读完就被覆盖":
dpn[6]作为上一行的身份还没被dpn[9]消费,就先以本行的身份被改写了。
修复:两处改动
go
for i := 1; i < m+1; i++ {
for j := n; j >= nums[i-1]; j-- { // 倒序 + 边界融合进循环条件
dpn[j] = dpn[j] || dpn[j-nums[i-1]] // || 合并:不选=保持(隐式),选=左上
}
}
修复版五用例全绿,[3,5,2] 的逐轮状态里两个 t 全部保住,单调性恢复。
5. 方向开关:本课最值钱的沉淀
倒序保旧 → 0-1 背包(每个元素最多选一次,读左上旧值)
正序取新 → 完全背包(元素可以无限重复用,读本行新值)
同一个数组、同一个方程,方向翻转 = 两个世界 。而我在正序上踩的坑,恰好是下一题(LC322 零钱兑换,硬币无限用)的正解------今天踩的坑,明天是考点 。0-1 背包的三用例碰巧全对也值得记一笔:正序 bug 在"重复使用不改变答案"的用例上永远隐形,反例必须针对性构造([4,1,1]:唯一的 1 被复制;[3,5,2]:覆盖抹掉真解)。
6. 背包第一课拼图总表
| 拼图 | 内容 | 出处 |
|---|---|---|
| 转化 | 分割 → 凑 sum/2;奇数秒杀 false |
target 写成 max(用例巧合第五案) |
| 维度 | j 是容量/目标和,不是序列位置 |
双序列与背包的分水岭 |
| 种子 | dp[0][0]=t、dp[i][0]=t、dp[0][j>0]=f |
种子缺 → 整表死光 |
| 或族 | 可行性背包两分支用 ` | |
| 滚动 | 依赖只碰上一行 → i 维可滚 | 依赖形状定理,与返回类型无关 |
| 合并 | 一维里"不选 = 保持不动"是隐式的,` | |
| 方向 | 倒序保旧(0-1)/ 正序取新(完全) | 正序陷阱 + 重复使用反例 |
方法论增补(接前两篇的判据表):
| 问题 | 判据 |
|---|---|
| 这题是不是背包 | "选/不选 + 只要可行性/最值/数量"(139/140 对照的 DP 侧延伸) |
| 能否一维滚动 | 依赖只碰上一行(常数行)→ 能;回读历史 → 不能 |
| 内层循环方向 | 元素不可重复 → 倒序;可重复 → 正序 |
| 反例怎么构造 | 针对约束:唯一的元素 + 重复使用才凑得出的目标值;真解会被覆盖抹掉的局面 |
总结
一道题四个版本,每个版本死法不同,感受三条:
贪心的坟场上立着 DP 的路标 。"放小边"贪心死在 [3,3,3,4,5] 上,而杀死它的路径分析(选/不选、sum1/sum2、无后效性)恰好就是 DP 的建模材料------回溯代码没删,因为 DP 是从它逐行翻译过来的。写错的代码不是垃圾,是脚手架(当然,挂牌之后才配留在仓库里)。
背包的一半难度在"翻译",一半在"开关" 。翻译(分割 → 凑 sum/2)想错了,后面全白搭------target 写成 max 这种错,用例还恰好掩盖它(第五案)。开关(倒序/正序)则是滚动数组的命门:一个方向写错,元素就被复制或真解就被抹掉,而且常规用例大概率测不出来,两个针对性反例是唯一照妖镜。
自纠能力在涨。这题我自己造反例击毙贪心、自己修正了我漏批的手推格子------从"被批改"到"自己批改自己",这条曲线比六用例全绿更值钱。
下一篇:LC322 零钱兑换------正序的世界(硬币无限用,min 族回归,还有一个"不可达哨兵"的新坑等着我)。
参考资料
版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。