代码随想录算法训练营Day44 | 完全背包 | 518. 零钱兑换 II | 377. 组合总和 Ⅳ | 完全背包小总结

文章目录

  • 完全背包
    • 二维数组
    • 一维数组(滚动数组)
    • 注意
  • 518. 零钱兑换 II
    • 二维数组
    • 滚动数组
  • 377. 组合总和 Ⅳ
    • 类比
    • 注意
  • 总结

完全背包

理论基础

给定 N 件物品,一个最大重量为 W 的背包。第 i 件物品的重量是 weight[i],得到的价值是 value[i]每件物品都有无限个(也就是可以放入背包多次),求解将哪些物品装入背包里物品价值总和最大。

和 01 背包问题的区别在于,每个物品能取无数次。

二维数组

01 背包的核心 dp 递推公式:dp[i][j] = max(dp[i-1][j], dp[i-1][j-weight[i]] + value[i])

for i in range(1, len(values)):
	for j in range(capacity + 1):
		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])

根据之前的理论(和代码),01 背包的当前状态取决于前一层(物品 i-1)的左侧状态(重量 [0, j-1]),因为当前的物品 i 只有两种选择:“取”或者“不取”,所以不论当前物品是什么状态,都能直接回退到物品 i-1 的状态。
但这个核心性质对于完全背包不成立,因为完全背包中的当前物品 i 可以有无数种选择:“不取”、“取一次”、“取两次” … … \ldots \ldots …… 虽然限于当前背包重量 j,不是真的无限可能,但也无法根据当前的状态直接回退到物品 i-1 的状态。所以完全背包中分解的子问题有以下两种:

  • 不选取物品 i,显然 dp[i][j] = dp[i-1][j]
  • 选取了物品 i,因为无从得知之前的状态是否已经选取过物品 i,所以做出这个选择之前的状态应该是 dp[i][j-weight[i]] + value[i],即依赖于当前层的左侧状态
    • 在 01 背包中,不能使用这种回溯,因为 dp[i][j-weight[i]] 可能是“选取了物品 i”的状态,从而造成反复选取物品 i 的可能

完全背包的核心 dp 递推公式:dp[i][j] = max(dp[i-1][j], dp[i][j-weight[i]] + value[i])

for i in range(1, len(values)):
	for j in range(capacity + 1):
		if j < weight[i]:
			dp[i][j] = dp[i-1][j]
		else:
			dp[i][j] = max(dp[i-1][j], dp[i][j-weight[i]] + value[i])

可以看出,完全背包的当前状态依赖于当前层的左侧以及正上方的状态。这个性质也导致了之后遍历顺序的区别。

一维数组(滚动数组)

01 背包的滚动数组如下:

for i in range(len(values)):
	for j in range(capacity + 1):
		if j >= weight[i]:
			dp[j] = max(dp[j], dp[j-weight[i]] + value[i])

之前强调过,01 背包需要先物品后背包,同时背包内从大到小反向遍历。后者防止了任一物品被重复选取,而后者限制了前者不能“先背包后物品”,否则每个重量都只能放入一个物品。可以说,先物品后背包是由背包内从大到小反向遍历的需要导致的。

在完全背包中,背包内从小到大正向遍历从而允许重复选取同一物品,那么先物品后背包先背包后物品就都是正确的解法,因为这两种解法都可以保证 (i, j) 的左上角被预先计算过,从而得到正确的递推。

完全背包的滚动数组如下(特地写成先背包后物品):

for j in range(capacity + 1):				# capacity
	for i in range(len(weight)):			# items
		if j >= weight[i]:
			dp[j] = max(dp[j], dp[j-weight[i]] + value[i])

注意

以上关于先物品后背包先背包后物品的遍历顺序仅限于纯粹的完全背包问题。对于完全背包的应用,并非两种顺序总能混用。
这个重点的意义在于,理解为什么 01 背包只能先物品后背包,也就知道了为什么完全背包两种顺序均可。

518. 零钱兑换 II

题目链接 | 理论基础

二维数组

  1. dp 数组的下标含义:dp[i][j] 代表了给定 [0, i] 的硬币,能够组成金额 j 的组合方法数量。
  2. dp 递推公式:dp[i][j] += dp[i][j-coins[i]]
  3. dp 数组的初始化:
    • 对于第一个硬币 coins[0],当金额 j % coins[0] == 0 的时候,有一种方法可以组成金额 j;否则就是 0 种方法
    • 对于金额为 0 的情况,无论给定多少硬币,都有且只有一种方法可以组成金额 0(也就是都不取)
      • 这也符合了当硬币的值等于金额的情况:如果有一个面值为 1 的硬币,组成金额 1 的方法当然就只有一种
  4. dp 的遍历顺序:二维数组的遍历顺序无所谓
  5. 举例推导:省略
