背包九讲小结与 leetcode相关例题

引言

最近在做leetcode的动态规划相关题目时,遇到了背包DP的相关问题,由于之前没有接触过,于是去阅读了背包九讲并结合leetcode上的题目进行加深印象,在做的过程中,也遇到了一些问题,借此记录并总结~

问题描述:

基本背包问题:

基本背包问题分为0-1背包和完全背包,他们的区别在于每个物品只能拿一次或是无限次。

首先我们使用以下的符号来表示各个变量

  • c i c_i ci: 物品 i i i 所占用空间的大小
  • w i w_i wi:物品 i i i 的价值
  • V V V: 背包总容量

因此背包问题(Knapsack Problem)描述的就是在有容量约束的情况下,求考虑了所有物品(大小价值各异)之后的最优解或解的个数。

状态转移方程:

0-1背包问题: F ( i , v ) = max ⁡ { F ( i − 1 , v ) , F ( i − 1 , v − c i ) + w i } F({i},v) = \max \{ F(i - 1,v),F(i - 1,v - {c_i}) + {w_i}\} F(i,v)=max{F(i1,v),F(i1,vci)+wi}

首先状态方程 F ( i , v ) F(i,v) F(i,v) 定义为将前 i i i件物品放入容量为 v v v的背包中,由于每个当前第 i i i件物品可以放也可以不放,当不放时,就转化为“前i 1件物品放入容量为v的背包中”;当放时,子问题就转化为“前 i − 1 i -1 i1件物品放入剩下的容量为 v − c i v-c_i vci的背包中。由于上述的状态方程时由二维数组实现,而我们发现当前 i i i 物品只与 i − 1 i-1 i1物品的状态方程相关,因此我们可以利用滚动数组将二维转化为一维,但是在遍历时,为了避免当前 i i i物品对状态方程的影响,我们需要倒序遍历,具体原因在后面例题会进行分析。

空间优化后: F ( v ) = max ⁡ { F ( v ) , F ( v − c i ) + w i } F(v) = \max\{ F(v),{\rm{ }}F(v - {c_i}){\rm{ }} + {w_i}\} F(v)=max{F(v),F(vci)+wi}

完全背包问题: 其实和0-1背包问题非常的相似,但是由于一个物品可以多次选择,因此尽管当前没有选择 i i i物品,但是可能在之前就已经选过该物品了
所以该状态方程可以写为: F ( i , v ) = max ⁡ { F ( i − 1 , v ) , F ( i , v − c i ) + w i } F({i},v) = \max \{ F(i - 1,v),F(i,v - {c_i}) + {w_i}\} F(i,v)=max{F(i1,v),F(i,vci)+wi}。由于当前 i i i物品可以对状态方程有影响,因此在进行空间优化时,我们可以采用正序遍历的方法。状态转移方程仍然为:

F ( v ) = max ⁡ { F ( v ) , F ( v − c i ) + w i } F(v) = \max\{ F(v),{\rm{ }}F(v - {c_i}){\rm{ }} + {w_i}\} F(v)=max{F(v),F(vci)+wi}

// 0-1背包:因为只利用到上一行信息,倒序保证安全更新
for(int num :nums){
    for(int j = V; V>=ci;j--)
}
    

// 完全背包:因为可能利用到本行信息,正序保证及时更新
for(int num :nums){
    for(int j = 0; j<V;j++)
}

同时,由于背包问题所要求的结果不一定是价值,还可能是方案数等其他情况。

dp[j] = Math.max(dp[j], dp[j-ci]+wi) // 最值
dp[j] += dp[j-ci] // 方案数
dp[j] |= dp[j-ci]// 可行性

0-1背包问题相关例题:

leetcode 416.分割等和子集(可行性)

题目描述:

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

示例:

Input: nums = [1,5,11,5]
Output: true 数组可以分割成 [1, 5, 5] 和 [11]
Input: nums = [1,2,3,5]
Output: false

解题思路:

该题如果需要转化为背包问题,也就是从nums数组里抽出一定的元素,对于每个元素,我可以抽,也可以不抽(背与不背),那么总容量是什么呢?我们这里需要转化一下,因为我们是使得两个子集的元素和相等,那么也就是一个子集的元素和为原nums数组元素和的一半,因此容量为 s u m / 2 sum/2 sum/2。每个物品的大小也就是元素的值。

