【算法】动态规划第三篇:背包第一课——贪心之死与正序倒序开关

【算法】动态规划第三篇:背包第一课------贪心之死与正序倒序开关

摘要

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,但贪心逐个放小边永远走不出这条分法

死因两条:

  1. 放差值小的一边 = 每步只走一条路 。DP 的本质是两个分支都算(不选它怎样、选它怎样),贪心只算了"看起来更优"的一条。可行性问题里,"当前差值最小"不保证"最后能归零"------3+3+3 | 4+5 这条路径在贪心的决策树上根本不存在
  2. 这是"发明局部规则回避枚举"的第四次登场:博弈题的"选最小"、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]=truedp[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]=tdp[i][0]=tdp[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 版权协议,转载请附上原文出处链接和本声明。

相关推荐
黄金龙PLUS1 小时前
Xoodoo置换算法的优缺点
算法·网络安全·密码学·哈希算法·同态加密
Cccp.1231 小时前
【leetcode】(四)排序算法汇总和链表
leetcode·链表·排序算法
LayZhangStrive2 小时前
数据结构与算法 - 堆排序
java·数据结构·算法·排序算法·堆排序·大根堆
residual_fan2 小时前
深度渐进收缩学习(DPSL):面向强噪声与类内分散的机械复合故障诊断方法
人工智能·算法·数据挖掘·数据分析
a187927218313 小时前
【算法】动态规划第五篇:区间 DP——从“看什么都像背包“到“最后戳谁“
算法·leetcode·动态规划·dp·区间·区间dp·算法讲解
whitelbwwww3 小时前
c++ 多线程
开发语言·c++·算法
额鹅恶饿呃3 小时前
随着CentOS官方停服的时间越来越久,大量仍在使用CentOS7的企业和运维从业者
java·python·算法·c#·ruby
白狐_7983 小时前
408 数据结构|KMP算法核心:为什么不用主串回退
数据结构·算法
wuyk5553 小时前
13.堆排序:基于完全二叉树的高效排序算法一、什么是堆排序?
开发语言·算法·排序算法