背包问题
0-1背包(物品只有一件)
二维数组
- dp[i][j] 容量为j的背包从[0, i]选物品,背包的最大价值
- 背包中放物品i dp[i][j] = dp[i-1][j-weights[i]] + values[i]
背包中不放物品i(j<weights[i]时肯定放不下,还可能能放下物品i,但是物品i的价值不高,浪费了背包重量,不如用这些重量放其他的物品) dp[i][j] = dp[i-1][j]
dp[i][j] = max(dp[i-1][j-weights[i]] + values[i], dp[i-1][j])
- 初始化: dp[:][0] 为 0 dp[0][j] 对于j<weights[0]的j,dp[0][j]都为0,对于j>=weights[0]的j,都为values[0]
- 遍历方向 双重循环,外层循环遍历物品,内层循环遍历重量,对于每个物品,背包容量逐渐升高,计算最大价值
- 背包最大重量4
j=0 | j=1 | j=2 | j=3 | j=4 | |
---|---|---|---|---|---|
i=0 | 0 | 15 | 15 | 15 | 15 |
i=1 | 0 | 15 | 15 | 20 | 35 |
i=2 | 0 | 15 | 15 | 20 | 35 |
一维滚动数组
观察上述二维数组发现,dp[i][j]当前值,只与上一行的值有关,所以可以使用一维数组,只保存上一次的计算结果
- dp[j]容量为j的背包物品的最大价值
- dp[j] = max(dp[j], dp[j-weights[i]] + values[i])
解释比较的两项 dp[j]:不放物品i,放物品[0, i-1]最大价值
dp[j-weights[i]] + values[i]:放物品i
- dp[0] 肯定为0 如果物品价值非负 其他都初始化为0
- 遍历方向 双重循环,外层循环遍历物品,内层循环遍历重量,但是遍历重量时要从大到小遍历,因为递推公式中dp[j-weights[i]] + values[i],dp[j-weights[i]]是剩余的重量,该重量的最大价值由[0,i-1]物品决定,是上一次遍历到物品i-1时保存的结果,如果从小到大遍历,那么dp[0]至dp[j-1]上一层的结果都被覆盖了
- 例子同上
j=4 j=3 j=2 j=1 j=0
i= 0 15 15 15 15 0
i= 1 35 20 15 15 0
i= 2 35 20 15 15 0
0-1背包问题的变体
dp[j]不是容量为j的背包物品最大价值,而是能装满容量为j的背包的方案数量
分割等和子集-最大价值
https://leetcode.cn/problems/partition-equal-subset-sum/
1 | 转为背包问题,背包容量 sum/2 物品的重量就是元素值 物品的价值也是元素值 |
1 | public boolean canPartition(int[] nums) { |
目标和-满背包组合数
https://leetcode.cn/problems/target-sum/
- 回溯也能解决
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36/*
left - right = target
即 left - (sum - left) = target
即 left = (target + sum) / 2 如果 target + sum是奇数,
(target + sum) / 2是小数,
不存在这样的left组合
用回溯法解决问题,组合总和问题
*/
private int result = 0;
public int findTargetSumWays(int[] nums, int target) {
int sum = 0;
for (int value : nums) {
sum += value;
}
if ((sum + target) % 2 == 1) {
return 0;
}
this.backtrack(nums, 0, (sum + target) / 2);
return this.result;
}
// 回溯函数参数 搜索数组 开始的索引 目标和
private void backtrack(int[] nums, int startIndex, int target) {
// 递归终止
if (target < 0) {
return;
}
// 记录结果,注意元素中有0元素,所以在target为0时,不能剪枝返回,
// 当递归到更深层时,如果为0,相加仍然满足目标和,如果返回会丢解
if (target == 0) {
this.result++;
}
for (int i=startIndex; i<nums.length; i++) {
backtrack(nums, i+1, target-nums[i]);
}
} - 01背包动态规划
1
2
3
4
5
6
7
8
9left - right = target
即 left - (sum - left) = target
即 left = (target + sum) / 2 如果 target + sum是奇数,(target + sum) / 2是小数,不存在这样的left组合
转成01背包问题,把每个元素装到容量为(target + sum) / 2 的背包中
1. dp[j] 装满容量为j的背包的方法
2. dp[j] += dp[j - nums[i]] 累加所有元素的结果
3. dp[0] = 1 装满容量为0的背包,方法有一种,就是什么都不放
4. 外层循环遍历元素,内层循环倒序遍历容量
5. [1, 1, 1, 1, 1] 3
1 | public int findTargetSumWays(int[] nums, int target) { |
最后一块石头的重量Ⅱ-最大价值
https://leetcode.cn/problems/last-stone-weight-ii/
1 | 转为0-1背包问题,背包容量 sum/2,使该背包价值最大,剩余部分的石头重量, |
1 | public int lastStoneWeightII(int[] stones) { |
一和零-最大价值二维背包
https://leetcode.cn/problems/ones-and-zeroes/
1 | 0-1背包问题,不过是二维背包,背包的价值就是物品个数,每个物品价值都为1,让价值最大 |
1 | public int findMaxForm(String[] strs, int m, int n) { |
完全背包(物品有无数件)
0-1背包问题,需要倒叙遍历背包容量
完全背包问题,需要正序遍历背包容量
组合数问题,先遍历物品
物品只会按给出的顺序排列,在计算dp[j] += dp[j-value]时dp[j-value]的数量只被value之前的元素填满
排列数问题,先遍历背包容量
物品会按任意顺序排列,在计算dp[j] += dp[j-value]时,dp[j-value]的数量包含value之后满足条件的元素 [1, 2, 5]
dp[8] = dp[7] + dp[6] + dp[3]
dp[7]: 以1结尾,dp[7]肯定包含2+5与5+2
在计算dp[7]的时候 dp[7] = dp[6] + dp5 + dp2
所以dp[7]中肯定有2+5与5+2
dp[6]: 以2结尾,dp[6]肯定包含1+5与5+1,证明过程同上
dp[3]: 以5结尾,dp[3]肯定包含1+2与2+1,证明过程同上
跳台阶扩展
完全背包,组合总和
1 | public int jumpFloorII(int target) { |
组合总和Ⅳ
https://leetcode.cn/problems/combination-sum-iv/
1 | target 背包容量,整数是物品 |
1 | public int combinationSum4(int[] nums, int target) { |
- 也可以回溯,不过会超时
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20private int result;
public int combinationSum4(int[] nums, int target) {
this.backtrack(nums, target);
return this.result;
}
private void backtrack(int[] nums, int target) {
if (target < 0) {
return;
}
if (target == 0) {
// 不需要记录路径,只需要计数
this.result++;
return;
}
for (int i=0; i<nums.length; i++) {
backtrack(nums, target-nums[i]);
}
}
零钱兑换Ⅱ
https://leetcode.cn/problems/coin-change-2/
1 | 不限制硬币数量,完全背包 |
1 | public int change(int amount, int[] coins) { |
零钱兑换
https://leetcode.cn/problems/coin-change/
1 | 硬币数量不限,完全背包 |
1 | public int coinChange(int[] coins, int amount) { |
完全平方和
https://leetcode.cn/problems/perfect-squares/
1 | 完全背包 满背包最少数量 |
1 | public int numSquares(int n) { |
单词拆分
https://leetcode.cn/problems/word-break/
1 | 完全背包问题 满背包问题,但是不同于组合排列数与最小数量 |
1 | public boolean wordBreak(String s, List<String> wordDict) { |
打家劫舍
打家劫舍Ⅰ
https://leetcode.cn/problems/house-robber/
1 | 1. dp[i] 打劫[0, i]的数组最大金钱树 |
1 | public int rob(int[] nums) { |
打家劫舍Ⅱ
https://leetcode.cn/problems/house-robber-ii/
1 | 仍然是打家劫舍的逻辑,但是开头与结尾分别考虑 |
1 | public int rob(int[] nums) { |
打家劫舍Ⅲ
https://leetcode.cn/problems/house-robber-iii/
- 递归(超时)
- 递归返回值以及参数
int rob(root) 递归函数的意义很重要,就像dp[i]的意义一样,知道了rob的意义,就知道何时使用递归
rob函数的意义,抢劫root为根的树的最大值
- 递归终止条件
当root为空时,什么都抢不到 0
root左右孩子都为空时,只能抢root,使收益最大,返回root.val
- 单层递归逻辑
分两种情况,抢当前节点与不抢当前节点
抢当前节点的话,就不能抢儿子节点,抢当前节点的最大收益,就是当前节点的值+孙子一为根的树的最大值+孙子二为根的最大值+孙子三为根的最大值+孙子四为根的最大值
不抢当前节点的话,最大收益为 儿子一为根的最大值+儿子二为根的最大值
1 | public int rob(TreeNode root) { |
- 递归+记忆化
打劫当前节点时,会计算孙子节点
不打劫当前节点时,会计算儿子节点,在计算儿子节点时,还会计算孙子节点,孙子节点重复计算了,可以使用map保存计算的结果
1 | private Map<TreeNode, Integer> map = new HashMap<>(); |
- 树形dp
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27树形dp
1. 函数参数以及返回值
int[] robTree(TreeNode root)
递归函数的意义非常重要,不够该函数意义比较明显
不抢根节点,在当前树上获得的最大值;
抢根节点,在当前树上获得的最大值;
返回值是一个dp数组
dp[0] 表示不抢根节点,在当前树上获得的最大值;
dp[1] 表示抢根节点,在当前树上获得的最大值;
2. 递归终止条件
节点为空时 返回[0, 0]
3. 遍历顺序:后续遍历,根据左右孩子遍历的结果,确定当前节点值
4. 单层递归逻辑
抢当前节点,不能抢左右孩子 dp[1] = current + left[0] + right[0]
不抢当前节点,可以抢左右孩子,但是也可以不抢,要取最大值
dp[0] = Math.max(left[0], left[1]) + Math.max(right[0], right[1])
5. 举例 [3,2,3,null,3,null,1]
3 [6, 7]
/ \ / \
2 3 [3, 2] [1, 3]
\ \ \ \
3 1 [0, 3] [0, 1]
1 | public int rob(TreeNode root) { |