《背包问题九讲》部分注解

《背包问题九讲》部分注解

在朋友的推荐下,看过dd_engi写的经典动态规划背包九讲,重新刷新了我对背包问题的认识。同时也感到自己在动态规划方面存在的不足。在认真读完背包九讲后,感觉dd大牛已经道尽了背包问题的精髓。本文没有尝试对背包问题的本质进行扩展或深入挖掘,然而动态规划作为信息学奥赛中最为精致的部分,想要学好需要大量的思考,为了让读者更好地理解背包九讲,我决定写这篇《背包问题九讲部分注解》,用更通俗的语言和一些实例帮助大家学习动态规划。如果在写作内容上有错误或有疑问,请尽快联系我。
作者qq:957670947

01背包问题

有N件物品和一个容量为V的背包。第i件物品的重量是w[i],价值是val[i]。求解将哪些物品装入背包可使价值总和最大。
f[i][v]表示前i件物品放入一个容量为v的背包(可以不全放)可以获得的最大价值
则其状态转移方程便是:
f[i][v]=max{f[i-1][v],f[i-1][v-w[i]]+val[i]}
先讲一下上面这个二维dp方程 (感谢洛谷用户“ 刘浩宇(寂)”提供数据)
让我假设现在的背包的容量是10;

物品编号: 1 2 3

物品重量: 6 5 4

物品价值:10 20 12

我们考虑这个问题时,会注意到背包的容量是有限的,不一定能将所有物品装入,我们需要选一些性价比高的物品装入,但这又涉及到性价比的判断以及后效性,问题会很复杂。

所以我们不妨把这个问题简化,我们发现每个物品无非有两种状态,装或不装
f[i][v]表示前i件物品放入一个容量为v的背包(可以不全放)可以获得的最大价值
假设我们已经知道了前i-1个物品装或不装
这时我们只考虑第i件物品是否装入。那么第i件物品不装入可以表示为f[i][v]=f[i-1][v]
也就是说,v没有变化,价值也没有变化,和i-1的时候一样。
第i件物品装入可以表示为f[i][v]=f[i-1][v-w[i]]+val[i]. 也就是说,这时和i-1时不同的有两点,一个是背包空间变成了v-w[i],(装入了第i件物品),价值加上了val[i],(获得了第i件物品的价值)

而判断到底要不要加入,我们只需要比较一下上面两个式子的大小,于是得到了如下方程
f[i][v]=max{f[i-1][v],f[i-1][v-w[i]]+val[i]}
有人可能会有疑惑,我们怎么保证在算f[i][v] 的时候已经知道了上面两个式子的值呢?

很简单,我们可以用一个循环来确保f[i-1][v],f[i-1][v-w[i]]+val[i] 在f[i][v] 之前得到
for(int i=1;i<=n;i++) (n为物品数量)
for(int j=1;j<=V;j++)
f[i][j]=max{f[i-1][j],f[i-1][j-w[i]]+val[i]}
下面我们来模拟一下这个过程(用上面的数据)

首先当i=1时,只有第一个物品,当背包容量大于等于第一件物品质量的时候,也就是当前背包可以装下第一个物品时,最佳方案显然是装下这个物品获得这个物品的价值。(因为只有一个物品,只需要按照方程定义计算,不需要考虑后面的物品)

这时进行循环,得到了如下结果
f[1][1]=0 f[1][2]=0 f[1][3]=0 f[1][4]=0 f[1][5]=0
f[1][6]=10 f[1][7]=10 f[1][8]=10 f[1][9]=10 f[1][10]=10

i=1的情况过完了,然后i变成2
这时f[2][1] 到 f[2][4] 没有改变,仍为0,因为第二个物品质量为5
到f[2][5] 时,我们发现 f[2-1][5]=0 小于 f[2-1][5-w[2]]+val[2]=20
所以f[2][5] 为20
继续循环到f[2][6] 时,f[2-1][6]=10 小于 f[2-1][6-w[2]]+val[2]=20
所以f[2][6] 为20
同理 f[2][7] f[2][8] f[2][9] f[2][10] 也变为20

