01背包
模板:01背包问题
问题描述
背包最多能容纳的体积是
V
V
V。
现在有
n
n
n个物品,其中第
i
i
i个物品的体积为
v
[
i
]
v[i]
v[i]、价值为
w
[
i
]
w[i]
w[i]。
求这个背包至多能装多大价值的物品?
特点:每一个物品只有一个,每一个物品可以选择装入背包(1)或者不装入背包(0)
解法一:二维dp
dp[i][j]:将下标小于等于 i 的物品放入容量为 j 的背包中所能取得的最大价值
在计算dp[i][j]时:对于下标为 i 的物品进行讨论:
- 如果放不下物品 i(当前背包容量小于物品 i 的体积,
j < v[i]
):dp[i][j] = dp[i-1][j]
,即将下标小于等于 i-1 的物品放入容量为 j 的背包中所能取得的最大价值 - 如果能放下物品 i(
j >= v[i]
):- 有两种选择:
- 不放入物品 i :
dp[i][j] = dp[i-1][j]
- 放入物品 i :
dp[i][j] = w[i] + dp[i-1][j-v[i]]
- 不放入物品 i :
- 从二者中选择能取得的最大价值更大的一个:
dp[i][j] = max{dp[i-1][j], w[i] + dp[i-1][j-v[i]]}
- 有两种选择:
递推公式:
d
p
[
i
]
[
j
]
=
{
j
<
v
[
i
]
:
d
p
[
i
−
1
]
[
j
]
j
≥
v
[
i
]
:
max
(
d
p
[
i
−
1
]
[
j
]
,
w
[
i
]
+
d
p
[
i
−
1
]
[
j
−
v
[
i
]
]
)
dp[i][j] = \left\{\begin{matrix} j < v[i]: & dp[i-1][j]\\ j \ge v[i]: & \max (dp[i-1][j], w[i] + dp[i-1][j-v[i]]) \end{matrix}\right.
dp[i][j]={j<v[i]:j≥v[i]:dp[i−1][j]max(dp[i−1][j],w[i]+dp[i−1][j−v[i]])
计算顺序:
在计算dp[i][j]时可能会用到dp[i-1][0…j]位置的值,在计算dp[i][j]之前要保证这些位置的值已经计算过了。
下面几种计算顺序都可以实现这一目标:
- 外循环遍历物品,内循环遍历背包容量 / 一行一行计算
- 每一行从左向右计算
- 每一行从右向左计算
- 外循环遍历背包容量,内循环遍历物品 / 一列一列计算
- 每一列从上向下计算
在计算每一行时既可以按照从左向右的顺序计算,也可以按照从右向左的顺序计算,因为计算dp[i][j]时只会用到前一行的数据,不会用到本行该位置之前的数据(dp[i][0…j-1])。
但是在计算每一列时必须按照从上向下的顺序计算,因为计算dp[i][j]时会用到本行该位置之前的数据(dp[i-1][j])。
初始化:
不同的计算顺序需要初始化的内容有所不同:
- 外循环遍历物品,内循环遍历背包容量 / 一行一行计算:只需要初始化第一行
- 外循环遍历背包容量,内循环遍历物品 / 一列一列计算:既需要初始化第一行,又需要初始化第一列
具体来说:
- 对第一行的初始化:
i=0, j=0->V
- dp[0][j]:在容量为j的背包中放物品0
j < v[0]
:放不下,dp[0][j] = 0
j >= v[0]
:能放下,dp[0][j] = w[0]
- dp[0][j]:在容量为j的背包中放物品0
- 对第一列的初始化:
i=0->n-1, j=0
dp[i][0]
:在容量为0的背包中放物品,dp[i][0] = 0
代码实现(计算顺序一):
// 不用恰好装满的01背包 - 计算顺序1:外循环遍历物品,内循环遍历背包容量 / 一行一行计算
public int package01_1(){
// dp[i][j]: 将下标小于等于 i 的物品放入容量为 j 的背包中所能取得的最大价值
int[][] dp = new int[n][V+1];
// 初始化第一行: dp[0][0...j]
for(int j = 0; j <= V; j++){
// dp[0][j]:在容量为j的背包中放物品0
if(j < v[0]){
dp[0][j] = 0; // 放不下物品0
}else{
dp[0][j] = w[0]; // 能放下物品0
}
}
// 递推计算: 外循环遍历物品,内循环遍历背包容量
for(int i = 1; i < n; i++){
for(int j = 0; j <= V; j++){ // for(int j = V; j >= 0; j--)也可以
if(j < v[i]){
// 放不下物品i
dp[i][j] = dp[i-1][j];
}else{
// 能放下物品i
dp[i][j] = Math.max(
dp[i-1][j], // 不放入物品i
w[i] + dp[i-1][j-v[i]] // 放入物品i
);
}
}
}
// 返回结果
return dp[n-1][V];
}
代码实现(计算顺序二):
// 不用恰好装满的01背包 - 计算顺序2:外循环遍历背包容量,内循环遍历物品 / 一列一列计算
public int package01_2(){
// dp[i][j]: 将下标小于等于 i 的物品放入容量为 j 的背包中所能取得的最大价值
int[][] dp = new int[n][V+1];
// 初始化第一行: dp[0][0...j]
for(int j = 0; j <= V; j++){
// dp[0][j]:在容量为j的背包中放物品0
if(j < v[0]){
dp[0][j] = 0; // 放不下物品0
}else{
dp[0][j] = w[0]; // 能放下物品0
}
}
// 初始化第一列: dp[0...n-1][0]
for(int i = 0; i < n; i++){
dp[i][0] = 0; // 在容量为0的背包中放物品
}
// 递推计算: 外循环遍历背包容量,内循环遍历物品
for(int j = 1; j <= V; j++){
for(int i = 1; i < n; i++){
if(j < v[i]){
// 放不下物品i
dp[i][j] = dp[i-1][j];
}else{
// 能放下物品i
dp[i][j] = Math.max(
dp[i-1][j], // 不放入物品i
w[i] + dp[i-1][j-v[i]] // 放入物品i
);
}
}
}
// 返回结果
return dp[n-1][V];
}
解法二:一维dp / 滚动数组
观察二维dp的递推公式发现:在计算dp[i][j]时只会用到前一行的数据,并且只会用到前一行的前j个数据。
d
p
[
i
]
[
j
]
=
{
j
<
v
[
i
]
:
d
p
[
i
−
1
]
[
j
]
j
≥
v
[
i
]
:
max
(
d
p
[
i
−
1
]
[
j
]
,
w
[
i
]
+
d
p
[
i
−
1
]
[
j
−
v
[
i
]
]
)
dp[i][j] = \left\{\begin{matrix} j < v[i]: & dp[i-1][j]\\ j \ge v[i]: & \max (dp[i-1][j], w[i] + dp[i-1][j-v[i]]) \end{matrix}\right.
dp[i][j]={j<v[i]:j≥v[i]:dp[i−1][j]max(dp[i−1][j],w[i]+dp[i−1][j−v[i]])
如果我们按照 “一行一行计算,每一行从右向左计算” 的顺序进行二维dp的计算:
...
在计算dp[i][j+1]时会用到dp[i-1][0], dp[i-1][1]...dp[i-1][j-1], dp[i-1][j], dp[i-1][j+1]
在计算dp[i][j] 时会用到dp[i-1][0], dp[i-1][1]...dp[i-1][j-1], dp[i-1][j]
在计算dp[i][j-1]时会用到dp[i-1][0], dp[i-1][1]...dp[i-1][j-1]
...
观察发现,在计算完dp[i][j]之后,就再也用不到dp[i-1][j]。
综上所述,我们可以只用一行(即一个长度为V+1的数组)作为dp数组,滚动存储每一行的计算结果,不再需要的数据被新计算的结果覆盖使用。
- 一开始先将该数组的内容初始化为原二维dp的第一行的内容
- 在计算原二维dp的第 i 行(
2
≤
i
<
n
2 \leq i < n
2≤i<n)时:
- 计算开始前:滚动数组中正好是原二维dp第 i-1 行的内容
- 从右向左计算(
for(int j=V; j>=0; j--)
) - 递推公式修改为: d p [ j ] = { j < v [ i ] : d p [ j ] j ≥ v [ i ] : max ( d p [ j ] , w [ i ] + d p [ j − v [ i ] ] ) dp[j] = \left\{\begin{matrix} j < v[i]: & dp[j]\\ j \ge v[i]: & \max (dp[j], w[i] + dp[j-v[i]]) \end{matrix}\right. dp[j]={j<v[i]:j≥v[i]:dp[j]max(dp[j],w[i]+dp[j−v[i]])
- 由递推公式计算出的 dp[j] 实际上就是原二维dp中的 dp[i][j],而此时滚动数组中下标为 j 的位置存放的是 dp[i-1][j],这个数值在之后的计算中都用不到了,因此将其覆盖用于存储 dp[i][j]。
public int package01(){
// dp[j]: 向容量为 j 的背包中装物品所能取得的最大价值
int[] dp = new int[V+1];
// 初始化第一行: 在容量为j的背包中放物品0
for(int j = 0; j <= V; j++){
if(j < v[0]){
dp[j] = 0; // 放不下物品0
}else{
dp[j] = w[0]; // 能放下物品0
}
}
// 递推计算: 外循环遍历物品,内循环遍历背包容量
for(int i = 1; i < n; i++){
for(int j = V; j >= 0; j--){
// if(j < v[i]){
// // 放不下物品i
// dp[j] = dp[j];
// }else{
// // 能放下物品i
// dp[j] = Math.max(
// dp[j], // 不放入物品i
// w[i] + dp[j-v[i]] // 放入物品i
// );
// }
// 因为j < v[i]时其实不用有任何操作,所以简化为:
if(j >= v[i]){
dp[j] = Math.max(
dp[j], // 不放入物品i
w[i] + dp[j-v[i]] // 放入物品i
);
}
}
}
// 返回结果
return dp[V];
}
写好代码之后,再回过头来重新思考一维dp数组中每个状态的含义:
- dp[j]: 向容量为 j 的背包中装物品所能取得的最大价值
- 在计算dp[j]时,对每一个物品进行考察
- 在对第 i 个物品的考察中:
- 如果放不下第i个物品:dp[j] = dp[j](什么也没做),放在二维dp中理解就是dp[i][j] = dp[i-1][j]
- 如果能放下第i个物品,可以选择放入第 i 个物品,也可以选择不放入第 i 个物品,取最大值
- 在遍历
i = 0 -> n-1
结束后,将本轮的全局最大值作为 dp[j] 的值
- 在对第 i 个物品的考察中:
恰好装满的01背包
- dp[j]: 恰好装满容量为 j 的背包,所能取得的最大价值。如果不能恰好装满,则等于 -1。
- 在计算
dp[j]
时,对每一个物品进行考察:- 在对第 i 个物品的考察中:
- 如果放不下第i个物品:
dp[j] = dp[j]
(什么也没做),放在二维dp中理解就是dp[i][j] = dp[i-1][j]
- 如果能放下第i个物品,可以选择放入第 i 个物品,也可以选择不放入第 i 个物品,取最大值
- 不放入第 i 个物品:
dp[j] = dp[j]
,放在二维dp中理解就是dp[i][j] = dp[i-1][j]
- 放入第 i 个物品:
- 如果从当前背包的容量 j 中减去第 i 个物品的容量后,剩下的容量(
j-v[i]
)不能恰好装满(dp[j-v[i]] == -1
) ,那么放入第 i 个物品后也无法恰好装满:dp[j] = -1
- 如果剩下的容量能够恰好装满,则:
dp[j] = w[i] + dp[j-v[i]]
- 如果从当前背包的容量 j 中减去第 i 个物品的容量后,剩下的容量(
- 不放入第 i 个物品:
- 如果放不下第i个物品:
- 在遍历
i = 0 -> n-1
结束后,将本轮的全局最大值作为dp[j]
的值
- 在对第 i 个物品的考察中:
- 递推公式: d p [ j ] = { j < v [ i ] : d p [ j ] j ≥ v [ i ] : max ( d p [ j ] , { j − v [ i ] = − 1 : d p [ j ] j − v [ i ] ≠ − 1 : w [ i ] + d p [ j − v [ i ] ] } ) dp[j] = \left\{\begin{matrix} j < v[i]: & dp[j]\\ j \ge v[i]: & \max (dp[j], \begin{Bmatrix} j-v[i] = -1: & dp[j]\\ j-v[i] \ne -1: & w[i] + dp[j-v[i]] \end{Bmatrix} ) \end{matrix}\right. dp[j]=⎩ ⎨ ⎧j<v[i]:j≥v[i]:dp[j]max(dp[j],{j−v[i]=−1:j−v[i]=−1:dp[j]w[i]+dp[j−v[i]]})
public int package01_fillPerfect(){
// dp[j]: 恰好装满容量为 j 的背包,所能取得的最大价值
// 如果不能恰好装满,则等于 -1
int[] dp = new int[V+1];
// 初始化第一行: 在容量为j的背包中放物品0
dp[0] = 0; // 在容量为0的背包中,什么也不放时认为是恰好装满
for(int j = 1; j <= V; j++){
if(j != v[0]){
dp[j] = -1; // 放不下物品0 and 能放下物品0但无法恰好装满
}else{
dp[j] = w[0]; // 恰好能放下物品0
}
}
// 递推计算: 外循环遍历物品,内循环遍历背包容量
for(int i = 1; i < n; i++){
for(int j = V; j >= 0; j--){
if(j >= v[i]){
dp[j] = Math.max(
dp[j], // 不放入物品i
dp[j-v[i]] == -1 ? -1 : w[i] + dp[j-v[i]] // 放入物品i
);
}
}
}
// 返回结果
return dp[V] == -1 ? 0 : dp[V];
}
416. 分割等和子集
代码实现:二维dp
class Solution {
public boolean canPartition(int[] nums) {
int n = nums.length;
// 数组长度小于2时,不可能将数组分割成元素和相等的两个子集
if(n < 2){
return false;
}
// 计算整个数组的元素和sum、数组中最大元素maxNum
int sum = 0;
int maxNum = 0;
for(int i = 0; i < n; i++){
sum += nums[i];
if(nums[i] > maxNum){
maxNum = nums[i];
}
}
// sum为奇数时,不可能将数组分割成元素和相等的两个子集
if(sum % 2 == 1){
return false;
}
int target = sum / 2; // 目标背包容量
// System.out.println("target: " + target);
// 当maxNum > target(即maxNum > sum/2)时,其余元素的元素和一定小于sum/2,不可能将数组分割成元素和相等的两个子集
if(maxNum > target){
return false;
}
// dp[i][j]: 从下标小于等于0的元素中选取元素,装入最大容量为j的背包中,能否恰好装满背包
boolean[][] dp = new boolean[n][target+1];
// 初始化:将下标为0的元素放入容量为j=0->target的背包中
for(int j = 0; j <= target; j++){
if(nums[0] == j){
// 下标为0的元素恰好能装入容量为j的背包
dp[0][j] = true;
}else{
dp[0][j] = false;
}
// System.out.println(0 + ", " + j + ": " + dp[0][j]);
}
// 递推计算
for(int i = 1; i < n; i++){
for(int j = 0; j <= target; j++){
if(j < nums[i]){
// 装不下第i个元素
dp[i][j] = dp[i-1][j];
}else{
// 能装下第i个元素
dp[i][j] = dp[i-1][j] || dp[i-1][j-nums[i]];
}
// System.out.println(i + ", " + j + ": " + dp[i][j]);
}
}
// 返回结果
return dp[n-1][target];
}
}
代码实现:一维dp
class Solution {
public boolean canPartition(int[] nums) {
int n = nums.length;
// 数组长度小于2时,不可能将数组分割成元素和相等的两个子集
if(n < 2){
return false;
}
// 计算整个数组的元素和sum、数组中最大元素maxNum
int sum = 0;
int maxNum = 0;
for(int i = 0; i < n; i++){
sum += nums[i];
if(nums[i] > maxNum){
maxNum = nums[i];
}
}
// sum为奇数时,不可能将数组分割成元素和相等的两个子集
if(sum % 2 == 1){
return false;
}
int target = sum / 2; // 目标背包容量
// 当maxNum > target(即maxNum > sum/2)时,其余元素的元素和一定小于sum/2,不可能将数组分割成元素和相等的两个子集
if(maxNum > target){
return false;
}
// dp[j]: 最大容量为j的背包能否恰好装满
boolean[] dp = new boolean[target+1];
// 初始化:将下标为0的元素放入容量为j=0->target的背包中
for(int j = 0; j <= target; j++){
if(nums[0] == j){
// 下标为0的元素恰好能装入容量为j的背包
dp[j] = true;
}else{
dp[j] = false;
}
}
// 递推计算
for(int i = 1; i < n; i++){
for(int j = target; j >= 0; j--){
// if(j < nums[i]){
// // 装不下第i个元素
// dp[j] = dp[j]; // 什么也没做
// }else{
// // 能装下第i个元素
// dp[j] = dp[j] || dp[j-nums[i]];
// }
if(j >= nums[i]){
// 能装下第i个元素
dp[j] = dp[j] || dp[j-nums[i]];
}
}
}
// 返回结果
return dp[target];
}
}
1049. 最后一块石头的重量 Ⅱ
按照题目的定义:如果将stones分成两个子集,则最后一块石头的重量等于两个子集元素和的差值
因此,求 “最后一块石头的最小重量” 等价于 “使得两个子集的元素和的差值尽可能的小”
不妨设元素和较小的子集为子集A,元素和较大的子集为子集B,即 sum(子集A) <= totalSum/2 <= sum(子集B)
此时,问题进一步转换为 “使得子集A中元素和尽可能接近totalSum / 2
”,即在保证子集A中元素和不超过totalSum/2
的前提下,求子集A中元素和的最大值
进一步转换为01背包问题:
- 有n个物品,每个物品只有一个,第i个物品的重量为
stones[i]
- 有一个最大容量为
totalSum/2 = target
的背包 - 从这n个物品中选择若干个放入背包中,在保证不超过背包容量的前提下,求能放入的最大重量
通过背包问题求解出子集A中能放入的最大容量sum(子集A) = dp[n-1][target]
后,计算最后一块石头的最小重量:sum(子集B) - sum(子集A) = (totalSum - sum(子集A)) - sum(子集A) = totalSum - 2*sum(子集A)
代码实现:二维dp
class Solution {
public int lastStoneWeightII(int[] stones) {
int n = stones.length;
// 只有一块石头,不能进行粉碎操作,剩下的最小重量就是该石头的重量
if(n < 2){
return stones[0];
}
// 计算所有石头的总重量totalSum
int totalSum = 0;
for(int i = 0; i < n; i++){
totalSum += stones[i];
}
int target = totalSum / 2;
/*该问题可以转换为01背包问题:
- 有n个物品,每个物品只有一个,第i个物品的重量为stones[i]
- 有一个最大容量为totalSum/2的背包
- 从这n个物品中选择若干个放入背包中,在保证不超过背包容量的前提下,求能放入的最大重量
*/
// dp[i][j]:从下标小于等于i的物品中进行选择,放入最大容量为j的背包,能放入的最大重量
int[][] dp = new int[n][target+1];
// 初始化:向容量为j的背包中装物品0
for(int j = 0; j <= target; j++){
if(j < stones[0]){
dp[0][j] = 0; // 装不下物品0
}else{
dp[0][j] = stones[0]; // 能装下物品0
}
}
// 递推计算
for(int i = 1; i < n; i++){
for(int j = 0; j <= target; j++){
if(j < stones[i]){
// 装不下物品i
dp[i][j] = dp[i-1][j];
}else{
// 能装下物品i
dp[i][j] = Math.max(
dp[i-1][j], // 选择1:不装入物品i
stones[i] + dp[i-1][j-stones[i]] // 选择2:装入物品i
);
}
}
}
// 返回结果
// sum(子集A) = dp[n-1][target]
// 最后一块石头的最小重量 = totalSum - 2*sum(子集A)
return totalSum - 2 * dp[n-1][target];
}
}
代码实现:一维dp
略
494. 目标和
该问题可以转换为01背包问题(如代码注释所述),需要注意的是在初始化的时候对背包容量为0时的情况的单独处理。
class Solution {
public int findTargetSumWays(int[] nums, int target) {
/* 将nums中的元素分成两个子集:加号集合、减号集合
* 加号集合 - 减号集合 = target
* 加号集合 + 减号集合 = totalSum
* => 加号集合 = (target + sum)/2 = plusSum
*/
int n = nums.length;
int totalSum = 0; // 所有元素的元素和
for(int i = 0; i < n; i++){
totalSum += nums[i];
}
if((target + totalSum) % 2 != 0){
return 0;
}
int plusSum = (target + totalSum) / 2; // 加号元素的元素和
if(plusSum < 0){
return 0;
}
/* 转换为01背包问题:
* 共有n个物品,每个物品的重量是nums[i],每个物品只有一个
* 放入容量为plusSum的背包中
* 将背包恰好装满的装法有多少种
*/
// dp[i][j]: 从下标小于等于n的物品中进行选择,装入最大容量为j的背包中,将背包恰好装满的装法有多少种
int[][] dp = new int[n][plusSum+1];
// 初始化第一行:将下标为0的物品装入背包
// 背包容量为0时:如果物品0重量不为0,则只有一种情况(什么也不装);如果物品0重量为0,则有两种情况(装入物品0、什么也不装)
dp[0][0] = (nums[0] == 0) ? 2 : 1;
for(int j = 1; j <= plusSum; j++){
if(nums[0] == j){
// 物品0恰好装满背包
dp[0][j] = 1;
}else{
dp[0][j] = 0;
}
}
// 递推计算
for(int i = 1; i < n; i++){
for(int j = 0; j <= plusSum; j++){
if(j < nums[i]){
// 装不下物品i
dp[i][j] = dp[i-1][j];
}else{
// 能装下物品i
dp[i][j] = dp[i-1][j] // 选择不装入物品i
+ dp[i-1][j-nums[i]]; // 选择装入物品i
}
}
}
// 返回结果
return dp[n-1][plusSum];
}
}
474. 一和零
这道题可以看作是同时对体积和重量都有限制的01背包问题。
代码实现:三维dp
class Solution {
public int findMaxForm(String[] strs, int m, int n) {
int N = strs.length;
// 统计每个字符串中0的个数(1的个数 = 该字符串的长度 - 0的个数)
Map<String, Integer> map = new HashMap<>();
for(int i = 0; i < N; i++){
int zeroCnt = 0;
for(int j = 0; j < strs[i].length(); j++){
if(strs[i].charAt(j) == '0'){
zeroCnt++;
}
}
map.put(strs[i], zeroCnt);
}
// dp[i][j][k]: 只从下标小于等于i的字符串中进行选择,装入最多有j个0和k个1的背包中,最多能装入的字符串个数
int[][][] dp = new int[N][m+1][n+1];
// 初始化第一行:在背包中装入物品0
int zeroCnt = map.get(strs[0]);
int oneCnt = strs[0].length() - zeroCnt;
for(int j = 0; j <= m; j++){
for(int k = 0; k <=n; k++){
if(j < zeroCnt || k < oneCnt){
// 装不下物品0
dp[0][j][k] = 0;
}else{
dp[0][j][k] = 1;
}
}
}
// 递推计算
for(int i = 1; i < N; i++){
for(int j = 0; j <= m; j++){
for(int k = 0; k <= n; k++){
int curZeroCnt = map.get(strs[i]);
int curOneCnt = strs[i].length() - curZeroCnt;
if(j < curZeroCnt || k < curOneCnt){
// 装不下物品i
dp[i][j][k] = dp[i-1][j][k];
}else{
// 能装下物品i
dp[i][j][k] = Math.max(
dp[i-1][j][k], // 可以选择不装入物品i
1 + dp[i-1][j-curZeroCnt][k-curOneCnt] // 也可以选择装入物品i
);
}
}
}
}
// 返回结果
return dp[N-1][m][n];
}
}
优化:略