代码随想录 动态规划-0-1背包问题

目录

标准0-1背包问题

二维dp数组01背包

一维dp数组01背包(滚动数组)

416.分割等和子集

1049.最后一块石头的重量||

494.目标和

474.一和零 


背包问题的分类

标准0-1背包问题

46. 携带研究材料(第六期模拟笔试)

时间限制:5.000S  空间限制:128MB

题目描述

小明是一位科学家,他需要参加一场重要的国际科学大会,以展示自己的最新研究成果。他需要带一些研究材料,但是他的行李箱空间有限。这些研究材料包括实验设备、文献资料和实验样本等等,它们各自占据不同的空间,并且具有不同的价值。 

小明的行李空间为 N,问小明应该如何抉择,才能携带最大价值的研究材料,每种研究材料只能选择一次,并且只有选与不选两种选择,不能进行切割。

输入描述

第一行包含两个正整数,第一个整数 M 代表研究材料的种类,第二个正整数 N,代表小明的行李空间。

第二行包含 M 个正整数,代表每种研究材料的所占空间。 

第三行包含 M 个正整数,代表每种研究材料的价值。

输出描述

输出一个整数,代表小明能够携带的研究材料的最大价值。

输入示例

6 1
2 2 3 1 5 2
2 3 1 5 4 3

输出示例

5

提示信息

小明能够携带 6 种研究材料,但是行李空间只有 1,而占用空间为 1 的研究材料价值为 5,所以最终答案输出 5。 

数据范围:
1 <= N <= 5000
1 <= M <= 5000
研究材料占用空间和价值都小于等于 1000

二维dp数组01背包

背包问题给出的物品和背包问题容量这两个参数,可以作为递推的规则

依然动规五部曲分析一波。

确定dp数组以及下标的含义

对于背包问题,有一种写法, 是使用二维数组,即dp[i][j] 表示从下标为[0-i]的物品里任意取,放进容量为j的背包,价值总和最大是多少

只看这个二维数组的定义,大家一定会有点懵,看下面这个图:

动态规划-背包问题1

要时刻记着这个dp数组的含义,下面的一些步骤都围绕这dp数组的含义进行的,如果哪里看懵了,就来回顾一下i代表什么,j又代表什么。

确定递推公式

再回顾一下dp[i][j]的含义:从下标为[0-i]的物品里任意取,放进容量为j的背包,价值总和最大是多少。

那么可以有两个方向推出来dp[i][j],

不放物品i:由dp[i - 1][j]推出,即背包容量为j,里面不放物品i的最大价值,此时dp[i][j]就是dp[i - 1][j]。(其实就是当物品i的重量大于背包j的重量时,物品i无法放进背包中,所以背包内的价值依然和前面相同。)

放物品i:由dp[i - 1][j - weight[i]]推出,dp[i - 1][j - weight[i]] 为背包容量为j - weight[i]的时候不放物品i的最大价值,那么dp[i - 1][j - weight[i]] + value[i] (物品i的价值),就是背包放物品i得到的最大价值

所以递归公式: dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);

dp数组如何初始化

关于初始化,一定要和dp数组的定义吻合,否则到递推公式的时候就会越来越乱

首先从dp[i][j]的定义出发,如果背包容量j为0的话,即dp[i][0],无论是选取哪些物品,背包价值总和一定为0。如图:

动态规划-背包问题2

在看其他情况。

状态转移方程 dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]); 可以看出i 是由 i-1 推导出来,那么i为0的时候就一定要初始化。

dp[0][j],即:i为0,存放编号0的物品的时候,各个容量的背包所能存放的最大价值。

那么很明显当 j < weight[0]的时候,dp[0][j] 应该是 0,因为背包容量比编号0的物品重量还小。

当j >= weight[0]时,dp[0][j] 应该是value[0],因为背包容量放足够放编号0物品。

代码初始化如下:

for (int j = 0 ; j < weight[0]; j++) {  // 当然这一步,如果把dp数组预先初始化为0了,这一步就可以省略,但很多同学应该没有想清楚这一点。
    dp[0][j] = 0;
}
// 正序遍历
for (int j = weight[0]; j <= bagweight; j++) {
    dp[0][j] = value[0];
}

此时dp数组初始化情况如图所示:

动态规划-背包问题7

dp[0][j] 和 dp[i][0] 都已经初始化了,那么其他下标应该初始化多少呢?

其实从递归公式: dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]); 可以看出dp[i][j] 是由左上方数值推导出来了,那么 其他下标初始为什么数值都可以,因为都会被覆盖。

初始-1,初始-2,初始100,都可以!

但只不过一开始就统一把dp数组统一初始为0,更方便一些。

如图:

动态规划-背包问题10

最后初始化代码如下:

// 初始化 dp
vector<vector<int>> dp(weight.size(), vector<int>(bagweight + 1, 0));
for (int j = weight[0]; j <= bagweight; j++) {
    dp[0][j] = value[0];
}

