01背包问题

文章详细介绍了01背包问题的动态规划解决方案,从确定dp数组、初始化、递推公式到优化过程,包括先遍历物品再遍历背包的策略,以及如何转换为滚动数组降低空间复杂度。还探讨了如何将01背包问题应用于其他类似问题,如分割等和子集、最后一块石头的重量II和目标和等。
摘要由CSDN通过智能技术生成

01背包问题

重量价值
物品0115
物品1320
物品2430

一个背包最大的容量为4,问:背包能装的最大的价值为多少?

01背包问题是一个很经典的问题,后面可以延伸出很多同类的题型,下面我们按照动态规划的解题步骤一步一步的来思考

  1. 确定dp函数

    dp[i][j]:从0到i的物品中任取一件物品,放进容量为j的背包中,最大的价值。

  2. 确定dp递推函数

    当遍历到物品i的时候,有什么事决定当前的dp数组的值的呢

    • 不放当前物品i:当前的最大容量为dp[i-1][j]

    • 放当前物品i(当前物品放得下):当前最大容量为:dp[i-1][j-weight[i]] + value[i]

    故当前的dp数组的值为两者的最大值:Math.max(dp[i-1][j], dp[i-1][j-weight[i]] + value[i])

  3. 初始化,因为dp[i][j]是根据i-1j-weight[i]来决定的,所以,第一列和第一行很定要初始化,那么初始化为多少呢

    • 第一列:指的是当背包容量为0的时候的最大价值为多少,因为没有重量为0的物品,故第一列都是0;

    • 第一行:指的是当物品0在放在背包里的最大价值为多少。

      • 当物品0的重量 > 背包容量的时候,dp数组的值为0

      • 当物品0的重量 < 背包容量的时候,do数组的值为物品0的价值,即为value[0]

  4. 确定遍历顺序:根据递推公式可知,数组的值都来自于左侧左上角,所以递归顺寻基本为从左到右,从上到下,但是是先遍历物品还是先遍历背包,都是可以的。我们这里以先遍历物品为例写出代码

    for(int i = 1; i < weight.length; i++) {
        for(int j = 1; j <= pageSize; j++) {
            if(j < weight[i]) {
                do[i][j] = dp[i-1][j];
            }else {
                dp[i][j] = Math.max(dp[i-1][j], dp[i-1][j-weight[i]] + value[i]);
            }
        }
    }
  5. 距离推导dp数组

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

public class BagProblem {
    public static void main(String[] args) {
        int[] weight = {1,3,4};
        int[] value = {15,20,30};
        int bagSize = 4;
        testWeightBagProblem(weight,value,bagSize);
    }
​
    /**
     * 动态规划获得结果
     * @param weight  物品的重量
     * @param value   物品的价值
     * @param bagSize 背包的容量
     */
    public static void testWeightBagProblem(int[] weight, int[] value, int bagSize){
​
        // 创建dp数组
        int goods = weight.length;  // 获取物品的数量
        int[][] dp = new int[goods][bagSize + 1];
​
        // 初始化dp数组
        // 创建数组后,其中默认的值就是0
        for (int j = weight[0]; j <= bagSize; j++) {
            dp[0][j] = value[0];
        }
​
        // 填充dp数组
        for (int i = 1; i < weight.length; i++) {
            for (int j = 1; j <= bagSize; j++) {
                if (j < weight[i]) {
                    /**
                     * 当前背包的容量都没有当前物品i大的时候,是不放物品i的
                     * 那么前i-1个物品能放下的最大价值就是当前情况的最大价值
                     */
                    dp[i][j] = dp[i-1][j];
                } else {
                    /**
                     * 当前背包的容量可以放下物品i
                     * 那么此时分两种情况:
                     *    1、不放物品i
                     *    2、放物品i
                     * 比较这两种情况下,哪种背包中物品的最大价值最大
                     */
                    dp[i][j] = Math.max(dp[i-1][j] , dp[i-1][j-weight[i]] + value[i]);
                }
            }
        }
​
        // 打印dp数组
        for (int i = 0; i < goods; i++) {
            for (int j = 0; j <= bagSize; j++) {
                System.out.print(dp[i][j] + "\t");
            }
            System.out.println("\n");
        }
    }
}
​

优化