这样i=2的情况也过完了,i变为3
f[3][1] 到f[3][3] 仍然不变,因为第三个物品的质量为4 这样,通过前面的推导,不难想到f[3][4] 会变为12
再往后,f[3][5] 到 f[3][8] 仍为20,因为12<20
这时注意,到f[3][9]时,我们又可以发现f[3-1][9]=20,小于f[3-1][9-w[3]]+val[3]=32
所以f[3][9] 为32,同理f[3][10] 为32

循环结束,我们想要的答案是三个物品装进一个容量为10的背包中获得的最大价值
于是我们只需要输出f[3][10] 便是答案

01背包问题就这样结束了吗? 当然没有
我们可以看到,这样写01背包问题需要开一个二维数组,如果题目卡空间怎么办呢?
细心的同学也许已经发现,我们在循环求每一阶段的解的时候,第二重循环内用到的i-1的值在本循环内是不变的,也就是第i件物品永远都是从第i-1件物品转移而来。既然在一次循环内不变,我们是否可以省去第一维的i呢?
我们试着写一下,省去第一维的i,循环就变成了这样:
for(int i=1;i<=n;i++)
for(int j=1;j<=V;j++)
f[j]=max{f[j],f[j-w[i]]+val[i]}
那这个循环求出来的解对吗?经程序运算发现与正确答案相差甚远
为什么会这样呢?
我们发现,在这个循环中,缺少了i-1的保证,我们的f[i][j] 是从本循环中的f[i][j-w[i]] 转移而来的
什么意思呢?我们来举个栗子
还是上面的数据,假设这时候我们已经循环到了f[2][7]
因为第二个物品质量为5,所以f[2][7] 本应该由 f[1][7] 和 f[1][7-5]+val[2]转移而来
但是在这个循环中,我们没有了第一维,这时的 f[2][7] 会由在i=2的这个循环中早些得到的f[2][7-5] 转移而来 。
如果这个不够直观,我们再看f[2][10] .这时的f[2][10]=f[2][5]+val[2]
而在f[2][5] 的值的确定过程中,我们已经选过的第二个物品,这时确定f[2][10] 的过程中,我们又加了一次val[2],也就是说,第二个物品又选了一次
但是在01背包的规则中,每个物品只能选择一次,这样得出的答案自然是错的

那么如何将这个循环改对呢?
其实上面的循环之所以会从本循环中转移而来,是因为在计算f[2][10] 之前已经计算过了f[2][5],这样一来它就顺理成章地转移过来了
我们只需要保证在算后面的值(比如f[2][10])的时候,前面的值(比如f[2][5])还没有被计算过,这样就可以避免转移错误
想要做到这点,我们可以将第二重循环顺序变一下,将原先的顺序改为逆序

for(int i=1;i<=n;i++)
for(int j=V;j>=w[i];j–) (容量小于w[i]的就不用管它了)
f[j]=max{f[j],f[j-w[i]]+val[i]}

这样我们就成功地把01背包的转移方程变成了一维,大大减小了空间的消耗
关于01背包的变式我们会在后面讲到。

完全背包问题

完全背包问题和01背包唯一的不同点就是每个物品可以选择多次,每个物品不再是01背包中只有两个状态装或不装,还多了一个装几次的问题。如果我们再加一维k,来记录每个物品装的次数,时间复杂度会大大增加。还有其他的很多方案将完全背包进行转换,背包九讲中都有详细的介绍,在此不多加赘述
正解如下:
回忆01背包一维转移方程的循环,我们将第二重循环变成了倒序,这是为了保证每个物品由上一个物品转移而来,为什么要这样保证?从上面的例子我们可以知道,这是为了满足01背包的要求,也就是每个物品只能选一次。
但是在完全背包中,每个物品可以选择多次,恰恰可能从本物品转移而来,这样也就是第二重循环进行顺序计算,也就考虑进了判断第i个物品是否选择时,上一次也选择了第i件物品这样的情况。
所以完全背包的循环直接就可以写成这样
for(int i=1;i<=n;i++)
for(int j=1;j<=V;j++)
f[j]=max{f[j],f[j-w[i]]+val[i]}

