背包问题是动态规划非常重要的一类问题,它有很多变种,但题目万变不离其宗。我们需要抓住关键的解题思路,现将解题模板总结如下:
在这里插入图片描述

背包问题的定义

那么什么样的问题可以被称作为背包问题?换言之,我们拿到题目如何透过题目的不同包装形式看到里面背包问题的不变内核呢?
我对背包问题定义的理解:给定一个背包容量target,再给定一个数组nums(物品),能否按一定方式选取nums中的元素得到target
注意:

1、背包容量target和物品nums的类型可能是数,也可能是字符串
2、target可能题目已经给出(显式),也可能是需要我们从题目的信息中挖掘出来(非显式)(常见的非显式target比如sum/2等)
3、选取方式有常见的一下几种:每个元素选一次/每个元素选多次/选元素进行排列组合 那么对应的背包问题就是下面我们要讲的背包分类

背包问题的分类

在我看来,背包问题可以总结为三类:01背包问题、完全背包问题以及分组背包问题。
01背包问题:每个元素最多取1次。具体来讲:一共有 N 件物品,第 i(i 从 1 开始)件物品的重量为 w[i],价值为 v[i]。在总重量不超过背包承载上限 W 的情况下,能够装入背包的最大价值是多少?

完全背包问题:每个元素可以取多次。具体来讲:完全背包与 01 背包不同就是每种物品可以有无限多个:一共有 N 种物品,每种物品有无限多个,第 i(i 从 1 开始)种物品的重量为 w[i],价值为 v[i]。在总重量不超过背包承载上限 W 的情况下,能够装入背包的最大价值是多少?

分组背包问题:有多个背包,需要对每个背包放入物品,每个背包的处理情况与完全背包完全相同。

在完全背包问题当中根据是否需要考虑排列组合问题(是否考虑物品顺序),可分为两种情况,我们可以通过内外循环的调换来处理排列组合问题,如果题目不是排列组合问题,则这两种方法都可以使用(推荐使用组合来解决)

而每个背包问题要求的也是不同的,按照所求问题分类,又可以分为以下几种:
1、最值问题:要求最大值/最小值
2、存在问题:是否存在…………,满足…………
3、组合问题:求所有满足……的排列组合

解题模板

背包问题大体的解题模板是两层循环,分别遍历物品nums和背包容量target,然后写转移方程,根据背包的分类我们确定物品和容量遍历的先后顺序,根据问题的分类我们确定状态转移方程的写法。

首先是背包分类的模板:
1、0/1背包:外循环nums,内循环target,target倒序且target>=nums[i];
2、完全背包(组合):外循环nums,内循环target,target正序且target>=nums[i];
3、完全背包(排列):外循环target,内循环nums,target正序且target>=nums[i];
4、分组背包:这个比较特殊,需要多重循环:外循环nums,内部循环根据题目的要求构建多重背包循环

LeetCode原题

我们通过一些例题来更好的理解背包问题

01背包

416. 分割等和子集

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

示例 1:

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

提示:

  • 1 <= nums.length <= 200
  • 1 <= nums[i] <= 100

思路:本题要求把数组分成两个等和的子集,相当于找到一个子集,其和为 sum / 2,这个 sum / 2 就是 target(target 间接给出)。
于是转化为是否可以用 nums 中的数组合和成 target,01 背包问题(组合),外层循环为选择池 num: nums,内层循环为 target。

class Solution {
    public boolean canPartition(int[] nums) {
        // 01背包问题
        int le = nums.length;
        int sum = 0;
        for(int num : nums)
            sum += num;
        if(sum % 2 == 1 || le == 1)
            return false;
        int target = sum/2;
        boolean[] dp = new boolean[target + 1];
        dp[0] = true;   // target等于0时为true

        for(int num : nums) {
            for(int i = target; i >= num; i--) {
                dp[i] |= dp[i - num];
            }
        }
        return dp[target];
    }
}

复杂度分析

  • 时间复杂度:O(target × n),其中 n 是数组 nums 的长度。
  • 空间复杂度:O(target)。

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