算法代码:

class Solution {
    public boolean canPartition(int[] nums) {
        int n = nums.length;
        int maxNum = 0, sum = 0;
        for(int num : nums){
            sum += num;
            //记录最大值,如果最大值超过了一半,那么肯定凑不出两个子集相等的元素。
            maxNum = Math.max(maxNum, num);
        }
        int target = sum / 2;
        // sum必须为偶数,否则子集和就会带小数点,而题目要求每个元素为正整数。
        if(sum % 2 != 0 || maxNum > target) return false;
        boolean[] dp = new boolean[target + 1];
        // dp[0] 的意思当前要求和为0,那么方案是当0个元素选时,可以凑出这个答案。因此dp[0]初始值为true.
        dp[0] = true;
        // 从1开始是由于dp[0][1...target]一定为false,就没必要额外的去计算了。
        for(int i = 1; i < n; i ++){
            for(int j = target; j >= nums[i]; j--){
                dp[j] = dp[j] || dp[j-nums[i]];
            }
        }
        return dp[target];
    }
}
倒序遍历的思考:

对于为什么倒序遍历,这里我用语言解释一下,我们知道在0-1背包问题中,为什么能用1维代替2维,是因为第i行的状态转移方程只与i-1行有关,因此在1维中,我们必须保证dp[j] = dp[j] || dp[j-nums[i]]中,等号右边的值都是上一行更新的的,在这一行我们从未更新。而如果我们采用正序遍历会出现什么情况呢?由于等号右边存在dp[j-nums[i]]。我们一眼就可以看出j-nums[i]一定比j小吧,也就是说当前外循环进行到i行时,我们从小往大遍历j,那么j1 == j-nums[i] < j 是不是一定比j提前更新了呢,这时这个dp[j-nums[i]]是不是就跟当前i相关了。这和我们的需求不符。而如果采用倒序遍历则可以避免这种情况的出现!

leetcode 494. 目标和(可行方案数)

题目描述:

给你一个整数数组 nums 和一个整数 target 。向数组中的每个整数前添加 ‘+’ 或 ‘-’ ,然后串联起所有整数,可以构造一个 表达式 :

例如,nums = [2, 1] ,可以在 2 之前添加 ‘+’ ,在 1 之前添加 ‘-’ ,然后串联起来得到表达式 “+2-1” 。
返回可以通过上述方法构造的、运算结果等于 target 的不同 表达式 的数目。

示例:

Input: nums = [1,1,1,1,1], target = 3
Output: 5
Input: nums = [1], target = 1
Output: 1

解题思路:

题目要求将整数数组的元素之间加或减,最终得到一个target。该题转化为背包问题的关键在于一个元素要么是加要么是减(要么背,要么不背)。因此我们也就是从数组中挑出一定的元素,作为相减的元素或者相加的元素,那么这些元素跟target之间有什么关系呢?已知我们可以得到原数组元素的总sum,假如posSum 为正数的和,negSum为负数的绝对值和,那么可以得到两个方程: p o s S u m − n e g S u m = t a r g e t posSum-negSum = target posSumnegSum=target p o s S u m + n e g S u m = s u m posSum + negSum = sum posSum+negSum=sum。两方程联立就可以求出正数或负数的绝对值和是多少,而这将作为背包问题中的容量V。元素的值作为物品的大小,这里我们采取负数的元素作为求解。

算法代码:

class Solution {
    public int findTargetSumWays(int[] nums, int target) {
        int n = nums.length;
        int sum = 0;
        for(int num : nums){
            sum += num;
        }
        int diff = sum - target;
        // 由于我们求的为绝对值,且negSum为正整数,因此一定为偶数。
        if(diff < 0 || diff % 2 != 0) return 0;
        int neg = diff / 2;
        int dp[] = new int[neg+1];
        // 元素和为0时,不管能选几个元素,总有一种方案。
        dp[0] = 1;
        for(int i = 1; i < n; i++){
            for(int j = neg; j >= nums[i]; j --){
                dp[j] += dp[j - nums[i]];
            }
        }
        return dp[neg];
    }
}
leetcode 1049. 最后一块石头的重量 II(最小值问题,实际还是可行性问题)

题目描述:

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

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

