动态规划进阶(2) - 背包问题
作者:光火
系列文章:动态规划进阶(1) - 经典问题
本文主要修改自 《背包九讲》- 博客园,原文结构清晰,逻辑连贯,但在部分内容的叙述上存在问题,且缺乏较为完整的代码框架及样例分析。因此,本文在此基础上做进一步的提炼与扩充,并尝试为背包问题提供一套完整的解决方案
01背包
题目
有 件物品和一个载重量为 的背包。第 件物品的重量为 ,价值为 。求解将哪些物品放入背包,可使这些物品的重量总和不超过背包的载重量,且价值总和最大
基本思路
这是最基础的背包问题,每种物品仅有一件,可以选择放或不放。
定义状态 为:对于一个载重量为 的背包,当考虑到前 件物品时,所能得到的最大价值。则有状态转移方程:
该方程非常重要,基本上所有背包问题的转移方程均由其衍生而来。所以,有必要详细解释一番:对于第 件物品,我们有两种选择(即放或不放)。由于我们采用顺序遍历,因此倘若选择不放,则背包此时所包含的东西只与前 件物品有关,且载重量不变。但倘若选择放,则需保证背包能够容纳下物品 ,因此我们要求前 件物品必须能够放置在载重量为 的背包中,这样当物品 放入后,整体才不会把背包压坏。同时,由于放入了物品 ,背包的价值也获得了提升,需要加上
为避免歧义,需要说明一下, 所表示的含义和 类似,只是将 替换为了 ,它并不要求前 件物品都放进背包,只是强调我们目前只考虑到前 件
有意义当且仅当存在一个前 件物品的子集,其重量总和为 。所以按照这个方程递推完毕后,最终的答案并不一定是 ,而是 的最大值
优化空间复杂度
以上方法的时间和空间复杂度均为 ,其中时间复杂度基本不能再优化,但空间复杂度却可以优化到
先考虑上述思路如何实现,肯定是有一个主循环 ,每次计算出二维数组 的所有值。那么,对于第 轮循环,能否只用一维数组 表示我们定义的状态 呢? 是由 和 这两个子问题递推而来的,那能否保证在推导 时, 和 还能获取到呢?
事实上,这要求在主循环的每一轮中,我们以 的顺序,推导 ,这样就可以保证在计算 时, 储存的仍旧是状态 的值。伪代码如下:
for i = 1 ... N
for w = W ... 0
dp[w] = max(dp[w], dp[w - w[i]] + v[i])
其中, 就相当于原来的 ,因为现在的 等价于之前的 。如果将内层循环 由逆序改为顺序的话,就变成了 由 推知,与题意不符,但它却是完全背包问题最简单的解决方案,因此学会用一维数组解决 问题是十分必要的
相关题目
建议先掌握所有知识,习题会统一在后文给出
内容总结
是最简单的背包问题,但它包含了求解背包问题时设计状态、方程的基本思想,而且其他类型的背包问题往往可以转换成 进行解决。因此,一定要仔细体会上述基本思路的得出,状态转移方程的意义,以及优化空间复杂度的方法
完全背包
题目
有 类物品和一个载重量为 的背包,每类物品均有无数件可用。第 类物品的重量均为 ,价值均为 。求解将哪些物品放入背包,可使这些物品的重量总和不超过背包的载重量,且价值总和最大
基本思路
该问题类似于 ,只是物品由单件变为了无数件。从每类物品的角度考虑,与它相关的策略并非是取或不取两种,而是有取件、取件、取件……等多种。如果仍然按照解 的思路,令 表示前 类物品放入一个载重量为 的背包,所能获得的最大权值。则可以按照每类物品不同的策略写出状态转移方程:,其中 。这和 一样,有 个状态需要求解,只是求解每个状态的时间不再是常数。求解 的时间为 ,因此总的复杂度要高于
将 问题的基本思路加以改进,就得到了这样一个清晰的方法。这说明 问题的方程的确很重要,可以推及其它类型的背包问题。但我们还是试图优化这个复杂度
一个简单有效的优化
完全背包问题有一个简单有效的优化:若两件物品 满足 且 ,则可以将物品 去掉,不再考虑。这个优化显然正确,任何情况下都可将价值小重量沉的物品 换成物美价廉的物品 ,得到至少不会更差的方案。对于随机生成的数据,这个方法往往会大幅减少物品的类数,从而加快速度。然而,这并不能改善最坏情况的复杂度,因为特别设计的数据可以让一件物品也去不掉
转化为 求解
既然 是最基本的背包问题,那么我们可以考虑把完全背包问题转化为 问题求解。最简单的想法是,考虑到第 类物品最多选 件,于是可以把第 类物品转化为 件重量及价值完全相同的物品,然后求解这个 问题。这样虽然没有改进基本思路的时间复杂度,但给了我们将完全背包问题转化为 问题的思路:将一种物品拆成多件。
更高效的转化方法是:把第 类物品拆成重量为 、价值为 的若干件物品,其中 满足 且 。这是二进制的思想,因为不管最优策略选几件第 类物品,总可以表示成若干个 件物品之和。这样把每类物品拆成 件物品,是一个很大的改进。
但我们有更优的 算法。 的算法使用一维数组,先看伪代码:
for i = 1 ... N
for w = 0 ... W
dp[w] = max(dp[w], dp[w - w[i]] + v[i])
你会发现,这个伪代码与 的伪代码只有 的循环次序不同而已。那为什么这样一改就可行呢?首先想想为什么 中要按照 的逆序来迭代。这是因为要保证第 次循环中的状态 是由状态 递推而来。换句话说,这正是为了确保每件物品只选一次,保证在考虑选入第 件物品时,依据的是一个没有选入第 件物品的子结果 。而现在完全背包的特点恰好是每类物品可选无限件,所以在考虑加选一件第 类物品这种策略时,正需要一个可能已选入第 类物品的子结果 ,所以就可以且必须采用 的顺序循环
这个算法也可以由另外的思路得出。例如状态转移方程可以等价地变形成这种形式: ,将这个方程用一维数组实现,便得到了上面的伪代码
内容总结
问题也是一个相当基础的背包问题。希望你能够对这两个状态转移方程都认真体会,不仅记住,也要弄明白它们是怎么得出来的,最好想一种得到这些方程的方法。事实上,对每一道动态规划题目都思考其方程的意义以及如何得来,是加深理解、提高功力的好方法
多重背包
题目
有 类物品和一个载重量为 的背包。第 类物品最多有 件可用,且每件的重量均为 ,价值均为 。求解将哪些物品放入背包,可使这些物品的重量总和不超过背包的载重量,且价值总和最大
基本思路
本题和 很类似,基本的方程只需将 问题的方程略微调整即可。对于第 类物品有 种策略:取 件,取 件……取 件。令 表示前 类物品放入一个载重量为 的背包,所能获得的最大权值,则 ,其中 。复杂度为
转化为 求解
可以考虑将第 类物品替换为 件 中的物品,则得到了物品数为 的 问题,直接求解,复杂度仍然是
但我们期望将它转化为 问题之后能够像 一样降低复杂度。仍然考虑二进制的思想,将第 类物品换成若干件物品,使得原问题中第 类物品可取的每种策略(即取 )均能等价于取若干件代换后的物品。并且,取超过 件的策略必不能出现
方法是:将第 类物品分成若干件,其中每件物品均有一个系数,这件物品的重量和价值均是原来的重量和价值乘以这个系数。使这些系数分别为 , ,且 是满足 的最大整数。例如,如果 ,就将这类物品分成系数分别为 的四件物品
分成的这几件物品的系数和为 ,表明不可能取多于 件的第 类物品。另外,这种方法也能保证对于 间的每一个整数,均可以用若干个系数的和表示
这样就将第 类物品分成了 种物品,将原问题转化为了复杂度为 的 问题,是很大的改进
上述过程的大致实现如下:
int n = 0;
int p = 0;
scanf("%d", &n);
while(n > pow(2.0, p) - 1) {
p ++;
}
for(int i = 0; i < p - 1; i ++) {
printf("%d ", int(pow(2.0, i)));
}
printf("%d\n", n - int(pow(2.0, p - 1)) + 1);
的算法
问题同样有复杂度为 的算法。该算法基于基本算法的状态转移方程,但应用单调队列的方法使每个状态的值可以用均摊 的时间求解。由于用单调队列优化的 已超出 的范围,故本文不再展开讲解。我最初了解到这个方法是在楼天成的 “男人八题” 幻灯片上
内容总结
这里我们看到了将一个算法的复杂度由 改进到 的过程,还知道了存在超出 知识范围的 算法。希望你特别注意 “拆分物品” 的思想和方法,自行证明其的正确性,并用尽量简洁的程序来实现
混合背包
题目
如果将前三种背包混合起来,也就是说,有的物品只可以取一次,有的物品可以取无限次,有的物品可以取的次数则有一个上限,该如何求解呢?
和 的混合
考虑到前两种背包问题的伪代码只有一处不同,故如果只有两类物品:一类只能取一次,另一类可以取无限次,那么只需在对每个物品应用转移方程时,根据物品的类别选用顺序或逆序即可,复杂度是 。伪代码如下:
for i = 1...N
if(第 i 件物品只可取 1 次) // 01背包
for w = W ... 0
dp[w] = max(dp[w], dp[w - w[i]] + v[i])
else // 完全背包
for w = 0 ... W
dp[w] = max(dp[w], dp[w - w[i]] + v[i])
再考虑
如果再加上限制,有的物品最多只能取有限次,那么原则上也可以给出 的解法:遇到 类型的物品用单调队列求解即可。但如果不考虑超出 范围的算法话,用 中将每个这类物品分成 个 物品的方法也很优了
内容总结
有人说,困难的题目都是由简单的题目叠加而来的。这句话是否放之四海而皆准暂且不论,但它在本讲中得到了充分的体现。本来 、、 都不是什么难题,但将它们简单地组合起来以后就得到了这样一道一定能吓倒不少人的题目。但只要基础扎实,理解三种基本背包问题的思想,就可以做到把困难的题目拆分成简单的题目来解决
二维背包
题目
问题是指:对于每件物品,均具有两种不同的开销;选择这件物品必须同时付出这两种代价;对于每种代价都有一个可付出的最大值。问怎样选择物品可以得到最大的价值
比方说,设这两种代价分别为重量 和费用 ,第 件物品的重量为 ,需要支付的费用为 ,价值为 。你的背包极限载重量为 ,你携带的零钱总额为 ,问在不超过这两项限制的前提下,怎样选择,得到的价值总和最大
基本思路
代价增加了一维,状态也随之增一维即可。设 表示考虑到前 件物品时,在金额上限为 ,极限载重量为 的情况下,可获得的最大价值
状态转移方程:。如前述方法,可以只使用二维数组:当每件物品仅能取一次时,变量 和 采用逆序迭代,当物品可以取无限次时,采用正向迭代。当物品可取次数超过 ,但有上限时,考虑进行拆分
物品总个数限制
有时,“二维费用”的条件是以这样一种隐含的方式给出的:最多只能取 件物品。这事实上相当于每件物品多了一种 “件数” 费用,每个物品的件数费用均为 ,可以付出的最大件数费用为 。换句话说,设 表示载重量为 、最多选 件时可得到的最大价值,根据物品的类型(01、完全、多重)用不同的方法循环更新,最后在 范围内寻找答案
如果要求“恰取 件物品”,则在 范围内寻找答案
内容总结
当发现由熟悉的动态规划题目变形得来的题目时,在原来的状态中加一纬以满足新的限制是一种比较通用的方法。希望你能从本讲中初步体会到这种方法
分组背包
题目
有 件物品和一个载重量为 的背包。第 件物品的重量为 ,价值为 。这些物品被划分为若干组,每组中的物品互相冲突,最多选一件。求解将哪些物品装入背包可使这些物品的重量总和不超过背包载重量,且价值总和最大
基本思路
这个问题变成了每组物品有若干种策略:是选择本组的某一件,还是一件都不选。也就是说设 表示考虑到前 组物品,且背包载重量为 时,能够获得的最大权值,则有
利用一维数组求解的伪代码
for 所有的组 k
for 组 k 重所有的物品 i
for w = W ... 0
dp[w] = max(dp[w], dp[w - w[i]] + v[i])
另外,显然可以对每组中的物品应用 中 “一个简单有效的优化”
内容总结
问题将彼此互斥的若干物品称为一个组,建立了一个很好的模型。不少背包问题的变形都可以转化为 问题(例如 有依赖的背包问题),由 问题进一步可定义 的概念,十分有利于解题
依赖背包
简化版题目
这种背包问题的物品间存在某种“依赖”关系。也就是说, 依赖于 ,表示若选物品 ,则必须选物品 。为了简化起见,我们先假设没有某个物品既依赖于别的物品,又被别的物品所依赖;另外,没有某件物品同时依赖多件物品
基本思路
这个问题由 金明的预算方案一题扩展而来。遵从该题的提法,将不依赖于其他物品的物品称为“主件”,依赖于某主件的物品称为“附件”。由这个问题的简化条件可知所有的物品由若干主件和依赖于每个主件的附件集合组成
按照背包问题的一般思路,仅考虑一个主件和它的附件集合。可是,可用的策略非常多,包括:一个也不选,仅选择主件,选择主件后再选择一个附件,选择主件后再选择两个附件……无法用状态转移方程来表示如此多的策略。(事实上,设有 个附件,则策略有 个,为指数级)
考虑到这些策略都是互斥的(也就是说,你只能选择一种策略),所以一个主件和它的附件集合实际上对应于 问题中的一个物品组,每个选择了主件又选择了若干个附件的策略对应于这个物品组中的一个物品,其重量和价值都是这个策略中的物品的值求和。但仅仅是这一步转化并不能给出一个好的算法,因为物品组中的物品还是像原问题的策略一样多
再考虑 问题中的一句话:可以对每组中的物品应用 中 “一个简单有效的优化”。这提示我们,对于一个物品组而言,在所有重量相同的物品中只留一个价值最大的,不影响结果。所以,我们可以对主件 的 “附件集合” 先进行一次 (注意 指的是每个物品最多只能取一次,但是每组可以取多个物品),得到重量依次为 所有这些值时,相应的最大价值 。那么,这个主件及它的附件集合相当于 个物品的物品组,其中重量为 的物品的价值为 。也就是说原来指数级的策略中有很多策略都是冗余的,通过一次 后,将主件 转化为 个物品的物品组,就可以直接应用 问题的算法解决问题了
更为一般的问题
更一般的问题是:依赖关系以图论中 “森林” 的形式给出(森林即多叉树的集合),也就是说,主件的附件仍然可以具有自己的附件集合,限制只是每个物品最多只依赖于一个物品(只有一个主件)且不出现循环依赖
解决这个问题仍然可以用将每个主件及其附件集合转化为物品组的方式。唯一不同的是,由于附件可能还有附件,就不能将每个附件都看作一个一般的 中的物品了。若这个附件也有附件集合,则它必定要被先转化为物品组,然后用 问题解出主件及其附件集合所对应的附件组中,各个重量的附件所对应的价值
事实上,这是一种 ,其特点是每个父节点都需要对它的各个子节点的属性进行一次 以求得自身的相关属性。这已经触及到了 的思想。看完下节 后,你会发现这个“依赖关系树”每一个子树都等价于一件泛化物品,求以某节点为根的子树对应的泛化物品相当于求其所有子节点对应的泛化物品之和
内容总结
用物品组的思想可以解决特殊的依赖关系,比如:假设物品不能既作主件又作附件,每个主件最多有两个附件,则可以发现一个主件和它的两个附件等价于一个由四个物品组成的物品组
泛化物品
定义
考虑这样一种物品,它不仅没有固定的费用和价值,其价值还会随着你分配给它的费用而变化。这就是泛化物品的概念
更为严格地进行定义。在载重量为 的背包问题中,泛化物品是一个定义域为 中整数的函数 ,当分配给它的重量为 时,能得到的价值为 (举个简单的例子,华强买了五斤西瓜,则意味着华强为西瓜这一物品分配的重量为五斤)
另一种理解是一个泛化物品就是一个数组 ,给它分配重量 ,则得到价值
考虑一个重量为 价值为 的物品:
-
如果它是 中的物品,则将其看成是泛化物品时,它就是除了 外,其他函数值都为 的一个函数。
-
如果它是 中的物品,可以将其看成是这样一个函数,仅当 被 整除时有 ,其它函数值均为 。
-
如果它是 中重复次数最多为 的物品,则仅当 被 整除且 时,有 ,其它情况函数值均为
一个物品组可以看作一个泛化物品 。对于一个 中的 ,若物品组中不存在重量为 的的物品,则 ,否则 为所有重量为 的物品的最大价值。上节中每个主件及其附件集合等价于一个物品组,自然也可看作是一个泛化物品
泛化物品的和
考虑两个泛化物品 和 ,要用给定的载重量从这两个泛化物品中得到最大的价值,该怎么求呢?事实上,对于一个给定的载重量 ,只需枚举将这个载重量如何分配给两个泛化物品就可以了。同样的,对于 的每一个整数 ,可以求得载重量 分配到 和 中的最大价值 。也即 。可以看到, 也是一个由泛化物品 和 决定的定义域为 的函数,也就是说, 是一个由泛化物品 和 决定的泛化物品
由此可定义泛化物品的和: 、 都是泛化物品,若泛化物品 满足 ,则称 是 与 的和,即 。这个运算的时间复杂度为
背包问题的泛化物品
一个背包问题可能会给出很多条件,包括每种物品的重量、价值等属性,以及物品间的分组、依赖等关系。但肯定能将问题对应于某个泛化物品。也就是说,给定了所有条件以后,就可以对每个非负整数 求得:若背包载重量为 ,将物品装入背包可得到的最大价值是多少,这可以认为是定义在非负整数集上的一件泛化物品。这个泛化物品——或者说问题所对应的一个定义域为非负整数的函数——包含了关于问题本身的高度浓缩的信息。一般而言,求得这个泛化物品的一个子域(例如 )的值之后,就可以根据这个函数的取值得到背包问题的最终答案
综上所述,一般而言,求解背包问题,即求解这个问题所对应的一个函数,即该问题的泛化物品。而求解某个泛化物品的一种方法就是将它表示为若干泛化物品的和然后求之
内容总结
本讲是原作者的原创思想,是用函数编程的眼光审视各类背包问题得出的理论
千变万化
以上涉及的各种背包问题都是要求在背包载重量的限制下求可以取到的最大价值,但背包问题还有很多种灵活的问法,在这里值得提一下。不过我认为,只要深入理解了求背包问题最大价值的方法,即使问法变化了,也是不难想出解法的
例如,求解最多可以放多少件物品或者最多可以装满多少背包的空间。这都可以根据具体问题利用前面的方程求出所有状态的值( 数组)之后得到
还有,如果要求的是 “总价值最小”、“总件数最小”,只需简单的将上面的状态转移方程中的 改成 即可
下面说一些变化更大的问法
输出方案
一般而言,背包问题是要求一个最优值,如果要求输出这个最优值的方案,可以参照一般动态规划问题输出方案的方法:记录下每个状态的最优值是由状态转移方程的哪一项推出来的,换句话说,记录下它是由哪一个策略推出来的。便可根据这条策略找到上一个状态,从上一个状态接着向前推即可(注:该方法同样适用于搜索问题的路径输出,可以参照 Pac Man: AI 搜索算法的理解与应用 (1)
其他类型动态规划题目的方案输出,也可参照 动态规划进阶(1) - 经典问题
还是以 为例,方程为 。再用一个数组 ,设 表示推出 的值时是采用了方程的前一项(也即 ), 表示采用了方程的后一项。注意这两项分别表示了两种策略:未选第 个物品及选了第 个物品。那么输出方案的伪代码可以这样写(设最终状态为 ):
i = N
w = W
while(i > 0)
if(step[i][w] == 0)
print "未选择第 i 件物品"
else if(step[i][w] == 1)
print "选择了第 i 件物品"
w -= w[i]
另外,采用方程的前一项或后一项也可以在输出方案的过程中根据 的值实时地求出来,也即不须纪录 数组,将上述代码中的 改成 , 改成 即可
输出字典序最小的最优方案
这里 “字典序最小” 的意思是 号物品的选择方案排列出来以后字典序最小。以输出 最小字典序的方案为例
一般而言,求一个字典序最小的最优方案,只需要在转移时注意策略。首先,子问题的定义要略改一些。我们注意到,如果存在一个选了物品 的最优方案,那么答案一定包含物品 ,原问题转化为一个背包载重量为 ,物品为 的子问题。反之,如果答案不包含物品 ,则转化成背包载重量仍为 ,物品为 的子问题。不管答案怎样,子问题的物品都是以 而非前所述的 的形式来定义的,所以状态的定义和转移方程都需要改一下。但也许更简易的方法是先把物品逆序排列一下,以下按物品已被逆序排列来叙述
在这种情况下,可以按照前面经典的状态转移方程来求值,只是输出方案的时候要注意:从 到 输入时,如果 及 同时成立,应该按照后者(即选择了物品 )来输出方案
方案总数
对于一个给定了背包载重量、物品费用、物品间相互关系(分组、依赖等)的背包问题,除了再给定每个物品的价值后求可得到的最大价值外,还可以得到装满背包或将背包装至某一指定重量的方案总数
对于这类改变问法的问题,一般只需将状态转移方程中的 改成 即可。例如若每件物品均是 中的物品,转移方程即为 ,初始条件
事实上,这样做可行的原因在于状态转移方程已经考察了所有可能的背包组成方案
最优方案总数
这里的最优方案是指物品总价值最大的方案。还是以 为例
结合求最大总价值和方案总数两个问题的思路,最优方案的总数可以这样求: 意义同前述, 表示这个子问题的最优方案总数,则在求 的同时求 的伪代码如下:
for i = 1...N
for w = 0...W
count[i][w] = 0
if(dp[i - 1][w] > dp[i - 1][w - w[i]] + v[i])
dp[i][w] = dp[i - 1][w]
inc(count[i][w], count[i - 1][w])
else
dp[i][w] = dp[i - 1][w - w[i]] + v[i]
inc(count[i][w], count[i - 1][w - w[i]])
内容总结
显然,这里不可能穷尽背包类动态规划问题所有的问法。甚至还存在一类将背包类动态规划问题与其它领域(例如数论、图论)结合起来的问题,在这篇论背包问题的专文中也不会论及。但只要深刻领会前述所有类别的背包问题的思路和状态转移方程,遇到其它的变形问法,只要题目难度还属于 ,应该也不难想出算法
补充说明
考虑到文章的篇幅已经很长了,配套的习题会在之后的文章中给出