leetcode/notes/src/knapsack.md
Sainnhe Park 8dc3a66883
All checks were successful
ci/woodpecker/push/test Pipeline was successful
s0518
2023-02-09 17:06:22 +08:00

9.2 KiB
Raw Blame History

背包问题

01 背包

n 件物品和一个最多能背重量为 knapsackWeight 的背包。

i 件物品的重量是 weight[i],其价值是 value[i],它们都是正整数。

每件物品只能用一次,求解将哪些物品装入背包里物品价值总和最大。

  • dp[i][j] 表示从下标为 [0 - i] 的物品里任意取,放进容量为 j 的背包,价值总和最大是多少。
  • 递推公式:
    • 不放物品 idp[i - 1][j]
    • 放物品 idp[i - 1][j - weight[i]] + value[i]
    • 这两种情况选价值最大的那个,即 dp[i][j] = max{dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]}
  • 初始化
    • dp[i][0] = 0 即当前背包能装的重量为 0,就是什么都装不下,当然价值为 0
    • dp[0][j]
      • if (j < weight[0]), dp[0][j] = 0 背包装不下第 0 个物品
      • if (j >= weight[0]), dp[0][j] = value[0] 背包能装下第 0 个物品
    • i = 0j = 0 的情况都初始化完了,因此我们写双重循环的时候 ij 应该从 1 开始。
  • ij 都从前往后遍历
void knapsack_problem_2d() {
  vector<int> weight = {1, 3, 4};
  vector<int> value = {15, 20, 30};
  int knapsackWeight = 4;

  // 二维数组
  // 之所以初始化 j 的范围是 0 ~ knapsackWeight + 1 ,是因为我们会索引 dp[i][knapsackWeight]
  vector<vector<int>> dp(weight.size(), vector<int>(knapsackWeight + 1, 0));

  // 初始化
  for (int j = weight[0]; j <= knapsackWeight; j++) {
    dp[0][j] = value[0];
  }

  // 开始遍历
  for (int i = 1; i < weight.size(); i++) {  // 遍历物品
    for (int j = 1; j <= knapsackWeight; j++) {   // 遍历背包容量
      if (j < weight[i])
        dp[i][j] = dp[i - 1][j];
      else
        dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
    }
  }
}

接下来优化我们的代码。

注意到递推公式的右侧只用到了 dp[i - 1],我们可以把它看成是 dp[i] 上一步的状态,因此每一次迭代的时候我们完全可以将 dp[i - 1] 覆盖到 dp[i],这样可以将二维数组压缩到一维。

递推公式可以修改成:dp[j] = max{dp[j], dp[j - weight[i]] + value[i]}

这就是滚动数组的思路,当上一层可以重复利用的时候,我们直接把上一层拷贝到当前层。从递推公式来看,只要递推公式满足了右侧只用了 dp[i - 1] 那么就可以压缩。

来分析 DP 的思路:

  • dp[j] 表示第 i 层容量为 j 的背包所能背的物品的最大价值。
  • 递推公式:
    • dp[j] = dp[j], if j < weight[i] 因为如果现在的物品重量比背包容量还大,那背包就装不下了,只能不装现在的这一个,那就是 dp[i][j] = dp[i - 1][j],也就是 dp[j] = dp[j]
    • dp[j] = max{dp[j], dp[j - weight[i]] + value[i]}, if j >= weight[i] 这是能装下的情况
    • 总结一下就是只有当 j >= weight[i] 的时候我们才会调用第二个递推公式,否则 dp[j] 不变,于是我们可以将这一个放到第二层 for 循环里,第二层 for 循环的遍历范围是 weight[i] <= j <= knapsackWeight
  • 由于滚动数组每次都会覆盖上一层,因此初始化的时候我们只需要将滚动数组作为二维数组的第一层初始化
    • dp[j]j >= weight[0] 时应该为 value[0] 也就是能装下第 0 个物品,否则为 0
    • 如果初始化了 i = 0 的第一层的话,那么双重 for 循环就应该从 1 开始。
    • 当你实际写代码的时候会发现,双重 for 循环可以用来初始化 i = 0 的情况,因此我们只需要初始化 dp[j] = 0,然后 i0 开始就行。
  • 遍历顺序:
    • 物品 i 应该从前往后遍历
    • 但是背包重量 j 应该从后往前遍历
void knapsack_problem_1d() {
  vector<int> weight = {1, 3, 4};
  vector<int> value = {15, 20, 30};
  int knapsackWeight = 4;

  // 初始化
  // 之所以初始化 j 的范围是 0 ~ knapsackWeight + 1 ,是因为我们会索引 dp[knapsackWeight]
  vector<int> dp(knapsackWeight + 1, 0);
  for (int i = 0; i < weight.size(); i++) {         // 遍历物品
    for (int j = knapsackWeight; j >= weight[i]; j--) {  // 遍历背包容量
      dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
    }
  }
}

Q: 能不能先遍历容量,再遍历物品?

A: 不行,因为我们本来就是要用上一层的 i - 1 来覆盖这一层的 i

Q: 为啥二维不用从后往前呢?

