03 0-1背包问题

195 阅读17分钟

03 0-1背包问题

1、0-1背包理论基础

题目简介:

有n件物品和一个最多能背重量为w 的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。每件物品只能用一次,求解将哪些物品装入背包里物品价值总和最大。假设背包最大重量为4,问背包能背的物品最大价值是多少?

01背包1.png

题解:

1、确定dp数组及下标的含义:对于背包问题,有一种写法是采用二维数组,即dp[i][j]表示从下标为[0~i]的物品里任意取,放进容量为j的背包里,价值总和最大是多少。 0302.png

2、确定递推公式:因为dp[i][j]表示从下标为[0~i]的物品里任意取,放进容量为j的背包里,价值总和最大是多少。可由由两个方向推出来dp[i][j]。

不放物品i:由dp[i-1][j]推出,即背包容量为j,里面不放物品i的最大价值,此时dp[i][j]就是dp[i-1][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得到的最大价值。

因此递推公式为:dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);

3、dp数组初始化:首先如果背包容量j为0时,无论如何选取价值都为0,因此第一列全部赋值为0。 0303片.png 通过状态转移方程知道i是由i-1推导出来的,因此一定要初始化i为0的时候,dp[0][j]表示当i为0时,存放编号0的物品时,各个容量的背包所能存放的最大价值。因此当剩余容量小于物品重量时,应该赋值为0,即j < weight[0];而当背包剩余容量大于物品重量时,可以存放,则赋值为value[0],即j >= weight[0]。

初始化:
for (int j = 0 ; j < weight[0]; j++) {  
    dp[0][j] = 0;
}
for (int j = weight[0]; j <= bagweight; j++) {
    dp[0][j] = value[0];
}

此时的dp数组为: 0304.png dp[0][j]和dp[i][0]都已经初始化了,如何初始化其他下标?通过递推公式可知dp[i][j]的值可以通过左上方的值推导出来,因此剩余位置统一赋值为0就行。

0305.png

4、确定遍历顺序:有两个我们需要遍历的东西:物品和背包容量。这里其实谁先谁后都是可以,但是先遍历物品比较好理解。原因在于通过递推公式知道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]的左上角方向(包括正上方向),根本不影响dp[i][j]公式的推导。

5、5. 举例推导dp数组。

    public static void main(String[] args) {
        int[] weight = {1,3,4};    //设置物品重量
        int[] value = {15,20,30};    //设置物品价值
        int bagSize = 4;    //设置背包容量
        int maxValue = testWeightBagProblem(weight,value,bagSize);
    }
    public static int testWeightBagProblem(int[] weight, int[] value, int bagSize){
        int goods = weight.length;    //获取物品总数
        int[][] dp = new int[goods][bagSize + 1];    //创建dp数组,大小为:物品总数 * (背包容量+1)
        for (int j = weight[0]; j <= bagSize; j++) {    //初始化背包第一列,即把下标为0的物品装入背包的情况
            dp[0][j] = value[0];
        }
        for (int i = 1; i < weight.length; i++) {    //遍历物品,从第二件物品开始遍历,下标为1
            for (int j = 1; j <= bagSize; j++) {    //遍历背包容量,从下标为1开始
                if (j < weight[i]) {    //如果当前背包容量小于当前物品重量则无法放入,直接让dp[i][j]等于上一个物品i-1的价值,而剩余容量j不变
                    dp[i][j] = dp[i-1][j];
                } else {    //如果能够放入,则判断放入i和不放入i的价值大小
                    dp[i][j] = Math.max(dp[i-1][j] , dp[i-1][j-weight[i]] + value[i]);
                }
            }
        }
        return dp[weight.length-1][bagSize];    //返回最后一个坐标即代表最大价值
    }

2、01背包理论基础(滚动数组)

何为滚动数组:

通俗来讲就是把二维dp数组转为一维来表达。在上一题中,我们找到了递推公式为: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]。

题目简介:

有n件物品和一个最多能背重量为w 的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。每件物品只能用一次,求解将哪些物品装入背包里物品价值总和最大。假设背包最大重量为4,问背包能背的物品最大价值是多少?

01背包1.png

题解:

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

2、一维dp数组的递推公式: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])。

3、一维dp数组如何初始化:dp[j]表示:容量为j的背包,所背的物品价值可以最大为dp[j],那么dp[0]就应该是0,因为背包容量为0所背的物品的最大价值就是0。那么我假设物品价值都是大于0的,所以dp数组初始化的时候,都初始为0就可以了。