class Solution:
    def change(self, amount: int, coins: List[int]) -> int:
        # dp[i][j] represents the number of ways to make j given coins [0, i]
        dp = [[0] * (amount + 1) for _ in range(len(coins))]
        
        for j in range(amount + 1):
            if j % coins[0] == 0:
                dp[0][j] = 1
        for i in range(len(coins)):
            dp[i][0] = 1
        
        # dp formula
        for i in range(1, len(coins)):
            for j in range(amount + 1):
                dp[i][j] = dp[i-1][j]
                if j >= coins[i]:
                    dp[i][j] += dp[i][j-coins[i]]
        
        return dp[-1][-1]

滚动数组

之前在理论部分提到过,最基础的完全背包问题是无所谓背包、物品的遍历顺序的,因为只要能够获得最大的价值即可,并不在意获得最大价值的方法。而本题则不同,着重强调了获得组合的方法。由于明确了无顺序的要求,所以对于遍历顺序就有要求。

如果使用了先背包后物品的遍历顺序,假设给定 coins = [1, 2, 5], target=5,则 dp 数组如下

0 1 2 3 4 5
1 1 1 1 2 3 6
2 1 1 2 3 5 8
5 1 1 2 3 5 9

其中的更新记录没有正确地记录组合数。例如,当 j=3 的更新结束时,dp[3] = 3。这个值来源于 1+1+1, 1+2, 2+1:在更新到 coins[0]=1 的时候,记录了一次 2 + 1 = 3;更新到 coins[1]=2 的时候,又记录了一次 1 + 2 = 3。很明显这些记录是重复的,不满足题目要求的组合。

而使用先物品后背包的遍历顺序时,每个物品在不同金额 j 更新时都是在不同组合内第一次使用,是符合组合结果的需求的。

class Solution:
    def change(self, amount: int, coins: List[int]) -> int:
        # dp[j] represents the number of ways to make j
        dp = [0] * (amount + 1)
        dp[0] = 1
        
        # dp formula
        for i in range(len(coins)):
            for j in range(amount + 1):
                if j >= coins[i]:
                    dp[j] += dp[j-coins[i]]
        
        return dp[-1]

377. 组合总和 Ⅳ

题目链接 | 理论基础

本题和上一题非常相似,只不过上一题要求的是组合数,本题变成了排列数。如上一题的题解所说,先背包后物品的遍历顺序就可以解决。

  • dp 数组的初始化:只有 dp[0]=1 需要被初始化,从而使 dp 的递推能够正常进行。由于给定的数组是正整数,不需要 dp[0] 的实际意义。不过这个初始化允许了第一次滚动时,dp[nums[0]] = 1 的正确递推。
  • dp 递推顺序:先背包后物品,如上所述。
class Solution:
    def combinationSum4(self, nums: List[int], target: int) -> int:
        dp = [0] * (target + 1)
        dp[0] = 1
        
        for j in range(target + 1):
            for i in range(len(nums)):
                if j >= nums[i]:
                    dp[j] += dp[j-nums[i]]
    
        return dp[-1]

类比

本题和之前的爬楼梯变种非常相似,可以将 target 看成是要攀爬的楼层高度,nums 是每一次能够攀爬的层数。这样的问题下,很明显应该先遍历背包再遍历物品。

注意

值得一提的是,二维数组似乎无法解决本题,因为无论如何更换顺序,只要还是需要继承 dp[i-1][j],就不能得到完整的解决 j-1 背包的解法,也就是说二维数组因为 i-1 的记录,总是保持着对物品的顺序。

总结

在求装满背包有几种方案的时候,认清遍历顺序是非常关键的;

  • 先物品后背包时,当进行物品 i 对背包容量的更新时,物品 i+1 肯定不会出现在当前的结果中,也就是说在得到的结果中,物品 i+1 一定会出现在物品 i 的后面,顺序被固定了。
    • e.g. 只会出现 [coins[0], coins[0], coins[1] ...],绝不会出现 [coins[0], coins[1], coins[0] ...]
  • 先背包后物品时,会在大小为 j 的背包内选取所有物品的可行结果,然后对大小为 j+1 的背包进行选取,所以物品出现的顺序是不固定的。
    • e.g. 可能会出现 [coins[0], coins[0], coins[1] ...][coins[0], coins[1], coins[0] ...]
  • 在纯粹的完全背包问题中,只要能够达成最大价值,物品的选取顺序无所谓,所以两种遍历方式都可以。

如果求组合数就是外层遍历物品,内层遍历背包:先物品后背包
如果求排列数就是外层遍历背包,内层遍历物品:先背包后物品

你可能感兴趣的:(代码随想录算法训练营一刷,算法)