多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

动态规划入门:0/1背包问题核心原理与代码实现详解

动态规划入门:0/1背包问题核心原理与代码实现详解 1. 背包问题从新手到精通的必经之路如果你刚开始接触算法尤其是动态规划那么“0/1背包问题”绝对是你绕不开的一座大山也是检验你是否真正理解动态规划思想的绝佳试金石。我见过太多朋友一看到“状态转移方程”这几个字就开始头疼代码写出来要么超时要么结果不对最后只能对着别人的题解“复制粘贴”过几天又忘得一干二净。这其实是因为没有把背包问题的“骨架”和“灵魂”吃透。今天我们就抛开那些让人眼花缭乱的公式和抽象定义用最直白的话、最详细的步骤和一眼就能看懂的图示把0/1背包问题从里到外拆解清楚。我们的目标不止是让你看懂一道题而是帮你建立起解决一整类动态规划问题的思维框架。你会发现一旦掌握了这个核心模型很多所谓的难题比如分割等和子集、目标和、一和零都不过是换了一件“马甲”的背包问题。2. 问题本质一个关于选择的经典模型在深入代码之前我们必须先搞清楚我们在解决一个什么问题。想象一下这个场景你有一个最大承重为W的背包面前摆着N件物品。每件物品i都有两个属性重量weight[i]和价值value[i]。现在你要从这些物品中挑选一些放进背包目标很简单——在背包能装得下的前提下总重量不超过W让你选中的物品总价值尽可能高。这就是0/1背包问题。“0/1”这个名字非常形象它意味着每件物品只有两种命运要么被选中状态为1要么被放弃状态为0。你不能把一件物品拆开只拿一半。这种“非此即彼”的特性是后续我们设计算法时最关键的约束。注意很多人容易混淆“背包容量”和“物品重量”的单位。在实际解题中我们通常假设它们都是整数。如果题目给出的是小数一般可以通过乘以一个倍数转化为整数处理这是算法题中常见的技巧。那么最直接的暴力解法是什么枚举所有可能的物品组合。对于每件物品都有“选”或“不选”两种可能N件物品就有2^N种组合。我们检查每种组合的总重量是否超载在不超载的组合中找出价值最高的那个。当N稍微大一点比如30组合数就超过10亿了计算机也算不过来。所以暴力法行不通我们必须寻找更聪明的办法这就是动态规划登场的时候。3. 动态规划解法的核心状态与选择动态规划之所以高效是因为它“记住”了过去的计算结果避免了重复劳动。它的核心思想可以概括为“过去的状态决定了现在的选择现在的选择构成了未来的状态”。对于背包问题我们需要定义清楚什么是“状态”以及怎么做“选择”。3.1 定义状态数组dp我们定义一个二维数组dp[i][j]。这个数组的含义是整个理解过程的基石请务必牢记dp[i][j]表示从前i件物品物品编号从0到 i-1中进行选择在背包容量恰好为j的情况下能够获得的最大价值。这里有三个关键点需要解释“前 i 件物品”这代表我们决策的范围。i从0到N当i0时意味着没有任何物品可选。“容量恰好为 j”这是一种非常经典且严谨的定义方式。它要求我们最终装满的背包总重量正好是j。与之相对的另一种定义是“容量不超过 j”两种定义在初始化时略有不同但核心转移逻辑相通。我们先从“恰好”这个更清晰的定义入手。“最大价值”这是我们最终要优化的目标。3.2 状态转移方程决策的艺术现在我们站在dp[i][j]这个状态上思考我们已经处理完了前i-1件物品现在面对第i-1号物品因为我们的i是从0开始计数的前i件物品的索引是0到i-1我们要做出选择。对于第i-1件物品我们只有两种选择不放入背包那么当前的最大价值完全等同于不考虑这件物品时的最大价值也就是dp[i-1][j]。因为背包容量j没变我们只是跳过了这件物品。放入背包前提是背包容量j必须大于等于这件物品的重量weight[i-1]。如果放入那么背包会消耗掉weight[i-1]的容量剩下的容量是j - weight[i-1]。这部分剩余容量在前i-1件物品中能创造的最大价值是dp[i-1][j - weight[i-1]]。再加上当前物品的价值value[i-1]总价值就是dp[i-1][j - weight[i-1]] value[i-1]。我们的目标是价值最大所以在这两种选择中取最大值。因此状态转移方程就诞生了如果 j weight[i-1]: dp[i][j] max(dp[i-1][j], dp[i-1][j - weight[i-1]] value[i-1]) 否则 dp[i][j] dp[i-1][j] // 放不下只能不选这个方程就是动态规划解决背包问题的“心脏”。它清晰地告诉我们当前状态的最优解完全由之前已经计算出的、更小的子问题的最优解推导而来。3.3 图文解析一步步推演让我们用一个具体的例子把上面的过程画出来这是理解动态规划最直观的方式。假设有4件物品背包容量W8。 物品信息如下物品编号重量 (weight)价值 (value)023134245356我们初始化一个dp[5][9]的表格i从0到4共5行j从0到8共9列。根据“恰好装满”的定义我们初始化dp[0][0] 0表示没有物品、容量为0时最大价值为0。而dp[0][j] (j0)则初始化为一个“不可能”的值比如负无穷 (-inf)因为用0件物品不可能装满任何正数的容量。在实际代码中我们常用一个非常小的负数来代表。现在开始填表i1(处理物品0)j 2放不下dp[1][j] dp[0][j] -inf(除了j0)。j 2可以选择放或不放。不放dp[0][2] -inf放dp[0][0] 3 0 3 3取最大值max(-inf, 3) 3。所以dp[1][2] 3。 同理dp[1][3]...dp[1][8]在j2时因为dp[0][j]都是-inf所以最大值都是来自“放入”的选择。例如dp[1][8] dp[0][6] 3 -inf 3但dp[0][6]是-inf这里有个关键当j - weight[i-1]对应的状态是“不可能”时“放入”这个选择本身也是不可能的。所以我们需要在代码中处理这种情况。为了简化理解我们换一种更常用的初始化“容量不超过j”。在这种定义下dp[i][j]表示前i件物品在容量不超过j时的最大价值。此时dp[0][j] 0因为不放任何物品价值总是0且不会超容量。这样更直观。让我们用“不超过”的定义重新表述和填表。状态定义dp[i][j]表示从前i件物品中选择总重量不超过j的最大价值。初始化dp[0][j] 0。转移方程dp[i][j] max(dp[i-1][j], dp[i-1][j - weight[i-1]] value[i-1])(当j weight[i-1])否则dp[i][j] dp[i-1][j]。填表过程部分i0行全是0。i1(处理物品0重2价3):j0,1: 容量小于2放不下dp[1][j] dp[0][j] 0。j2:max(dp[0][2]0, dp[0][0]33) 3。j3:max(dp[0][3]0, dp[0][1]33) 3。...j8:max(dp[0][8]0, dp[0][6]33) 3。规律一旦j大于等于物品重量从该j开始往后所有dp[1][j]都至少是3因为我们可以选择放入物品0。i2(处理物品1重3价4):j0,1,2: 容量小于3放不下物品1继承上一行dp[2][j] dp[1][j]。j3: 可以不放(dp[1][3]3)或者放(dp[1][0]4044)。max(3,4)4。j4: 不放(dp[1][4]3)放(dp[1][1]4044)。max(3,4)4。j5: 不放(dp[1][5]3)放(dp[1][2]4347)。max(3,7)7。注意这里得到了价值7它是物品0和物品1的组合重量235价值347。j8: 不放(dp[1][8]3)放(dp[1][5]4347)。max(3,7)7。通过这样一步步填表最终dp[4][8]就是我们的答案。这个过程就像是在有限的资源和多种选择中不断做出局部最优的决策并记录下结果最终汇聚成全局最优解。图表能清晰地展示每个状态是如何从左上角或正上方的状态转移而来强烈建议你在学习时亲手画一遍这个表格。4. 代码实现与空间优化从二维到一维的飞跃理解了状态转移代码实现就是水到渠成。我们先写出最直观的二维DP版本。4.1 基础二维DP实现def knapsack_2d(weight, value, capacity): n len(weight) # 初始化dp表大小为 (n1) x (capacity1) dp [[0] * (capacity 1) for _ in range(n 1)] # 开始状态转移 for i in range(1, n 1): # i从1到n代表前i件物品 w_i, v_i weight[i-1], value[i-1] # 当前物品的重量和价值 for j in range(capacity 1): # j从0到capacity代表当前背包容量 if j w_i: # 当前背包容量装不下第i件物品 dp[i][j] dp[i-1][j] else: # 装得下决策不装 vs 装 dp[i][j] max(dp[i-1][j], dp[i-1][j - w_i] v_i) # 最终结果存储在dp[n][capacity] return dp[n][capacity] # 测试用例 weight [2, 3, 4, 5] value [3, 4, 5, 6] capacity 8 print(knapsack_2d(weight, value, capacity)) # 输出应为 10 (物品13 重量358价值4610)这个版本非常清晰完全对应了我们上面的推导。但是它有一个问题空间复杂度是O(N*W)。当背包容量很大时比如W10000这个二维数组会占用很多内存。观察状态转移方程dp[i][j] max(dp[i-1][j], dp[i-1][j - w_i] v_i)你会发现计算dp[i][j]时只用到了上一行 (i-1) 的数据并且是上一行中j列以及j-w_i列的数据。这意味着我们并不需要保存整个二维表格只需要一个一维数组滚动更新就够了。4.2 优化为一维DP滚动数组我们把二维数组dp[i][j]压缩成一维数组dp[j]。在计算第i件物品时dp[j]在更新之前存储的其实就是dp[i-1][j]的值。那么状态转移就变成了dp[j] max(dp[j], dp[j - w_i] v_i)。这里有一个至关重要的细节内层循环必须从大到小遍历j。 为什么因为dp[j - w_i]需要的是“上一轮” (i-1时) 的结果。如果我们从小到大遍历j那么在计算dp[j]时dp[j - w_i]可能已经被“当前轮” (i时) 更新过了这就变成了dp[i][j - w_i]而不是我们需要的dp[i-1][j - w_i]这相当于同一件物品被多次放入这解决的是“完全背包”问题而不是0/1背包。从大到小遍历可以保证在更新dp[j]时dp[j - w_i]还是上一轮的值。def knapsack_1d(weight, value, capacity): n len(weight) # 初始化一维dp数组dp[j]表示容量为j的背包能装的最大价值 dp [0] * (capacity 1) # 遍历每一件物品 for i in range(n): w_i, v_i weight[i], value[i] # 关键内层循环从大到小遍历容量 for j in range(capacity, w_i - 1, -1): dp[j] max(dp[j], dp[j - w_i] v_i) # 上面的代码等价于 # if j w_i: # dp[j] max(dp[j], dp[j - w_i] v_i) # 因为循环范围已经是 j w_i所以可以直接计算 return dp[capacity] # 测试 print(knapsack_1d(weight, value, capacity)) # 同样输出 10这个一维DP版本是面试和刷题中最常见的写法空间复杂度优化到了O(W)。务必理解并记住“逆序更新”这个关键点这是0/1背包的核心代码模板。实操心得一维DP的写法简洁高效但可读性稍差且丢失了具体物品选择方案的信息。在初次学习或调试时我建议先用二维DP写出来确保逻辑正确再优化成一维。这能帮你建立更扎实的理解。5. 问题变形与实战应用掌握了标准的0/1背包模型我们就可以解决一大票变形问题了。它们的本质都是“在某种限制容量下对一组物品进行选择选或不选以优化某个目标最大/最小值”。5.1 恰好装满 vs 不超过容量我们之前讨论过这两种初始化方式。总结一下要求恰好装满dp[0][0]0其他dp[0][j] -inf或一个非常小的负数。最终dp[n][capacity]就是答案。如果结果是负数说明无法恰好装满。要求不超过容量dp[0][j] 0。最终dp[n][capacity]是答案。这种方法更常用。在一维DP中“恰好装满”的初始化变为dp[0]0,dp[1..capacity]-inf。5.2 求方案数比如“目标和”问题问题给定一个非负整数数组nums和一个目标数S给每个数前面添加或-使得表达式结果等于S求有多少种添加符号的方法。 这可以转化为背包问题设所有带的数之和为P带-的数之和为N则有P - N S且P N sum(nums)。解方程得P (S sum) / 2。问题就变成了从nums中选若干个数使它们的和恰好为(S sum) / 2有多少种选法这就是一个“恰好装满”的背包问题但dp[j]的含义从“最大价值”变成了“方案数”。状态转移dp[j] dp[j - nums[i]]。初始化dp[0] 1凑出和为0的方案有一种什么都不选其他为0。5.3 求最小物品数比如“硬币找零”的硬币最少版本问题给定不同面额的硬币coins和一个总金额amount计算可以凑成总金额所需的最少的硬币个数。 这可以看作背包容量为amount每个物品硬币的重量是coins[i]价值是1代表硬币个数。我们要找的是“恰好装满”背包时的“最小价值”。状态转移dp[j] min(dp[j], dp[j - coins[i]] 1)。初始化dp[0]0,dp[1..amount]inf一个大数。5.4 二维费用背包问题物品不仅有重量weight还有体积volume背包有重量限制W和体积限制V。这就是二维费用背包。 解决方案很简单将状态数组升到三维dp[i][j][k]或者用优化后的二维滚动数组dp[j][k]。状态转移方程类似dp[j][k] max(dp[j][k], dp[j-w_i][k-v_i] value[i])。遍历顺序则需要两层逆序循环。6. 常见陷阱与调试技巧即使理解了原理自己写代码时还是会踩坑。下面是我总结的几个常见问题和解决方法。6.1 遍历顺序错误这是最经典的错误尤其在一维DP中。错误内层循环对容量j进行从小到大遍历。这会导致物品被重复计算变成了“完全背包”的解法。正确内层循环对容量j必须从大到小遍历。6.2 索引混淆在二维DP中物品下标i从1开始对应物品属性时要减1 (weight[i-1])。在一维DP中物品下标i从0开始直接对应 (weight[i])。混合使用会导致数组越界或逻辑错误。调试技巧在循环开始打印i,w_i,v_i的值确认你取到的物品信息是正确的。6.3 初始化问题“恰好装满”问题忘记初始化-inf这会导致程序将“装不满”的状态也当作合法状态参与转移得出错误的最大值。例如用全0初始化去解“恰好装满”问题程序可能会用一个“装不满”但价值更高的假状态来更新dp[j]。“求最小值”问题忘记初始化inf同理如果用0初始化min(dp[j], dp[j - w] 1)的结果永远会是0。6.4 状态转移方程的条件判断遗漏在一维DP的逆序循环中我们通常把循环范围写成for j in range(capacity, w_i - 1, -1)这样保证了j w_i可以直接进行max比较。如果你写的是for j in range(capacity, -1, -1)那么在循环体内就必须加上if j w_i:的判断否则会访问dp[j - w_i]的负索引。6.5 如何输出具体选择了哪些物品标准的DP只给出了最大价值要回溯找到具体方案需要额外的记录。 在二维DP中我们可以从最终状态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件物品被选了然后我们跳到状态dp[i-1][j - weight[i-1]]继续判断。 这种方法需要完整的二维DP表。一维DP由于覆盖了历史信息无法直接回溯。如果题目要求输出方案通常就得使用二维DP。最后学习动态规划和背包问题没有捷径最好的方法就是“动手”。找几道经典的力扣题目如416. 分割等和子集、494. 目标和、474. 一和零先用我们这里讲的思路去分析识别出它是不是背包问题容量是什么物品是什么价值是什么然后自己动手实现。遇到问题就回来看看状态定义和转移方程或者画一个小的表格手动模拟一下过程。这个过程可能会重复很多次但每重复一次你的理解就会加深一层。当你不再害怕状态转移方程能够自如地将各种问题映射到背包模型时你就真正掌握了这把算法利器。
返回列表