如果 x == y,那么两块石头都会被完全粉碎;
如果 x != y,那么重量为 x 的石头将会完全粉碎,而重量为 y 的石头新重量为 y-x。
最后,最多只会剩下一块 石头。返回此石头 最小的可能重量 。如果没有石头剩下,就返回 0。

示例:

Input: stones = [2,7,4,1,8,1]
Output: 1
Input: stones = [31,26,33,21,40]
Output: 5

解题思路:

其实这题和上面两题有点相似,因为该题本质上就是元素的加减问题,但是这个target我们并不确定,而是我们需要求的一个最小值,什么时候最后一块石头的重量会是最小的呢,只有当两边的子集和大小非常接近时,这时他们相减,最后一块石头的重量就会接近于0。我们可以设置target也就是容量为石头总重量的一半,由于该容量不一定可行,因此我们需要逐次递减,求最接近target的可行的容量。

算法代码:

class Solution {
    // 本质上是stones元素的相减,y-x。
    // 当第一次两两相减时,得到新的stones数组,下一次仍然要两两相减
    // 所以正负号只有得到最优解的时候才能确定。
    // 因此就是选出一定数量的石头,使得选出的石头趋于总重量的一半,
    // 因此我们就从重量的一半开始,逆序进行判断,是否能够凑齐这个重量的石头
    public int lastStoneWeightII(int[] stones) {
        int sum = 0;
        for(int weight : stones){
            sum += weight;
        }
        int target = sum / 2;  // 如果选出的石头能凑齐m最好,凑不齐则逐渐-1。
        boolean[] dp = new boolean[t+1];
        dp[0] = true; // 重量为0的一定能凑齐
        for(int weight : stones){
            for(int j = target; j >= weight; j--){
                dp[j] |= dp[j - weight];
            }
        }
        int res = 0;
        for(int j = target; j>=0; j--){
            if(dp[j]){
                res = sum - 2 * j;
                break;
            } 
        }
        return res;
    }
}

完全背包问题相关例题:

leetcode 322. 零钱兑换(求最小值)

题目描述:

给你一个整数数组 coins ,表示不同面额的硬币;以及一个整数 amount ,表示总金额。

计算并返回可以凑成总金额所需的 最少的硬币个数 。如果没有任何一种硬币组合能组成总金额,返回 -1 。

你可以认为每种硬币的数量是无限的。

示例:

Input: coins = [1, 2, 5], amount = 11
Output: 3
Input: coins = [2], amount = 3
Output: -1

解题思路:

本题与0-1背包的区别在于,这里的每个物品的大小为硬币的面额,而不再是每个物品只能选一次,而是能选无限次,在前面我们提到,完全背包和0-1背包的区别在于遍历的顺序不同,知道这一点,本题就比较好做了,而有一些细节问题再代码注释中给出。

算法代码:

class Solution {
    public int coinChange(int[] coins, int amount) {
        if(amount < 0) return -1;
        int[] dp = new int[amount+1];
        // 这里初始化dp数组的值,由于题目要求求最大值,因此我们给每个元素赋予amount+1的初始值
        // 如果算法执行完,数组元素没有更新,说明这个总金额无法用硬币凑成,返回-1.
        Arrays.fill(dp, amount+1);
        // 0金额的只需要0个硬币
        dp[0]=0;
        for(int coin : coins){
            // 注意这里采用正序遍历,因为选完当前硬币后,继续遍历剩余余额时,仍然可以选择当前硬币。
            for(int j = 0; j <= amount; j++){
                if(coin <= j){
                    dp[j] = Math.min(dp[j], dp[j-coin] + 1);
                }
            }
        }
        return dp[amount] > amount ? -1 : dp[amount];
    }
}
leetcode 518. 零钱兑换 II(求方案数)

题目描述:

给你一个整数数组 coins ,表示不同面额的硬币;以及一个整数 amount ,表示总金额。

计算并返回可以凑成总金额所需的 最少的硬币个数 。如果没有任何一种硬币组合能组成总金额,返回 -1 。

你可以认为每种硬币的数量是无限的。

示例:

Input: coins = [1, 2, 5], amount = 11
Output: 3
Input: coins = [2], amount = 3
Output: -1

解题思路:

跟上题类似,区别在于本题求的是方案数。

