跟着卡哥学算法Day 35:动态规划part3
找问题最好的方式就是把 dp 数组打印出来,看看是不是和我们推导的公式一致。
做动规题目前,一定要把状态转移在 dp 数组上的具体情况模拟一遍,确定最后推出的是想要的结果。
01 背包理论基础
01 背包问题是指有 n 件物品和一个最多能背重量为 w 的背包。第 i 件物品的重量为 weight[i],价值为 value[i],每个物品只能选择一次,在不超过背包容量的情况下,使得物品的总价值最大。比如:旅行时行李箱大小有限,如何选择最有价值的物品装满行李箱。
首先思考如何使用暴力解法?
每件物品的状态有两种:放入背包或不放入背包,有 2^n 种状态,可以使用回溯法搜索出所有的情况,时间复杂度为 O(2^n)。
回溯三部曲:
初始化 maxValue = 0 表示总价值
-
回溯函数返回值以及参数
- 参数 1:startIndex 表示当前处理的物品索引
- 参数 2:currentWeight 表示当前已选物品的总重量
- 参数 3:currentValue 表示已选物品的总价值
-
回溯函数终止条件
当 startIndex 等于物品数量 n 时,比较并更新最大价值
-
单层搜索的过程
- 不选当前物品:直接处理下一个物品(index + 1),重量和价值不变
- 选当前物品:如果加入后不超过容量,则更新重量和价值,再处理下一个物品
// 不选当前物品,直接处理下一个backtrack(startIndex + 1, currentWeight, currentValue)// 选当前物品(需检查容量)if (currentWeight + weight[startIndex] <= W) {backtrack(startIndex + 1,currentWeight + weight[startIndex],currentValue + value[startIndex])}
function knapsack01Backtrack(W, weight, value) { let maxValue = 0 const n = weight.length
const backtracking = (startIndex, currentWeight, currentValue) => { if (startIndex === n) { maxValue = Math.max(maxValue, currentValue) return }
// 不选当前物品,直接处理下一个 backtracking(startIndex + 1, currentWeight, currentValue)
// 选当前物品(需检查容量) if (currentWeight + weight[startIndex] <= W) { backtracking( startIndex + 1, currentWeight + weight[startIndex], currentValue + value[startIndex] ) } }
backtracking(0, 0, 0) return maxValue}暴力解法是指数级别的时间复杂度,所以需要动态规划的解法来优化
01 背包动态规划解法(常规解法)
以下面的输入为例:
背包最大重量为 4。
物品为:
| 重量 | 价值 | |
|---|---|---|
| 物品 0 | 1 | 15 |
| 物品 1 | 3 | 20 |
| 物品 2 | 4 | 30 |
问背包能背的物品最大价值是多少?
二维 dp 数组 01 背包
动规五部曲:
-
确定 dp 数组以及下标的含义
需要定义二维数组:一个维度表示背包容量,一个维度表示表示物品,即
dp[i][j]表示前 i 个物品放入容量为 j 的背包时的最大价值。-
处理物品 0(重量 1,价值 15)
当 j = 0(容量为 0)时,无法放入,价值为 0;若 j ≥ 1,可以放入物品 0,价值为 15。
背包容量 0 1 2 3 4 物品 0 0 15 15 15 15 -
处理物品 1(重量 3,价值 20)
-
容量 1-2:无法放入物品 1,继承物品 0 的值 15
-
容量 3:
- 不放入物品 1:那么背包的价值应该是
dp[1][3]=dp[0][3] = 15,即容量为 3 的背包,只放物品 0 的情况 - 放入物品 1:那么背包要先留出物品 1 的容量,目前容量为 3,物品 1 的重量为 3,背包剩余容量为 0 时的价值为
dp[0][0] = 0,所以dp[1][3]=dp[0][3-3] + 20 = 0 + 20 = 20 - 取最大值
20
- 不放入物品 1:那么背包的价值应该是
-
容量 4:
- 不放入物品 1:那么背包的价值应该是
dp[1][4]=dp[0][4] = 15,即容量为 4 的背包,只放物品 0 的情况 - 放入物品 1:目前容量为 4,物品 1 的重量为 3,背包剩余容量为 1,背包容量为 1 时的价值为
dp[0][1] = 15, 所以dp[1][4]=dp[0][4-3] + 20 = 15 + 20 = 35 - 取最大值
35
背包容量 0 1 2 3 4 物品 0 0 15 15 15 15 物品 1 0 15 15 20 35 - 不放入物品 1:那么背包的价值应该是
-
-
处理物品 2(重量 4,价值 30)
-
容量 1-3:无法放入物品 2,继承物品 1 的值。
-
容量 4:
- 不放入物品 2:
dp[2][4]=dp[1][4] = 35,即容量为 4 的背包,放入物品 0 和 1 的情况 - 放入物品 2:目前容量为 4,物品重量为 4,放入后背包剩余 0 的价值为
dp[0][0] = 0,所以dp[2][4]=dp[1][4-4] + 30 = 0 + 30 = 30 - 取最大值
35
- 不放入物品 2:
背包容量 0 1 2 3 4 物品 0 0 15 15 15 15 物品 1 0 15 15 20 35 物品 2 0 15 15 20 35 -
-
-
确认递推公式
通过上面的示例,我们可以得出不放入第 i 个物品和放入第 i 个物品的最大价值:
- 不放入第 i 个物品:背包容量为 j,里面不放物品 i 的最大价值是
dp[i][j] = dp[i-1][j] - 放入第 i 个物品:背包空出物品 i 的容量后,背包容量为
j - weight[i],dp[i-1][j-weight[i]]为背包容量为j - weight[i]且不放物品 i 的最大价值,dp[i][j] = dp[i-1][j-weight[i]] + value[i]就是背包放入物品 i 得到的最大价值
两者取最大值:
dp[i][j] = Math.max(dp[i-1][j], dp[i-1][j-weight[i]] + value[i]) - 不放入第 i 个物品:背包容量为 j,里面不放物品 i 的最大价值是
-
dp 数组初始化
初始化一定要和 dp 数组的定义吻合,否则到递推公式的时候会越来越乱
- 当 j = 0(背包容量为 0)时,背包价值为 0,即
dp[i][0] = 0 - 由状态转移方程可知,i 是由 i-1 推导出来的,所以 i 为 0 需要初始化
- 当
j < weight[0]时,dp[0][j] = 0,背包容量比编号 0 的物品重量小 - 当
j >= weight[0]时,dp[0][j] = value[0],背包容量比编号 0 的物品重量大,可以放入,此时价值就是value[0]
- 当
此时,dp 数组初始化应该如下:
背包容量 0 1 2 3 4 物品 0 0 15 15 15 15 物品 1 0 物品 2 0 由递推公式可以
dp[i][j]都会由左上方的值决定,所以初始化任何数都可以,统一填充为 0 - 当 j = 0(背包容量为 0)时,背包价值为 0,即
-
确定遍历顺序
有两个维度,物品和背包容量,先遍历物品还是先遍历背包容量呢?
先遍历物品,再遍历背包容量,就类似于上面的推导过程,比较好理解
for (let i = 1; i < weight.length; i++) {// 物品for (let j = 0; j <= W; j++) {// 背包容量// 1. 不放入物品 iif (j < weight[i]) {dp[i][j] = dp[i - 1][j]} else {// 2. 放入物品 idp[i][j] = Math.max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i])}}} -
举例推导 dp 数组
上述示例的 dp 数组应该是:
[[0, 15, 15, 15, 15],[0, 15, 15, 20, 35],[0, 15, 15, 20, 35]]
代码
function knapsack01(W, weight, value) { const n = weight.length const dp = new Array(n).fill().map(() => new Array(W + 1).fill(0))
// 初始化 dp 数组 for (let j = 0; j <= W; j++) { if (j >= weight[0]) { dp[0][j] = value[0] } }
for (let i = 1; i < n; i++) { for (let j = 0; j <= W; j++) { if (j < weight[i]) { dp[i][j] = dp[i - 1][j] } else { dp[i][j] = Math.max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]) } } }
console.log(dp) return dp[n - 1][W]}01 背包动态规划解法(滚动数组)
上面二维数组的解法中,每个状态 dp[i][j] 依赖于上一行的数据,dp[i-1][j] 和 dp[i-1][j-weight[i]]
这说明其实每次处理新物品的时候,只需要前一行的数据,而不需要保留之前所有行的信息。这样的话,理论上可以用一维数组来滚动更新,减少空间使用。
二维数组的递推公式为:dp[i][j] = Math.max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i])
把 dp[i - 1]拷贝到 dp[i],表达式可以是 dp[i][j] = Math.max(dp[i][j], dp[i][j - weight[i]] + value[i])
因此可以使用滚动数组:如果上一层可以重复利用,直接拷贝到当前层
动规五部曲:
-
确定 dp 数组以及下标的含义
在上面的二维数组中,
dp[i][j]表示从下标为[0-i]的物品里任意取,放进容量为 j 的背包的最大价值一维数组:
dp[j]表示背包容量为 j 时的最大价值 -
确定递推公式
当把
dp[i - 1]拷贝到dp[i]的时候,递推公式可以简化为:dp[i][j] = Math.max(dp[i][j], dp[i][j - weight[i]] + value[i])此时去掉 i,
dp[j] = Math.max(dp[j], dp[j - weight[i]] + value[i])- dp[j]: 相当于二维 dp 数组中的
dp[i-1][j],即不放物品 i - dp[j - weight[i]] + value[i]:容量为 j - weight[i]的背包所背的最大价值 + 当前物品 i 的价值,即放物品 i
- dp[j]: 相当于二维 dp 数组中的
-
dp 数组初始化
dp[0] = 0,即容量为 0 的背包,不放任何物品,最大价值为 0
初始化为全 0,表示没有物品时的价值为 0。
-
确定遍历顺序
二维遍历的时候,背包从小到大,而一维遍历时,背包应从大到小
示例:
- 物品 0:重量
weight[0] = 1,价值value[0] = 15 - 背包容量:
W = 2 - 一维数组初始化:
dp = [0, 0, 0](容量 0、1、2)
-
正序遍历容量(错误方式)
遍历顺序:容量从小到大(
j = 1 → 2)执行步骤:
-
j = 1:
dp[1] = max(0, dp[0] + 15) = 15- 此时
dp = [0, 15, 0]
-
j = 2:
dp[2] = max(0, dp[1] + 15) = 15 + 15 = 30- 此时
dp = [0, 15, 30]
结果分析:
dp[2] = 30此时物品 0 被放入两次- 错误原因:正序遍历时,
dp[j - weight[i]]已经被当前物品的更新污染,导致重复选择
-
-
逆序遍历容量(正确方式)
遍历顺序:容量从大到小(
j = 2 → 1)执行步骤:
-
j = 2:
dp[2] = max(0, dp[1] + 15),但此时dp[1]尚未更新,仍为 0。- 因此
dp[2] = 0 + 15 = 15 - 此时
dp = [0, 0, 15]
-
j = 1:
dp[1] = max(0, dp[0] + 15) = 15- 此时
dp = [0, 15, 15]
结果分析:
dp[2] = 15表示物品 0 仅被放入一次- 正确原因:逆序遍历时,较大的容量 j 优先处理,此时较小的容量 j - weight[i] 尚未被修改,仍保留上一轮的值,避免重复计算
-
- 物品 0:重量
-
举例推导 dp 数组
- 物品 0 遍历背包:dp = [0, 15, 15, 15, 15]
- 物品 1 遍历背包:dp = [0, 15, 15, 20, 35]
- 物品 2 遍历背包:dp = [0, 15, 15, 20, 35]
代码
function knapsack01(W, weight, value) { const dp = new Array(W + 1).fill(0) // 初始化一维数组 const n = weight.length
for (let i = 0; i < n; i++) { // 遍历物品 for (let j = W; j >= weight[i]; j--) { // 逆序遍历容量 dp[j] = Math.max(dp[j], dp[j - weight[i]] + value[i]) } console.log(dp) } return dp[W]}416. 分割等和子集 🌟🌟
力扣链接 🌟🌟
题目描述
给定一个只包含正整数的非空数组。是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。
注意: 每个数组中的元素不会超过 100 数组的大小不会超过 200
示例 1:
- 输入: [1, 5, 11, 5]
- 输出: true
- 解释: 数组可以分割成 [1, 5, 5] 和 [11].
示例 2:
- 输入: [1, 2, 3, 5]
- 输出: false
- 解释: 数组不能分割成两个元素和相等的子集.
提示:
- 1 <= nums.length <= 200
- 1 <= nums[i] <= 100
解题思路
前提:
- 数组和为偶数(否则无法等分)
- 数组长度大于 2(否则无法分割子集)
- 最大元素必须小于总和的一半(否则剩下的所有元素和一定小于总和的一半,不可能等分)
满足以上三个条件,如[1, 5, 11, 5],总和为 22,可以等分为 [1, 5, 5] 和 [11]
此题关键点是将这个问题视为一个背包问题,不仅可以求背包能背的最大价值,还可以求这个背包是否可以装满,其中背包的容量是数组总和的一半。如果能找到一个子集的和等于总和的一半,那么剩下的元素自然也能组成另一半,这样就满足了题目的条件。
即求合集内是否出现总和为 sum/2 的子集。
转为 01 背包问题,求背包容量为 sum/2 时,物品的重量和价值都是数字本身,这些数字能否把背包装满(和为 sum/2)
二维 dp 数组
动规五部曲:
-
确定 dp 数组和下标的含义
- 01 背包中,
dp[i][j]表示在[0, i - 1]范围内的物品放入容量为 j 的背包,所背物品的最大价值 - 本题中,
dp[i][j]表示在[0, i - 1]范围内是否存在和为 j 的子集
- 01 背包中,
-
确定递推公式
- 01 背包二维数组的递推公式为:
dp[i][j] = Math.max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]]) - 此题的递推公式为:
dp[i][j] = dp[i - 1][j](不选) || dp[i - 1][j - nums[i]](选择)
- 01 背包二维数组的递推公式为:
-
初始化 dp 数组
- 初始化所有
dp[i][0]为 true,因为不选择任何元素时,和为 0 总是成立的 - 初始化
dp[0][nums[0]]为 true,只选第一个数字时,和为 nums[0] 总是成立的 - 其他位置填充为 false