提示:

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

思路:题目所求的 target = 正数和 - 负数和 = Y - X
而已知 X与 Y 的和是数组总和:X + Y = sum
可以求出负数和 X = (sum - target) / 2 = temp (注意这个temp是我们临时作为的目标值target)
也就是我们要从 nums 数组里选出几个数(组合问题),令其和为 temp( 间接给出)。
注意:负数组的和为X,这里不能用正数组,因为target可能是负数

class Solution {
    public int findTargetSumWays(int[] nums, int target) {
        int le = nums.length;
        int sum = 0;
        for(int num: nums) {
            sum += num;
        }
        // 如果sum<target,即都为正数也不能组成target,则直接返回0
        if(sum < target || (sum - target) % 2 == 1)
            return 0;
        int X = (sum - target) / 2;     // 负数组的和为X,这里不能用正数组,因为target可能是负数
        int[] dp = new int[X + 1];
        dp[0] = 1;

        for(int num : nums) {
            for(int i = X; i >= num; i--) {
                dp[i] = dp[i] + dp[i - num];    // 负数组选num(dp[i-num])和不选num(dp[i])的数量相加为dp[i]
            }
        }
        return dp[X];
    }
}

复杂度分析

  • 时间复杂度:O(target × n),其中 n 是数组 nums 的长度。
  • 空间复杂度:O(target)。
完全背包

139. 单词拆分

给你一个字符串 s 和一个字符串列表 wordDict 作为字典。请你判断是否可以利用字典中出现的单词拼接出 s 。

注意:不要求字典中出现的单词全部都使用,并且字典中的单词可以重复使用。

示例 1

输入: s = “leetcode”, wordDict = [“leet”, “code”]
输出: true
解释: 返回 true 因为 “leetcode” 可以由 “leet” 和 “code” 拼接成。

示例 2

输入: s = “applepenapple”, wordDict = [“apple”, “pen”]
输出: true
解释: 返回true 因为 “applepenapple” 可以由 “apple” “pen” “apple” 拼接成。
注意:你可以重复使用字典中的单词。

提示

  • 1 <= s.length <= 300
  • 1 <= wordDict.length <= 1000
  • 1 <= wordDict[i].length <= 20
  • s 和 wordDict[i] 仅有小写英文字母组成
  • wordDict 中的所有字符串 互不相同

思路:转化为是否可以用 wordDict 中的词组合成 s,完全背包问题(排列),外层循环为 target ,内层循环为选择池 wordDict。
dp[i] 表示以 i 结尾的字符串是否可以被 wordDict 中组合而成。

  • 外层遍历 s 中每一个与 word 同长度的字串s.substring(i - wordLen, i) ;
  • 内层遍历 wordDict 每个 word。

判断 s.substring(i - wordLen, i).equals(word):

  • 若不相等,说明与该 word 不匹配,继续遍历;
  • 若相等,说明从 i - wordLen 到 i的字符与 word 匹配。

dp[i] = dp[i] || dp[i - wordLen];
对于边界条件,我们定义 dp[0] = true 表示空串且合法。
最后返回 dp[len];

class Solution {
    public boolean wordBreak(String s, List<String> wordDict) {
        int len = s.length();
        // 根据字符串s建dp数组
        boolean[] dp = new boolean[len + 1];
        dp[0] = true;

        for(int i = 1; i <= len; i++) {
            for(String word : wordDict) {
                int wordLen = word.length();   
                if(i - wordLen >= 0 && s.substring(i - wordLen, i).equals(word))      
                    dp[i] |= dp[i - wordLen];                   
            }
        }
        return dp[len];
    }
}

复杂度分析:
时间复杂度:O(target × n),其中 n 是数组 nums 的长度。
空间复杂度:O(target)。

279. 完全平方数
给你一个整数 n ,返回 和为 n 的完全平方数的最少数量 。
完全平方数 是一个整数,其值等于另一个整数的平方;换句话说,其值等于一个整数自乘的积。例如,1、4、9 和 16 都是完全平方数,而 3 和 11 不是。