费了这么大的功夫,才把如何初始化讲清楚,相信不少同学平时初始化dp数组是凭感觉来的,但有时候感觉是不靠谱的。

确定遍历顺序

在如下图中,可以看出,有两个遍历的维度:物品与背包重量

动态规划-背包问题3

那么问题来了,先遍历 物品还是先遍历背包重量呢?

其实都可以!! 但是先遍历物品更好理解

那么我先给出先遍历物品,然后遍历背包重量的代码。

// weight数组的大小 就是物品个数
for(int i = 1; i < weight.size(); i++) { // 遍历物品
    for(int j = 0; j <= bagweight; j++) { // 遍历背包容量
        if (j < weight[i]) dp[i][j] = dp[i - 1][j];
        else dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);

    }
}

先遍历背包,再遍历物品,也是可以的!(注意我这里使用的二维dp数组)

例如这样:

// weight数组的大小 就是物品个数
for(int j = 0; j <= bagweight; j++) { // 遍历背包容量
    for(int i = 1; i < weight.size(); i++) { // 遍历物品
        if (j < weight[i]) dp[i][j] = dp[i - 1][j];
        else dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
    }
}

为什么也是可以的呢?

要理解递归的本质和递推的方向

dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]); 递归公式中可以看出dp[i][j]是靠dp[i-1][j]和dp[i - 1][j - weight[i]]推导出来的。

dp[i-1][j]和dp[i - 1][j - weight[i]] 都在dp[i][j]的左上角方向(包括正上方向),那么先遍历物品,再遍历背包的过程如图所示:

动态规划-背包问题5

再来看看先遍历背包,再遍历物品呢,如图:

动态规划-背包问题6

大家可以看出,虽然两个for循环遍历的次序不同,但是dp[i][j]所需要的数据就是左上角,根本不影响dp[i][j]公式的推导!

但先遍历物品再遍历背包这个顺序更好理解。

其实背包问题里,两个for循环的先后循序是非常有讲究的,理解遍历顺序其实比理解推导公式难多了

举例推导dp数组

来看一下对应的dp数组的数值,如图:

动态规划-背包问题4

最终结果就是dp[2][4]。

建议大家此时自己在纸上推导一遍,看看dp数组里每一个数值是不是这样的。

做动态规划的题目,最好的过程就是自己在纸上举一个例子把对应的dp数组的数值推导一下,然后在动手写代码!

很多同学做dp题目,遇到各种问题,然后凭感觉东改改西改改,怎么改都不对,或者稀里糊涂就改过了。

主要就是自己没有动手推导一下dp数组的演变过程,如果推导明白了,代码写出来就算有问题,只要把dp数组打印出来,对比一下和自己推导的有什么差异,很快就可以发现问题了。

import java.util.*;  
  
public class Main {  
  
    public static void main(String[] args) {  
        // 初始化一个Scanner对象用于读取用户输入  
        Scanner sc = new Scanner(System.in);  
  
        // 读取背包的容量N和物品种类M  
        int M = sc.nextInt();  // 物品种类  
        int N = sc.nextInt();  // 背包容量  
  
        // 初始化两个数组,分别用于存储物品的价值和重量  
        int[] values = new int[M];  
        int[] weights = new int[M];  
  
        // 读取每种物品的重量  
        for(int i = 0; i < M; i++) {  
            weights[i] = sc.nextInt();  
        }  
  
        // 读取每种物品的价值  
        for(int i = 0; i < M; i++) {  
            values[i] = sc.nextInt();  
        }  
  
        // 初始化动态规划数组dp,其中dp[i][j]表示前i个物品放入容量为j的背包中所能获得的最大价值 
        //容量可以为0,故这里定义为N + 1
        int[][] dp = new int[M][N + 1];  
  
        // 对于第一个物品,如果其重量小于等于当前背包容量,则直接将其价值赋给dp数组对应位置  
        for(int i = weights[0]; i <= N; i++) {  
            dp[0][i] = values[0];  
        }  
  
        // 动态规划过程  
        for(int i = 1; i < dp.length; i++){  // 遍历所有物品  
            for(int j = 1; j < dp[0].length; j++){  // 遍历所有背包容量  
                // 如果当前物品重量大于背包容量,则当前背包无法装入该物品,价值与除去这个物品的情况一样 
                if(weights[i] > j) {  
                    dp[i][j] = dp[i-1][j];  
                } else {  
                    // 如果当前物品重量小于等于背包容量,则比较放入和不放入该物品两种情况下的价值,取最大值
                    //放入的情况是除去当前物品,容量减少该物品能放入的最大价值加该物品的价值
                    dp[i][j] = Math.max(dp[i-1][j], dp[i-1][j-weights[i]] + values[i]);  
                }  
            }  
        }  
  
        // 输出最大价值,即dp数组的最后一个元素  
        System.out.println(dp[M - 1][N]);  
    }  
}

一维dp数组01背包(滚动数组)

对于背包问题其实状态都是可以压缩的。

