前言
背包问题是动态规划的一系列经典问题,想必不少人刚接触动态规划问题时都是从这一类问题入手。最近刷LintCode时遇到了动态规划的专题,结合一直想读却一直懒得读的《背包问题九讲》,特意开一篇Blog学习一下。
正题
1. 01 背包问题
有N件物品和一个容量为V的背包。放入第i件物品耗费的费用是Ci,得到的价值是Wi。求解将哪些物品装入背包可以使价值总和最大。
基本思路
这是最基本的背包问题,特点是:每种物品仅有一件,可以选择放或者是不放。
用子问题定义状态:即F[i, v]表示前i件物品放入一个背包容量为v的背包时可以获得的最大价值。则其状态转移方程是:
这个方程非常重要,基本上所有的背包相关的问题的方程都是由它衍生出来的。具体的思路就是针对第i件物品,有两种策略,放还是不放,所获得的最大值就是两种选择中的最大值。
伪代码如下:
F[0, 0...V] ← 0
for i ← 1 to N
for j ← 0 to Ci-1
F[i,v] ← F[i-1, v]
for j ← Ci to V
F[i,v] ← max{F[i-1, v], F[i-1, v-Ci]+Wi }
优化空间复杂度
以上方法的空间和时间复杂度均为O(VN),其中时间复杂度已经不能在优化了,但是空间复杂度还可以优化到O(V)。
先考虑上面讲的思路如何实现,肯定是有一个主循环i←1…N,每次算出来二维数组F[i, 0…V]的所有值。那么,如果只用一个数组F[0…V],能不能保证第i次循环结束以后F[v]中表示的就是我们定义的状态F[i,v]呢?F[i,v]是由F[i-1, v], F[i-1, v-Ci]两个子问题推导而来的,能否保证在推F[i, v]时能够取用F[i-1, v],F[i-1, v-Ci]的值呢?也就是说,如果用一维数组F[0..V]的话,我就需要在第i次循环时F[v]的更新要早于F[v-Ci],这样的话F[v-Ci]和F[v]在取用时都还没有被第i次循环更新,所以代表的就是第i-1次的值。
伪代码如下:
F[0...V] ← 0
for i ← 1 to N
for v ← V to Ci
F[v] ← max{F[v], F[v-Ci]+Wi}
初始化的细节问题
我们看到的求最优解的背包问题当中,事实上有两种不太相同的问法。有的题目要求恰好装满背包时的最优解,有的题目则并没有要求必须把背包装满。一种区别这两种问法的实现方法是在初始化的时候有所不同。
如果是第一种问法,要求恰好装满背包,那么在初始化时除了F[0]为0,其他的F[1…V]均设为负无穷,这样就可以保证最终得到的F[V]是一种恰好装满背包的最优解。
如果没有这种要求,那么只需要将F[0…V]全部初始化为0即可。
可以这样理解:初始化的F数组事实上就是在没有任何物品可以放入背包时的合法状态。如果要求背包恰好装满,那么此时只有容量为0的背包可以在什么也不装入且价值为0的情况下被“恰好装满”,其他容量的背包均没有合法解,属于未定义的状态,因此都要被初始化为负无穷(INT_MIN)。
LintCode 相对应的题目
02 完全背包问题
有N种物品和一个容量为V的背包,每种物品都有无限件可以使用。放入第i种物品的费用是Ci,价值是Wi,求解:将哪些物品装入背包,可以使这些物品的耗费费用总和不超过背包容量,且价值总和最大。
基本思路
这个问题非常类似于01背包问题,所不同的是每种物品有无限件。也就是从每种物品的角度考虑,与它相关的策略并非取或不取两种,而是有取0件,取1件,取2件…直至取V/Ci件等许多种。
如果仍按解01背包问题时的思路,令F[i, v]表示前i种物品放入一个容量为v的背包的最大权值。仍然可以按照每种物品不同的策略写出状态转移方程:
但是这种方法的复杂度还是比较高的,代码实际上需要一个三重循环来完成。
一个简单有效的优化
若两件物品i,j满足Ci<=Cj且Wi>=Wj,则可以将物品j直接去掉,不做任何考虑。这个优化的正确性是显然的。
转化为01背包问题求解
01背包问题是最基本的背包问题,我们可以考虑把完全背包问题转化为01背包问题来解。
最简单的想法是,考虑到第i件物品最多选V/Ci件,于是可以把第i种物品转为为V/Ci件费用及价值均不变的物品,然后求解这个01背包问题。
更高效的转化方法是:把第i种物品拆成费用Ci2^k, 价值为Wi2^k的若干件物品,其中k取遍满足Ci2^k <= V的所有非负整数。这是一种二进制的思想。(emmm…..)
O(VN)的算法
这个算法使用一维数组,先看伪代码
F[0...V] ← 0
for i ← 1 to N
for v ← Ci to V
F[v] ← max{F[v], F[v-Ci]+Wi}
你会发现,这个伪代码与01背包问题的伪代码只有v的循环次序不同而已。
为什么这个算法可行呢?首先想想为什么01背包中要按照v递减的次序来循环。让v递减是为了保证第i次循环中的状态F[i, v]是由F[i-1, v-Ci]递推而来。换句话说,这是为了保证每件物品只选一次,保证在考虑“选入第i件物品”这个策略时,依据的是一个绝无已经选入第i件物品的子结果F[i-1, v-Ci]。而现在完全背包的特点恰是每种物品可以选无限件,所以在考虑”加选一件第i种物品”这种策略时,却正需要一个可能已经选入第i种物品的子结果F[i, v-Ci],所以就可以并且必须采用v递增的顺序循环。
完全背包的递归公式:
其中F[i, v-Ci]表示在前i件物品中容量为v-Ci时,尽可能的放入第i件物品时所能得到的最大价值。F[i, v]就可以再次基础上再放入一个物品i。
总结
事实上,对每一道动态规划的题目都思考其方程的意义以及如何得来,是加深对动态规划的理解,提高动态规划功力的好方法。
最后贴一个LintCode BackPack六讲,虽然我只在LintCode上发现了3道题..
https://segmentfault.com/a/1190000006325321