dynamic-programming-code31 - 初始化所有
-
确定遍历顺序
先遍历数字,再遍历每一个可能的和 [1…sum/2]
for (let i = 1; i < nums.length; i++) {// 数字for (let j = 1; j <= sum / 2; j++) {// 和// 1. 不选择数字// 不选择当前元素,继承上一行的结果if (j < nums[i]) {dp[i][j] = dp[i - 1][j]} else {// 2. 选择数字// 如果当前元素的值小于等于j,则`dp[i][j]`取决于是否选择当前元素dp[i][j] = dp[i - 1][j] || dp[i - 1][j - num[i]]}}} -
举例推导 dp 数组

dynamic-programming-code32
function canPartition(nums) { const sum = nums.reduce((a, b) => a + b) if (sum % 2 !== 0) return false
const target = sum / 2 const len = nums.length const dp = new Array(len).fill().map(() => new Array(target + 1).fill(false))
// 初始化第一列为true 不选择任何元素时和为0总是成立的 for (let i = 0; i < len; i++) { dp[i][0] = true } // 只选第一个数字时,和为nums[0] 总是成立的 if (nums[0] <= target) { dp[0][nums[0]] = true }
for (let i = 1; i < len; i++) { const num = nums[i] for (let j = 1; j <= target; j++) { if (j < num) { dp[i][j] = dp[i - 1][j] } else { dp[i][j] = dp[i - 1][j] || dp[i - 1][j - num] } } } console.log(dp)
return dp[len - 1][target]}滚动数组
即 dp[sum/2] === sum/2。
按照上题,只要找出 dp[11] 等于 11,则返回 true。
动规五部曲:
-
确定 dp 数组和下标的含义
- 01 背包中,dp[j]指的是容量为 j 的背包,所背物品的最大价值为 dp[j]
- 本题中, dp[j]指的是容量为 j 的背包,所背数字的最大和为 dp[j]
当 dp[j] === j 时,表示背包容量为 j 时,背包刚好装满
如
dp[6] === 1 + 5,表示容量为 6 的背包,放入 1 和 5 后,刚好装满,即和为 6 -
确定递推公式
- 01 背包的递推公式为:
dp[j] = Math.max(dp[j], dp[j - weight[i]] + value[i]) - 本题重量和价值都是数字本身,所以递推公式:
dp[j] = Math.max(dp[j], dp[j - nums[i]] + nums[i])
- 01 背包的递推公式为:
-
dp 数组初始化
- 根据 dp 的定义,dp[0] = 0
- dp[1…sum/2]初始化也设为 0
-
确定遍历顺序
如果使用一维数组,需要先遍历物品,再遍历背包,且背包按逆序遍历
-
举例推导 dp 数组
输入[1,5,11,5],得到的 dp 数组为:
// 1dp = [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]// 5dp = [0, 1, 1, 1, 1, 5, 6, 6, 6, 6, 6, 6]// 11dp = [0, 1, 1, 1, 1, 5, 6, 6, 6, 6, 6, 11]// 5dp = [0, 1, 1, 1, 1, 5, 6, 6, 6, 6, 10, 11]最后
dp[11] === 11,返回 true
代码
function canPartition(nums) { const sum = nums.reduce((acc, cur) => acc + cur, 0) if (!sum % 2) return false
const dp = new Array(sum / 2 + 1).fill(0)
for (let i = 0; i < nums.length; i++) { // 先遍历物品 for (let j = sum / 2; j >= nums[i]; j--) { // 逆序遍历背包 dp[j] = Math.max(dp[j], dp[j - nums[i]] + nums[i]) } } console.log(dp)
if (dp[sum / 2] === sum / 2) return true}