在使用二维数组的时候,递推公式:dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);

其实可以发现如果把dp[i - 1]那一层拷贝到dp[i]上,表达式完全可以是:dp[i][j] = max(dp[i][j], dp[i][j - weight[i]] + value[i]);

与其把dp[i - 1]这一层拷贝到dp[i]上,不如只用一个一维数组了,只用dp[j](一维数组,也可以理解是一个滚动数组)。

这就是滚动数组的由来,需要满足的条件是上一层可以重复利用,直接拷贝到当前层。

读到这里估计大家都忘了 dp[i][j]里的i和j表达的是什么了,i是物品,j是背包容量。

dp[i][j] 表示从下标为[0-i]的物品里任意取,放进容量为j的背包,价值总和最大是多少

一定要时刻记住这里i和j的含义,要不然很容易看懵了。

动规五部曲分析如下:

确定dp数组的定义

在一维dp数组中,dp[j]表示:容量为j的背包,所背的物品价值可以最大为dp[j]。

一维dp数组的递推公式

dp[j]为 容量为j的背包所背的最大价值,那么如何推导dp[j]呢?

dp[j]可以通过dp[j - weight[i]]推导出来,dp[j - weight[i]]表示容量为j - weight[i]的背包所背的最大价值。

dp[j - weight[i]] + value[i] 表示 容量为 j - 物品i重量 的背包 加上 物品i的价值。(也就是容量为j的背包,放入物品i了之后的价值即:dp[j])

此时dp[j]有两个选择,一个是取自己dp[j] 相当于 二维dp数组中的dp[i-1][j],即不放物品i,一个是取dp[j - weight[i]] + value[i],即放物品i,指定是取最大的,毕竟是求最大价值,

所以递归公式为:

dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);

可以看出相对于二维dp数组的写法,就是把dp[i][j]中i的维度去掉了。

一维dp数组如何初始化

关于初始化,一定要和dp数组的定义吻合,否则到递推公式的时候就会越来越乱

dp[j]表示:容量为j的背包,所背的物品价值可以最大为dp[j],那么dp[0]就应该是0,因为背包容量为0所背的物品的最大价值就是0。

那么dp数组除了下标0的位置,初始为0,其他下标应该初始化多少呢?

看一下递归公式:dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);

dp数组在推导的时候一定是取价值最大的数,如果题目给的价值都是正整数那么非0下标都初始化为0就可以了。

这样才能让dp数组在递归公式的过程中取的最大的价值,而不是被初始值覆盖了

那么我假设物品价值都是大于0的,所以dp数组初始化的时候,都初始为0就可以了。

一维dp数组遍历顺序

代码如下:

for(int i = 0; i < weight.size(); i++) { // 遍历物品
    for(int j = bagWeight; j >= weight[i]; j--) { // 遍历背包容量
        dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);

    }
}

这里大家发现和二维dp的写法中,遍历背包的顺序是不一样的!

二维dp遍历的时候,背包容量是从小到大,而一维dp遍历的时候,背包是从大到小。

为什么呢?

倒序遍历是为了保证物品i只被放入一次!。但如果一旦正序遍历了,那么物品0就会被重复加入多次!

dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
通过递归公式以及上边的分析来说,在原来的二维数组中dp[j]的值是由上方及左上方的值决定的,换到一维数组时,就由dp[j](上一层的值)和左侧上一层的值决定,如果正序遍历,则左边的值已经变成了本层的dp[j],所以要倒序遍历。

举一个例子:物品0的重量weight[0] = 1,价值value[0] = 15

如果正序遍历

dp[1] = dp[1 - weight[0]] + value[0] = 15

dp[2] = dp[2 - weight[0]] + value[0] = 30

此时dp[2]就已经是30了,意味着物品0,被放入了两次,所以不能正序遍历。

为什么倒序遍历,就可以保证物品只放入一次呢?

倒序就是先算dp[2]

dp[2] = dp[2 - weight[0]] + value[0] = 15 (dp数组已经都初始化为0)

dp[1] = dp[1 - weight[0]] + value[0] = 15

所以从后往前循环,每次取得状态不会和之前取得状态重合,这样每种物品就只取一次了。

那么问题又来了,为什么二维dp数组遍历的时候不用倒序呢?

因为对于二维dp,dp[i][j]都是通过上一层即dp[i - 1][j]计算而来,本层的dp[i][j]并不会被覆盖!

(如何这里读不懂,大家就要动手试一试了,空想还是不靠谱的,实践出真知!)

再来看看两个嵌套for循环的顺序,代码中是先遍历物品嵌套遍历背包容量,那可不可以先遍历背包容量嵌套遍历物品呢?

不可以!

因为一维dp的写法,背包容量一定是要倒序遍历(原因上面已经讲了),如果遍历背包容量放在上一层,那么每个dp[j]就只会放入一个物品,即:背包里只放入了一个物品。

倒序遍历的原因是,本质上还是一个对二维数组的遍历,并且右下角的值依赖上一层左上角的值,因此需要保证左边的值仍然是上一层的,从右向左覆盖。

