背包问题应该是动态规划里最经典、也最能体现“状态设计”思想的一类题目。我在用 Go 语言重刷数据结构和算法时,发现很多人(包括早期的我)都会陷入一个误区:能默写 0-1 背包的递推公式,但一碰到完全背包、多重背包,或者面试里稍微改一下限制条件就卡壳。问题不在于记不住代码,而在于没理解背包问题背后的状态转移本质。这篇文章我会用 Go 语言从零到一拆解动态规划背包问题,包括二维 DP 的写法、一维空间优化的原理、完全背包和多重背包的变体处理、路径还原,以及实测中我踩过的那些边界条件的坑。适合正要准备算法面试的开发者,也适合在工作中遇到“资源有限、收益最大化”这类取舍问题的工程师。
1. 背包问题到底是什么:从“选还是不选”开始的递推
1.1 0-1背包、完全背包、多重背包的定义差异
背包问题的场景描述很简单:你有一个容量为 W 的背包,面前有 n 件物品,每件物品有重量 weight[i] 和价值 value[i],问在不超过背包容量的前提下,能装走的最大总价值是多少。这个描述看似只是“选物品”,但细看会发现里面藏着关键约束——每件物品到底能拿几次。
如果每件物品只能拿一次,就是 0-1 背包,名字来源于“拿(1)或不拿(0)”;如果每件物品可以无限拿,就是完全背包;如果每件物品有一个数量上限 count[i],那就是多重背包。三种问题在现实中的对应场景也很直观:0-1 背包像买车,每款车只能买一辆;完全背包像买水,每次买一瓶但可以不限次数;多重背包像买限量版球鞋,每款最多能拿固定的几双。
这三种变体的递推关系高度相似,但遍历顺序和拆分方式完全不同,这也是背包问题最容易出错的地方之一。后面我会逐个展开,先看最基础的 0-1 背包。
1.2 为什么贪心算法在这里翻车
很多刚接触背包问题的同学第一反应是贪心:按单位价值(value/weight)从大到小装不就行了吗?这个思路在“分数背包”里是成立的——物品可以切割,装满为止就行。但 0-1 背包里的物品是离散的,装不进去就是装不进去,你没法只装半件。
举个反例:背包容量为 10,有三件物品:A(重量 6,价值 8,单位价值 1.33)、B(重量 5,价值 6,单位价值 1.2)、C(重量 5,价值 6,单位价值 1.2)。按单位价值贪心会先拿 A,剩下容量 4,什么都装不下,总价值是 8。但最优解是选 B 和 C,总价值是 12。差距在这类题目里往往非常明显。
这也解释了为什么动态规划是背包问题的标准解法:它枚举的是“某个容量下、处理到某件物品时”的所有状态,而不是只看眼前的最优单价。状态一旦设计好,递推就是机械但严谨的工作。
2. 二维DP表先把正确性做出来:状态定义与递推公式
2.1 dp[i][j]的含义与转移方程的直觉
0-1 背包最朴素的做法是用一个二维数组,其中 dp[i][j] 表示“只考虑前 i 件物品、背包容量恰好为 j(或者不超过 j,后面再说这个细节)时能获得的最大价值”。关键是这个状态是“由哪些小状态推导出来”的。
处理第 i 件物品时,其实只有两种选择:不放,那结果就是 dp[i-1][j];放,那必须腾出重量 weight[i-1],容量变成 j-weight[i-1],价值加上 value[i-1],即 dp[i-1][j-weight[i-1]] + value[i-1]。取两者较大就是 dp[i][j] 的值。这里注意物品下标从 0 开始,所以第 i 件物品对应的是 weight[i-1]。
这个递推公式就是背包问题的“心法”:当前状态的最优解,建立在“上一个阶段处理完所有物品后”的最优解之上。每一件物品都在问自己一个问题:把背包容量的空间留给你值不值。
2.2 Go语言朴素版本的实现与边界初始化
二维版本的 Go 实现非常直白,我建议第一次接触背包问题的人至少亲手写一遍,因为后面的空间优化其实就是对这张表的压缩:
func knapsack01(weights, values []int, capacity int) int { n := len(weights) // dp[i][j] 表示前 i 件物品,容量为 j 时的最大价值 dp := make([][]int, n+1) for i := range dp { dp[i] = make([]int, capacity+1) } for i := 1; i <= n; i++ { for j := 0; j <= capacity; j++ { // 默认不放第 i 件物品 dp[i][j] = dp[i-1][j] // 如果放得下,比较放与不放哪个价值更大 if j >= weights[i-1] { if dp[i-1][j-weights[i-1]]+values[i-1] > dp[i][j] { dp[i][j] = dp[i-1][j-weights[i-1]] + values[i-1] } } } } return dp[n][capacity] }需要注意初始化的细节:dp 数组的长度是 (n+1) x (capacity+1),多出来的第 0 行和第 0 列是为了让状态转移不需要额外判断。dp[0][j] 表示不考虑任何物品时价值是 0,dp[i][0] 表示容量为 0 时装不下任何东西,价值也是 0。Go 语言里切片的默认零值就是 0,所以这里不需要手动初始化,但我见过不少同学用make([][]int, n)忘记 +1,导致循环里访问 dp[i-1] 时越界,这个问题我在第 5 章会专门讲。
这个朴素版本的时间复杂度是 O(nW),空间复杂度也是 O(nW)。面试时一般不会要求你写出这个版本,但理解这张表,对后面的路径还原至关重要。
3. 内存优化:一维数组和遍历顺序为什么是背包问题的灵魂
3.1 从二维压缩到一维的推导
二维 DP 表的时间复杂度 O(n*W) 在大多数面试场景下已经能接受,瓶颈在于空间。如果 n 和 W 都到 10^4 量级,二维切片的内存就非常夸张了。所以背包问题有个十分经典的优化:把二维数组压缩成一维数组,也就是用 dp[j] 表示“当前已处理的物品中,容量为 j 时的最大价值”。
为什么可以压缩?观察递推公式你会发现,第 i 行的值只依赖第 i-1 行,也就是说,计算完第 i 行之后,第 i-2 行及更早的数据就永远用不到了。我们完全可以只保留一行,并在迭代中不断更新这一行。这本质上就是动态规划里的“滚动数组”,它要求我们在更新时保证“手里的旧数据还没有被覆盖污染”。
3.2 为什么0-1背包必须逆序遍历
一维数组的更新顺序是整个优化的关键。0-1 背包要求每件物品只能拿一次,所以内层循环必须从 capacity 往重量方向逆序遍历:
func knapsack01Optimized(weights, values []int, capacity int) int { dp := make([]int, capacity+1) for i := 0; i < len(weights); i++ { // 逆序很重要:保证每个物品最多被选一次 for j := capacity; j >= weights[i]; j-- { if dp[j-weights[i]]+values[i] > dp[j] { dp[j] = dp[j-weights[i]] + values[i] } } } return dp[capacity] }用一个具体例子解释为什么逆序。假设当前处理物品 i,重量为 2,价值为 3。正序遍历时,假设 j 先更新了 dp[2]=3,当 j 到 4 时,dp[4-2]读到的 dp[2] 已经不是上一轮处理完前 i-1 件物品后的值,而是已经被本轮更新过、已经装入了物品 i 的值。于是 dp[4] 可能被更新成 6,相当于同一件物品被装了两遍,这违反了 0-1 背包的前提。
逆序遍历时,j 从大往小走,dp[j-weight[i]] 一定还没被本轮更新过,读到的必然还是上一层的数据,因此每件物品只会被考虑一次。这个细节理解了,完全背包的代码就只是把这一行反过来而已。
3.3 完全背包反过来:正序遍历的真正原因
完全背包唯一的不同是每件物品可以无限取。如果你想体会“为什么遍历顺序反过来就对了”,可以拿前面那个例子再走一遍:正序遍历时 dp[2] 被更新为 3,dp[4] 会基于 dp[2] 再取一次该物品得到 6,相当于同一物品重复取。这恰好正是完全背包默认可以做的事!
所以完全背包的一维写法就是内层从 0 到 capacity 正序遍历:
func knapsackComplete(weights, values []int, capacity int) int { dp := make([]int, capacity+1) for i := 0; i < len(weights); i++ { for j := weights[i]; j <= capacity; j++ { if dp[j-weights[i]]+values[i] > dp[j] { dp[j] = dp[j-weights[i]] + values[i] } } } return dp[capacity] }这里有个很多人都没注意的点:完全背包的一维写法不需要再去嵌套一层“取 k 件”的循环,因为正序遍历已经天然允许多次取用当前物品了。如果在正序遍历的外面再加一个 for k 循环,那时间复杂度会被推到 O(nWW),完全没有必要。我在某次 review 同事代码时就看到他写了三层循环,虽然结果对,但性能白白差了一个量级。
4. 多重背包的二进制拆分:把数量摊平成01背包
4.1 为什么不能简单拆成多个相同物品
多重背包的约束是每件物品最多取 count[i] 件。最朴素的想法是把 count[i] 件物品复制成 count[i] 个“独立”的新物品,然后直接跑 0-1 背包。这样做的正确性没有问题,但复杂度会变成 O(ncountW),如果 count 特别大,那就直接超时了。
复制法的本质是把“数量”引入状态,但其实有更聪明的做法。我们要思考的是,怎么用尽量少的“组合块”拼出 1 到 count[i] 之间的任意数量。这就像用不同面额的硬币凑出一个数额,最优组合方式就是二进制拆分:把 count[i] 拆成 1、2、4、8……等一系列 2 的幂,最后不够再补一个余数。任意 1 到 count[i] 的取值,都能由这些块的一部分组合出来。
4.2 二进制分组代码与复杂度分析
二进制拆分后,每个块变成了一个全新的物品,其重量是 kweights[i],价值是 kvalues[i],然后统一跑一遍 0-1 背包:
func knapsackMultiple(weights, values, counts []int, capacity int) int { type item struct{ w, v int } var items []item for i := 0; i < len(weights); i++ { k := 1 count := counts[i] for k <= count { items = append(items, item{k * weights[i], k * values[i]}) count -= k k <<= 1 } if count > 0 { items = append(items, item{count * weights[i], count * values[i]}) } } dp := make([]int, capacity+1) for _, it := range items { for j := capacity; j >= it.w; j-- { if dp[j-it.w]+it.v > dp[j] { dp[j] = dp[j-it.w] + it.v } } } return dp[capacity] }例如某件物品数量上限是 13,二进制拆分会得到 1、2、4、6 四块。1、2、4 可以组合出 0-7 之间的任何数量,加上 6 后可以组合出 1-13 的任意数量。而拆分的块数远小于 13,原始数量越大,这个优化越明显。整体时间复杂度降到 O(W * Σ log(count[i])),在工程实践和面试里都够用。
这里有一个容易睡着的细节:为什么拆出的余数是 6 而不是 8?因为如果拆成 1、2、4、8,总和是 15,超出了 13。超过数量的组合虽然逻辑上不会用到,但会导致两个小问题:一是组合块重量偏大,影响后续背包容量;二是语义上已经不符合原题上限。所以拆的时候要边拆边减,最后补一个剩余值。这个“边拆边减”的习惯我从写第一版开始就一直保持。
5. 动手实践后的踩坑清单:这些错我基本都犯过
5.1 容量、物品数和dp数组长度之间的+1关系
背包问题的代码模板一旦写对,做基础题非常快;但如果打错,排查起来却特别烦人。最常见的坑就是容量和物品数的边界处理。二维版本的 dp 行数是 n+1、列数是 capacity+1,循环中 i 从 1 到 n,j 从 0 到 capacity。第 i 件物品在数组里对应的下标是 i-1,这个偏移量我反复忘记过。
一维版本虽然不需要 n+1,但 dp 的长度依然是 capacity+1。如果只申请了 capacity 长度,访问 dp[capacity] 时会越界;如果申请太多倒不会出错,但语义就不对了。
我的习惯是写代码前先明确一句话:“长度要比最大下标多 1,因为下标 0 是合法容量。”这个习惯让我省了很多 debug 时间。
5.2 初始化语义:0和最小值的区别
另一个容易踩的坑是 dp 数组的初始值。默认状态下最大价值是 0,这没问题,但有些变体问题要求“恰好装满背包”,这时初始化的语义就要改了:dp[capacity] 必须表示容量恰好为 capacity 时的最大价值,未达到该容量的状态应该视为不可达。做法是把 dp 数组初始化为一个很负的值,比如 math.MinInt32 的一半,然后令 dp[0]=0。在状态转移时,如果 dp[j-weights[i]] 还是负值,说明这个状态没有合法来源,就不能参与更新。
如果该设置 0 的地方设成了负值,或者反过来,最终答案可能会差一个恒定的偏移,让人怎么查都查不出来。所以拿到题目时,一定要先问自己:dp[j] 的语义是“不超过 j”还是“恰好等于 j”?两种语义决定了初始化的方案,也决定了能不能直接用一维滚动数组去解释结果。
5.3 顺手总结:常见错误的对照表
我把自己和身边人犯过的背包问题错误整理成了一张表,方便快速排查:
| 错误类型 | 现象 | 原因 | 排查建议 |
|---|---|---|---|
| 数组越界 | panic: index out of range | dp 长度没 +1 或物品下标忘记偏移 | 打印 n、capacity 和 len(dp) |
| 结果偏小 | 答案比期望小 | 0-1 背包内层正序遍历,同一物品被重复选时结果反而偏大;结果偏小通常是容量循环没覆盖到 0 | 检查 j 的循环起点和终点 |
| 重复选物品 | 答案异常大 | 0-1 背包内层没有逆序 | 将for j := capacity; j >= w; j--作为默认模板 |
| 恰好装满变体 | 结果永远是 0 | 初始值没有设置为负无穷 | 检查 dp[0]=0 的语义是否成立 |
| 多重背包超时 | 大数据量 TLE | 简单复制 count 件物品导致 O(ncountW) | 改用二进制拆分 |
| int 溢出 | 错误的大数值或负数 | value 很大时 dp 累加超过 int 上限 | 根据题目范围改用 int64 |
这些错误几乎都是“知道答案后一秒就改对、不知道时查半天”的类型。我的建议是不要强行背模板,而是把每种含义和相应遍历顺序的原因写下来,写几道题之后自然就内化了。
6. 从最大价值到最优方案:路径还原与真实项目中的变形
6.1 反向追踪:怎么知道选了哪些物品
很多时候面试官会追加一个问题:不仅要求最大价值,还要求输出具体选了哪些物品。这个问题的答案藏在二维 DP 表里。你知道 dp[i][j] 从哪来,就能反推出第 i 件物品到底选了没有。
从最后一个状态 dp[n][capacity] 开始倒推,如果 dp[i][j] 等于 dp[i-1][j],说明第 i 件物品没有被选,直接跳到上一行;如果 dp[i][j] 等于 dp[i-1][j-weight[i-1]] + value[i-1],说明第 i 件物品被选了,记录下标,容量减去 weight[i-1],再向前一行继续追。
func knapsack01WithPath(weights, values []int, capacity int) (int, []int) { n := len(weights) dp := make([][]int, n+1) for i := range dp { dp[i] = make([]int, capacity+1) } for i := 1; i <= n; i++ { for j := 0; j <= capacity; j++ { dp[i][j] = dp[i-1][j] if j >= weights[i-1] && dp[i-1][j-weights[i-1]]+values[i-1] > dp[i][j] { dp[i][j] = dp[i-1][j-weights[i-1]] + values[i-1] } } } selected := []int{} j := capacity for i := n; i > 0; i-- { if dp[i][j] != dp[i-1][j] { selected = append(selected, i-1) j -= weights[i-1] } } return dp[n][capacity], selected }这里有几个细节值得注意。如果使用了一维滚动数组做空间优化,路径还原就会变得很麻烦,因为旧状态已经被覆盖了。所以在“需要输出方案”的题目里,我建议直接保留二维 DP 表,不要为了省空间给自己挖坑。另外,当选中的物品本身有多组最优解时,这个回溯方法只能输出其中一组,题目如果要求全部方案,那就得额外维护一个“前驱状态列表”,复杂度会上升不少。
6.2 背包问题在现实场景里的常见变形
背包问题看起来像是算法题里的纸面产物,但工程中到处能见到它的影子。我做过一个资源预算的小工具,上游给了一堆可选的云资源规格,每种规格有固定成本、固定收益和数量上限,要求在某个月的预算额度内选出一组收益最高的资源组合——这本质就是一个多重背包问题。另一个更常见的场景是广告预算分配:多个广告位各有 CPM 成本和预期曝光量,要在总预算限制下最大化总曝光,这也是完全背包或分组背包的变体。
这些场景有一个共同点:约束条件往往不是“不超过 W”,而可能附带“至少达到某个收益”“每种资源最少采购一批”等条件。处理思路是给状态增加维度,或者在转移时增加判断。比如“至少达到收益”可以转换成求“达到该收益的最小成本”,这就是把背包问题反过来用;再比如“每种至少选一个”,可以先把必须选的部分从容量和收益中各扣掉一次,剩下的再跑普通背包。
面试题里还有一个高频变形是“每组最多选一个”的分组背包,它的状态设计是在外层枚举组,内层先枚举容量,再枚举组内物品。理解组背包的方法和我前面讲的一脉相承:先想清楚“当前状态能从哪几个上一个状态转移来”,再写循环,就会自然很多。
最后再说一个我的体会:背包问题真正让人痛苦的从来不是代码本身,而是它包含的“状态抽象”和“边界细节”太多,稍不留神就写错。而一旦你在纸上完整推导过一遍二维 DP 表的每一行怎么生成,再在 Go 里把它跑通,后面无论遇到什么背包变体,都能很快映射回这几种基本模型。这也是我在刷题时最受益的一点:不是记模板,而是掌握状态设计和遍历顺序背后的原因。希望这篇文章能帮你少走我当年走过的那些弯路。