我们来观察下,可以放进背包的时候,dp数组的递推公式:Math.max(dp[i-1][j], dp[i-1][j-weight[i]] + value[i]),我们发现如果把i-1层的数据拷贝到i层,再做赋值的话,原递推公式可以写成:Math.max(dp[i][j], dp[i][j-weight[i]] + value[i]),此时我们发现,其实用一个一维数组dp[j]就可以解决01背包问题了。这种数组就是滚动数组。

  1. 确定数组的定义:dp[j]:容量为j的背包,所背的物品的最大价值。

  2. 确定递推公式:dp[j] = Math.max(dp[j], dp[j-weight[i]] + value[i])

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

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

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

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

  4. 确定遍历顺序

    for (int i = 0; i < wLen; i++){
        for (int j = bagWeight; j >= weight[i]; j--){
            dp[j] = Math.max(dp[j], dp[j - weight[i]] + value[i]);
        }
    }

    可以发现,和二维数组的情况下,都是先遍历物品再遍历背包,但是遍历背包的顺寻是发声变化的。

    背包变成从后往前遍历了。因为如果从前往后的话,会导致重复的选取一个物品。我们来举个例子:

    重量价值
    物品0115
    物品1320
    物品2430

    假设我们此时遍历到i = 0的时候,我们来试一下从前往后遍历,dp数组的赋值

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

    • dp[2] = dp[2- weight[0]] + value[0] = dp[1] + value[0] = 15 ,此时我们发现,这个地方加了两次物品0。

    那为什么二维的时候不会进行重复的选取呢?我们把二维的遍历赋值的代码再写出来一次

    for(int i = 1; i < weight.length; i++) {
        for(int j = 1; j <= pageSize; j++) {
            if(j < weight[i]) {
                do[i][j] = dp[i-1][j];
            }else {
                dp[i][j] = Math.max(dp[i-1][j], dp[i-1][j-weight[i]] + value[i]);
            }
        }
    }

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

    再来看遍历顺序,这里直接给出结论,一位数组的遍历时不可以先遍历背包再遍历物品的!只能先遍历物品再遍历背包

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

  5. 举例推导dp数组

public static void main(String[] args) {
    int[] weight = {1, 3, 4};
    int[] value = {15, 20, 30};
    int bagWight = 4;
    testWeightBagProblem(weight, value, bagWight);
}
public static void testWeightBagProblem(int[] weight, int[] value, int bagWeight){
    int wLen = weight.length;
    //定义dp数组:dp[j]表示背包容量为j时,能获得的最大价值
    int[] dp = new int[bagWeight + 1];
    //遍历顺序:先遍历物品,再遍历背包容量
    for (int i = 0; i < wLen; i++){
        for (int j = bagWeight; j >= weight[i]; j--){
            dp[j] = Math.max(dp[j], dp[j - weight[i]] + value[i]);
        }
    }
    //打印dp数组
    for (int j = 0; j <= bagWeight; j++){
        System.out.print(dp[j] + " ");
    }
}

416. 分割等和子集

本题一开始看起来比较像是回溯类的问题,结果回溯算法写出来以后,可能会超时,所以,用01背包问题试着进行解答。

根据题意,我们可以理解成:找出一个总和为原数组总和一半的子集。转化为01背包问题有以下几个要点

  • 背包容量为原数组总和一半

  • 物品为nums数组的每个元素

  • nums中的每个元素,既是物品的价值又是物品的重量

  • 背包中的的物品不能重复

  • 当背包正好装满,则说明找到了等和子集

class Solution {
    public boolean canPartition(int[] nums) {
        int sum = 0;
        for (int num : nums) {
            sum+=num;
        }
        if (sum%2 != 0) return false;//首先排除总和为奇数的情况,肯定没有等和子集
        int pageSize = sum/2;
        //回溯也可以解决,但是可能会超时,使用01背包
        //背包容量:sum/2,重量 = 价值,是否能正好装满,每个元素不可重复放入
        int[] dp = new int[pageSize+1];
        for (int i = 0; i < nums.length; i++) {
            for (int j = pageSize; j >= nums[i] ; j--) {
                dp[j] = Math.max(dp[j], dp[j-nums[i]] + nums[i]);
            }
        }
        if (dp[pageSize] == pageSize) return true;
        else return false;
    }
}

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

本题和上一题其实本质上是一样的,本题是尽可能的把石头分成重量相近的两堆,然后两堆石头重量的差值就是题目要求的。

前面的解题思路和上题一样,

  • 先求出总和的一半作为背包容量(但是不用排除奇数的情况,除出来的不是整数,直接去尾法)

  • 物品为stones的每个元素,其既是物品的价值又是物品的重量

  • 背包中的的物品不能重复