(这里如果读不懂,就再回想一下dp[j]的定义,或者就把两个for循环顺序颠倒一下试试!)

所以一维dp数组的背包在遍历顺序上和二维其实是有很大差异的!,这一点大家一定要注意。

举例推导dp数组

一维dp,分别用物品0,物品1,物品2 来遍历背包,最终得到结果如下:

动态规划-背包问题9

import java.util.*;  
  
public class Main {  
  
    public static void main(String[] args) {  
        // 创建一个Scanner对象,用于从控制台读取用户输入  
        Scanner sc = new Scanner(System.in);  
  
        // 从用户处读取背包的总容量N和物品种类数量M  
        int M = sc.nextInt();  // M表示物品种类的数量  
        int N = sc.nextInt();  // N表示背包的总容量  
  
        // 初始化两个数组,分别用于存储每种物品的价值和重量  
        int[] values = new int[M];  // 物品价值数组  
        int[] weights = new int[M]; // 物品重量数组  
  
        // 读取每种物品的重量,并存入weights数组  
        for (int i = 0; i < M; i++) {  
            weights[i] = sc.nextInt();  
        }  
  
        // 读取每种物品的价值,并存入values数组  
        for (int i = 0; i < M; i++) {  
            values[i] = sc.nextInt();  
        }  
  
        // 初始化动态规划数组dp,用于存储每个容量下能装入物品的最大价值  
        // 由于背包容量可以为0到N,故数组长度为N+1  
        int[] dp = new int[N + 1];  
  
        // 动态规划过程,从后往前遍历背包容量,确保每个物品只被考虑一次  
        for (int i = 0; i < M; i++) {  // 遍历每一种物品  
            for (int j = N; j >= weights[i]; j--) {  // 从最大容量开始遍历到当前物品重量  
                // 如果当前物品重量小于等于当前背包容量,则比较放入和不放入该物品两种情况下的价值  
                dp[j] = Math.max(dp[j], dp[j - weights[i]] + values[i]);  
            }  
        }  
  
        // 输出最大价值,即dp数组在容量为N时的值  
        System.out.println(dp[N]);  
  
        // 关闭Scanner对象,释放资源  
        sc.close();  
    }  
}

416.分割等和子集

416. 分割等和子集

中等

给你一个 只包含正整数 的 非空 数组 nums 。请你判断是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。

示例 1:

输入:nums = [1,5,11,5]
输出:true
解释:数组可以分割成 [1, 5, 5] 和 [11] 。

示例 2:

输入:nums = [1,2,3,5]
输出:false
解释:数组不能分割成两个元素和相等的子集。

提示:

  • 1 <= nums.length <= 200
  • 1 <= nums[i] <= 100
class Solution {  
    public boolean canPartition(int[] nums) {  
        // 假设物品的重量和价值相等,因此这里直接使用原数组作为重量和价值数组  
        int[] weight = nums;  
        int[] value = nums;  
        int sum = 0;  
        // 计算数组总和  
        for(int i = 0; i < nums.length; i++){  
            sum += nums[i];  
        }  
        // 如果总和为奇数,则无法平均分割,直接返回false  
        if(sum % 2 == 1){  
            return false;  
        }  
        // 初始化动态规划数组dp,长度为总和的一半加1  
        // dp[j]表示容量为j的背包所能装下的最大价值  
        int dp[] = new int[sum/2 + 1];  
          
        // 遍历每个物品  
        for(int i = 0; i < nums.length;i++){  
            // 从背包的最大容量开始向前遍历  
            for(int j = dp.length - 1; j >= 0; j--){  
                // 如果当前背包容量j大于等于当前物品的重量weight[i]  
                if(j >= weight[i]){  
                    // 更新dp[j],比较当前物品放入背包和不放入背包两种情况下的最大价值  
                    dp[j] = Math.max(dp[j], dp[j - weight[i]] + value[i]);  
                }  
            }  
        }  
          
        // 如果dp数组中最大容量的价值等于总和的一半,说明可以将数组平均分割成两个子集,使得它们的和相等  
        if(dp[dp.length - 1] == sum/2){  
            return true;  
        }  
        // 否则无法平均分割  
        return false;  
    }  
}
class Solution {  
    public boolean canPartition(int[] nums) {  
        // 假设物品的重量和价值相等,因此这里直接使用原数组作为重量和价值数组  
        int[] weight = nums;  
        int[] values = nums;  
  
        // 获取数组长度  
        int len = nums.length;  
        // 如果数组为空,则无法分割成两个子集,直接返回false  
        if(len == 0){  
            return false;  
        }  
  
        // 计算数组的总和  
        int sum = 0;  
        for (int num : nums){  
            sum += num;  
        }  
          
        // 如果总和是奇数,则无法平均分割成两个子集,直接返回false  
        if(sum % 2 == 1){  
            return false;  
        }  
          
        // 目标值,即分割后每个子集应达到的和  
        int target = sum / 2;  
          
        // 初始化动态规划数组dp,dp[i][j]表示考虑前i个物品时,背包容量为j时的最大价值  
        int[][] dp = new int[nums.length][target + 1];  
  
        // 处理第一个物品  
        // 如果背包容量j大于等于第一个物品的重量,则放入第一个物品  
        for(int j = nums[0]; j <= target; j++){  
            dp[0][j] = nums[0];  
        }  
  
        // 处理剩余物品  
        for(int i = 1; i < len; i++){  
            for(int j = 0; j <= target; j++){  
                // 如果当前背包容量j小于当前物品的重量,则无法放入该物品,最大价值与前一个物品相同  
                if (j < nums[i]) {  
                    dp[i][j] = dp[i - 1][j];  
                } else {  
                    // 如果当前背包容量j大于等于当前物品的重量,则比较放入和不放入该物品两种情况下的最大价值  
                    dp[i][j] = Math.max(dp[i - 1][j], dp[i - 1][j - weight[i]] + values[i]);  
                }  
            }  
        }  
          
        // 如果考虑所有物品后,背包容量为target时的最大价值等于target,说明可以分割成两个和相等的子集  
        return dp[len - 1][target] == target;  
    }  
}

