目录
背包问题是动态规划的入坑级问题,掌握了背包问题,你也能骄傲地说一声:“俺也学过动态规划了!!!”。接下来,我将会在本文中展示各类背包问题的动态规划版本的解题思路及全过程,让大家对动态规划有一个新的认识。
注:有一类背包问题是贪心类算法问题,解题思路十分直接明了,本文不予讲解。在这里我只讨论典型的动态规划类的背包问题。
总体思路:
1.将问题抽象化,拆分成若干个子问题进行最优化以达到整体问题的最优解(背包问题是满足最优性原理的,读者可自行百度,不过并不重要,只是为了有一个成文的定理而已)。
2.建立一个模型,便于我们更加直观地考虑这个问题。
3.寻找递推关系,建立状态转移方程,比较好的方式是填表格观察各个状态量之间的关联(子问题之间的关联)。
一、0-1背包
假设有1个背包,最大容纳质量为C;n个物体,每个物体有两种属性:(1)体积-W (2)价值-V。现在让你从这n个物体中取出任意个物品放在背包中(总质量不能超过M),求背包能容纳的物品的最大价值是多少?
题意很简洁,就是要逃荒了,让你赶紧收拾收拾,把最值钱的都塞背包里面,看你怎么塞。大多数人看第一眼的两种思路:
- 按照价值倒叙排序,依次把对应的物品放进去就可以了。但是,如果价值大的物品同时质量也很大,很可能我们得到的就不是最优解。
- 按照体积从小到大排序,依次把对应的物品放进去。同样,虽然这样放的物品多了,但是有的物品可能“值质比”很低,放再多也不划算,也未必能得到最优解。
显然,我们不要这两种太过直接的想法。由动态规划本身的性质,我们应当将整个问题划分成若干个子问题,并利用状态转移的过程从子问题的解逐步推演到整个问题的解。
类似硬币问题,我们可以假设在只考虑前k种物品时,已经知道了容量小于等于c的背包,能存放的最大价值;那么只考虑前k种物品的前提不变,容量为c + 1的背包,能存放的最大价值为多少呢?或者说,我们把前提变成前k + 1种物品时,容量为c的背包,能存放的最大价值又是多少呢?显然,到这里,我们对父子问题的划分已经比较明显了。
为了方便讲解,我定义一些变量:Wi表示第i个物品的体积,Vi表示第i个物品的价值,V(i,j)代表仅考虑前i种物品且容量为j的背包所能收集物品的最大价值之和。
那么,对同一容量的背包,我们在求解它的V(i, j)时,应当考虑的是第i种物品能不能装下,即使装下了,我是装这件,还是留着空间给后面更有价值的物品呢?因为我们每一次转移都要取价值最大的那种装法,可以考虑以下两种转移过程:
- 当前物品体积大于背包容量,那么这件物品肯定要被舍弃,即
。解释一下:因为第i件物品我们没有装,那么考虑前i种物品和考虑前i - 1件物品的结果是一样的,最大价值也当然是一样的,也就是V(i, j) = V(i - 1, j)。
- 当前物品体积小于等于背包容量,背包能装下。那么此时我们该考虑装它,能否带来更大的收益:
。也就是将装入它和不装入它的情况做对比,如果装入它能使价值更大,说明它在前i种物品中同等体积情况下对价值的贡献比较大,也就是V(i - 1, j - Wi) + Vi这个部分更大,那么我们就选则装入这件物品;否则同上一种情况,不装。
- 综上:
递推关系有了,状态转移过程不就信手拈来~话不多说,代码如下:
/**
dp[i][j]代表考虑前i种物品时,容量为j的背包所能容纳的最大价值
下面的代码为01背包的关键代码
*/
if(W[i] > j)
dp[i][j] = dp[i - 1][j];
else
dp[i][j] = max(dp[i - 1][j], dp[i - W[i]] + V[i];
转移方程有了,那么我们如何从子问题推广到整个问题最后的解,即V(n, C)。下面我将举一个例子,用填表的方式为大家描述出整个状态转移的过程:
eg:假设背包容量c为8,有四种物品,对应的属性如下表:
讲到这,即使还是不甚理解的小伙伴依然可以从头到尾走一遍这个表格,顺序是先行后列,从上到下(你也可以尝试先列后行,从左到右,事实上结果是一样的,因为我们计算当前位置的数据时,所用到的数据全部在当前点的左上方)。表格填完了,你也就明白一半了。示范一下,假如我已经推导(4,8)了,那么
很显然我们是没有取第四件物品的,如箭头所示:
根据表格,我们很容易得出整个问题状态转移的循环过程,代码如下:
/**
dp[][]为状态数组,W[],V[]分别是体积、价值
这里我习惯先物品种类作外层循环,背包容量作内层循环,对于01背包,颠倒
过来结果并没有任何区别,只是解释方法上有所差异。
最终结果为dp[n][C]的内容
*/
for(int i = 1; i <= n; i++)
{
for(int j = 1; j <= C; j++) //C为背包最大容量
{
if(W[i] > j)
{
dp[i][j] = dp[i - 1][j];
}
else
{
dp[i][j] = max(dp[i - 1][j], dp[i - 1][j] + V[i]);
}
}
}
空间优化:
第一种优化:
在上述状态转移过程中,我们使用了一个二维数组dp[ i ][ j ]代表了只考虑前i中物品时,容量为j的背包的最大收纳价值。从状态转移的过程中我们可以知道,dp[ i ][ j ]只与dp[ i - 1][ ]有关,对应到表格上就更直观了,是我一直在说的当前状态的转移过程只与其所在位置左上方的状态有关。我将这个有关细化一下:
也就是在每一次转移过程中,仅仅用左上方形容还不够,具体一点,确定第i行状态所需要的状态全部来自于i - 1行,再上面的行根本没有用到,属于一次性产品,用过一次就变成废品了。因此,我们没有必要给这些没有用的数据提供空间,也就产生了空间优化问题。
我们每次只需要保留当前行状态以及当前行上一行状态就可以了。因此我们可以将数组规模缩减到dp[ 2 ][ C ],即只留两行,一行为上一次的状态,另一行为当前状态,每次更新完当前状态后,当前状态变为既有数据,用来服务下一个新的状态,需要维护的就是dp[ 0 ][ ]和dp[ 1 ][ ]哪个存放的是当前状态的一个状态值:
//在这里,我选择用对j取模的方式维护dp的状态
for(int i = 1; i <= n; i++)
{
for(int j = 1; j <= C; j++) //C为背包最大容量
{
if(W[i] > j)
{
dp[i % 2][j] = dp[(i - 1) % 2][j];
}
else
{
dp[i % 2][j] = max(dp[(i - 1) % 2][j], dp[(i - 1) % 2][j] + V[i]);
}
}
}
第二种优化:
第一种优化的方式显然比较直观,但实际上我们还可以把数组优化至一维状态。那么需要对状态转移的过程再次细化:
实际上,求dp[ i ][ j ]时,我们仅用到dp[ i - 1][ k ],(0 ≤ k ≤ j)的状态,对应到图表上是这样的:
我先把代码段给出来,再进行讲解:
//dp[j]代表容量为j的背包所能收纳的最大价值
for(int i = 1; i <= 5; i++)
{
//这里一定要是倒序
for(int j = C; j >= 1; j--)
{
if(W[i] <= j)
{
dp[j] = max(dp[j], dp[j - W[i]] + V[i];
}
}
}
大家会发现在除了省去第一维的步骤,我还把第二重循环变为倒序遍历背包容量,这是因为有二维数组的时候,我们在更新当前状态时,不会影响到他上一行的状态。
而只有一维数组的时候,相当于本行既要扮演当前状态,又要充当上一个状态来为当前状态服务,那么为了使当前状态的更新 不影响到上一个状态,我们要从后往前去走这个表格,这样新的状态不会覆盖它左侧的状态,能保证继续更新时,所用到的左侧的状态依然还是上一个状态。
否则,将会出现一物多选。
二、完全背包
假设有1个背包,最大容纳质量为C[;n种物体,每种物体无限个且有两种属性:(1)体积-W (2)价值-V。现在让你从这n个物体中取出任意个物品放在背包中(总质量不能超过M),求背包能容纳的物品的最大价值是多少?
完全背包和01背包的差别在于完全背包中,每种物品的数量是无限的,同一件物品可以多次放置,而01背包中每件物品只能选择一次。
那么,每件物品只能选择一次和可以选择多次有什么区别呢?01背包中,我们又是怎么控制每件物品不会被多次选择的呢?还是拿表格来说话:
在这里安排一下01背包的状态转移过程:
别忘了我们dp数组的含义,他表示从前i种物品选择物品时,容量为j的背包所能容纳的最大价值。细心的小伙伴都会发现,在01背包中,无论当前的状态是从哪个部分转移过来的,它的依赖项始终处于表格中它的左上方,也就是dp[ i ][ ]始终与dp[ i - 1][ ]有关,很显然,在考虑第i种物品放不放时,我从i - 1的状态中中选择它所依赖的状态,保证了i种物品不会重复选择。
那么到了完全背包中,我们不需要考虑物品数量的限制,一件物品放完了可以再放,顺着上一段的思路,dp[ i ]如果也可以从dp[ i ]这一行来选择,那是不是代表在转移的过程中就可以多次选择同一物品了呢?结果显然是的,而且从01到完全的转变就这么简单。
在表格中体现出来的:
- 01背包:当前状态只能由表格中上一行的状态转移而来。
- 完全背包:当前状态可以由上一行/本行转移而来。
代码如下:
for(int i = 1; i <= n; i++)
{
for(int j = 1; j <= C; j++) //C为背包最大容量
{
if(W[i] > j)
{
dp[i][j] = dp[i - 1][j];
}
else
{
//dp[i][j - W[i]] + V[i],此处与01背包不同
dp[i][j] = max(dp[i - 1][j], dp[i][j - W[i]] + V[i]);
}
}
}
空间优化:
由于完全背包物品数量无限,而对01背包的空间优化时,我们选择了倒序遍历,防止状态覆盖导致一物多选。而多重背包恰好是在要考虑一物多选的情况下去寻找最优解,那么我们就需要采用顺序遍历的方式,不理解的话可以观察二维时的状态转移过程:
dp[ i ][ j ]如果仍旧利用dp[ i ][ ]所在行的数据,字面上解释就是,在考虑前i种物品的状态时,是以考虑过前i种状态为基础的(有点绕但不难理解),也就是相当于,放完一件物品,我还要拿他来去做尝试。
有了前面的基础,我直接给出代码:
/**
dp[j]代表容量为j的背包所能收纳的最大价值不变
*/
for(int i = 1; i <= 5; i++)
{
//这里一定要是顺序
for(int j = 1; j <= C; j++)
{
if(W[i] <= j)
{
dp[j] = max(dp[j], dp[j - W[i]] + V[i];
}
}
}
三、多重背包
假设有1个背包,最大容纳质量为C;n种物体,每种物体有固定的个数且有两种属性:(1)体积-W (2)价值-V。现在让你从这n个物体中取出任意个物品放在背包中(总质量不能超过M),求背包能容纳的物品的最大价值是多少?
相较于完全背包,多重背包的条件更严格了一些。它把物品的个数限制到了一个阈值,在阈值之内,随便拿,就是不能超过这个阈值。
多重背包中,既然题目说每种物品有固定的个数,我们在原有的两重循环的基础上,再加一重循环控制物品的选择次数可不可以呢?答案显然是可以的。
for(int i = 1; i <= n; i++)
{
for(int j = 1; j <= C; j++) //C为背包最大容量
{
//k的存在相当于控制了物品的个数num[i]
for(int k = 1; k <= num[i]; k++)
{
if(W[i] * k > j)
{
dp[i][j] = dp[i - 1][j];
}
else
{
dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - W[i] * k] + V[i] * k);
}
}
}
}
解读一下代码,实际上多的那重循环,就相当于我们把每种物品按照不同的数量打包成多件物品,再对打包过后的物品堆使用01背包。比如我有一种体积为2,价值为3的物品共9件,我可以按如下方式打包:
这样我们相当于把一件有5个的物品拆成了5种物品,这样看来,01背包其实就是一种特殊的多重背包,理解起来相信还是很容易的。当然,你可以提前把打包工作做好,这样我们依然可以在规划的时候使用二重循环,看起来就和01背包没有区别了。
这样做是比较直观的想法,然而,第三重循环的增加却把复杂度的层级提高了一个档次。O(n^2)变成了O(n^3),于是,有了多重背包的二进制优化。
二进制优化:
假如我有一种体积为2,价值为3的物品共9件,我可以把9拆分成如下形式:
为什么要这么做呢?我们按二进制的方式重新对已有物品打一下包:
件数 | 体积 | 价值 |
---|---|---|
1 | 1 * 2 | 1 * 3 |
2 | 2 * 2 | 2 * 3 |
4 | 4 * 2 | 4 * 3 |
3 | 3 * 2 | 3 * 3 |
很显然,9件物品,我们如果按正常方法遍历,可以有10种取法(0~10),同样的需要10次循环。
但是,用二进制对9进行拆分,上面几个二进制数1,2,4及最后的3进行组合,组合种类是否能够满足0~10中所有数字呢?答案显然是的。也就是说,打包成这样的结果是一样的,而且只需要4次循环。(如果物品件数更多,优化程度就更明显,比如有1024件,一件一件地拆分要循环1025次,而二进制只需要10次循环,即拆分到2^9就能代表所有的1024内的数字了)。通过二进制优化,我们可以近似地将多重背包的时间复杂度降到O(n^2)。代码如下:
for(int i = 1; i <= n; i++)
{
for(int j = 1; j <= C; j++) //C为背包最大容量
{
//这部分进行二进制拆分
for(int k = 1; k <= num[i]; k *= 2)
{
if(W[i] * k > j)
{
dp[i][j] = dp[i - 1][j];
}
else
{
dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - W[i] * k] + V[i] * k);
}
num[i] -= k; //二进制优化的步骤之一,请自行思考
}
}
//这部分处理二进制拆分所剩下的余数,没有这个余数,无法产生0~num[i]的所有组合
for(int j = 1; j <= C; j++)
{
if(W[i] * num[i] > j)
{
dp[i][j] = dp[i - 1][j];
}
else
{
dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - W[i] * num[i]] + V[i] * num[i]);
}
}
我们也可以直接对物品进行二进制打包,相当于把第三重循环提前来做,能使代码看起来更像01背包:
/**
cnt维护划分出的新的物品堆的个数
newW[], newV[]为新物品堆的体积、价值
*/
int cnt = 0;
//重新划分打包过程
for(int i = 1; i <= n; i++)
{
int k = 1;
while(k <= num[i])
{
newW[++cnt] = W[i] * k;
newV[cnt] = V[i] * k;
num[i] -= k;
k *= 2;
}
}
//规划过程,同01背包
for(int i = 1; i <= cnt; i++)
{
for(int j = 1; j <= C; j++)
{
if(newW[i] > j)
{
dp[i][j] = dp[i - 1][j];
}
else
{
dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - newW[i]] + newV[i]);
}
}
}
空间优化:
实际上掌握了前面两种背包问题的精髓,多重背包就已经不再是一个问题了,多重背包的重点在于二进制优化,侧重点不同,由于篇幅问题,在这里我就不展示多重背包的空间优化了。