文章目录
- 背包问题概览
- 01背包
- 二维dp数组写法
- 一维dp数组写法
- 完全背包
- 关于遍历顺序
- 相关题目
- [416. 分割等和子集](https://leetcode.cn/problems/partition-equal-subset-sum/)
- [279. 完全平方数](https://leetcode.cn/problems/perfect-squares/)
- [518. 零钱兑换 II](https://leetcode.cn/problems/coin-change-ii/)
- 总结
本文主要用于通过两个例题记录 01背包 和 完全背包 问题的模板。
背包问题概览
直接先去看他的:动态规划:01背包理论基础
01背包
494. 目标和
这道题目等价于:选一部分数字,前面是正号,剩下一部分数字前面是负号。
我们记这两部分数字集合的和分别是 a 和 b ,那么有:
a
+
b
=
t
o
t
a
l
S
u
m
a + b = totalSum
a+b=totalSum 和
a
−
b
=
t
a
r
g
e
t
a - b = target
a−b=target ,得到
a
=
(
t
o
t
a
l
S
u
m
+
t
a
r
g
e
t
)
/
2
a = (totalSum + target) / 2
a=(totalSum+target)/2。
除此之外,还有两个额外条件,1 是 (totalSum + target) 需要是偶数,2 是 totalSum 至少也要达到 target 的绝对值(也就是数字集足够组成 target)。
这样这道题目就可以转变成 : 恰好装 capacity,求方案数。
二维dp数组写法
不熟练的可以先从 二维数组 出发。
- 确定 dp 数组以及下标的含义
定义 dp 数组 dp[][] ,其中 dp[i][j] 表示从下标为[0-i]的物品里任意取,放进恰好容量为j的背包,方案数是多少 - 确定递推公式
对于每个物品,无非是放和不放两种方式。
不放对应着 f[i + 1][c] = f[i][c] (其实不是不放,是放不了)
放对应着 f[i + 1][c] = f[i][c] + f[i][c - x] (f[i][c]是不放的方案数,f[i][c-x]是放i+1的方案数,需要加起来) - dp 数组初始化
dp[][0] = 1; // 也就是只有都不选,价值和才是 0 . - 确定遍历顺序
这里无论是先遍历物品还是先遍历背包都是可以的,唯一需要注意的是在遍历物品时的顺序需要从前往后。(因为递推公式中 dp[i][ ] 依赖 dp[i - 1][]) - 举例推导 dp 数组
class Solution {
public int findTargetSumWays(int[] nums, int target) {
int totalSum = Arrays.stream(nums).sum(), n = nums.length;
if ((totalSum + target) % 2 != 0 || totalSum < Math.abs(target)) return 0;
target = (totalSum + target) / 2;
int[][] dp = new int[n + 1][target + 1];
dp[0][0] = 1; // 0个数字的时候,有1种可能得到0价值
for (int i = 1; i <= n; ++i) { // 遍历1个数字到n个数字
int num = nums[i - 1];
for (int j = 0; j <= target; ++j) {
dp[i][j] = dp[i - 1][j];
if (j >= num) dp[i][j] += dp[i - 1][j - num];
}
}
return dp[n][target];
}
}
一维dp数组写法
一维 dp 数组的写法和二维 dp 数组的写法很不一样,因为背包容量一定要倒序遍历!,所以必须先遍历物品嵌套遍历背包容量。
其中背包容量一定要倒序遍历的原因是:本质上还是一个对二维数组的遍历,并且右下角的值依赖上一层左上角的值,因此需要保证左边的值仍然是上一层的,从右向左覆盖。
(即必须先遍历物品嵌套遍历背包,且物品正序遍历,背包倒序遍历)
class Solution {
public int findTargetSumWays(int[] nums, int target) {
int totalSum = Arrays.stream(nums).sum(), n = nums.length;
if ((totalSum + target) % 2 != 0 || totalSum < Math.abs(target)) return 0;
target = (totalSum + target) / 2;
int[] dp = new int[target + 1];
dp[0] = 1;
for (int i = 0; i < n; ++i) { // 遍历物品
for (int j = target; j >= nums[i]; --j) { // 遍历背包
dp[j] += dp[j - nums[i]];
}
}
return dp[target];
}
}
Q:为什么可以从二维数组变成一维数组?
A:因为 无后效性
。也就是这一行的二维数组的取值,只和紧挨着的上一层数组有关。
完全背包
完全背包和01背包问题唯一不同的地方就是,每种物品有无限件。
322. 零钱兑换
class Solution {
public int coinChange(int[] coins, int amount) {
int n = coins.length;
int[] dp = new int[amount + 1];
Arrays.fill(dp, amount + 1);
dp[0] = 0;
for (int i = 0; i < n; ++i) {
for (int j = coins[i]; j <= amount; ++j) {
dp[j] = Math.min(dp[j], dp[j - coins[i]] + 1);
}
}
return dp[amount] == amount + 1? -1: dp[amount];
}
}
在完全背包中,对于一维dp数组来说,其实两个for循环嵌套顺序是无所谓的!
因为dp[j] 是根据 下标j之前所对应的dp[j]计算出来的。 只要保证下标j之前的dp[j]都是经过计算的就可以了。 (即物品和背包的遍历都是正序遍历即可)
。(作者个人认为还应该加上:物品for循环嵌套背包容量for循环,解释详见最后的总结章节)
关于遍历顺序
如果 dp[i + 1][j] 的状态 与 (dp[i][j - c] 或 dp[i + 1][j + c])有关,就是倒序遍历。(来自左上右下)
如果 dp[i + 1][j] 的状态 与 (dp[i + 1][j - c] 或 dp[i][j + c])有关,就是正序遍历。(来自左下右上)
或者这样理解:我不能先覆盖 我之后还会使用到的地方
相关题目
416. 分割等和子集
实际上就是判断能否取一定数量的数字,使其总和也是全部数字总和的一半。(类似于 目标和 那道题目,只不过这道题目的目标和是 0)。
判断为 01 背包。
写法一:
class Solution {
public boolean canPartition(int[] nums) {
int n = nums.length, totalSum = Arrays.stream(nums).sum(), target = totalSum / 2;
if (totalSum % 2 == 1) return false;
boolean[] dp = new boolean[target + 1];
dp[0] = true;
for (int i = 0; i < n; ++i) {
for (int j = target; j >= nums[i]; --j) {
dp[j] |= dp[j - nums[i]];
}
}
return dp[target];
}
}
写法二:
class Solution {
public boolean canPartition(int[] nums) {
int n = nums.length, totalSum = Arrays.stream(nums).sum(), halfSum = totalSum / 2;
if (totalSum % 2 == 1) return false;
int[] dp = new int[halfSum + 1];
for (int i = 0; i < n; ++i) {
for (int j = halfSum; j >= nums[i]; --j) {
dp[j] = Math.max(dp[j], dp[j - nums[i]] + nums[i]);
}
}
return dp[halfSum] == halfSum;
}
}
279. 完全平方数
每个平方数都可以重复选择,所以是完全背包问题。
class Solution {
public int numSquares(int n) {
int[] dp = new int[n + 1];
Arrays.fill(dp, n + 1); // 因为求最小值,所以都先设成最大值
dp[0] = 0; // dp数组初始化
for (int i = 1; i * i <= n; ++i) { // 遍历不同的物品
for (int j = i * i; j <= n; ++j) { // 完全背包问题需要:正向遍历背包
dp[j] = Math.min(dp[j], dp[j - i * i] + 1);
}
}
return dp[n];
}
}
518. 零钱兑换 II
和 零钱兑换 相同,都是 完全背包问题。
但这道题目求的是方案数量,所以状态转移方程不同,其他部分均大致相同。
class Solution {
public int change(int amount, int[] coins) {
int n = coins.length;
int[] dp = new int[amount + 1];
dp[0] = 1; // 总金额为0的方案数是1
for (int i = 0; i < n; ++i) {
for (int j = coins[i]; j <= amount; ++j) {
dp[j] += dp[j - coins[i]]; // 求方案数,所以是 dp[j] += dp[j - coins[i]];
}
}
return dp[amount];
}
}
总结
其实 01背包 和 完全背包 问题,最重要的就是两个问题:
- 把原问题转换成背包问题。
- 背包问题的模板。
关于模板,作者推荐都只记住 一维 dp 数组的方法。
对于 01背包:必须先遍历物品嵌套遍历背包,且物品正序遍历,背包倒序遍历
对于 完全背包:必须先遍历物品嵌套遍历背包,且物品正序遍历,背包倒序遍历
备注:
有一种说法是:
来自https://programmercarl.com/%E8%83%8C%E5%8C%85%E9%97%AE%E9%A2%98%E7%90%86%E8%AE%BA%E5%9F%BA%E7%A1%80%E5%AE%8C%E5%85%A8%E8%83%8C%E5%8C%85.html(代码随想录)
但实际上在 518. 零钱兑换 II 这道题目中,如果调换 两个 for 循环的嵌套顺序,结果会出现问题!
因此建议大家都 先遍历物品嵌套遍历背包。