A: 因为 dp[i][j] 都是通过上一层即 dp[i - 1][j] 计算而来,本层的 dp[i][j] 并不会被覆盖。

Q: 一维从后往前的本质是什么?

A: 如果从后往前的话,dp[j - weight[i]] + value[i] 就用的是上一层的数据(这才是我们想要的),但如果从前往后的话,dp[j - weight[i]] + value[i] 就用的是这一层的数据,这将会导致物品被重复放进去。

Q: 怎样初始化?

A:

  1. 先确定 dp[j] 应该初始化为多少,一般是 0
  2. 接下来确定 dp[0] 应该初始化为多少,我们直接看下一次访问到 dp[0] 时是什么情况就行,当访问到 dp[0] 时它应该是多少
  3. 我们接下来看看用当前的初始化值跑 i = 0 也就是第一层,逻辑是否正确。如果逻辑正确,那么第一层 for 循环的 i 就从 0 开始
  4. 如果不正确,我们专门对 i = 0 也就是第一层进行初始化,然后第一层 for 循环的 i1 开始。

416. 分割等和子集

二维数组:

  • dp[i][j] 表示是否可以从 0 ~ i 选取一些元素,使得总和等于 j
  • 递推公式为:
    • dp[i][j] = dp[i - 1][j] || dp[i - 1][j - nums[i]], j >= nums[i]
    • dp[i][j] = dp[i - 1][j], j < nums[i]
  • 初始化:
    • dp[0][j] = (j == nums[0])
    • dp[i][0] = false
  • ij 都从前往后

滚动数组:

  • dp[j] 表示是否可以从 0 ~ i 选取一些元素,使得总和等于 j
  • 递推公式为:
    • dp[j] = dp[j] || dp[j - nums[i]], j >= nums[i]
    • dp[j] = dp[j], j < nums[i]
    • 因此第二层 for 循环的范围可以直接定成 nums[i] <= j <= target,然后调用第一个递推公式
  • 初始化:
    • dp[j] = (j == nums[0]) 这是第一层也就是 i == 0 的情况
    • 这种情况可以写成默认初始化为 false,而 dp[nums[0]] = true
  • 遍历顺序:
    • i 从前往后,范围是 1 <= i < length
    • j 从后往前,范围是 nums[i] <= j <= target

本题中可以不初始化第一层,然后 i 从 0 开始。

1049. 最后一块石头的重量 II

仔细思考一下每个石头重量的加减方式,你会发现其实最终的重量可以这样表示:

final = k0 * w0 + k1 * w1 + k2 * w2 + ...

其中 ki+1-1wi 为第 i 个石头的重量。

那么 ki 取负的所有石头重量之和我们表示为 neg,其它石头重量之和为 total - neg

我们的目的就是要在 neg <= total/2 的前提下,让 neg 达到最大。

这就是一个 01 背包问题。

  • i 对应石头下标,每个石头的重量为 stones[i],价值为 stones[i]
  • j 对应背包容量,最大为 total/2

我们直接上滚动数组:

  • dp[j] 表示从 0 ~ i 中选石头,放进容量为 j 的背包,所能达到的最大价值
  • 迭代公式:
    • if (j < stones[i]) dp[j] = dp[j]
    • if (j >= stones[i]) dp[j] = max{dp[j], dp[j - stones[i]] + stones[i]}
    • 第二层迭代的范围是 stones[i] ~ total/2
  • 初始化:
    • if (j < stones[0]) dp[j] = 0
    • if (j >= stones[0]) dp[j] = stones[0]
  • 遍历:
    • i 从 1 到 length - 1
    • jtotal/2 向下取整,遍历到 stones[i]

本题中可以不初始化第一层,然后 i 从 0 开始。

完全背包

和 01 背包的区别就在于01 背包的每个元素只能用一次,而完全背包的每个物品能够重复使用。

代码也很简单,我们知道 01 背包的第二层 for 循环是从大到小遍历,这是为了去重,而完全背包是可以重复添加物品的,因此要从小到大遍历

void complete_knapsack_problem_1d() {
  vector<int> weight = {1, 3, 4};
  vector<int> value = {15, 20, 30};
  int knapsackWeight = 4;

  vector<int> dp(knapsackWeight + 1, 0);
  for (int i = 0; i < weight.size(); i++) {         // 遍历物品
    for (int j = weight[i]; j <= knapsackWeight; j++) {  // 遍历背包容量
      dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
    }
  }
}

518. 零钱兑换 II

  • dp[j] 的含义是从 0 ~ i 这些硬币中选择,组合成总金额为 j 的组合数
  • dp[j] += dp[j - coins[i]]
  • 初始化:
    • i = 0 的时候,也就是只有 coins[0] 这一种硬币的时候,当 j 可以被 coins[0] 整除的时候就赋值为 1,否则赋值为 0
    • 一种特殊情况是 dp[0],它表示组合成总金额为 0 的组合数,我们必须把它赋值为 1,这是因为当我们执行 dp[j] += dp[j - coins[i]] 的时候,如果 j == coins[i] 那么显然应该自增 1
    • 看看能不能不初始化第一层,我们直接删掉代码试试发现可以,那就不初始化第一层。
  • 遍历顺序都是从前向后