背包九讲
https://blog.csdn.net/yandaoqiusheng/article/details/84782655
leedcode
0-1 背包问题
第 416 题:分割等和子集;
第 474 题:一和零;
第 494 题:目标和。
第 377 题. 组合总和 Ⅳ
完全背包问题如下:
第 322 题:零钱兑换;
第 518 题:零钱兑换 II。
常见背包问题分类
1、组合问题:
377. 组合总和 Ⅳ
494. 目标和
518. 零钱兑换 II
组合问题公式:
dp[0] = 1//怎么理解??
dp[i] += dp[i-num]//怎么理解??
2、True、False问题:
139. 单词拆分
416. 分割等和子集
True、False问题公式:
dp[i] = dp[i] or dp[i-num]
3、最大最小问题:
474. 一和零
322. 零钱兑换
最大最小问题公式:
dp[i] = min(dp[i], dp[i-num]+1)或者dp[i] = max(dp[i], dp[i-num]+1)
当然拿到问题后,需要做到以下几个步骤:
1.分析是否为背包问题。
2.是以上三种背包问题中的哪一种。
3.是0-1背包问题还是完全背包问题。也就是题目给的nums数组中的元素是否可以重复使用。
4.如果是组合问题,是否需要考虑元素之间的顺序。需要考虑顺序有顺序的解法,不需要考虑顺序又有对应的解法。
接下来讲一下背包问题的判定
背包问题具备的特征:给定一个target,target可以是数字也可以是字符串,再给定一个数组nums,nums中装的可能是数字,也可能是字符串,问:能否使用nums中的元素做各种排列组合得到target。
背包问题技巧:
- 1.如果是0-1背包,即数组中的元素不可重复使用,nums放在外循环,target在内循环,且内循环倒序;
0-1背包问题,为什么内循环要倒序呢?因为01背包不能重复选择
for num in nums:
for i in range(target, nums-1, -1):
- 2.如果是完全背包,即数组中的元素可重复使用,nums放在外循环,target在内循环。且内循环正序。
完全背包的内外循环可以颠倒~
for num in nums:
for i in range(nums, target+1):
- 3.如果组合问题需考虑元素之间的顺序,需将target放在外循环,将nums放在内循环。
为什么考虑顺序需要把循环层次对调?target 放在外层循环的话,是一个target的值对应nums所有的值,说的简单点就是这个target的值由nums中的某些组成,所以是有可能重复的。
for i in range(1, target+1):
for num in nums:
代码
class Solution:
def combinationSum4(self, nums: List[int], target: int) -> int:
if not nums:
return 0
dp = [0] * (target+1)
dp[0] = 1
for i in range(1,target+1):
for num in nums:
if i >= num:
dp[i] += dp[i-num]
return dp[target]
416. 分割等和子集 动态规划
给定一个只包含正整数的非空数组。是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。
注意:
每个数组中的元素不会超过 100
数组的大小不会超过 200
示例1:
输入: [1, 5, 11, 5]
输出: true
解释: 数组可以分割成 [1, 5, 5] 和 [11].
示例2:
输入: [1, 2, 3, 5]
输出: false
解释: 数组不能分割成两个元素和相等的子集.
首先回忆一下背包问题大致的描述是什么:
给你一个可装载重量为 W 的背包和 N 个物品,每个物品有重量和价值两个属性。
其中第 i 个物品的重量为 wt[i],价值为 val[i],现在让你用这个背包装物品,最多能装的价值是多少?
那么对于这个问题,我们可以先对集合求和,得出 sum,把问题转化为背包问题:
给一个可装载重量为 sum / 2 的背包和 N 个物品,每个物品的重量为 nums[i]。
现在让你装物品,是否存在一种装法,能够恰好将背包装满?
解法步骤
动态规划=填表
背包问题的表长什么样?为什么会多一行?
填表的顺序是什么样?
表格怎么初始化?(重点)
如何优化空间复杂度?
优化空间复杂度时,为什么是从右到左(逆序)填表?
第一步要明确两点,「状态」和「选择」。
0-1 背包问题 :
- 状态就是「背包的容量」和「可选择的物品」,
- 选择就是「装进背包」或者「不装进背包」。
第二步要明确 dp 数组的定义。
按照背包问题的套路,可以给出如下定义:
dp[i][j] = x
表示,对于前 i 个物品
,当前背包的容量为 j 时,若 x 为 true,则说明可以恰好将背包装满,若 x 为 false,则说明不能恰好将背包装满。
比如说,如果 dp[4][9] = true
,其含义为:对于容量为 9 的背包,若只是用前 4 个物品,可以有一种方法把背包恰好装满。
或者说对于本题,含义是对于给定的集合中,若只对前 4 个数字进行选择,存在一个子集的和可以恰好凑出 9。
根据这个定义,我们想求的最终答案就是 dp[N][sum/2]
,base case 就是 dp[..][0] = true
和 dp[0][..] = false
,因为背包没有空间的时候,就相当于装满了,而当没有物品可选择的时候,肯定没办法装满背包。
关于bace case的讨论:
dp[0][0]表明装入0个物品,容量为0的背包恰好可以装满,理应为true,但是不符合题意,因此写成false~
第三步,根据「选择」,思考状态转移的逻辑。
回想刚才的 dp 数组含义,可以根据「选择」对 dp[i][j]
得到以下状态转移:
for i 前0......n个物品
for j 背包容量0.......m
选择{
if (j - nums[i - 1] < 0) {
// 背包容量不足,不能装入第 i 个物品
dp[i][j] = dp[i - 1][j]; // 保持不装入的状态
} else {
// 装入或不装入背包
dp[i][j] = dp[i - 1][j] | dp[i - 1][j-nums[i-1]];
}
}
如果不把 nums[i]
算入子集,或者说你不把这第 i 个物品装入背包,那么是否能够恰好装满背包,取决于上一个状态 dp[i-1][j]
,继承之前的结果。
如果把 nums[i]
算入子集,或者说你把这第 i 个物品装入了背包,那么是否能够恰好装满背包,取决于状态 dp[i - 1][j-nums[i-1]]
。
首先,由于 i 是从 1 开始的,而数组索引是从 0 开始的,所以第 i 个物品的重量应该是 nums[i-1]
,这一点不要搞混。
dp[i - 1][j-nums[i-1]]
也很好理解:你如果装了第 i 个物品,就要看背包的剩余重量j - nums[i-1]
限制下是否能够被恰好装满。
换句话说,如果j - nums[i-1]
的重量可以被恰好装满,那么只要把第 i 个物品装进去,也可恰好装满 j 的重量;否则的话,重量 j 肯定是装不满的。
bool canPartition(vector<int>& nums) {
int sum = 0;
for (int num : nums) sum += num;
// 和为奇数时,不可能划分成两个和相等的集合
if (sum % 2 != 0) return false;
int n = nums.size();
sum = sum / 2;
vector<vector<bool>>
dp(n + 1, vector<bool>(sum + 1, false));
// base case
for (int i = 0; i <= n; i++)
dp[i][0] = true;
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= sum; j++) {
if (j - nums[i - 1] < 0) {
// 背包容量不足,不能装入第 i 个物品
dp[i][j] = dp[i - 1][j];
} else {
// 装入或不装入背包
dp[i][j] = dp[i - 1][j] | dp[i - 1][j-nums[i-1]];
}
}
}
return dp[n][sum];
}
第四步:空间复杂度优化
注意到dp[i][j]
都是通过上一行dp[i-1][..]
转移过来的,之前的数据都不会再使用了。
所以,我们可以进行状态压缩,将二维 dp 数组压缩为一维,节约空间复杂度:
bool canPartition(vector<int>& nums) {
int sum = 0, n = nums.size();
for (int num : nums) sum += num;
if (sum % 2 != 0) return false;
sum = sum / 2;
vector<bool> dp(sum + 1, false);
// base case
dp[0] = true;
for (int i = 0; i < n; i++)
for (int j = sum; j >= 0; j--)
if (j - nums[i] >= 0)
dp[j] = dp[j] || dp[j - nums[i]];
return dp[sum];
}
这就是状态压缩,其实这段代码和之前的解法思路完全相同,只在一行 dp 数组上操作,i 每进行一轮迭代,dp[j] 其实就相当于 dp[i-1][j]
,所以只需要一维数组就够用了。
唯一需要注意的是 j 应该从后往前反向遍历,因为每个物品(或者说数字)只能用一次,以免之前的结果影响其他的结果。
- 为什么要反向遍历?
因为本次状态dp[10]时会需要使用到上次状态dp[1],上次状态dp[1]必须在被本次状态dp[1]覆盖前,先用来求取本次状态dp[10],然后再求取本次状态dp[1]。所以反向遍历,才能避免:计算本次状态dp[10]误用本次状态dp[1],应该要用上次状态dp[1]才可以。
至此,子集切割的问题就完全解决了,时间复杂度 O(n*sum),空间复杂度 O(sum)。
474. 一和零 动态规划
现在,假设你分别支配着 m 个 0 和 n 个 1。另外,还有一个仅包含 0 和 1 字符串的数组。
你的任务是使用给定的 m 个 0 和 n 个 1 ,找到能拼出存在于数组中的字符串的最大数量。每个 0 和 1 至多被使用一次。
注意:
给定 0 和 1 的数量都不会超过 100。
给定字符串数组的长度不会超过 600。
示例 1:
输入: Array = {"10", "0001", "111001", "1", "0"}, m = 5, n = 3
输出: 4
解释: 总共 4 个字符串可以通过 5 个 0 和 3 个 1 拼出,
即 "10","0001","1","0" 。
示例 2:
输入: Array = {"10", "0", "1"}, m = 1, n = 1
输出: 2
解释: 你可以拼出 "10",但之后就没有剩余数字了。
更好的选择是拼出 "0" 和 "1" 。
注意点:
每个0和1都只能被使用1次,因此是01背包。
m 个 0 和 n 个 1,因此有2个背包容量。
最大值问题,因此状态转移是dp[i] = max(dp[i], dp[i-num]+1)
class Solution {
public:
int findMaxForm(vector<string>& strs, int m, int n) {
// strs.size()+1使表格多了一行,dp[0][..][..]表示前0个物品的装入情况,
// 增加该行初始化成0.因为没有物品
vector<vector<vector<int>>> dp(strs.size()+1,vector<vector<int>>(m+1, vector<int>(n+1, 0)));
for(int i = 1; i <= strs.size(); i++){
// strs[i-1]需要偏移,因为i的意义是指前i个物品。
// i=1时,指前1个物品,而str[1]已经指向第2个物品,所以需要偏移
string s = strs[i-1];
int n0 = 0, n1 = 0;
getNumsOf01(s, n0, n1);
// 01背包没有空间优化时,内循环的正序倒序。
// 但是,加入空间优化后,就必须是倒序了。
for(int j = 0; j <= m; j++){
for(int k = 0; k <= n; k++){
// 比较物品重量与背包容量
if(n0 <= j && n1 <= k){
dp[i][j][k] = max(dp[i-1][j][k], 1+dp[i-1][j-n0][k-n1]);
}
else dp[i][j][k] = dp[i-1][j][k];
}
}
}
return dp[strs.size()][m][n];
}
int getNumsOf01(string& str, int& numOfZero, int& numOfOne){
for(int i = 0; i < str.size(); i++){
char c = str[i];
if(c == '0') numOfZero++;
else if(c == '1') numOfOne++;
}
return 0;
}
};
空间优化
观察动态转移方程,我们发现dp[i][][] 只和dp[i-1][][]
有关,所以可以去掉第一维,只用一个二维数组保存上一次计算的结果
注意:使用空间优化后,内循环需要逆序。
class Solution {
public:
int findMaxForm(vector<string>& strs, int m, int n) {
// strs.size()+1使表格多了一行,dp[0][..][..]表示前0个物品的装入情况,而该行通常被初始化成0.因为没有物品
vector<vector<int>> dp(vector<vector<int>>(m+1, vector<int>(n+1, 0)));
for(int i = 0; i < strs.size(); i++){
// strs[i-1]需要偏移,因为i的意义是指前i个物品。
// i=1时,指前1个物品,而str[1]已经指向第2个物品,所以需要偏移
string s = strs[i];
int n0 = 0, n1 = 0;
getNumsOf01(s, n0, n1);
for(int j = m; j >= n0; j--){
for(int k = n; k >= n1; k--){
// 比较物品重量与背包容量
if(n0 <= j && n1 <= k){
dp[j][k] = max(dp[j][k], 1 + dp[j-n0][k-n1]);
}
}
}
}
return dp[m][n];
}
int getNumsOf01(string& str, int& numOfZero, int& numOfOne){
for(int i = 0; i < str.size(); i++){
char c = str[i];
if(c == '0') numOfZero++;
else if(c == '1') numOfOne++;
}
return 0;
}
};
494. 目标和
从一个数组中挑选几个数组合成目标和,每个数只使用1次,即为01背包问题。
暴力法
dfs遍历所有元素,分为+
和-
两种情况,当目标和被满足时,res++
一种常规的思路
在【416.分割等和子集】这道题中,要求的输出结果就是boolean值,因此我们定义的dp数组只需要记录T/F即可,但是这道题要求返回结果是方法数,那么我们dp数组需要记录的数据就是具体的方法数。
- 状态:将
dp[ i ][ j ]
定义为从数组nums中 0 - i 的元素进行加减可以得到 j 的方法数量。 - 转移方程:这道题的关键不是
nums[i]
的选与不选,而是nums[i]
是加还是减,那么我们就可以将方程定义为:
dp[ i ][ j ] = dp[ i - 1 ][ j - nums[ i ] ] + dp[ i - 1 ][ j + nums[ i ] ]
- 表格:每一行的长度表示为:t=(sum*2)+1,其中一个sum表示nums中执行全部执行加/减能达到的数
由于数组中所有数的和不超过 1000,那么 j 的最小值可以达到 -1000。在很多语言中,是不允许数组的下标为负数的,因此我们需要给 dp[i][j]
的第二维预先增加 1000,即:
dp[i][j + nums[i] + 1000] += dp[i - 1][j + 1000]
dp[i][j - nums[i] + 1000] += dp[i - 1][j + 1000]
怎么把题目转化成简单的01背包的形式
思路:把整个集合看成两个子集,Q表示整个集合,P表示正数子集,N表示负数子集, TAREGT表示目标和,用SUM(X)表示集合的求和函数,集合中均为非负数,N集合是指选中这部分元素作为负数子集。
SUM(P)−SUM(N)=TARGET
SUM(P)+SUM(N) SUM(P)-SUM(N) = TARGET+SUM(P)+SUM(N)
SUM(P)+SUM(N)+SUM(P)-SUM(N)=TARGET+SUM(P)+SUM(N)
2SUM(P) = SUM(Q) + TARGET
2SUM(P) = SUM(Q) + TARGET
也就是:正数集的和的两倍 == 等于目标和 + 序列总和
所以问题就转换成了,找到一个正数集P,其和的两倍等于目标和+序列总和。
class Solution {
public:
int findTargetSumWays(vector<int>& nums, int S) {
// 所有元素合,作为背包容量。
int sum = 0;
for (const int &it : nums) sum += it;
// S + sum准备除以2,因此必须是奇数,并且给定的S可能为无穷大
if ((S + sum) % 2 == 1 || S > sum) return 0;
// 从nums中找到一个正数集P,其和的等于(目标和+序列总和)/2
S = (S + sum) / 2;
// 创建状态表格
int *dp = new int[S + 1];
memset(dp, 0, (S + 1) * sizeof(int));
// base case 目标数为0的取法有一种,就是什么数都不取
dp[0] = 1;
// 外层遍历:所有物品
for (const int &it : nums) {
// 内层遍历:所有容量
for (int j = S; j >= it; j--)
dp[j] += dp[j - it];
}
int ans = dp[S];
delete[] dp;
return ans;
}
};