4、4. 一维dp数组遍历顺序:二维dp遍历的时候,背包容量是从小到大,而一维dp遍历的时候,背包是从大到小。因为从后往前循环每次取得状态不会和之前取得状态重合,这样每种物品就只取一次了。再来看看两个嵌套for循环的顺序,代码中是先遍历物品嵌套遍历背包容量,那可不可以先遍历背包容量嵌套遍历物品呢?当然是不可以的,因为一维dp的写法,背包容量一定是要倒序遍历,如果遍历背包容量放在上一层,那么每个dp[j]就只会放入一个物品,即:背包里只放入了一个物品。

5、5. 举例推导dp数组

    public static void main(String[] args) {
        Main n1 = new Main();
        int[] weight = {1, 3, 4};    //定义物品重量
        int[] value = {15, 20, 30};    //定义物品价值
        int bagWight = 4;    //定义背包容量
        int maxValue = n1.testWeightBagProblem(weight, value, bagWight);
        System.out.print(maxValue);
    }
    public  int testWeightBagProblem(int[] weight, int[] value, int bagWeight){
        int wLen = weight.length;    //获得物品数量
        int[] dp = new int[bagWeight + 1];    //创建一维dp数组,大小为背包容量 + 1
        for (int i = 0; i < wLen; i++){    //外层遍历物品的下标
            for (int j = bagWeight; j >= weight[i]; j--){    //内层倒序遍历背包容量,容量必须大于当前物品重量weight[i]
                dp[j] = Math.max(dp[j], dp[j - weight[i]] + value[i]);    //容量为j的最大价值为:不放i号物品的最大价值(即本身dp[j])和放i号物品的最大价值中的最大值
            }
        }
        return dp[bagWeight];    //最后返回dp[bagWeight]就是最大价值
    }

3、分割等和子集

题目简介:

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

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

题解:

这道题目是要找是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。那么只要找到集合里能够出现 sum / 2 的子集总和,就算是可以分割成两个相同元素和子集了。把01背包问题套到本题上来:(1)背包的体积为sum / 2;(2)背包要放入的商品(集合里的元素)重量为 元素的数值,价值也为元素的数值;(3)背包如果正好装满,说明找到了总和为 sum / 2 的子集。 ;(4)背包中每一个元素是不可重复放入。

1、1. 确定dp数组以及下标的含义:dp[j]表示:背包总容量(所能装的总重量)是j,放进物品后,背的最大重量为dp[j]。那么如果背包容量为target, dp[target]就是装满 背包之后的重量,所以 当 dp[target] == target 的时候,背包就装满了。

2、确定递推公式:物品i的重量是nums[i],其价值也是nums[i]。所以递推公式:dp[j] = max(dp[j], dp[j - nums[i]] + nums[i]);

3、dp数组如何初始化:从dp[j]的定义来看,首先dp[0]一定是0。如果题目给的价值都是正整数那么非0下标都初始化为0就可以了,如果题目给的价值有负数,那么非0下标就要初始化为负无穷。这样才能让dp数组在递推的过程中取得最大的价值,而不是被初始值覆盖了。本题题目中 只包含正整数的非空数组,所以非0下标的元素初始化为0就可以了。

4、确定遍历顺序:如果使用一维dp数组,物品遍历的for循环放在外层,遍历背包的for循环放在内层,且内层for循环倒序遍历。

5、5. 举例推导dp数组:dp[j]的数值一定是小于等于j的。如果dp[j] == j 说明,集合中的子集总和正好可以凑成总和j,即可均分为两个总和相等的数组。

    public static void main(String[] args) {
        Main n1 = new Main();
        int[] arr = new int[]{1,5,11,5};
        boolean flag = n1.canPartition(arr);    //flag为true时,代表此数组可以均分为两个相等总和子数组
        System.out.print(flag);
    }
    public boolean canPartition(int[] nums) {
        if(nums == null || nums.length == 0) return false;    //先判断该数组是否为空
        int n = nums.length;    //获取数组长度
        int sum = 0;    //sum记录整个数组总和
        for(int num : nums) {
            sum += num;
        }
        if(sum % 2 != 0) return false;    //判断这个总数是否为偶数,如果不是则直接返回false
        int target = sum / 2;    //如果是偶数则除以2,赋值给target
        int[] dp = new int[target + 1];    //创建dp数组,大小为:target + 1
        for(int i = 0; i < n; i++) {    //外循环遍历所有整数
            for(int j = target; j >= nums[i]; j--) {    //内循环逆序遍历子数组容量
                dp[j] = Math.max(dp[j], dp[j-nums[i]] + nums[i]);    //当前容量的子数组dp[j]的值为:不放入当前整数(dp[j])和放入当前整数(dp[j-nums[i]] + nums[i])的最大值
            }
        }
        return dp[target] == target;    //最后返回dp[target]是否等于target,如果等于则说明可以均分
    }