然而有些同学会有疑问,这样的循环确实可以让每个物品不止选一次,但它真的可以保证考虑到所有的选择情况吗?

那我们继续来看中间的数据变化过程
来看这组数据
5 20
3 2
6 5
15 7
1 1
7 8
(第一行为物品数和总容量,后面每行两个数,分别为质量和价值)

第一次循环结果如下

这里写图片描述
可以看到,只有第一个物品时所有情况都有了

第二次循环结果如下
这里写图片描述
加入第二个物品开始更新答案

第三次循环结果如下
这里写图片描述
第三个物品的加入没有使任何结果更优

第四次循环结果如下
这里写图片描述
继续

第五次循环结果如下
这里写图片描述

从数据可以看出,完全背包完美地执行了我们的方程,得到了正确答案。

多重背包问题

基本描述: 有N种物品和一个容量为V的背包。第i种物品最多有n[i]件可用,每件费用是c[i],价值是w[i]。求解将哪些物品装入背包可使这些物品的费用总和不超过背包容量,且价值总和最大。

思路:这时的物品每个都有了具体的数量,每个物品可以选多件,每个物品选的次数又有不同的限制。我们试着将这个问题转化一下。

完全背包转化方案:比起完全背包,多重背包只是在每个物品选的次数上多了一个限制。
取0件,取1件……取n[i]件。令f[i][v]表示前i种物品恰放入一个容量为v的背包的最大权值,则有状态转移方程:
f[i][v]=max{f[i-1][v-k*c[i]]+k*w[i]|0<=k<=n[i]}
复杂度是O(V*Σn[i])。

01背包转化方案:我们可以将一个物品拆开,比如假设有一个物品共有3个,我们可以将它转化为三个相同的物品,每个只能选一次,这样就变成了我们熟悉的01背包问题。

优化方案: 然而如果题目想考你多重背包,绝对不会让你这么简单就转化成01背包的。我们可以想到,将每个物品都变成多个同样的物品,如果每个物品数量很多,那么时间复杂度将大大增加。出题人只要稍微一卡时间,凉凉。

我们自然会想,如何优化?
我们为什么要把物品拆成一个一个?是为了可以选择任意次,也就是说如果一个物品有n个,我们需要做到拆完后可以选择1到n任意数字。实际上做到这点我们并不需要把物品拆成一个一个。这里介绍一个非常有用的技巧:二进制拆分
大家可以试一下,用在1,2,4,8,16,32…到2的n次方这些数中,每个数最多用一次,就可以拼出1加到2的n次方所有的数。

3=1+2,5=4+1,6=4+2,7=4+2+1,9=8+1,10=8+2,11=8+2+1,13=8+4+1,14=8+4+2
15=8+4+2+1 …..

那么如何证明呢?
这里用到了二进制的思想
1写成二进制是1,
2写成二进制是10,
4写成二进制是100,
8写成二进制是1000
……
不难发现,2的n次方的二进制相加,可以拼出1+2+4+…+2的n次方以内的所有二进制数。而每一个十进制数又可以用二进制数表示,并且有唯一对应,于是便得出上面的结论。
这样,我们就不需要把每个物品都拆那么多份,假如一个物品有18个,我们只需要拆成
1,2,4,8,3
这个3是哪里来的呢?当然是18-8-4-2-1.
这样新拆出的这五个物品的价值就是val[i],2*val[i],4*val[i],8*val[i],3*val[i]
质量也是一样
时间复杂度会降到O(V*Σlog n[i])

来看具体拆分过程代码:

for(int i=1;i<=n;i++){
    scanf("%d%d%d",&a,&b,&c);
    for(int j=1;j<=c;j<<=1){   //二进制拆分 
        val[++cnt]=j*a,w[cnt]=j*b; c-=j;
    }
    if(c) val[++cnt]=a*c,w[cnt]=b*c;
}