class Solution {
    public int change(int amount, int[] coins) {
        int[] dp = new int[amount + 1];
        dp[0] = 1;
        for(int coin : coins){
            for(int i = 1; i <= amount; i++){
                if(coin <= i)
                    dp[i] += dp[i - coin];
            }
        }
        return dp[amount];
    }
}
关于内外循环顺序问题:

如果amount在外面,coin金额大小在里面的话,会有重复方案的出现,比如说拥有硬币[1,2,5],amount为5时,amount从小变大, amount=1 时,只有{1}这个方案,即dp[1] = 1,而dp[0] = 1。dp[1] = dp[1] + dp[1 - 1] 这时就会出现{1,0},{0,1} 两种情况,而题目要求并不考虑顺序问题。简单来说就是 dp[i] = dp[i] + dp[i - coin],这里面的dp[i-coin]会包括重复的情况,累加后,重复的就会继续累加。再举一个例子:

当amount = 5 时,有即dp[0] = {5}, dp[4] = {2,2,1}, dp[3] = {1,2,2}, {2,1,2}这几个方案,我们发现dp[3] 的方案实际上时重复的,重复的原因是在计算dp[3]时重复了。

当amount = 3时, 有dp[3-2] = {1,2}, dp[2] ={2,1}这两个方案,由于我们在遍历时,选择coin的硬币由小到大,这时dp[2]就可以选择要1这个硬币凑成余额3, dp[1] 就可以选择要2这个硬币凑成余额3,但是实际上这两个方案只是选择硬币的前后时间。因此我们需要避免这样的情况发生,就需要在计算一个余额的方案时,只有一个硬币的选项可选,要么要这个硬币,要么就不要。为达到这样的目的,就需要把硬币的选项放在外循环,先计算由这个硬币产生的方案个数。

而如果反过来:coin 已经确定,内部则是amount从小到大,此时dp[i] 则表示当前只用这个金额大小的coin能不能凑齐amount,凑不齐,方案就不行。而dp[i-coin] 表示我不用当前这个coin,这时amount = i - coin,这个amount只在上一个coin的时候用过,当前的coin还没有用来凑amount。通过这样的循环,可以保证每次计算一个新的amount时,我这个coin要么用要么没用,从来没有在之前计算amount时用过coin,也就不会出现重复情况了。

leetcode 377.组合总和 Ⅳ(求方案数,不算背包问题,是一种特例)

题目描述:

给你一个由 不同 整数组成的数组 nums ,和一个目标整数 target 。请你从 nums 中找出并返回总和为 target 的元素组合的个数。

题目数据保证答案符合 32 位整数范围。

示例:

Input: nums = [1,2,3], target = 4
Output: 7
Input: nums = [9], target = 3
Output: 0

解题思路:

跟上一题类似,但是不属于背包问题,因为背包问题出现的时候,并没有说因为先后选择这个物品,产生的容量方案就会发生改变,所以我认为该题属于背包问题的变例,仍然是使用动态规划来做,由于出现多次重复的方案,因此需要交换循环位置。

算法代码:

class Solution {
    public int combinationSum4(int[] nums, int target) {
        int[] dp = new int[target + 1];
        dp[0] = 1;
        for(int i = 0; i <= target; i++){
            for(int num : nums){
                if(num <= i)
                    dp[i] += dp[i - num];
            }
        }
        return dp[target];
    }
}

二维背包问题相关例题:

leetcode 474. 一和零(二维求最大值)

题目描述:

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

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

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

示例:

Input: strs = ["10", "0001", "111001", "1", "0"], m = 5, n = 3
Output: 4
Input: strs = ["10", "0", "1"], m = 1, n = 1
Output: 2

解题思路:

该题属于多维的背包问题,即选择一个物品具有两种不同的代价,因此我们需要二维空间来记录这两个代价,其余跟0-1背包相同。

算法代码:

class Solution {
    public int findMaxForm(String[] strs, int m, int n) {
        // 背包问题,一个元素里面需要花费不同的代价
        int[][] dp = new int[m+1][n+1];
        dp[0][0] = 0;
        for(String str : strs){
            int[] count = getZerosOnes(str);
            int zeros = count[0];
            int ones = count[1];
            for(int j = m; j >= zeros; j--){
                for(int k = n; k >= ones; k--){
                    dp[j][k] = Math.max(dp[j][k], dp[j-zeros][k-ones] + 1);
                }
            }
        }
        return dp[m][n];
    }

