【算法】小汉堡再探动态规划,01背包智取等和子集
Part 1.介绍01背包问题
背包问题:
先略后详,一句话概括背包问题,就是如何让背包内物品价值达到最大。
有n种物品,每个物品有自己的重量w,有自己的价值v,有一个承重能力为质量m的背包,每种物品有一个或多个,求解这个背包最多可以装载价值为多少的物品。
不同于别的算法思想,01背包这个名字似乎很难“望名生意”,像二分、前缀和等算法,都可以在接触之前通过猜测名称的由来,从而大致了解算法的用途。本篇博客就由01背包名称由来说起。
由刚刚的简介可以得知,背包问题有几个关键要素:背包容量、物品价值、物品重量、物品数量。其中关于物品数量的描述显然具有歧义。实际上根据物品数量的不同,背包问题也被分解为了几个子问题,如图所示:
今天我们介绍其中最基础的01背包,上图同时也解释了01背包名称的由来:在01背包问题中,每种物品数量只有一个,故而一个物品也仅有两种状态(被选中或不被选中)。
Part 2.如何用DP解决01背包
在开始最关键的解题过程之前,首先需要理解背包问题为什么可以用动态规划解决,让我们回顾一下在什么情况下,可以优先使用动态规划:
动态规划,英文:Dynamic Programming,简称DP,如果某一问题有很多重叠子问题,使用动态规划是最有效的。
动态规划中每一个状态一定是由上一个状态推导出来的。
在01背包问题中,我们将问题拆解成两个维度,一个是物品,一个是背包容量,通过一步步增加物品或是背包容积,从而推导出问题的解。
略举一例:已知如下物品及其重量和价值,背包可承重质量为5的物品,求解背包最大可以装下多少价值的物品。
背包问题既属于动态规划,便离不开动规五部曲(终于可以套公式力):
-
确定dp数组(dp table)以及下标的含义
如图,建立一个二维的dp数组,dp数组的值表示从下标为[0-i]的物品里任意取,放进容量为j的背包,价值总和最大是多少。
-
确定递推公式
直接确定递推公式似乎有些困难,不妨先顺着刚刚的思路推导几步,像拼图一样先将边角等关键位置拼接好。(其实就是先初始化再试着推导一下)
当重量为0时,任何物品都无法放入,故而本列的数值都是0,而容纳量大于等于1时的情况下,都可以放入物品1,故而第一行剩下的部分都可以填入10,即物品1的价值。
重点从第二行开始,第二行的首个元素刚刚已经写过,而从第二个元素起,我们需要考虑几种情况:
简单来说,递推公式将从两种状态里抉择,一是保持原状,二是容纳新物品。则可以得出递推公式为:
dp[i][j] = fmax(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
从刚刚的推导过程以及递推公式都可以看出,某一位置上的dp数组元素,可由其上方或左上方元素推出。
若从正上方推出,则证明该元素无变动,并未加入新的物品,而从左上方推出,则证明背包中加入了新的物品。
由此可以发现背包问题的一个特征,即本行元素都由前一行推导出,这里可以引出新的概念:滚动数组。但请先允许笔者买个关子,放在之后详解。
-
dp数组如何初始化
针对于本问题的初始化在刚刚的推导过程中已经完成了,此处再着重说明一下,由于遍历时元素的值由其左上方元素确定,故而需要初始化第一行以及第一列的元素。
至于其他部分的元素,初始化任意值即可,这里默认初始化为0。
-
确定遍历顺序
无论是先遍历背包重量亦或是先遍历物品都可以。
这里放上先遍历物品再遍历背包的代码(先遍历背包只要两个for的顺序即可):
// weight数组的大小 就是物品个数 for(int i = 1; i < weight.size(); i++) { // 遍历物品 for(int j = 0; j <= bagweight; j++) { // 遍历背包容量 if (j < weight[i]) dp[i][j] = dp[i - 1][j]; else dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]); } }
最后得出结果:
本场嘉宾:滚动数组
由刚刚推导的过程可以得知,当前层是由上一层推导而来的,既然如此,即可直接将上一层拷贝至当前层,从当前层开始计算。即可将二维的dp数组压缩成一维的dp数组,大大减少了空间复杂度。
但这也意味着在遍历时需要设置更为严苛的条件。
首先需要对递推公式作一定修改:
dp[j] = fmax(dp[j], dp[j - weight[i]] + value[i]);
在初始化时,也需要注意:
若背包容量为0,能装的物品自然也是0,所以dp[0] = 0,这点毋庸置疑。但除0以外的下标该如何取值?可以选择参考递推公式,当物品的重量都大于零时,第一次遍历即可让每一位都选取到最大值,故而其余元素都初始化为0即可。
可以得到求解一维dp01背包问题的解:
for(int i = 0; i < weight.size(); i++) { // 遍历物品
for(int j = bagWeight; j >= weight[i]; j--) { // 遍历背包容量
dp[j] = fmax(dp[j], dp[j - weight[i]] + value[i]);
}
}
需要注意的是,此处的内层循环是从后向前的倒序遍历,且仅能这样遍历,之所以需要从后向前更新状态,是因为我们需要使用到之前的状态。如果从前向后更新,原先的状态会被新状态覆盖掉,导致在后面需要使用时无法找到,从而出现计算错误。
Part 3.来一道紧张刺激的算法题
按照一般步骤这里应该要开始直接解题了,但我想先讲讲我是如何判断这道题归属于01背包问题的,或者说如何将这道题化解为一道01背包问题。
这道题看似与背包问题相去甚远,给出一个集合,让我们将其分割成两个等和子集,如果想去生搬硬套,会发现虽然有物品(即nums数组中的元素),但背包的大小并不明确,物品的重量与价值也不清楚。
且先顺着题意思考一下,要使其能分割为两个等和数组,则分出的两个数组的元素和都必然为数组总元素和的一半。如若想化解为背包问题,则是要同时填满两个背包,使得两个背包里面价值相等,设总的元素和等于sum,背包只要有其中一个重量能够达到sum/2,则可以判定这个数组可以分割成两个等和子集。
则可以得出,背包的总体积为总元素和的一半,物品的重量与价值均为元素的值。
到这里,这道题实际上就已经被化解为一个01背包问题了,套公式解决即可:
1.确定dp数组以及下标的含义
本题中每一个元素的数值既是重量,也是价值。
套到本题,dp[j]表示 背包总容量(所能装的总重量)是j,放进物品后,背的最大重量为dp[j]。
那么如果背包容量为target, dp[target]就是装满 背包之后的重量,所以 当 dp[target] == target 的时候,背包就装满了。
2.确定递推公式
01背包的递推公式为:
dp[j] = fmax(dp[j], dp[j - weight[i]] + value[i]);
本题中,物品i的重量是nums[i],其价值也是nums[i],故而将上面式子中的重量和价值替换成数值nums[i]即可。
所以递推公式:
dp[j] = max(dp[j], dp[j - nums[i]] + nums[i]);
3.dp数组如何初始化
显然,dp[0]为0,nums数组中的值都为正整数,所以其他值都初始化为0即可。
4.确定遍历顺序
在刚刚介绍滚动数组时已经说明:如果使用一维dp数组,物品遍历的for循环放在外层,遍历背包的for循环放在内层,且内层for循环倒序遍历。
代码如下:
// 开始 01背包
for(int i = 0; i < nums.size(); i++) {
for(int j = target; j >= nums[i]; j--) { // 每一个元素一定是不可重复放入,所以从大到小遍历
dp[j] = fmax(dp[j], dp[j - nums[i]] + nums[i]);
}
}
放上我的题解:
bool canPartition(int* nums, int numsSize){
int sum = 0;
for(int i = 0;i <= numsSize - 1;i++){
sum += nums[i];
}
if(sum % 2 == 1){
return false;
}
int* dp = (int*)malloc(sizeof(int) * (sum/2 + 1));
memset(dp, 0, sizeof(int) * (sum/2 + 1));
for(int i = 0; i <= numsSize - 1; i++) {//遍历每一个数字(物品)
for(int j = sum/2; j >= nums[i]; j--) {//遍历元素和(背包重量)
dp[j] = fmax(dp[j], dp[j - nums[i]] + nums[i]);
}
}
return dp[sum/2] == sum/2;
}
Part 4.结语
背包问题是动态规划的经典问题,也是非常难以理解的问题,说老实话,直到书写至此,笔者也尚未完全理解01背包,只能算是理解个大概。动态规划问题大抵都是如此,需要不断通过学习理论和刷题实践来获取一种“经验”,让自己可以快速判断题目、理解题意、解决问题。