4、最后一块石头的重量II

题目简介:

有一堆石头,用整数数组 stones 表示。其中 stones[i] 表示第 i 块石头的重量。 每一回合,从中选出任意两块石头,然后将它们一起粉碎。假设石头的重量分别为 x 和 y,且 x <= y。那么粉碎的可能结果如下:

如果 x == y,那么两块石头都会被完全粉碎;

如果 x != y,那么重量为 x 的石头将会完全粉碎,而重量为 y 的石头新重量为 y-x。

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

示例:
输入: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],这就是最优值。

题解:

本题其实就是尽量让石头分成重量相同的两堆,相撞之后剩下的石头最小,这样就化解成01背包问题了。本题物品的重量为stones[i],物品的价值也为stones[i]。

1、确定dp数组以及下标的含义:dp[j]表示容量为j的背包,最多可以背最大重量为dp[j] 。

2、确定递推公式:根据01背包的递推公式可知此题的递推公式为:dp[j] = max(dp[j], dp[j - stones[i]] + stones[i]);

3、dp数组如何初始化:既然 dp[j]中的j表示容量,那么最大容量(重量)是多少呢,就是所有石头的重量和。因为提示中给出1 <= stones.length <= 30,1 <= stones[i] <= 1000,所以最大重量就是30 * 1000 。 而我们要求的target其实只是最大重量的一半,所以dp数组开到15000大小就可以了。 当然也可以把石头遍历一遍,计算出石头总重量 然后除2,得到dp数组的大小。 我这里就直接用15000了。因为重量都不会是负数,所以dp[j]都初始化为0就可以了,这样在递归公式dp[j] = max(dp[j], dp[j - stones[i]] + stones[i]);中dp[j]才不会初始值所覆盖。

4、确定遍历顺序:如果使用一维dp数组,物品遍历的for循环放在外层,遍历背包的for循环放在内层,且内层for循环倒序遍历。

5、举例推导dp数组:dp[target]里是容量为target的背包所能背的最大重量。 那么分成两堆石头,一堆石头的总重量是dp[target],另一堆就是sum - dp[target]。 在计算target的时候,target = sum / 2 因为是向下取整,所以sum - dp[target] 一定是大于等于dp[target]的。 那么相撞之后剩下的最小石头重量就是 (sum - dp[target]) - dp[target]。

    public static void main(String[] args) {
        Main n1 = new Main();
        int[] arr = new int[]{2,4,1,1};
        int res = n1.lastStoneWeightII(arr);    //res记录返回的剩余值
        System.out.print(res);
    }
    public int lastStoneWeightII(int[] stones) {
        int sum = 0;    //sum记录数组中的整数和
        for (int i : stones) {
            sum += i;
        }
        int target = sum >> 1;    //target保存sum / 2的值
        int[] dp = new int[target + 1];    //创建dp数组,大小为target + 1
        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]);    ////当前容量的子数组dp[j]的值为:不放入当前整数(dp[j])和放入当前整数(dp[j-stones[i]] + stones[i])的最大值
            }
        }
        return sum - 2 * dp[target];    //相当于(sum - dp[target]) - dp[target]
    }

5、目标和

题目简介:

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

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

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

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

示例:
输入: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

题解:

本题要如何使表达式结果为target,既然为target,那么就一定有 left组合 - right组合 = target。left + right = sum,而sum是固定的。right = sum - left,公式来了, left - (sum - left) = target 推导出 left = (target + sum)/2 。target是固定的,sum是固定的,left就可以求出来。此时问题就是在集合nums中找出和为left的组合。如何转化为01背包问题,假设加法的总和为x,那么减法对应的总和就是sum - x。所以我们要求的是 x - (sum - x) = target,因此x = (target + sum) / 2,此时问题就转化为,装满容量为x的背包,有几种方法。这里的x,就是bagSize,也就是我们后面要求的背包容量。这次和之前遇到的背包问题不一样了,之前都是求容量为j的背包,最多能装多少。本题则是装满有几种方法。其实这就是一个组合问题了。

1、确定dp数组以及下标的含义:dp[j] 表示:填满j(包括j)这么大容积的包,有dp[j]种方法。

