动态规划(Dynamic Programming,简称 DP),适用于具有重叠子问题和最优子结构的问题

难点:状态定义 和 状态转移方程

方法步骤:

  1. 确定 dp 数组及下标的含义
  2. 确定递推公式
  3. dp 数组初始化
  4. 确定遍历顺序
  5. 举例推导dp数组

重叠子问题

leetcode:

打家劫舍问题

leetcode:

买卖股票问题

leetcode:

不限交易次数:

交易至多(恰好) k 次:

线性 dp

leetcode:

二维 dp

leetcode:

编辑距离:

区间 dp

leetcode:

连续的问题 dp 采用 true/false,也可以采用中心扩展法
不连续的问题 dp 采用 长度

背包问题

分为 0-1背包 和 完全背包,区别是 0-1 背包中的物品只有一个,完全背包的物品有无数个

0-1 背包

第一步,确定 dp 数组及下标的含义:
dp[i][j]:表示从下标为 [0 ~ i] 的物品里任意取,放进容量为 j 的背包,价值总和最大是多少


第二步,确定递推公式:
dp[i][j] 需要分为 2 种情况:

  • 不放物品 i:其实就是相当于在 [0 ~ i-1] 里选物品,所以等于 dp[i-1][j]
  • 放物品 i:那么就要留出空间放 i,背包容量就变成了 j - weight[i],相当于求在 [0 ~ i-1] 里选物品满足容量为 j - weight[i] 的最大价值,并且再加上物品 i 的价值,所以等于 dp[i-1][j-weight[i]] + value[i] 最终取它们中的最大值:dp[i][j] = Math.max( dp[i-1][j], dp[i-1][j-weight[i]] + value[i] )

第三步,dp 数组初始化:
根据递推公式,i 需要由 i-1 推出,所以先初始化 i 为 0 的情况
其他地方都初始化为 0 即可,因为都会在推导过程中被重新覆盖


第四步,确定遍历顺序: 先遍历物品还是先遍历背包重量?
因为 dp[i][j] 是根据左上角的 dp 数据推导而来,所以两种方式遍历都可以

1.先遍历物品,再遍历背包重量:

// weight 数组的大小 就是物品个数
for (let i = 1; i < weight.length; i++) { // 遍历物品
  for (let j = 0; j <= bagweight; j++) { // 遍历背包容量
    if (j < weight[i]) dp[i][j] = dp[i - 1][j] // 背包放不下物品 i,所以只考虑不放物品 i 的情况
    else dp[i][j] = Math.max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i])
  }
}

2.先遍历背包容量,再遍历物品:

for (let j = 0; j <= bagweight; j++) { // 遍历背包容量
  for (let i = 1; i < weight.length; i++) { // 遍历物品
    if (j < weight[i]) dp[i][j] = dp[i - 1][j] // 背包放不下物品 i,所以只考虑不放物品 i 的情况
    else dp[i][j] = Math.max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i])
  }
}

优化

将二维 dp 数组改为一维滚动数组:
因为每次赋值都是根据 正上方 和 左上方 数据得来,所以如果把 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])


初始化:
背包容量为 0 时,装的价值最大为 0,所以 dp[0] = 0,其他情况下,根据递推公式得知需要取最大值,所以默认一个最小的数 0 即可。所以全部初始化为 0


遍历顺序:
正序 or 倒序?

如果正序遍历,那么后面求的值,用的是前面被更新覆盖后的值,无法读到原始值,且表示物品可以被添加多次,所以只能倒序遍历。

先物品 or 先背包?

只能先物品再背包,因为先背包的话,相当于二维里竖着更新,此时由于倒序遍历,前面的值还没有更新,所以读不到左上角的值。
而先物品的话是横着更新,前面的值已经计算过,可以读取到。

所以最终只有一种遍历方式:

for (let i = 0; i < weight.length; i++) { // 遍历物品
  for (let j = bagweight; j >= weight[i]; j--) { // 遍历背包容量,因为 j < weight[i] 时不用更新,所以跳过
    dp[j] = Math.max(dp[j], dp[j - weight[i]] + value[i])
  }
}

leetcode:

完全背包

和 0-1 背包的区别:同一个物品可以被放入无数次 所以,递推公式:

  • 不放物品 i:其实就是相当于在 [0 ~ i-1] 里选物品,所以等于 dp[i-1][j]
  • 放物品 i:那么就要留出空间放 i,背包容量就变成了 j - weight[i],区别在放了物品 i,还是可以再放物品 i。所以相当于求在 [0 ~ i] 里选物品满足容量为 j - weight[i] 的最大价值,并且再加上物品 i 的价值,所以等于 dp[i][j-weight[i]] + value[i]

优化

正序 or 倒序?

物品可以被添加多次,所以采用正序遍历

先物品 or 先背包?

求组合数(不强调元素之间的顺序):外层物品,内层背包
求排列数(强调元素之间的顺序):外层背包,内层物品
无要求:顺序都可以

先物品后背包遍历方式:

for (let i = 0; i < weight.length; i++) { // 遍历物品
  for (let j = weight[i]; j <= bagweight; j++) { // 遍历背包容量,因为 j < weight[i] 时不用更新,所以直接从 weight[i] 开始遍历
    dp[j] = Math.max(dp[j], dp[j - weight[i]] + value[i])
  }
}

先背包后物品遍历方式:

for (let i = 0; i <= bagweight; i++) { // 遍历背包
  for (let j = 0; j < weight.length; j++) { // 遍历物品
    if (i >= weight[j]) {
      dp[i] = Math.max(dp[i], dp[i - weight[j]] + value[j])
    }
  }
}

leetcode:

总结:https://leetcode.cn/problems/combination-sum-iv/solutions/124393/xi-wang-yong-yi-chong-gui-lu-gao-ding-bei-bao-wen-