1049.最后一块石头的重量||

1049. 最后一块石头的重量 II

中等

提示

有一堆石头,用整数数组 stones 表示。其中 stones[i] 表示第 i 块石头的重量。

每一回合,从中选出任意两块石头,然后将它们一起粉碎。假设石头的重量分别为 x 和 y,且 x <= y。那么粉碎的可能结果如下:

  • 如果 x == y,那么两块石头都会被完全粉碎;
  • 如果 x != y,那么重量为 x 的石头将会完全粉碎,而重量为 y 的石头新重量为 y-x

最后,最多只会剩下一块 石头。返回此石头 最小的可能重量 。如果没有石头剩下,就返回 0

示例 1:

输入:stones = [2,7,4,1,8,1]
输出:1
解释:
组合 2 和 4,得到 2,所以数组转化为 [2,7,1,8,1],
组合 7 和 8,得到 1,所以数组转化为 [2,1,1,1],
组合 2 和 1,得到 1,所以数组转化为 [1,1,1],
组合 1 和 1,得到 0,所以数组转化为 [1],这就是最优值。

示例 2:

输入:stones = [31,26,33,21,40]
输出:5

提示:

  • 1 <= stones.length <= 30
  • 1 <= stones[i] <= 100

本题其实就是尽量让石头分成重量相同的两堆,相撞之后剩下的石头最小,这样就化解成01背包问题了。(和分割等和子集相似)

本题物品的重量为stones[i],物品的价值也为stones[i]。

对应着01背包里的物品重量weight[i]和 物品价值value[i]。

接下来进行动规五步曲:

确定dp数组以及下标的含义

dp[j]表示容量(这里说容量更形象,其实就是重量)为j的背包,最多可以背最大重量为dp[j]

可以回忆一下01背包中,dp[j]的含义,容量为j的背包,最多可以装的价值为 dp[j]。

相对于 01背包,本题中,石头的重量是 stones[i],石头的价值也是 stones[i] ,可以 “最多可以装的价值为 dp[j]” == “最多可以背的重量为dp[j]”

确定递推公式

01背包的递推公式为:dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);

本题则是:dp[j] = max(dp[j], dp[j - stones[i]] + stones[i]);

一些同学可能看到这dp[j - stones[i]] + stones[i]中 又有- stones[i] 又有+stones[i],看着有点晕乎。

大家可以再去看 dp[j]的含义。

dp数组如何初始化

既然 dp[j]中的j表示容量,那么最大容量(重量)是多少呢,就是所有石头的重量和。

把石头遍历一遍,计算出石头总重量 然后除2,得到dp数组的大小。

接下来就是如何初始化dp[j]呢,因为重量都不会是负数,所以dp[j]都初始化为0就可以了,这样在递归公式dp[j] = max(dp[j], dp[j - stones[i]] + stones[i]);中dp[j]才不会初始值所覆盖。

代码为:

vector<int> dp(15001, 0);

确定遍历顺序

如果使用一维dp数组,物品遍历的for循环放在外层,遍历背包的for循环放在内层,且内层for循环倒序遍历!

代码如下:

for (int i = 0; i < stones.size(); i++) { // 遍历物品
    for (int j = target; j >= stones[i]; j--) { // 遍历背包
        dp[j] = max(dp[j], dp[j - stones[i]] + stones[i]);
    }
}

举例推导dp数组

举例,输入:[2,4,1,1],此时target = (2 + 4 + 1 + 1)/2 = 4 ,dp数组状态图如下:

1049.最后一块石头的重量II

最后dp[target]里是容量为target的背包所能背的最大重量。

那么分成两堆石头,一堆石头的总重量是dp[target],另一堆就是sum - dp[target]。