至此,我们已经学完了背包问题中三个最经典,最基本,最重要的问题。01背包,完全背包和多重背包。
我们可以发现,实际上这三种背包也是可以互相转化的,这大概也是动态规划的精髓所在。每一种背包问题,每一种动态规划都不会是独立存在的。这也就是为什么学习动态规划需要我们大量的思考和灵活运用。在接下来的其他各种背包问题的学习过程中也是这样。


混合背包问题

既然背包问题互相都有联系,我们不妨打开脑洞搞一点事情,如果把上面三种背包问题混到一起会怎么样呢?也就是说有的物品只可以取一次(01背包),有的物品可以取无限次(完全背包),有的物品可以取的次数有一个上限(多重背包)。应该怎么求解呢?
有了上面的基础,希望大家先自己独立思考。

——————————思考分割线——————————————————

首先,我们已经知道多重背包可以转化为01背包求解,所以经过二进制拆分,物品中就只剩下了两种,一种是只能拿一次,一种是可以拿无限次。
这时,问题变成了如何将01背包和完全背包混合起来

其实我们只需要在循环的时候判断一下这个物品属于哪个种类,然后进入不同的循环。
伪代码:
for i=1..N
if 第i件物品是01背包
for v=V..0
f[v]=max{f[v],f[v-c[i]]+w[i]};
else if 第i件物品是完全背包
for v=0..V
f[v]=max{f[v],f[v-c[i]]+w[i]};

这样循环的正确性大家可以按照上面中间输出的方法自己证明。

来引用一下dd大牛的小结:“ 有人说,困难的题目都是由简单的题目叠加而来的。这句话是否公理暂且存之不论,但它在本讲中已经得到了充分的体现。本来01背包、完全背包、多重背包都不是什么难题,但将它们简单地组合起来以后就得到了这样一道一定能吓倒不少人的题目。但只要基础扎实,领会三种基本背包问题的思想,就可以做到把困难的题目拆分成简单的题目来解决。”

二维费用背包问题

二维费用,顾名思义,每个物品需要消耗两个不同代价。对于每种代价都有一个可付出的最大值(背包容量)。问怎样选择物品可以得到最大的价值。

既然费用增加了一维,那么我们的转移方程也增加一维即可。
设f[i][v][u]表示前i件物品付出两种代价分别为v和u时可获得的最大价值。状态转移方程就是:
f[i][v][u]=max{f[i-1][v][u],f[i-1][v-a[i]][u-b[i]]+w[i]}
如前面01背包所讲解的那样,这个方程我们同样可以去掉第一维,思路和原理都一样
学完前面的背包问题后,二维费用背包问题变得非常简单,《背包九讲》中也有很清晰的介绍。
dd大牛:“当发现由熟悉的动态规划题目变形得来的题目时,在原来的状态中加一维以满足新的限制是一种比较通用的方法。希望你能从本讲中初步体会到这种方法。”

分组背包问题

有N件物品和一个容量为V的背包。第i件物品的费用是c[i],价值是w[i]。这些物品被划分为m组,每组中的物品互相冲突,最多选一件(可以一件都不选)。求解将哪些物品装入背包可使这些物品的费用总和不超过背包容量,且价值总和最大。

首先我们想一个问题,如果只有一个物品组我们会怎么做?
当然是贪心地选取那个价值最大的能装下的物品。

但是正如在01背包中我们不能只选价值大的往进放一样,当我们的组数不是一组时,这样做是有后效性的。
所以我们仍然要像01背包一样记录每个容量的最大价值。
而且这次我们需要在每一组进行处理,并要保证每一组只选择一个。

首先有一个大循环,就是循环每一个物品组
for(int k=1;k<=m;k++)
然后是组内循环,为了保证每一组只选一个物品,我们需要调整一下内外顺序
for(int j=1;j<=v;j++)
for(int i=1;i<=n;i++)
循环内就是我们的转移方程
f[v]=max{f[v],f[v-c[i]]+w[i]}

仔细看一看,其实这个循环和01背包的逻辑几乎相同,只是增加了几个条件,这就变成了一个全新的模型。