2、确定递推公式:只要搞到nums[i],凑成dp[j]就有dp[j - nums[i]] 种方法。那么凑整dp[5]有多少方法呢,也就是把 所有的 dp[j - nums[i]] 累加起来。

所以求组合类问题的公式,都是类似这种:dp[j] += dp[j - nums[i]]

3、dp数组如何初始化:从递推公式可以看出,在初始化的时候dp[0] 一定要初始化为1,因为dp[0]是在公式中一切递推结果的起源,如果dp[0]是0的话,递推结果将都是0。所以本题我们应该初始化 dp[0] 为 1。dp[j]其他下标对应的数值也应该初始化为0,从递推公式也可以看出,dp[j]要保证是0的初始值,才能正确的由dp[j - nums[i]]推导出来。

4、确定遍历顺序:一维dp的遍历,nums放在外循环,target在内循环,且内循环倒序。

5、举例推导dp数组。

    public static void main(String[] args) {
        Main n1 = new Main();
        int[] arr = new int[]{1,1,1,1,1};
        int res = n1.findTargetSumWays(arr,3);    //传入数组和目标值3
        System.out.print(res);
    }
    public int findTargetSumWays(int[] nums, int target) {
        int sum = 0;    //sum记录所有整数和
        for (int i = 0; i < nums.length; i++) sum += nums[i];
        if ( target < 0 && sum < -target) return 0;    //如果给定目标值为负数或者太大,直接退出
        if ((target + sum) % 2 != 0) return 0;    //如果给定值target和总和sum之和不是偶数也直接退出
        int size = (target + sum) / 2;    //size保存taget + sum 的一半
        if(size < 0) size = -size;    //如果size为负数,转为正数
        int[] dp = new int[size + 1];    //创建dp数组,大小为size + 1
        dp[0] = 1;    //初始化dp[0]为1,代表容量为0的方法至少组合也有一种
        for (int i = 0; i < nums.length; i++) {
            for (int j = size; j >= nums[i]; j--) {
                dp[j] += dp[j - nums[i]];    //关于组合问题的递推式
            }
        }
        return dp[size];    //最后返回dp[size],即最后的组合数
    }

6、一和零

题目简介:

给你一个二进制字符串数组 strs 和两个整数 m 和 n 。 请你找出并返回 strs 的最大子集的长度,该子集中 最多 有 m 个 0 和 n 个 1 。

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

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

题解:

本题中strs 数组里的元素就是物品,每个物品都是一个!而m 和 n相当于是一个背包,两个维度的背包。而不同长度的字符串就是不同大小的待装物品。

1、确定dp数组以及下标的含义:dp[i][j]:最多有i个0和j个1的strs的最大子集的大小为dp[i][j] 。

2、确定递推公式: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背包的递推式,发现字符串的zeroNum和oneNum相当于物品的重量(weight[i]),字符串本身的个数相当于物品的价值(value[i])。

3、dp数组如何初始化:01背包的dp数组初始化为0就可以。因为物品价值不会是负数,初始为0,保证递推的时候dp[i][j]不会被初始值覆盖。

4、确定遍历顺序:外层for循环遍历物品,内层for循环遍历背包容量且从后向前遍历,物品就是strs里的字符串,背包容量就是题目描述中的m和n。

5、举例推导dp数组。

    public static void main(String[] args) {
        Main n1 = new Main();
        String[] arr = new String[]{"10","0001","111001","1","0"};
        int res = n1.findMaxForm(arr,3,3);
        System.out.print(res);
    }
    public int findMaxForm(String[] strs, int m, int n) {
        int[][] dp = new int[m + 1][n + 1];    //创建dp数组,大小为:(m+1) * (n+1)
        int oneNum, zeroNum;    //oneNum记录字符'1'个数,zeroNum记录字符'0'个数
        for (String str : strs) {    //遍历物品
            oneNum = 0;
            zeroNum = 0;
            for (char ch : str.toCharArray()) {    //遍历每一个字符进行统计
                if (ch == '0') {
                    zeroNum++;
                } else {
                    oneNum++;
                }
            }
            for (int i = m; i >= zeroNum; i--) {    //反序遍历背包容量
                for (int j = n; j >= oneNum; j--) {
                    dp[i][j] = Math.max(dp[i][j], dp[i - zeroNum][j - oneNum] + 1);
                }
            }
        }
        return dp[m][n];    //最后返回dp[m][n]代表m个0和n个1时的最大子集
    }