在计算target的时候,target = sum / 2 因为是向下取整,所以sum - dp[target] 一定是大于等于dp[target]的

那么相撞之后剩下的最小石头重量就是 (sum - dp[target]) - dp[target]。

class Solution {  
    public int lastStoneWeightII(int[] stones) {  
        // 计算所有石头的总重量  
        int sum = 0;  
        for (int i : stones) {  
            sum += i;  
        }  
        // 目标重量为总重量的一半,因为我们需要将石头分成尽可能接近的两堆  
        int target = sum >> 1;  
  
        // 初始化dp数组,dp[j]表示容量为j的背包所能装下的石头的最大重量  
        int[] dp = new int[target + 1];  
  
        // 遍历每块石头  
        for (int i = 0; i < stones.length; i++) {  
            // 逆序遍历目标重量,从大到小更新dp数组,保证每个石头只被使用一次  
            for (int j = target; j >= stones[i]; j--) {  
                // 对于当前石头,有两种选择:放入背包或不放入背包  
                // 放入背包:则背包的总重量为之前背包的总重量加上当前石头的重量  
                // 不放入背包:则背包的总重量不变  
                // 取两种情况中的较大值作为当前背包的总重量  
                dp[j] = Math.max(dp[j], dp[j - stones[i]] + stones[i]);  
                //int[] weight = stones
                //int[] values = stones
            }  
        }  
  
        // 最后一堆石头的重量为总重量减去两堆中较大堆的重量(即dp[target]),因为dp[target]是两堆中较大堆的重量  
        // 所以,剩余一堆的重量为 sum - 2 * dp[target]  
        // 题目要求返回的是最后剩下的石头的重量,即两堆石头重量的差值  
        return sum - 2 * dp[target];  
    }  
}
class Solution {  
    public int lastStoneWeightII(int[] stones) {  
        // 计算所有石头的总重量  
        int sum = 0;  
        for (int s : stones) {  
            sum += s;  
        }  
  
        // 目标重量为总重量的一半,向下取整  
        int target = sum / 2;  
  
        // 初始化二维dp数组,dp[i][j]表示前i个物品放入容量为j的背包所能达到的最大重量  
        int[][] dp = new int[stones.length][target + 1];  
  
        // 对于第一个石头,初始化其对应的dp列  
        // 当背包容量j大于等于stones[0]时,可以放入该石头  
        for (int j = stones[0]; j <= target; j++) {  
            dp[0][j] = stones[0];  
        }  
  
        // 遍历每个石头  
        for (int i = 1; i < stones.length; i++) {  
            // 遍历背包的每个容量  
            for (int j = 1; j <= target; j++) {  
                // 如果当前背包容量j大于等于当前石头stones[i]的重量  
                if (j >= stones[i]) {  
                    // 选择放入当前石头,此时背包的重量为前i-1个物品在容量为j-stones[i]的背包中的最大重量加上当前石头的重量  
                    // 或者选择不放入当前石头,此时背包的重量为前i-1个物品在容量为j的背包中的最大重量  
                    // 取两种情况中的较大值作为当前背包的最大重量  
                    dp[i][j] = Math.max(dp[i - 1][j], dp[i - 1][j - stones[i]] + stones[i]);  
                } else {  
                    // 如果当前背包容量j小于当前石头stones[i]的重量,则无法放入该石头,背包的最大重量与前i-1个物品在容量为j的背包中的最大重量相同  
                    dp[i][j] = dp[i - 1][j];  
                }  
            }  
        }  
  
        // 打印出背包的最大重量,便于调试(可选)  
        System.out.println(dp[stones.length - 1][target]);  
  
        // 两堆石头重量差值的最小值即为总重量减去两倍的背包最大重量  
        // 因为背包最大重量是使得两堆石头重量差值最小的那堆石头的重量  
        return sum - 2 * dp[stones.length - 1][target];  
    }  
}

494.目标和

494. 目标和

中等

给你一个非负整数数组 nums 和一个整数 target 。

向数组中的每个整数前添加 '+' 或 '-' ,然后串联起所有整数,可以构造一个 表达式 :

  • 例如,nums = [2, 1] ,可以在 2 之前添加 '+' ,在 1 之前添加 '-' ,然后串联起来得到表达式 "+2-1" 。

返回可以通过上述方法构造的、运算结果等于 target 的不同 表达式 的数目。

示例 1:

输入:nums = [1,1,1,1,1], target = 3
输出:5
解释:一共有 5 种方法让最终目标和为 3 。
-1 + 1 + 1 + 1 + 1 = 3
+1 - 1 + 1 + 1 + 1 = 3
+1 + 1 - 1 + 1 + 1 = 3
+1 + 1 + 1 - 1 + 1 = 3
+1 + 1 + 1 + 1 - 1 = 3

示例 2:

输入:nums = [1], target = 1
输出:1

提示:

  • 1 <= nums.length <= 20
  • 0 <= nums[i] <= 1000
  • 0 <= sum(nums[i]) <= 1000
  • -1000 <= target <= 1000