依赖背包问题

这种背包问题的物品间存在某种“依赖”的关系。也就是说,i依赖于j,表示若选物品i,则必须选物品j。为了简化起见,我们先设没有某个物品既依赖于别的物品,又被别的物品所依赖;另外,没有某件物品同时依赖多件物品。

不难发现这个问题和分组背包其实很像,每一个物品和依赖它的物品构成了“一组”。不同的是,要想选这组中的物品,我们必须先选那个被依赖的物品,而且选这组物品时没有了“只能选一个”的限制。
所以我们考虑一下如何转化。

也许有些同学会这样做:把组内物品的每一种组合都记录下来进行转移。但这种方法显然有着很大的局限性。在上面的分组背包中每一组只选择一个物品,我们可以轻易记录下来并进行转移。但在这个问题中,组内物品选择方案数是指数级的,无法用状态转移方程来表示如此多的策略。(金明的预算方案这个题中每个物品最多两个附属品,状态很少,所以才能用上述方法)

从动态规划原理上来看,我们似乎不得不考虑到所有的情况,但实际上有些情况是冗余的。

举个例子,我们在01背包中只是记录了每个容量下的最优方案,而不是记录每一种搭配方案。记录每一种搭配方案去比较大小是暴搜。

在这个问题中也是一样的道理,我们只需要像01背包一样在每组内记录每个容量下的最大价值就好。所以,我们可以对主件i的“附件集合”先进行一次01背包,得到费用依次为0..V-w[i]所有这些值时相应的最大价值f’[0..V-w[i]]。那么这个主件及它的附件集合相当于V-w[i]+1个物品的物品组,其中费用为w[i]+k的物品的价值为f’[k]+val[i]。

注意,这里为什么处理的背包容量是0到V-w[i],而不是0到V呢?因为我们选组内物品的前提是已经选择了被附属物品,所以容量需要减去w[i]

通过一次01背包后,将主件i转化为V-w[i]+1个物品的物品组,就可以直接应用分组背包的算法解决问题了。

上面我们解决的问题,是简化过的依赖背包问题。但是在实际问题中,我们还可能遇到某个物品既依赖于别的物品,又被别的物品所依赖的情况。这时的依赖关系其实构成了一个树形结构,这就需要用到以后要讲的树形DP,在这里就不展开论述。

奇怪背包问题

背包问题在各种试题中换了各种不同方式来问,在那些奇形怪状的问法和背包变式中,你能否一眼识破隐藏着的背包本质?
现在我们就来看一眼背包问题都有哪些奇怪的变式吧~
(以下大部分问题背包九讲中都有详细介绍,有些问题我没有多加解释)

1.背包恰好装满问题
要求恰好装满背包,那么在初始化时除了f[0]为0其它f[1..V]均设为-∞,这样就可以保证最终得到的f[V](这里原文中写的是f[n],也许是作者笔误,但经过程序验证确实是f[v])是一种恰好装满背包的最优解。
这里解释一下为什么要赋值为负无穷而不是别的。
假设背包容量为10,第一个物品质量为4,价值为1,当前枚举到的背包容量为5.
那么f[5]=max(f[5],f[5-4]+val[1])
虽然f[5]选取了后两个的较大值,但由于f[1]没有放东西,也就是说背包容量为5的时候放一个质量为4的物品并没有放满,所以f[5-4]为负无穷,即使加上val[1]也是负无穷。
继续枚举到背包容量为4的情况
f[4]=max(f[4],f[4-4]+val[1])
这时我们发现,由于f[0]被初始化为0,所以f[4]为1

同样的,我们不妨再想一下,就上面那个问题,如果第二个个物品质量为6,也就是说容量为10的背包恰好可以用他们装满,当我们枚举到背包容量为10的时候
f[10]=max(f[10],f[10-6]+val[2])
f[4]在上面处理过,所以就可以正常转移

表述起来有些费劲,如果没有理解建议大家自己写一个程序中间输出一下。

