文章目录
动态规划知识参考:
一、概述
背包问题是一类比较特殊的动态规划问题,我们还是使用之前提到的解动态规划问题的四个步骤来思考这类问题。
背包类动态规划问题和其他的动态规划问题的不同之处在于,背包类动态规划问题会选用值来作为动态规划的状态,而我们之前讨论的动态规划问题,基本上都是利用数组或者字符串的下标来表示动态规划的状态。
针对背包类问题,我们可以画表格来辅助我们思考问题。背包类问题有基本的雏形,题目特征明显,当你理解了这类问题的解法后,遇到类似问题基本上不需要额外的辅助就可以给出大致的解法。
二、0-1 背包问题详细分析
题目描述:
有 N 件物品和一个容量为 V 的背包。第 i 件物品的体积是 C[i],价值是 W[i]。求解将哪些物品装入背包可使价值总和最大,求出最大总价值。
我们按照前面的动态规划的四个步骤来分析:
问题拆解:
我们要求解的问题是 “背包能装入物品的总价值最大”,影响这个问题的两个因素是:背包的容量大小和物品的属性(大小和价值)。对于物品来说,有放入背包和不放入背包两种结果。
我们举个例子,画表格来分析一下。假设背包的大小是 10,有 4 个物品,体积分别是 [2,3,5,7],价值分别是 [2,5,2,5]。
- 如果我们只考虑将第一个物品放入背包,那么只要背包的体积大于等于 2,都可以获得价值为 2 的最大价值:
注:表格的行条目表示背包的大小,列条目表示放入背包的物品数,中间区域表示当前容量下能放入物品的最大价值。items\volume 0 1 2 3 4 5 6 7 8 9 10 1 0 0 2 2 2 2 2 2 2 2 2 - 如果我们只考虑将前两个物品放入背包,那么当背包体积大于等于 5 时,两个物品都可以放入背包,此时就可以获得价值为 2 + 5 = 7 的最大价值;背包体积小于 5 时,此时两个物品不能全部放入背包,就要选择体积不超、价值最大的那个:
items\volume 0 1 2 3 4 5 6 7 8 9 10 1 0 0 2 2 2 2 2 2 2 2 2 2 0 0 2 5 5 7 7 7 7 7 7 - 如果我们只考虑将前三个物品放入背包,那么当背包体积大于等于 10 时,三个物品都可以放入背包,此时就可以获得价值为 2 + 5 + 2 = 9 的最大价值;背包体积小于 10 时,此时三个物品不能全部放入背包,就要选择体积不超、价值最大的那个方案:
items\volume 0 1 2 3 4 5 6 7 8 9 10 1 0 0 2 2 2 2 2 2 2 2 2 2 0 0 2 5 5 7 7 7 7 7 7 3 0 0 2 5 5 7 7 7 7 7 9 - 如果我们只考虑将前四个物品放入背包(也就是全部物品),我们可以根据前三个物品放入的结果来制定方案(背包体积大于等于 17 时,四个物品全可以放入,但要注意背包的最大容量为 10,所以不能四个都放;背包体积小于等于 10 时,就可以根据前三个物品放入的结果来制定方案):
注:当背包容量为 10 时,我们选择体积不超、价值最大的方案:选择体积为 3 和 7 的两个物品,最大价值:5 + 5 = 10。items\volume 0 1 2 3 4 5 6 7 8 9 10 1 0 0 2 2 2 2 2 2 2 2 2 2 0 0 2 5 5 7 7 7 7 7 7 3 0 0 2 5 5 7 7 7 7 7 9 4 0 0 2 5 5 7 7 7 7 7 10
由此,我们就根据物品和体积将问题拆分成子问题,也就是 “前 n 个物品在体积 V 处的最大价值” 可以由 “前 n - 1 个物品的情况” 推导得到。
定义状态:
由问题拆解我们找到了第 i 个问题和第 i - 1 个问题的联系,我们定义 dp[i][j] 表示:考虑将前 i 个物品放入体积为 j 的背包里能够获得的最大价值。
推导状态转移方程:
对于第 i 个物品,我们有放入背包和不放入背包两种选择。但是要注意,不放入背包也有两种情况:一种是因为背包空间不足,放不下第 i 个物品;另一种是因为放入第 i 个物品后的最大价值小于不放入第 i 个物品的最大价值。因此
- 空间不足,背包中放不下第 i 个物品:那就相当于不考虑第 i 个物品,问题就变成了上一个子问题,即考虑将前 i - 1 个物品放入背包的最大价值,则 d p [ i ] [ j ] = d p [ i − 1 ] [ j ] dp[i][j] = dp[i - 1][j] dp[i][j]=dp[i−1][j]
- 背包的体积大于等于第 i 个物品的体积,能够装下第 i 个物品:这时我们选取放与不放中价值最大的方案: d p [ i ] [ j ] = m a x ( d p [ i − 1 ] [ j ] , d p [ i − 1 ] [ j − C [ i ] ] + W [ i ] ) dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - C[i]] + W[i]) dp[i][j]=max(dp[i−1][j],dp[i−1][j−C[i]]+W[i])
寻找边界条件:
数组初始化:
- 当 i = 0 时,dp[0][j] 表示把前 0 个物品放入体积为 j 的背包,价值为 0,则有 dp[0][j] = 0;
- 当 j = 0 时,dp[i][0] 表示把前 i 个物品放入体积为 0 的背包,价值为 0,则有 dp[i][0] = 0。
测试代码:
public class Test {
public static int zeroOnePack(int V, int[] C, int[] W){
if(V <= 0 || C.length != W.length)
return 0;
int n = C.length;
int[][] dp = new int[n + 1][V + 1];
for(int i = 0; i <= n; i++){
for(int j = 0; j <= V; j++){
if(i == 0 || j == 0){
dp[i][j] = 0;
}else{
// 这里注意索引的变化:C[i-1]表示第 i 个物品。
if(j < C[i - 1])// 背包放不下第 i 个物品
dp[i][j] = dp[i - 1][j];
else// 背包能放下第 i 个物品,选择价值最大的方案
dp[i][j] = Math.max(dp[i - 1][j], dp[i - 1][j - C[i - 1]] + W[i - 1]);
}
}
}
return dp[n][V];
}
public static void main(String[] args){
int[] C = {2, 3, 5, 7};
int[] W = {2, 5, 2, 5};
System.out.println(zeroOnePack(10, C, W));
}
}
输出:
10
空间优化:
我们发现,第 i 个问题的状态只依赖于第 i - 1 个问题的状态,也就是 dp[i][…] 只依赖于 dp[i - 1][…],另外一点就是当前考虑的背包体积只会用到比其体积小的物品。基于这些信息,我们状态数组的维度可以少开一维,但是遍历的方向上需要从后往前遍历,从而保证子问题需要用到的数据不被覆盖,优化版本如下:
public static int zeroOnePackOpt(int V, int[] C, int[] W){
if(V <= 0 || C.length != W.length)
return 0;
int n = C.length;
int[] dp = new int[V + 1];
dp[0] = 0;// 背包为空时,价值为 0
for(int i = 0; i < n; i++){
for(int j = V; j >= C[i]; j--){
dp[j] = Math.max(dp[j], dp[j - C[i]] + W[i]);
}
}
return dp[V];
}
因为物品只能被选中 1 次,或者被选中 0 次,所以我们称这种背包问题为 01 背包问题。
三、案例一:leetcode——416.分割等和子集
题目描述:
给定一个只包含正整数的非空数组。是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。
注意:
每个数组中的元素不会超过 100
数组的大小不会超过 200
示例 1:
输入: [1, 5, 11, 5]
输出: true
解释: 数组可以分割成 [1, 5, 5] 和 [11].
示例 2:
输入: [1, 2, 3, 5]
输出: false
解释: 数组不能分割成两个元素和相等的子集.
分析:
题目中说把数组分为两个子集,且两个子集的元素和相等。那就是说,每个子集的和等于数组所有元素的和的一半,这就要求数组的和一定是偶数,这可以当作一个特判。那我们就很容易想到,从数组中找出一部分元素,让它们的和等于数组元素总和的一半。
回顾 01 背包问题,实际上就是在一堆物品中找出一部分物品,这一部分物品的价值最大。所以我们就可以用 01 背包问题的求解过程来分析该问题。
- 定义状态: 在 01 背包问题中,我们定义状态:dp[i][j] 表示将前 i 个物品放入体积为 j 的背包里能够获得的最大价值。这里我们定义 dp[i][j] = true 表示从数组的 [ 0 , i ] [0, i] [0,i] 这个子区间内挑选一些正整数,使得这些数的和恰好等于 j(每个数只能用一次)。
- 状态转移方程: 在 01 背包问题中,我们考虑是否将第 i 个物品放入背包。那这里我们就可以考虑第 i 个数字选与不选。
如果不选择 nums[i],说明在 [ 0 , i − 1 ] [0, i - 1] [0,i−1] 这个区间内已经找到一部分元素,使得它们的和为 j j j,则 dp[i][j] = true;
如果选择 nums[i],说明在 [ 0 , i − 1 ] [0, i - 1] [0,i−1] 这个区间内已经找到一部分元素,使得它们的和为 j − n u m s [ i ] j - nums[i] j−nums[i]。
所以,状态转移方程为 d p [ i ] [ j ] = d p [ i − 1 ] [ j ] 或 d p [ i − 1 ] [ j − n u m s [ i ] ] dp[i][j] = dp[i - 1][j] 或 dp[i -1][j - nums[i]] dp[i][j]=dp[i−1][j]或dp[i−1][j−nums[i]] - 边界条件:
数组初始化:dp[0][0] = false,都是正整数,和肯定大于 0; 第 1 个数只能让容积为它自己的背包恰好装满,即 dp[0][nums[0]] = true,其中 nums[0] 要小于等于数组和的一半。
参考代码:
class Solution {
public boolean canPartition(int[] nums) {
int n = nums.length;
if(n == 0)
return false;
int sum = 0;
for(int i : nums)
sum += i;
// 数组元素总和为偶数才可以分割
if(sum % 2 == 1)
return false;
int target = sum / 2;
// dp[i][j]表示从数组的[0,i]区间内找到一些元素,使得它们的和等于j
boolean[][] dp = new boolean[n][target + 1];
// 第 1 个数只能让容积为它自己的背包恰好装满
if(nums[0] <= target)
dp[0][nums[0]] = true;
for(int i = 1; i < n; i++){
for(int j = 0; j <= target; j++){
dp[i][j] = dp[i - 1][j];
// j 恰好等于 nums[i],即单独 nums[j] 这个数恰好等于此时“背包的容积” j
if(nums[i] == j){
dp[i][j] = true;
continue;
}
if(nums[i] < j){
dp[i][j] = dp[i - 1][j] || dp[i - 1][j - nums[i]];
}
}
}
return dp[n - 1][target];
}
}
四、案例二:leetcode——322. 零钱兑换
题目描述:
给定不同面额的硬币 coins 和一个总金额 amount。编写一个函数来计算可以凑成总金额所需的最少的硬币个数。如果没有任何一种硬币组合能组成总金额,返回 -1。
示例 1:
输入: coins = [1, 2, 5], amount = 11
输出: 3
解释: 11 = 5 + 5 + 1
示例 2:
输入: coins = [2], amount = 3
输出: -1
说明:
你可以认为每种硬币的数量是无限的。
分析:
题目给定了一个硬币数组 coins 和一个总金额 amount,结合背包问题,总金额就可以看成背包的体积,硬币的面额可以看成背包问题中物品的体积。背包问题中还有物品的价值这一属性,这里我们可以把每一块硬币的价值看为 1,问题要求凑成总金额的硬币数最少,也就是 “填满背包的最小价值”。
- 定义状态: dp[i] 表示凑成金额 i 所需的最少硬币数。
- 状态转移方程: 我们先看一个例子: coins = [1, 2, 5], amount = 11。要凑成金额 11,有三种方案:
(1)凑成金额为 10 的最少硬币数 + 一枚面值为 1 的硬币;
(2)凑成金额为 9 的最少硬币数 + 一枚面值为 2 的硬币;
(3)凑成金额为 6 的最少硬币数 + 一枚面值为 5 的硬币。
取三种方案中硬币数最少的方案即可,即dp[11] = min(dp[10] + 1, dp[9] + 1, dp[6] + 1)。可以写出状态转移方程, d p [ i ] = m i n ( d p [ a m o u n t − c o i n s [ i ] ] + 1 ) dp[i] = min(dp[amount - coins[i]] + 1) dp[i]=min(dp[amount−coins[i]]+1)其中, i ∈ [ 0 , c o i n s . l e n g t h − 1 ] i \in [0, coins.length - 1] i∈[0,coins.length−1],且 c o i n s [ i ] ≤ a m o u n t coins[i] \leq amount coins[i]≤amount。 - 边界条件: 我们要找的填满背包的最小价值,也就是要比较最小值,所以我们初始化数组为一个较大的值且这个值是取不到的,比如 Arrays.fill(dp, amount + 1)。
参考代码:
class Solution {
public int coinChange(int[] coins, int amount) {
int[] dp = new int[amount + 1];
Arrays.fill(dp, amount + 1);
dp[0] = 0;
for(int i = 1; i <= amount; i++){
for(int j = 0; j < coins.length; j++){
if(i >= coins[j] && dp[i - coins[j]] != amount + 1)
dp[i] = Math.min(dp[i], 1 + dp[i - coins[j]]);
}
}
return dp[amount] == amount + 1 ? -1 : dp[amount];
}
}