    public int[] getZerosOnes(String str){
        int[] count = new int[2];
        for(int c : str.toCharArray()){
            count[c - '0']++;
        }
        return count;
    }
}
leetcode 879. 盈利计划(二维求方案数)

题目描述:

集团里有 n 名员工,他们可以完成各种各样的工作创造利润。

第 i 种工作会产生 profit[i] 的利润,它要求 group[i] 名成员共同参与。如果成员参与了其中一项工作,就不能参与另一项工作。

工作的任何至少产生 minProfit 利润的子集称为 盈利计划 。并且工作的成员总数最多为 n 。

有多少种计划可以选择?因为答案很大,所以 返回结果模 10^9 + 7 的值

示例:

Input: n = 5, minProfit = 3, group = [2,2], profit = [2,3]
Output: 2
Input: n = 10, minProfit = 5, group = [2,3,5], profit = [6,7,8]
Output: 7

解题思路:

本题实际跟上一题十分相似,但是它在leetcode中属于困难题,关键是需要理解题意,我们该将哪几个变量作为物品的代价,通过阅读题,我们发现员工是有限制的,最大不能超过n,总利润是有限制的,利润最小要达到minProfit,而这些代价都与第几种工作有关,因此我们选择的物品为工作,员工和利润作为代价,这样就可以写出相关代码,本题由于可以制定两种不同的状态方程,因此有两种类型的代码。

算法代码:

第一种状态转移方程,由于在初始化时,只初始化了dp[0][0] = 1, 该状态表示的是在0个员工时,当前最小利润为0 时,有一个方案,这种方程指定了员工的个数,也就是在计算不同的dp[j][k] 时,表示达到最小利润k时,所需要员工j的方案数,因此我们在最后计算时需要累加,将员工数量不同时的方案数进行累加。

class Solution {
    public int profitableSchemes(int n, int minProfit, int[] group, int[] profit) {
        int m = group.length;
        int[][] dp = new int[n + 1][minProfit + 1];
        //如果是定义为dp[0][0] = 1, 该dp说明在0个员工,0利润时,方案数为1,指定了员工的个数。
        dp[0][0] = 1;
        int mod =  (int)1e9 + 7;
        for(int i = 0; i < profit.length; i++){
            for(int j = n; j >= group[i]; j-- ){
                // 这里需要注意,每份工作所需要的员工不能超出总人数
                // 但是每份工作的利润可以超过最低利润,意味着只需要采用当前的工作,利润就可以达到最低利润。
                // 因此当k-profit[i]<0时,则采用最低利润为0时的状态。
                for(int k = minProfit; k >= 0; k --){
                    dp[j][k] = (dp[j][k] + dp[j - group[i]][Math.max(0,k - profit[i])]) % mod;
                }
            }
        }
        int sum = 0;
        for(int j = 0; j <=n; j++){
            sum = (sum + dp[j][minProfit]) % mod;
        }
        return sum;
    }
}

第二种方案的状态转移方程是在0-j个员工的前提下,达到minprofit的方案数,因此最后就不需要进行累加,由于实际上在以往的背包问题中,状态的定义都是类似这样的,数量并不指定,而是最大不能超过该数量的前提下来制定状态转移方程,所以我推荐使用该状态转移方程。

class Solution {
    public int profitableSchemes(int n, int minProfit, int[] group, int[] profit) {
        int m = group.length;
        int[][] dp = new int[n + 1][minProfit + 1];
        // 该dp的定义是,在0-j个员工中,达到minprofit的方案数,因此在后面就不再需要叠加求和。
        for(int j = 0; j <= n; j++){
            dp[j][0] = 1;  
        }
        int mod =  (int)1e9 + 7;
        for(int i = 0; i < profit.length; i++){
            for(int j = n; j >= group[i]; j-- ){
                for(int k = minProfit; k >= 0; k --){
                    dp[j][k] = (dp[j][k] + dp[j - group[i]][Math.max(0,k - profit[i])]) % mod;
                }
            }
        }

        return dp[n][minProfit];
    }
}

如有问题,欢迎大家指出!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值