示例 1:

输入:n = 12
输出:3
解释:12 = 4 + 4 + 4

示例 2:

输入:n = 13
输出:2
解释:13 = 4 + 9

提示:

  • 1 <= n <= 104

思路:我们想要的 S = 若干个完全平方数的和
完全平方数最小为 1,最大为 sqrt(n)
也就是我们要从 nums = [1, 2, …, sqrt(n)] 数组里选出几个数,令其平方和为 target = n。
于是转化为是否可以用 nums 中的数组合和成 target,完全背包问题(最值问题),外层循环为选择池 nums,内层循环为 target。
**注意:**本题是最值问题,所以不用考虑排列组合,内target外nums和内nums外target都可行。

class Solution {
    public int numSquares(int n) {
        int[] dp = new int[n + 1];
        Arrays.fill(dp, n + 1);
        dp[0] = 0;
        for(int i = 1; i <= n; ++i) {
            for(int j = 1; j * j <= i; ++j) {
                dp[i] = Math.min(dp[i], dp[i - j * j] + 1);
            }
        }
        return dp[n];
    }
}

复杂度分析:

  • 时间复杂度:O(n x sqrt{n}),在主步骤中,我们有一个嵌套循环,其中外部循环是 n 次迭代,而内部循环最多需要 sqrt{n} 迭代。
  • 空间复杂度:O(n),使用了一个一维数组 dp。

518. 零钱兑换 II
给你一个整数数组 coins 表示不同面额的硬币,另给一个整数 amount 表示总金额。
请你计算并返回可以凑成总金额的硬币组合数。如果任何硬币组合都无法凑出总金额,返回 0 。
假设每一种面额的硬币有无限个。
题目数据保证结果符合 32 位带符号整数。

示例 1:

输入:amount = 5, coins = [1, 2, 5]
输出:4
解释:有四种方式可以凑成总金额:
5=5
5=2+2+1
5=2+1+1+1
5=1+1+1+1+1

示例 2:

输入:amount = 3, coins = [2]
输出:0
解释:只用面额 2 的硬币不能凑成总金额 3 。

提示:

  • 1 <= coins.length <= 300
  • 1 <= coins[i] <= 5000
  • coins 中的所有值 互不相同
  • 0 <= amount <= 5000

思路:转化为是否可以用 coins 中的数组合和成 amount,完全背包问题(组合),外层循环为选择池 coins,内层循环为 amount。
对于元素之和等于 i - coin 的每一种组合,在最后添加 coin 之后即可得到一个元素之和等于 i 的组合,因此在计算 dp[i] 时,应该计算所有的 dp[i − coin] 之和。

dp[i] = dp[i] + dp[i - coin]

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 = coin; i <= amount; ++i) {
            	dp[i] += dp[i - coin];
            }
        }
        return dp[amount];
    }
}

复杂度分析:

  • 时间复杂度:O(amount x n),其中 n 为 coins 大小
  • 空间复杂度:O(amount)
多组背包

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中每个值的0和1个数
然后用dp来计算(01背包问题,只是这里是二维,即装两个背包)
使用空间优化

class Solution {
    public int findMaxForm(String[] strs, int m, int n) {
        
        int length = strs.length;
        int[][] dp = new int[m + 1][n + 1];
        for (int i = 1; i <= length; i++) {
            int[] zerosOnes = getZerosOnes(strs[i - 1]);
            int zeros = zerosOnes[0], ones = zerosOnes[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[] zerosOnes = new int[2];
        int length = str.length();
        for (int i = 0; i < length; i++) {
            zerosOnes[str.charAt(i) - '0']++;
        }
        return zerosOnes;
    }
}

复杂度分析:

  • 时间复杂度:O(lmn+L),遍历strs,其中求出zero和one,然后mn
  • 空间复杂度:O(mn),空间优化后为mn
  • l为strs的长度,L为strs中所有字符串总长度
Logo

为开发者提供学习成长、分享交流、生态实践、资源工具等服务,帮助开发者快速成长。

更多推荐