1 背包问题分类
其中,除了01背包和完全背包外,leetcode题目中好像还没有涉及其他类型的背包,在这里我就不做总结。
2 01背包理论
有N件物品和一个最大承载重量为W 的背包。第i件物品的重量是weight[i],其价值是value[i] 。每件物品只能用一次,求解将哪几种物品装入背包里物品价值总和最大。
现在假设如下:
有一个容量为4kg的背包,现有如下物品
物品 | 重量(kg) | 价格(元) |
---|---|---|
手办 | 1 | 1500 |
笔记本 | 4 | 3000 |
手机 | 3 | 2000 |
求背包能装入物品的最大价值。
- 确定dp数组的含义
dp[i][j]表示从下标为[1,i]的物品中任意选择,能装入容量为j的背包的最大价值;
0 | 1 | 2 | 3 | 4 | |
---|---|---|---|---|---|
0 | |||||
手办 | |||||
笔记本 | |||||
手机 |
二维数组的行表示不同的物品i,列表示对应的最大容量j。
- 求dp数组的递推关系式
dp[i][j]:容量为j的书包装入下标为[1,i]的物品能装入的最大价值。
dp[i - 1][j]:容量为j的书包装入下标为[1,i - 1]的物品能装入的最大价值。
- 如果第i件物品的重量大于背包的容量,即weight[i] > j,则
dp[i][j] = dp[i - 1][j] - 如果第i件物品的重量小于等于背包的容量,即weight <= j,则
dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i])
注:当有第i件物品时,是不是要判断如何装价值最大?生活中常常出现以下情形:
1)物品i的重量比背包还重,那么就根本装不进去,符合表达式
dp[i - 1][j]
2)背包不用拿出其他物品,还能装下第i件物品,这包含在表达式
dp[i - 1][j - weight[i]] + value[i] >> dp[i - 1][j]
3)背包再继续装第i件物品会爆,拿出部分物品保证能装入第i件物品然后与不装入第i件物品的最大价值情况进行比较即可,即
max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i])
2)和3)两种情况合并即可
- dp数组的初始化
dp[0][j] = dp[i][0] = 0 - dp数组的遍历
根据递推表达式
,dp[i][j]需要使用到dp[i - 1][j],所以应该从左往右遍历然后从上往下遍历(好像也可以从上往下,然后从左往右)
具体实现代码如下:
public static void main(String[] args) {
int[] weight = {1, 4, 2};
int[] value = {1500, 3000, 2000};
int[][] dp = new int[4][5];
//这里无需初始化为0,因为创建数组时默认元素的值为0,这里只是为了体现dp数组的初始化
// for (int i = 0; i < dp.length; i++)
// dp[i][0] = 0;
// for (int j = 1; j < dp[0].length; j++)
// dp[0][j] = 0;
for (int i = 1; i < dp.length; i++) {
for (int j = 1; j < dp[0].length; j++) {
if (weight[i - 1] > j)
dp[i][j] = dp[i - 1][j];
else
dp[i][j] = Math.max(dp[i - 1][j], dp[i - 1][j - weight[i - 1]] + value[i - 1]);
}
}
System.out.println("背包能装入的最大价值为: " + dp[3][4] + "元");
}
3 背包之滚动数组
3.1 滚动数组
DP是一个自底向上的扩展过程,所有就会有一系列连续的值,然而每次求解的值只与前几个DP值有关,所以前面的大部分解往往可以舍去,以节省空间。
滚动数组是DP中的一种编程思想。简单的理解就是让数组滚动起来,每次都使用固定的几个存储空间,来达到压缩,节省存储空间的作用。起到优化空间,
因为DP题目是一个自底向上的扩展过程,我们常常需要用到的是连续的解,前面的解往往可以舍去。所以用滚动数组优化是很有效的。利用滚动数组的话在N很大的情况下可以达到压缩存储的作用。
3.2 背包的一维求解问题
原则:
确定dp数组的含义
确定递推关系式
确定dp数组的遍历顺序(外物品,内容量;零一逆,完全顺)
dp数组的初始化
使用滚动数组来求解。我们看一下递推公式
d p [ i ] [ j ] = { d p [ i − 1 ] [ j ] w [ i − 1 ] > j m a x ( d p [ i − 1 ] [ j ] , d p [ i − 1 ] [ j − w [ i − 1 ] ] + v [ i − 1 ] ) w [ i − 1 ] ≤ j dp[i][j] = \left\{ \begin{array}{rcl} dp[i -1][j] & &{w[i - 1] > j}\\ max(dp[i - 1][j], dp[i - 1][j - w[i -1]] + v[i -1]) & & {w[i - 1] \leq j } \end{array} \right. dp[i][j]={dp[i−1][j]max(dp[i−1][j],dp[i−1][j−w[i−1]]+v[i−1])w[i−1]>jw[i−1]≤j
从上面的公式中我们可以发现dp[i][j]只由dp[i-1][j]和dp[i - 1][j - w[i -1]]决定,这说明二维数组dp[i][j]第i行的数据由i-1行决定,所以可以使用滚动数组解决。思考步骤如下:
确定dp数组的含义
dp[j]:容量为j的背包,所背的物品价值可以最大为dp[j]。确定递推关系式
此时dp[j]有两个选择,⼀个是取dp[j] (也就是二维数组中的dp[i -1][j]),⼀个是取dp[j - w[i]] + v[i] (也就是二维数组中的dp[i -1][j - w[i]]),取最大值即可。确定dp数组的遍历顺序
规则:外物品,内容量;零一逆,完全顺
含义:外循环遍历物品,内循环遍历背包容量(因为二维数组中只有上一行确定完了,下一行才好继续进行);01背包逆序遍历,完全背包顺序遍历。
原因:
我们以其中的吉他为例子
假设初始条件是物品为0时,即dp[j] = 0。
- 如果顺序遍历的情况
dp[1] = max(dp[1], dp[1 - 1] + v) = max(0, 0 + 1500) = 1500;背包加入了G
dp[2] = max(dp[2], dp[2 - 1] + v) = max(0, 1500 + 1500) = 3000;背包又重复加入了G。所以与01背包的情况不符合,与完全背包的情况相符合。 - 如果逆序遍历的情况
dp[4] = max(dp[4], dp[4 - 1] + v) = max(0, 0 + 1500) = 1500;
dp[3] = max(dp[3], dp[3 - 1] + v) = max(0, 0 + 1500) = 1500;
dp[2] = max(dp[2], dp[2 - 1] + v) = max(0, 0 + 1500) = 1500;
dp[1] = max(dp[1], dp[1 - 1] + v) = max(0, 0 + 1500) = 1500;
所以与01背包的情况相符合,与完全背包的情况不符合。
dp数组的初始化
dp[0]:背包的容量为0时能装入物品的最大价值,为0。
具体代码实现如下:
public static void main(String[] args) {
int[] weight = {1, 4, 2};
int[] value = {1500, 3000, 2000};
int[] dp = new int[5];
//这里无需初始化为0,因为创建数组时默认元素的值为0,这里只是为了体现dp数组的初始化
// dp[0] = 0;
for (int i = 0; i < weight.length; i++) {
for (int j = 4; j >= weight[i]; j--) {
dp[j] = Math.max(dp[j], dp[j - weight[i]] + value[i]);
}
}
System.out.println("背包能装入的最大价值为: " + dp[4] + "元");
}
4 背包的应用
4.1 背包应用的分类
背包的应用通常分为以下三类
最值问题: dp[j] = max/min(dp[j], dp[j-nums[i]]+1)或dp[j] = max/min(dp[j], dp[j-num[i]]+v[i])
存在问题(boolean):dp[j]=dp[j]||dp[i-nums[i]]
组合问题:dp[j]+=dp[j-nums[i]]
4.2 背包应用的转化
如何将应用问题转化为背包问题这是关键
确定题目求解的目标以及类别-----对应背包的容量target(难)
确定遍历的物品(中)
确定是01背包还是完全背包(易)
5 应用实例
5.1 存在问题
5.1.1 leetcode–416. 分割等和子集
416. 分割等和子集
题目如何转化为背包问题:
- 是否存在target = 数组总和 / 2 ⇒ \Rightarrow ⇒ 存在问题
- 背包的重量W是多少? ⇒ \Rightarrow ⇒ target = 数组元素总和 / 2
- 物品是什么? ⇒ \Rightarrow ⇒ 数组中的每个元素
- 物品的重量weight[i]是多少? ⇒ \Rightarrow ⇒ 每一个元素的值
- 物品的价值value[i]是多少? ⇒ \Rightarrow ⇒ 不知道
- 题目的问题是什么? ⇒ \Rightarrow ⇒ 能否恰好装满背包
- 求解不需要用到物品的价值,所以无需知道
-
确定dp数组的含义
dp[j] : 是否存在和为j的元素组合; -
确定递推关系式
1)如果nums[i] > j,则说明物品nums[i]肯定不能放入背包中,要不然重量一定超过j,那么前i项物品是否存在重量为j的组合就只能看前i - 1项物品中是否存在重量为j的组合,则
dp[i][j] = dp[i - 1][j]
2)如果nums[i] <= j, 则说明物品nums[i]能放入背包中,那么前i项物品是否存在重量为j的组合有两种可能:
- 前i - 1项物品中是否存在重量为j的组合:dp[i][j] = dp[i - 1][j]
- 将第i项物品装入后,前i - 1项物品是否存在重量为j - nums[i] 的组合,即
dp[i][j] = dp[i - 1][j - nums[i]]
只要其中任意一种情况满足即可,所以
dp[i][j] = dp[i - 1][j] || dp[i - 1][j - nums[i]]
简化为一维dp
dp[j] = dp[j] || dp[j - nums[i]];
-
确定dp数组的遍历顺序
数组中的每个元素只能使用一次,所以属于01背包问题。
先元素遍历,再元素和遍历,01背包逆序遍历
-
dp数组的初始化
dp[0]:假设数组元素为[2,4,6,8]。判断dp[2]是否存在需要使用
dp[2] = dp[2] || dp[2 - nums[0]] = dp[2] || dp[0]。而dp[2]最开始为false,所以要保证dp[0]为true。故dp[0] = true;
具体代码实现如下:
class Solution {
public boolean canPartition(int[] nums) {
int n = nums.length;
int target = 0, maxNum = 0;
for (int num : nums) {
target += num;
maxNum = Math.max(maxNum, num);
}
//这部分是剪枝操作
if (target % 2 != 0)
return false;
target /= 2;
if (maxNum > target)
return false;
boolean[] dp = new boolean[target + 1];
//初始化dp[0]
dp[0] = true;
for (int i = 0; i < nums.length; i++) {
for (int j = target; j >= nums[i]; j--) {
dp[j] = dp[j] || dp[j - nums[i]];
}
}
return dp[target];
}
}
5.2 最值问题
5.2.1 leetcode–1049. 最后一块石头的重量 II
最后一块石头的重量问题如何转化为背包问题
- 假设两块石头碰撞后[
不放回
]石头堆,要保证最后的石头重量最小,要将石头堆划分为正号堆和负号堆:
- 将每次操作中[
重量较大
]的石子放到[正号堆
],代表在这次操作中该石子重量在[最终运算结果
]中应用+
运算符 - 将每次操作中[
重量较少/相等
]的石子放到[负号堆
],代表在这次操作中该石子重量在[最终运算结果
]中应用−
运算符
所以,最终得到的结果,可以为原来 stones数组中的数字添加+
或−
符号,所形成的[计算表达式
]所表示。
- 假设两块石头碰撞后[
放回
]哪个石头堆,只是[某个原有石子
]所在[哪个堆
]发生变化,并不会真正意义上的产生[新的石子重量
]。
理由是:假设有石头a和b,且a >= b。两石头碰撞产生一个a - b的新石头。
- 将新石头放入[
正号堆
],最终结果相当于+(a - b) = + a - b
,而右边的式子的含义就是将a放入[正号堆
],将b放入[负号堆
]; - 将新石头放入[
负号堆
],最终结果相当于-(a - b) = - a + b
,而右边的式子含义就是将a放入[负号堆
],将b放入[正号堆
]。
所以不断地[重放
]就是不断地改变原始石头所在[哪个堆
]的位置,对所有的原始石头来说,只是新的一种分类方式,即便是[放回
]操作,最终的结果仍可以使用[为原来的数组添加+或-,形成计算表达式,得到最后一块石头的重量
],即
w e i g h t = ∑ i = 1 n k i × s t o n e s [ i ] k i = { + 1 , − 1 } weight = \sum_{i=1}^{n}k_i \times stones[i] \quad k_i = \{ +1, -1\} weight=i=1∑nki×stones[i]ki={+1,−1}
最终石头的重量就是weight,要使weight最小,即表达式的正项和负项非常接近,即保证[正号堆
]和[负号堆
]石头的重量相减,得到石头重量最小。
转化为背包问题:
从 stones数组中选择,凑成总重量不超\frac{7x+5}{1+y^2}过 s u m 2 \frac{sum}{2} 2sum的石头最大重量
- 找到不大于 s u m 2 \frac{sum}{2} 2sum的最大重量 ⇒ \Rightarrow ⇒ 最值问题
- 背包的重量W是多少? ⇒ \Rightarrow ⇒ t a r g e t = s u m 2 target = \frac{sum}{2} target=2sum
- 物品是什么? ⇒ \Rightarrow ⇒ 数组中的每个石头
- 物品的重量weight[i]是多少? ⇒ \Rightarrow ⇒ 每个元素的值
- 物品的价值value[i]是多少? ⇒ \Rightarrow ⇒ 每个元素的值
解题步骤
确定dp数组的含义
dp[j]:容量为j的书包能装入的最大重量确定递推关系式
dp[j] = Math.max(dp[j], dp[j - stones[i]] + stones[i])确定dp数组的遍历顺序(外物品,内容量;零一逆,完全顺)
先遍历石头,再遍历容量,由于每个石头只能用一次,所以是01背包,背包容量是逆序遍历的。dp数组的初始化
当物品重量为0时,dp数组能装入的最大重量为0即dp[j] = 0。
具体代码实现如下
class Solution {
public int lastStoneWeightII(int[] stones) {
int sum = 0;
for (int i = 0; i < stones.length; i++) {
sum += stones[i];
}
int target = sum / 2;
int[] dp = new int[target + 1];
//初始化
for(int j = 0; j <= target; j++)
dp[j] = 0;
//遍历
for (int i = 0; i < stones.length; i++) {
for (int j = target; j >= stones[i]; j--) {
dp[j] = Math.max(dp[j], dp[j - stones[i]] + stones[i]);
}
}
return sum - 2 * dp[target];
}
}
注意:
- 当sum为奇数时,
t
a
r
g
e
t
=
s
u
m
2
target =\frac{sum}{2}
target=2sum相当于一半向下取整达不到一半,但石头的重量都为整数,也不可能恰好为sum的一半,所以[**
负号堆
]最多只能一半重量向下取整符合target的目标; - 此题也能归纳为存在问题,就是看石头组合的重量从0到
s
u
m
2
\frac{sum}{2}
2sum是否存在,得到的dp数组从后往前遍历,选择存在的最大的重量即为[**
负号堆
]的重量,具体代码实现如下:
class Solution {
public int lastStoneWeightII(int[] stones) {
int sum = 0;
for (int i = 0; i < stones.length; i++) {
sum += stones[i];
}
int target = sum / 2;
boolean[] dp = new boolean[target + 1];
//初始化
dp[0] = true;
//遍历
for (int i = 0; i < stones.length; i++) {
for (int j = target; j >= stones[i]; j--) {
dp[j] = dp[j] || dp[j - stones[i]];
}
}
int flag = 0;
for (int j = target; j >= 0; j--) {
if (dp[j]) {
flag = j;
break;
}
}
return sum - 2 * flag;
}
}
5.2.1 leetcode–322. 零钱兑换
322. 零钱兑换
转化为背包问题:
从 coins数组中选择,凑成总数为amount的硬币的最小数量
- 找到凑成总数为amount的硬币的最小数量 ⇒ \Rightarrow ⇒ 最值问题
- 背包的重量W是多少? ⇒ \Rightarrow ⇒ amount
- 物品是什么? ⇒ \Rightarrow ⇒ 数组中的每种硬币
- 物品的重量weight[i]是多少? ⇒ \Rightarrow ⇒ 每种硬币的值
- 物品的价值value[i]是多少? ⇒ \Rightarrow ⇒ 硬币的数量
解题步骤
确定dp数组的含义
dp[j] : 构成值为j的硬币的最小数量。确定递推关系式
dp[j] = min(dp[j], dp[j - coins[i]] + 1)
注意:这里要考虑dp[j]和dp[j - coins[i]]是否存在的问题。
- 如果将dp[j]不存在设置为dp[j] = -1;那么上面的最小值计算只适合dp[j]和dp[j - coins[i]]都存在的情况;如果两个都不存在,dp[j] = -1;如果由一个存在,另一个不存在,dp[j] = max(dp[j], dp[j - coins[i]] + 1)。这种比较麻烦,分类讨论太多。
- 由于面值都为整数,即coins[i] >= 1。如果将dp[j]不存在设置为dp[j] = amount + 1;其中一个存在,另一个不存在时,存在的值一定小于amount + 1,所以符合上式;当两个都不存在时,dp[j] = min(amount +1, amount + 1 + 1) = amount + 1相当于不存在。也符合上式。
确定dp数组的遍历顺序(外物品,内容量;零一逆,完全顺)
先遍历金币的种类,再遍历容量;完全背包问题,顺序遍历内循环。dp数组的初始化
当金币面额值为0时,dp[0] = 0,dp[j] = amount + 1。
具体代码实现如下
class Solution {
public int coinChange(int[] coins, int amount) {
int[] dp = new int[amount + 1];
//初始化
for (int j = 1; j <= amount; j++)
dp[j] = amount + 1;
for (int i = 0; i < coins.length; 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];
}
}
5.3 组合问题
5.3.1 leetcode–518. 零钱兑换 II
518. 零钱兑换 II
转化为背包问题:
从 coins数组中选择,凑成总金额为amount的方式种类数
- 找到组合为amount的种类数量 ⇒ \Rightarrow ⇒ 组合问题
- 背包的重量W是多少? ⇒ \Rightarrow ⇒ amount
- 物品是什么? ⇒ \Rightarrow ⇒ 数组中每种面额的金币
- 物品的重量weight[i]是多少? ⇒ \Rightarrow ⇒ 每种金币的面额
解题步骤
确定dp数组的含义
dp[j]:总金额为j的金币组合方式共有dp[j]种。确定递推关系式
dp[j] = dp[j] + dp[j - coins[i]]
确定dp数组的遍历顺序(外物品,内容量;零一逆,完全顺)
先遍历金币,再遍历总金额,由于每种金币用无数次,所以是完全背包,背包容量是顺序遍历的。dp数组的初始化
当金币面额为0时,dp[0] = 1,dp[j] = 0。
具体代码实现如下
class Solution {
public int change(int amount, int[] coins) {
int[] dp = new int[amount + 1];
//初始化
dp[0] = 1;
for (int i = 0; i < coins.length; i++) {
for (int j = coins[i]; j <= amount; j++) {
dp[j] += dp[j - coins[i]];
}
}
return dp[amount];
}
}