经典0/1背包问题
给你一个可装载重量为 W
的背包和 N
个物品,每个物品有重量和价值两个属性。其中第 i
个物品的重量为 w[i]
,价值为 v[i]
,现在让你用这个背包装物品,最多能装的价值是多少?
经典0/1背包的思路就是二维动态规划,定义好二维数组dp[i][j]表示从0-i个物品中选择总重量不超过j的物品的最大价值,确认边界条件,根据每一步的状态和选择找状态转移方程。
解法代码
class Solution {
public int maxValue(int[] w, int[] v, int W) {
int n = w.length;
// dp[i][j]表示从0-i个物品中选择总重量不超过j的物品的最大价值
int[][] dp = new int[n][W + 1];
// 第一列都是0(容量为0价值为0) 第一行表示选择第一个物品时的价值
for (int j = W; j >= w[0]; j--) {
dp[0][j] = dp[0][j - w[0]] + v[0];
}
// 遍历物品 从1开始
for (int i = 1; i < n; i++) {
// 遍历背包容量
for (int j = 0; j <= W; j++) {
// 背包容量不够拿第i个物品了 最大价值就是拿第i-1个物品时的价值
if (j < weight[i]) {
dp[i][j] = dp[i - 1][j];
}
// 足够拿第i个物品 可拿可不拿 取两个之间的较大值
else {
dp[i][j] = Math.max(dp[i - 1][j], dp[i - 1][j - w[i]] + v[i]);
}
}
}
return dp[n - 1][W];
}
}
背包问题的扩展
背包分类
背包问题分为0/1背包、完全背包、组合背包、分组背包等不同类型。
- 0/1背包:每个元素只能选取一次
- 完全背包:每个元素可以重复选择
- 组合背包:背包中的物品要考虑顺序
- 分组背包:不止一个背包,需要遍历每一个背包
问题分类
根据需要求解的问题不同,又可以分为最值问题、存在问题、组合问题等。
- 最值问题:求最大值/最小值
- 存在问题:是否存在、是否满足
- 组合问题:求满足...的排列组合
根据以上分类,可以将背包问题大致分类为0/1背包最值问题、0/1背包存在问题、0/1背包组合问题等九种不同的问题。
解法模板
背包问题的大结构是两层循环,分别遍历物品nums和容量target,然后写转移方程。根据背包的分类确定物品和容量的遍历顺序,根据问题的分类确定状态转移方程的写法。
背包分类模板
- 0/1背包:外循环nums,内循环target,target倒序遍历,且target>=nums[i]
- 完全背包:外循环nums,内循环target,target正序遍历,且target>=nums[i]
- 组合背包:外循环target,内循环nums,target正序遍历,且target>=nums[i]
- 分组背包:三重循环,外循环bags,里面两层循环根据问题类型转换成1、2、3三种类型
注意,组合背包分类中,根据是否需要考虑顺序,又分为以下两种类型。考虑顺序的意思是,相同元素的不同顺序视为不同的组合,例如(1,1,2)和(2,1,1)被视为不同的组合。
- 考虑顺序:外循环target,内循环nums
- 不考虑顺序:外循环nums,内循环target
问题分类模板
- 最值问题:dp[i] = max/min(dp[i], dp[i-num]+1) or dp[i] = max/min(dp[i], dp[i-num]+num)
- 存在问题:dp[i]=dp[i] || dp[i-num]
- 组合问题:dp[i] += dp[i-num]
经典例题
0/1背包最值问题TODO
0/1背包存在问题
给你一个 只包含正整数 的 非空 数组 nums 。请你判断是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。
本题可以转换为,判断是否存在一个子集,使得其总和target=sum/2。应用外循环nums,内循环target,target倒序遍历,且target>=nums[i]。
class Solution {
public boolean canPartition(int[] nums) {
// dp[i]表示能否在nums中找到足够的数字刚好填满容量为i的背包
int sum = 0;
for (int num : nums) {
sum += num;
}
// 奇数无法分割
if ((sum & 1) == 1) return false;
int t = sum / 2;
boolean[] dp = new boolean[t + 1];
// base case
dp[0] = true;
// 枚举数字
for (int i = 0; i < nums.length; i++) {
// 枚举目标值
for (int j = t; j >= nums[i]; j--) {
dp[j] = dp[j] || dp[j - nums[i]];
}
}
return dp[t];
}
}
0/1背包组合问题
给你一个非负整数数组 nums 和一个整数 target 。
向数组中的每个整数前添加 '+' 或 '-' ,然后串联起所有整数,可以构造一个 表达式 :
例如,nums = [2, 1] ,可以在 2 之前添加 '+' ,在 1 之前添加 '-' ,然后串联起来得到表达式 "+2-1" 。
返回可以通过上述方法构造的、运算结果等于 target 的不同 表达式 的数目。
0/1背包不考虑顺序的组合问题,应用外循环nums,内循环target,target倒序遍历,且target>=nums[i]。设sum为数组和,target用s代替,假设p是正数项集合,q是负数项集合,则p+q=sum,p-q=s,两式相加可得: p=(sum+s)/2 两式相减可得: q=(sum-s)/2。因此问题转变为在集合中寻找目标和为p或q的组合数量,这就变成典型的01背包组合问题了。
class Solution {
public int findTargetSumWays(int[] nums, int s) {
int sum = 0;
for (int num : nums) {
sum += num;
}
// 剪枝
if (sum < s || ((sum + s) & 1) == 1) {
return 0;
}
int target = Math.abs((sum + s) / 2);
int[] dp = new int[target + 1];
dp[0] = 1;
// 遍历数字
for (int num : nums) {
// 遍历目标值
for (int j = target; j >= num; j--) {
dp[j] = dp[j] + dp[j - num];
}
}
return dp[target];
}
}
分组0/1背包组合问题
这里有
n
个一样的骰子,每个骰子上都有k
个面,分别标号为1
到k
。给定三个整数
n
、k
和target
,请返回投掷骰子的所有可能得到的结果(共有kn
种方式),使得骰子面朝上的数字总和等于target
。由于答案可能很大,你需要对
109 + 7
取模。
分组背包是看成 n 组,每组内有大小分别为 1,2,...,k 的物品,计算每组恰好选一个物品,组成大小为 target 的方案数。参考灵神的解析。
class Solution {
public int numRollsToTarget(int n, int k, int target) {
int MOD = (int) 1e9 + 7;
int[][] dp = new int[n + 1][target + 1];
dp[0][0] = 1;
// 投掷n次看成n个分组
for (int i = 1; i <= n; i++) {
// 遍历目标值
for (int j = 1; j <= target; j++) {
// 遍历每次投出的骰子点数
for (int z = 1; z <= k && j >= z; z++) {
dp[i][j] = (dp[i][j] + dp[i - 1][j - z]) % MOD;
}
}
}
return dp[n][target];
}
}
完全背包最值问题
给你一个整数数组 coins ,表示不同面额的硬币;以及一个整数 amount ,表示总金额。计算并返回可以凑成总金额所需的 最少的硬币个数 。如果没有任何一种硬币组合能组成总金额,返回 -1 。你可以认为每种硬币的数量是无限的。
根据题目要求,容易看出是一个完全背包最值问题。应用外循环nums,内循环target,target正序遍历,且target>=nums[i]。
class Solution {
public int coinChange(int[] coins, int amount) {
// 数组大小为 amount + 1,初始值也为 amount + 1
int[] dp = new int[amount + 1];
Arrays.fill(dp, amount + 1);
// base case
dp[0] = 0;
// 枚举总金额
for (int i = 0; i < dp.length; i++) {
// 枚举硬币
for (int coin : coins) {
// 子问题无解,跳过
if (i - coin < 0) continue;
dp[i] = Math.min(dp[i], 1 + dp[i - coin]);
}
}
return (dp[amount] == amount + 1) ? -1 : dp[amount];
}
}
完全背包存在问题TODO
完全背包组合问题
给你一个由 不同 整数组成的数组
nums
,和一个目标整数target
。请你从nums
中找出并返回总和为target
的元素组合的个数。题目数据保证答案符合 32 位整数范围。
完全背包考虑顺序的组合问题,应用外循环target,内循环nums,target正序遍历,且target>=nums[i]。
class Solution {
public int combinationSum4(int[] nums, int target) {
// dp[i]表示总和为i的元素组合的个数
int[] dp = new int[target + 1];
// 总和为0的元素组合的个数只有1个
dp[0] = 1;
// 遍历目标总和
for (int i = 0; i <= target; i++) {
// 遍历数字
for (int num : nums) {
if (i >= num) {
dp[i] = dp[i] + dp[i - num];
}
}
}
return dp[target];
}
}
组合背包最值问题TODO
组合背包存在问题TODO
组合背包组合问题TODO