假设加法的总和为x,那么减法对应的总和就是sum - x。

所以我们要求的是 x - (sum - x) = target

x = (target + sum) / 2

此时问题就转化为,装满容量为x的背包,有几种方法

这里的x,就是bagSize,也就是我们后面要求的背包容量。

大家看到(target + sum) / 2 应该担心计算的过程中向下取整有没有影响。

这么担心就对了,例如sum 是5,S是2的话其实就是无解的,所以:

(C++代码中,输入的S 就是题目描述的 target)
if ((S + sum) % 2 == 1) return 0; // 此时没有方案

同时如果 S的绝对值已经大于sum,那么也是没有方案的。

(C++代码中,输入的S 就是题目描述的 target)
if (abs(S) > sum) return 0; // 此时没有方案

再回归到01背包问题,为什么是01背包呢?

因为每个物品(题目中的1)只用一次!

这次和之前遇到的背包问题不一样了,之前都是求容量为j的背包,最多能装多少。

本题则是装满有几种方法。其实这就是一个组合问题了。

确定dp数组以及下标的含义

dp[j] 表示:填满j(包括j)这么大容积的包,有dp[j]种方法

其实也可以使用二维dp数组来求解本题,dp[i][j]:使用 下标为[0, i]的nums[i]能够凑满j(包括j)这么大容量的包,有dp[i][j]种方法。

下面我都是统一使用一维数组进行讲解, 二维降为一维(滚动数组),其实就是上一层拷贝下来

确定递推公式

有哪些来源可以推出dp[j]呢?

只要搞到nums[i],凑成dp[j]就有dp[j - nums[i]] 种方法。

例如:dp[j],j 为5,

已经有一个1(nums[i]) 的话,有 dp[4]种方法 凑成 容量为5的背包。

已经有一个2(nums[i]) 的话,有 dp[3]种方法 凑成 容量为5的背包。

已经有一个3(nums[i]) 的话,有 dp[2]中方法 凑成 容量为5的背包

已经有一个4(nums[i]) 的话,有 dp[1]中方法 凑成 容量为5的背包

已经有一个5 (nums[i])的话,有 dp[0]中方法 凑成 容量为5的背包

那么凑整dp[5]有多少方法呢,也就是把 所有的 dp[j - nums[i]] 累加起来。

所以求组合类问题的公式,都是类似这种:

dp[j] += dp[j - nums[i]]

这个公式在后面在讲解背包解决排列组合问题的时候还会用到!

dp数组如何初始化

这里看做组合问题,如果背包容量为1的话,那么只有一种方式就是往背包里什么都不放,所以dp[0] = 1;

确定遍历顺序

对于01背包问题一维dp的遍历,nums放在外循环,target在内循环,且内循环倒序。

举例推导dp数组

输入:nums: [1, 1, 1, 1, 1], S: 3

bagSize = (S + sum) / 2 = (3 + 5) / 2 = 4

dp数组状态变化如下:

class Solution {  
    // 公共方法,用于计算数组中和为目标值的组合数  
    public int findTargetSumWays(int[] nums, int target) {  
        //left是加和,right是减和
        //left - right = target
        //left + right = sum
        //right = sum - left
        //left = (target + sum) / 2
        // 计算数组的总和  
        int sum = 0;  
        for(int i = 0; i < nums.length; i++){  
            sum += nums[i];  
        }  
  
        // 如果目标值的绝对值大于数组的总和,那么不可能有任何组合的和等于目标值  
        if(Math.abs(target) > sum){  
            return 0;  
        }  
  
        // 如果目标值与数组总和之和是奇数,那么也不可能有任何组合的和等于目标值(因为和的一半必须是整数)  
        if((target + sum) % 2 == 1){  
            return 0;  
        }  
  
        // 计算目标值与数组总和之和的一半,这就是我们要在数组中找出其组合和为多少的数值  
        int size = (target + sum) / 2;  
  
        // 初始化动态规划数组,dp[i]表示和为i的组合数  
        int dp[] = new int[size + 1];  
        // 初始条件:和为0的组合有一种,即不选任何数字  
        dp[0] = 1;  
  
        // 遍历数组中的每个数字  
        for(int i = 0; i < nums.length; i++){  
            // 从大到小遍历可能的和,这样可以保证在计算dp[j]时,dp[j - nums[i]]的值是已经计算好的  
            for(int j = size; j >= nums[i]; j--){  
                //j >= nums[i]的时候下边的式子才有意义
                // dp[j]的值等于它自身(不选当前数字)与dp[j - nums[i]](选择当前数字)的和  
                dp[j] += dp[j - nums[i]];  
            }  
        }  
  
        // 返回和为size的组合数,即目标值与数组总和之和的一半的组合数  
        return dp[size];  
    }  
}

474.一和零 

474. 一和零

中等

给你一个二进制字符串数组 strs 和两个整数 m 和 n 。

请你找出并返回 strs 的最大子集的长度,该子集中 最多 有 m 个 0 和 n 个 1 。