2.输出具体方案问题
一般而言,背包问题是要求一个最优值,如果要求输出这个最优值的方案,可以参照一般动态规划问题输出方案的方法:记录下每个状态的最优值是由状态转移方程的哪一项推出来的,换句话说,记录下它是由哪一个策略推出来的。便可根据这条策略找到上一个状态,从上一个状态接着向前推即可。

还是以01背包为例,方程为f[i][v]=max{f[i-1][v],f[i-1][v-c[i]]+w[i]}。再用一个数组g[i][v],设g[i][v]=0表示推出f[i][v]的值时是采用了方程的前一项(也即f[i][v]=f[i-1][v]),g[i][v]表示采用了方程的后一项。注意这两项分别表示了两种策略:未选第i个物品及选了第i个物品。(摘自背包九讲)

3.计算最优方案数
这里的最优方案是指物品总价值最大的方案。以01背包为例。

结合求最大总价值和方案总数两个问题的思路,最优方案的总数可以这样求:f[i][v]意义同前述,g[i][v]表示这个子问题的最优方案的总数,则在求f[i][v]的同时求g[i][v]的伪代码如下:
(摘自背包九讲)
for i=1..N

for v=0..V

    f[i][v]=max{f[i-1][v],f[i-1][v-c[i]]+w[i]}

    g[i][v]=0

    if(f[i][v]==f[i-1][v])

        inc(g[i][v],g[i-1][v])

    if(f[i][v]==f[i-1][v-c[i]]+w[i])

        inc(g[i][v],g[i-1][v-c[i]])

这里是dd大牛给出的伪代码,这个地方也很好理解,就是将子问题方案数进行累加,得到最终结果。

4.计算装满方案数
这个问题是我在做题的时候发现的,看到背包九讲中没有具体介绍,这里补充一下。
和问题三很像,但这里要求的是装满背包的方案数,也就是说在这个问题中和物品价值不再有关系。
从大到小排一遍序,枚举取到了第i个物品f[j]表示这时剩余j容量的方案数.
取第i个物品:f[j]+=f[j-w[i]],若i取的话,i+1…n一定要被取到,那么剩余的容量在<=m-w[i+1] ,>m-w[i]的范围内时就可以更新。

5.字典序最小问题
这里“字典序最小”的意思是1..N号物品的选择方案排列出来以后字典序最小。以输出01背包最小字典序的方案为例。

一般而言,求一个字典序最小的最优方案,只需要在转移时注意策略。首先,子问题的定义要略改一些。我们注意到,如果存在一个选了物品1的最优方案,那么答案一定包含物品1,原问题转化为一个背包容量为v-c[1],物品为2..N的子问题。反之,如果答案不包含物品1,则转化成背包容量仍为V,物品为2..N的子问题。不管答案怎样,子问题的物品都是以i..N而非前所述的1..i的形式来定义的,所以状态的定义和转移方程都需要改一下。但也许更简易的方法是先把物品逆序排列一下,以下按物品已被逆序排列来叙述。

在这种情况下,可以按照前面经典的状态转移方程来求值,只是输出方案的时候要注意:从N到1输入时,如果f[i][v]==f[i-v]及f[i][v]==f[i-1][f-c[i]]+w[i]同时成立,应该按照后者(即选择了物品i)来输出方案。{取I与不取I相比,取的话一定在字典前}(摘自背包九讲)


到这里,背包问题也差不多讲完了(本文没有讲到背包九讲中第八讲 泛化物品)。后面我还会继续讲关于其他动态规划的问题。当然,背包的变式还有很多,千奇百怪,甚至还有很多背包问题和图论或数学等其他算法结合起来考,这也就会更加考验你对基础知识的掌握理解以及你的灵活运用水平。这里也不可能一一列举出来。但是我认为,我们学习背包问题不仅仅是为了学会这类问题的解法,作为基础的动态规划问题,我们更应该在这中间学到动态规划的思想,也就是解决动态规划问题的思考方式,就像dd大牛说的那样,“触类旁通、举一反三,应该也是一个OIer应有的品质吧。”

愿你的心中还有诗和远方

  • 3
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

袋装猫

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值