背包问题
0-1背包
给你一个可装载重量为 W
的背包和 N
个物品,每个物品有重量和价值两个属性。其中第 i
个物品的重量为 wt[i]
,价值为
val[i]
,现在让你用这个背包装物品,最多能装的价值是多少?
例如:
N = 3, W = 4
wt = [2,1,3]
val = [4,2,3]
算法返回 6,选择前两件物品装进背包,总重量 3 小于
W
,可以获得最大价值 6。
题目就是这么简单,一个典型的动态规划问题。这个题目中的物品不可以分割,要么装进包里,要么不装,不能说切成两块装一半。这就是 0-1 背包这个名词的来历。
解决这个问题没有什么排序之类巧妙的方法,只能穷举所有可能,根据我们[动态规划详解中的套路,直接走流程就行了。
第一步要明确两点,「状态」和「选择」
对于0-1背包状态有两个,就是「背包容量」和「可选择的物品」;
再说选择,对于每件物品,选择就是「装进背包」或者「不装进背包」
1 | for 状态1 in 状态1的所有取值: |
第二步要明确dp
数组的定义
首先看看刚才找到的「状态」,有两个,也就是说我们需要一个二维
dp
数组。
dp[i][w]
的定义如下:对于前 i
个物品,当前背包的容量为 w
,这种情况下可以装的最大价值是
dp[i][w]
。
比如说,如果
dp[3][5] = 6
,其含义为:对于给定的一系列物品中,若只对前 3
个物品进行选择,当背包容量为 5 时,最多可以装下的价值为 6。
1 | int[][] dp[N+1][W+1] |
第三步,根据「选择」,思考状态转移的逻辑
先重申下dp
数组的定义:
对于前 i
个物品,当前背包的容量为
w
,这种情况下可以装的最大价值是 dp[i][w]
。
如果你没有把这第i
个物品装入背包,
那么最大价值dp[i][w]
应该等于dp[i-1][w]
,继承之前的结果。
如果你把第i
个物品装入了背包,
那么dp[i][w]
应该等于dp[i-1][w-wt[i-1]]+val[i-1]
首先,由于i
是从1开始的,所以val
和wt
的索引是i-1
时表示第i
个物品的价值和重量。
而 dp[i-1][w - wt[i-1]]
也很好理解:你如果装了第
i
个物品,就要寻求剩余重量 w - wt[i-1]
限制下的最大价值,加上第 i
个物品的价值
val[i-1]
。
1 | for i in [1..N]: |
最后一步,处理边界
1 | int knapsack(int W, int N, int[] wt, int[] val) { |
416. 分割等和子集
给你一个 只包含正整数 的 非空 数组
nums
。请你判断是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。
示例:
输入:nums = [1,5,11,5]
输出:true
解释:数组可以分割成 [1, 5, 5] 和 [11] 。
思路:
那么对于这个问题,我们可以先对集合求和,得出
sum
,把问题转化为背包问题:
给一个可装载重量为 sum / 2
的背包和
N
个物品,每个物品的重量为
nums[i]
。现在让你装物品,是否存在一种装法,能够恰好将背包装满?
第一步要明确两点,「状态」和「选择」
状态就是「背包的容量」和「可选择的物品」,选择就是「装进背包」或者「不装进背包」。
第二步要明确dp
数组的定义
dp[i][j] = x
表示,对于前i
个物品,当前背包容量为j
时,若x
为true
,则说明可以恰好将背包装满,若为false
,则说明不能恰好将背包装满。
对于本题,我们相求的最终答案为dp[N][sum/2]
,结束条件为dp[..][0] = true
和dp[0][..] = false
,因为背包没有空间的时候,就相当于装满了,而当没有物品可选择的时候,肯定没办法装满背包。
第三步,根据「选择」,思考状态转移的逻辑
如果不把 nums[i]
算入子集,或者说你不把这第
i
个物品装入背包,那么是否能够恰好装满背包,取决于上一个状态
dp[i-1][j]
,继承之前的结果。
如果把 nums[i]
算入子集,或者说你把这第
i
个物品装入了背包,那么是否能够恰好装满背包,取决于状态
dp[i-1][j-nums[i-1]]
。
首先,由于 i
是从 1 开始的,而数组索引是从 0
开始的,所以第 i
个物品的重量应该是
nums[i-1]
,这一点不要搞混。
dp[i - 1][j-nums[i-1]]
也很好理解:你如果装了第
i
个物品,就要看背包的剩余重量 j - nums[i-1]
限制下是否能够被恰好装满。
换句话说,如果 j - nums[i-1]
的重量可以被恰好装满,那么只要把第 i
个物品装进去,也可恰好装满 j
的重量;否则的话,重量
j
肯定是装不满的。
最后一步,处理一些边界问题
1 | class Solution { |
进一步优化
再进一步,是否可以优化这个代码呢?注意到
dp[i][j]
都是通过上一行 dp[i-1][..]
转移过来的,之前的数据都不会再使用了。
将二维dp
数组压缩为一维
1 | class Solution { |
其实这段代码和之前的解法思路完全相同,只在一行 dp
数组上操作,i
每进行一轮迭代,dp[j]
其实就相当于 dp[i-1][j]
,所以只需要一维数组就够用了。
唯一需要注意的是 j
应该从后往前反向遍历,因为每个物品(或者说数字)只能用一次,以免之前的结果影响其他的结果。
完全背包问题
518. 零钱兑换 II
给你一个整数数组 coins 表示不同面额的硬币,另给一个整数 amount 表示总金额。
请你计算并返回可以凑成总金额的硬币组合数。如果任何硬币组合都无法凑出总金额,返回 0 。
假设每一种面额的硬币有无限个。
题目数据保证结果符合 32 位带符号整数。
示例:
输入:amount = 5, coins = [1, 2, 5]
输出:4
解释:有四种方式可以凑成总金额:
5=5
5=2+2+1
5=2+1+1+1
5=1+1+1+1+1
思路:
我们可以把这个问题转化为背包问题的描述形式:
有一个背包,最大容量为 amount
,有一系列物品
coins
,每个物品的重量为
coins[i]
,每个物品的数量无限。请问有多少种方法,能够把背包恰好装满?
这个问题和我们前面讲过的两个背包问题,有一个最大的区别就是,每个物品的数量是无限的,这也就是传说中的「完全背包问题」,没啥高大上的,无非就是状态转移方程有一点变化而已。
第一步要明确两点,「状态」和「选择」
状态有两个,就是「背包的容量」和「可选择的物品」,选择就是「装进背包」或者「不装进背包」嘛,背包问题的套路都是这样。
1 | for 状态1 in 状态1的所有取值: |
第二步要明确 dp
数组的定义
dp[i][j]
的定义如下:
若只使用前 i
个物品(可以重复使用),当背包容量为
j
时,有 dp[i][j]
种方法可以装满背包。
换句话说,翻译回我们题目的意思就是:
若只使用 coins
中的前 i
个硬币的面值,若想凑出金额 j
,有 dp[i][j]
种凑法。
经过以上的定义,可以得到:
base case 为
dp[0][..] = 0, dp[..][0] = 1
。因为如果不使用任何硬币面值,就无法凑出任何金额;如果凑出的目标金额为
0,那么“无为而治”就是唯一的一种凑法。
我们最终想得到的答案就是 dp[N][amount]
,其中
N
为 coins
数组的大小。
1 | int dp[N+1][amount+1] |
第三步,根据「选择」,思考状态转移的逻辑
如果你不把这第 i
个物品装入背包,也就是说你不使用 coins[i]
这个面值的硬币,那么凑出面额 j
的方法数
dp[i][j]
应该等于
dp[i-1][j]
,继承之前的结果。
如果你把这第 i
个物品装入了背包,也就是说你使用 coins[i]
这个面值的硬币,那么 dp[i][j]
应该等于
dp[i][j-coins[i-1]]
。
首先由于 i
是从 1 开始的,所以 coins
的索引是 i-1
时表示第 i
个硬币的面值。
dp[i][j-coins[i-1]]
也不难理解,如果你决定使用这个面值的硬币,那么就应该关注如何凑出金额
j - coins[i-1]
。
比如说,你想用面值为 2 的硬币凑出金额 5,那么如果你知道了凑出金额 3 的方法,再加上一枚面额为 2 的硬币,不就可以凑出 5 了嘛。
综上就是两种选择,而我们想求的 dp[i][j]
是「共有多少种凑法」,所以 dp[i][j]
的值应该是以上两种选择的结果之和:
1 | for (int i = 1; i <= n; i++) { |
最后一步,把伪码翻译成代码,处理一些边界情况
1 | class Solution { |
而且,我们通过观察可以发现,dp
数组的转移只和
dp[i][..]
和 dp[i-1][..]
有关,所以可以压缩状态,进一步降低算法的空间复杂度:
1 | class Solution { |