如果 x 的所有元素也是 y 的元素,集合 x 是集合 y 的 子集 。

示例 1:

输入:strs = ["10", "0001", "111001", "1", "0"], m = 5, n = 3
输出:4
解释:最多有 5 个 0 和 3 个 1 的最大子集是 {"10","0001","1","0"} ,因此答案是 4 。
其他满足题意但较小的子集包括 {"0001","1"} 和 {"10","1","0"} 。{"111001"} 不满足题意,因为它含 4 个 1 ,大于 n 的值 3 。

示例 2:

输入:strs = ["10", "0", "1"], m = 1, n = 1
输出:2
解释:最大的子集是 {"0", "1"} ,所以答案是 2 。

提示:

  • 1 <= strs.length <= 600
  • 1 <= strs[i].length <= 100
  • strs[i] 仅由 '0' 和 '1' 组成
  • 1 <= m, n <= 100

本题中strs 数组里的元素就是物品,每个物品都是一个!

而m 和 n相当于是一个背包,两个维度的背包

理解成多重背包的同学主要是把m和n混淆为物品了,感觉这是不同数量的物品,所以以为是多重背包。

但本题其实是01背包问题!

只不过这个背包有两个维度,一个是m 一个是n,而不同长度的字符串就是不同大小的待装物品。

开始动规五部曲:

确定dp数组(dp table)以及下标的含义

dp[i][j]:最多有i个0和j个1的strs的最大子集的大小为dp[i][j]

确定递推公式

dp[i][j] 可以由前一个strs里的字符串推导出来,strs里的字符串有zeroNum个0,oneNum个1。

dp[i][j] 就可以是 dp[i - zeroNum][j - oneNum] + 1。

然后我们在遍历的过程中,取dp[i][j]的最大值。

所以递推公式:dp[i][j] = max(dp[i][j], dp[i - zeroNum][j - oneNum] + 1);

此时大家可以回想一下01背包的递推公式:dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);

对比一下就会发现,字符串的zeroNum和oneNum相当于物品的重量(weight[i]),字符串本身的个数相当于物品的价值(value[i])。

这就是一个典型的01背包! 只不过物品的重量有了两个维度而已。

dp数组如何初始化

01背包的dp数组初始化为0就可以。

因为物品价值不会是负数,初始为0,保证递推的时候dp[i][j]不会被初始值覆盖。

确定遍历顺序

外层for循环遍历物品,内层for循环遍历背包容量且从后向前遍历!

那么本题也是,物品就是strs里的字符串,背包容量就是题目描述中的m和n。

代码如下:

for (string str : strs) { // 遍历物品
    int oneNum = 0, zeroNum = 0;
    for (char c : str) {
        if (c == '0') zeroNum++;
        else oneNum++;
    }
    for (int i = m; i >= zeroNum; i--) { // 遍历背包容量且从后向前遍历!
        for (int j = n; j >= oneNum; j--) {
            dp[i][j] = max(dp[i][j], dp[i - zeroNum][j - oneNum] + 1);
        }
    }
}

有同学可能想,那个遍历背包容量的两层for循环先后循序有没有什么讲究?

没讲究,都是物品重量的一个维度,先遍历哪个都行!

举例推导dp数组

以输入:["10","0001","111001","1","0"],m = 3,n = 3为例

最后dp数组的状态如下所示:

474.一和零

class Solution {  
    // 定义一个公共方法,用于找到最大形式的字符串数量  
    public int findMaxForm(String[] strs, int m, int n) {  
        // 初始化一个二维动态规划数组dp,其维度为(m+1)x(n+1)。  
        // dp[i][j]表示容量为i个'0'和j个'1'的子集中可以形成的最大字符串数。  
        int[][] dp = new int[m + 1][n + 1];  
  
        // 遍历字符串数组中的每一个字符串  
        for(String s : strs){  
            // 初始化计数器,用于统计当前字符串中'0'和'1'的数量  
            int zeroNum = 0;  
            int oneNum = 0;  
  
            // 遍历当前字符串的每一个字符  
            for(int k = 0; k < s.length(); k ++){  
                // 获取当前字符  
                char a = s.charAt(k);  
  
                // 根据字符类型增加对应的计数器  
                if(a == '0'){  
                    zeroNum++;  
                }else{  
                    oneNum++;  
                }  
            }  
  
            // 从最大容量开始递减,比该字符串中的0和1的范围大才有更新的意义  
            for(int i = m; i >= zeroNum; i--){  
                for(int j = n; j >= oneNum; j--){  
                    // 更新dp数组,比较当前状态与添加当前字符串后的状态哪个更优  
                    dp[i][j] = Math.max(dp[i][j], dp[i - zeroNum][j - oneNum] + 1);  
                }  
            }  
        }  
  
        // 返回最终结果,即在容量为m个'0'和n个'1'的情况下可以形成的最大字符串数  
        return dp[m][n];  
    }  
}

  • 14
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值