class Solution {
    public int lastStoneWeightII(int[] stones) {
        int sum = 0;
        for (int stone : stones) {
            sum+=stone;
        }
        int target = sum/2;
        int[] dp = new int[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]);
            }
        }
        return sum - 2*dp[target];
    }
}

494. 目标和

本题是有公式推导环节的,第一次做真的不容易做出来,但是公式后面基本就是01背包的思想了,但是本题求的是有多少种组合的方式,而不是求和,所以递推公式还要再改一下。

我们先来推导公式:

根据题目要求,其实本质就是把数组的数字分成两堆(left和right),然后让left - right。得出来的差值就是target。而left+right是固定不变得,我们命名为sum

于是我们有以下两个公式

  • left + right = sum

  • left - right = target

两个式子联立,得到left = (sum + target) / 2。此时本题就变成了,从nums中取物品,放到背包容量为left的背包里,问有多少种放法。

  1. dp数组

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

  2. 递推公式

    我们要想清楚dp[j]的来源,只来自于一个地方,就是dp[j - nums[i]],只需要每次循环把dp[j - nums[i]]加上去即可。

    dp[j] += dp[j - nums[i]]//这个公式可以记住,会经常用到
  3. 初始化

    根据递推公式,我们可以知道,dp数组只和它前面的值有关系,只在前面的值上加减,所以dp[0]一定不能是0。

    此处我们初始化为1。

  4. 遍历顺序

    就是普通的一维数组的01背包的遍历顺序

    for (int i = 0; i < nums.length; i++) {
        for (int j = pageSize; j >=nums[i] ; j--) {
            dp[j] += dp[j - nums[i]];
        }
    }
  5. 举例

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

class Solution {
    public int findTargetSumWays(int[] nums, int target) {
        //有一定的公式推导
        //求和
        int sum = 0;
        for (int num : nums) {
            sum+=num;
        }
        //排除不存在的情况
        if (sum < Math.abs(target)) return 0;
        if ((sum+target)%2 != 0) return 0;
        int pageSize = (sum+target)/2;
        //定义dp[j]数组,dp[j] 表示:填满j(包括j)这么大容积的包,有dp[j]种方法
        int[] dp = new int[pageSize+1];
        //初始化
        dp[0] = 1;
        for (int i = 0; i < nums.length; i++) {
            for (int j = pageSize; j >=nums[i] ; j--) {
                dp[j] += dp[j - nums[i]];
            }
        }
        return dp[pageSize];
    }
}

474. 一和零

本题还有比较难的,主要是不太好理解,不知道该怎么定义dp数组,怎么将背包问题套进这道题。

我们很容易就把m和n当成是物品了,然后想当然的认为结果集合为背包,这样就很容易把思路弄乱

我们应该把m和n看成是背包,只不过是两个维度的背包,此时就不能用滚动数组来一维的表示dp数组了。然后把strs中每个str作为物品

  1. 确定dp数组

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

  2. 推导公式

    我们要知道当前dp[i][j]的值,就要知道当前str中有多少个0和1,我们设为zeroNum和oneNum。

     dp[i][j] = max(dp[i][j], dp[i - zeroNum][j - oneNum] + 1);
  3. 初始化

    全部初始化为0

  4. 确定遍历顺序

    • 内外层的遍历顺序,先物品还是先背包都可以,我们习惯先物品

    • 但是循环内部应该都是从后往前的,因为从前往后的话,可能会出现当前的zeroNum(oneNum)大于m(n)的情况,从而出现数组下标越界。

      for (int i = 1; i <= zeroNum; i++) {
          for (int j = 1; j <= oneNum; j++) {
              dp[i][j] = Math.max(dp[i][j], dp[i - zeroNum][j - oneNum] + 1);
          }
      }

      所以应该是从后往前遍历才能避免越界的情况

      for (int i = 1; i <= zeroNum; i++) {
          for (int j = 1; j <= oneNum; j++) {
              dp[i][j] = Math.max(dp[i][j], dp[i - zeroNum][j - oneNum] + 1);
          }
      }
  5. 举例

class Solution {
    public int findMaxForm(String[] strs, int m, int n) {
        //dp[i][j]表示i个0和j个1时的最大子集
        int[][] dp = new int[m + 1][n + 1];
        int oneNum, zeroNum;
        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];
    }
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值