背包问题
P01: 01背包问题
题目:有N件物品和一个容量为V的背包。第i件物品的费用是c[i],价值是w[i]。求解将哪些物品装入背包可使价值总和最大。
基本思路:这是最基础的背包问题,特点是:每种物品仅有一件,可以选择放或不放。用子问题定义状态:即f[i][v]表示前i件物品恰放入一个容量为v的背包可以获得的最大价值。则其状态转移方程便是:
f[i][v] = max{ f[i-1][v], f[i-1][v-c[i]] + w[i]}
这个方程非常重要,基本上所有跟背包相关的问题的方程都是由它衍生出来的。所以有必要将它详细解释一下:“将前i件物品放入容量为v的背包中”这个子问题,若只考虑第i件物品的策略(放或不放),那么就可以转化为一个只牵扯前i-1件物品的问题。如果不放第i件物品,那么问题就转化为“前i-1件物品放入容量为v的背包中”,价值为f[i-1][v];如果放第i件物品,那么问题就转化为“前i-1件物品放入剩下的容量为v-c[i]的背包中”,此时能获得的最大价值就是f[i-1][v-c[i]]再加上通过放入第i件物品获得的价值w[i]。
优化空间复杂度
以上方法的时间和空间复杂度均为O(N*V),其中时间复杂度基本已经不能再优化了,但空间复杂度却可以优化到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-c[i]]两个子问题递推而来,能否保证在推f[i][v]时(也即在第i次主循环中推f[v]时)能够得到f[i-1][v]和f[i-1][v-c[i]]的值呢?事实上,这要求在每次主循环中我们以v=V..0的顺序推f[v],这样才能保证推f[v]时f[v-c[i]]保存的是状态f[i-1][v-c[i]]的值。伪代码如下:
for i = 1..N
for v = V..0
f[v] = max{ f[v], f[v-c[i]]+w[i] };
其中的f[v] = max{ f[v],f[v-c[i]] }一句恰就相当于我们的转移方程f[i][v] = max{f[i-1][v],f[i-1][v-c[i]]},因为现在的f[v-c[i]]就相当于原来的f[i-1][v-c[i]]。如果将v的循环顺序从上面的逆序改成顺序的话,那么则成了f[i][v]由f[i][v-c[i]]推知,与本题意不符,但它却是另一个重要的背包问题P02最简捷的解决方案,故学习只用一维数组解01背包问题是十分必要的。
#include <stdio.h> const int MAX = 10010; int V; //背包体积 int f[MAX]; void ZeroOnePack (int cost, int weight) { int v; for(v = V; v >= cost; --v) f[v] = f[v] > (f[v-cost] + weight) ? f[v] : (f[v-cost] + weight); } int main() { int num = 5; //东西个数 V = 10; //背包的体积 int volume[5] = {1,2,3,4,5}; int value[5] = {5,4,3,2,1}; for(int i = 0; i <= V; ++i) //初始化:没要求把背包装满 { f[i] = 0; } for(int i = 0; i < num; ++i) ZeroOnePack(volume[i], value[i]); printf("%d\n",f[V]); return 0; }
01背包问题最常见的两种问法:
一是要求“恰好装满背包”时的最优解。
二是“没有要求必须把背包装满”时的最优解。
这两种问法的实现方法不同点主要在初始化上:
如果是第一种问法,要求恰好装满背包,那么在初始化时除了f[0]为0其它f[1..V]均设为- ∞,这样就可以保证最终得到的f[N]是一种恰好装满背包的最优解。
如果并没有要求必须把背包装满,而是只希望价格尽量大,初始化时应该将f[0..V]全部设为0。
#include <stdio.h> const int MAX = 10010; int f[MAX]; int V; //背包的体积 void ZeroOnePack (int cost, int weight) { int v; for(v = V; v >= cost; v--) f[v] = f[v] > (f[v-cost] + weight) ? f[v] : (f[v-cost] + weight); } int main(void) { int num = 4; V = 5; int volume[] = {2,2,1,4}; int value[] = {1,3,2,3}; f[0] = 0; //f[0]初始化为0 for(int i = 1; i <= V; i++) //要求把背包装满 { f[i] = 0x8fffffff; //初始化为一个比较小的值 } for(int i = 0; i < num; i++) ZeroOnePack(volume[i],value[i]); printf("%d\n",f[V]); return 0; }
P02: 完全背包问题
题目:有N种物品和一个容量为V的背包,每种物品都有无限件可用。第i种物品的费用是c[i],价值是w[i]。求解将哪些物品装入背包可使这些物品的费用总和不超过背包容量,且价值总和最大。
基本思路:这个问题非常类似于01背包问题,所不同的是每种物品有无限件。也就是从每种物品的角度考虑,与它相关的策略已并非取或不取两种,而是有取0件、取1件、取2件……等很多种。如果仍然按照解01背包时的思路,令f[i][v]表示
前i种物品恰放入一个容量为v的背包的最大权值。仍然可以按照每种物品不同的策略写出状态转移方程,像这样:
f[i][v] = max{ f[i-1][v-k*c[i]]+k*w[i] | 0<=k*c[i]<=v }
这跟01背包问题一样有O(N*V)个状态需要求解,但求解每个状态的时间已经不是常数了,求解状态f[i][v]的时间是O(v/c[i]),总的复杂度是超过O(VN)的。
解题思路:既然01背包问题是最基本的背包问题,那么我们可以考虑把完全背包问题转化为01背包问题来解。最简单的想法是,考虑到第i种物品最多选V/c[i]件,于是可以把第i种物品转化为V/c[i]件费用及价值均不变的物品,然后求解这个01背包问题。这样完全没有改进基本思路的时间复杂度,但这毕竟给了我们将完全背包问题转化为01背包问题的思路:将一种物品拆成多件物品。
但我们有更优的O(VN)的算法。这个算法使用一维数组,先看伪代码:
for i = 1..N
for v = 0..V
f[v] = max{f[v],f[v-c[i]]+w[i]};
你会发现,这个伪代码与0-1背包的伪代码只有v的循环次序不同而已。为什么这样一改就可行呢?首先想想为什么0-1背包中要按照v=V..0的逆序来循环。这是因为要保证第i次循环中的状态f[i][v]是由状态f[i-1][v-c[i]]递推而来。换句话说,这正是为了保证每件物品只选一次,保证在考虑“选入第i件物品”这件策略时,依据的是一个绝无已经选入第i件物品的子结果f[i-1][v-c[i]]。而现在完全背包的特点恰是每种物品可选无限件,所以在考虑“加选一件第i种物品”这种策略时,却正需要一个可能已选入第i种物品的子结果f[i][v-c[i]],所以就可以并且必须采用v=0..V的顺序循环。这就是这个简单的程序为何成立的道理。
这个算法也可以以另外的思路得出。例如,基本思路中的状态转移方程可以等价地变形成这种形式:f[i][v] = max{f[i-1][v],f[i][v-c[i]]+w[i]},将这个方程用一维数组实现,便得到了上面的伪代码。
最后抽象出处理一件完全背包类物品的过程伪代码,以后会用到:
procedure CompletePack(cost,weight)
for v = cost..V
f[v] = max{ f[v], f[v-cost]+weight }
#include <stdio.h> const int MAX = 10010; int f[MAX]; //MAX要比背包的体积大 int V; //背包的体积 void CompletePack (int cost, int weight) //完全背包 { int v; for (v = cost; v <= V; v++) f[v] = f[v] > (f[v - cost] + weight) ? f[v] : (f[v - cost] + weight); } int main(void) { V = 9; int volume[3] = {1,2,3}; int value[3] = {1,4,3}; for(int i = 0; i <= V; i++) //没有要求把背包装满 { f[i] = 0; } for(int i = 0; i < 3; i++) CompletePack(volume[i],value[i]); printf("%d\n",f[V]); return 0; }
P03: 多重背包问题
题目:有N种物品和一个容量为V的背包。第i种物品最多有n[i]件可用,每件费用是c[i],价值是w[i]。求解将哪些物品装入背包可使这些物品的费用总和不超过背包容量,且价值总和最大。
基本算法:这题目和完全背包问题很类似。基本的方程只需将完全背包问题的方程略微一改即可,因为对于第i种物品有n[i]+1种策略:取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背包问题
另一种好想好写的基本方法是转化为01背包求解:把第i种物品换成n[i]件01背包中的物品,则得到了物品数为Σn[i]的01背包问题,直接求解,复杂度仍然是O(V*Σn[i])。
但是我们期望将它转化为01背包问题之后能够像完全背包一样降低复杂度。仍然考虑二进制的思想,我们考虑把第i种物品换成若干件物品,使得原问题中第i种物品可取的每种策略——取0..n[i]件——均能等价于取若干件代换以后的物品。另外,取超过n[i]件的策略必不能出现。
方法是:将第i种物品分成若干件物品,其中每件物品有一个系数,这件物品的费用和价值均是原来的费用和价值乘以这个系数。使这些系数分别为1,2,4,...,2^(k-1),n[i]-2^k+1,且k是满足n[i]-2^k+1>0的最大整数。例如,如果n[i]为13,就将这种物品分成系数分别为1,2,4,6的四件物品。 分成的这几件物品的系数和为n[i],表明不可能取多于n[i]件的第i种物品。另外这种方法也能保证对于0..n[i]间的每一个整数,均可以用若干个系数的和表示,这个证明可以分0..2^k-1和2^k..n[i]两段来分别讨论得出,并不难,希望你自己思考尝试一下。
这样就将第i种物品分成了O(log n[i])种物品,将原问题转化为了复杂度为O(V*Σlog n[i])的01背包问题,是很大的改进。
下面给出O(log amount)时间处理一件多重背包中物品的过程,其中amount表示物品的数量:
procedure MultiplePack(cost,weight,amount)
if cost*amount >= V
{
CompletePack(cost,weight)
Return
}
integer k = 1
while k<amount
{
ZeroOnePack(k*cost,k*weight)
amount = amount-k
k = k*2
}
ZeroOnePack(amount*cost,amount*weight)
希望你仔细体会这个伪代码,如果不太理解的话,不妨翻译成程序代码以后,单步执行几次,或者头脑加纸笔模拟一下,也许就会慢慢理解了。
#include <stdio.h> #include <stdlib.h> int V; //背包的体积 const int MAX = 10010; int f[MAX]; //MAX要比背包的体积大 void ZeroOnePack (int cost, int weight) //01背包 { int v; for (v = V; v >= cost; v--) f[v] = f[v] > (f[v - cost] + weight) ? f[v] : (f[v - cost] + weight); } void CompletePack (int cost, int weight) //完全背包 { int v; for (v = cost; v <= V; v++) f[v] = f[v] > (f[v - cost] + weight) ? f[v] : (f[v - cost] + weight); } void MultiplePack(int cost, int weight, int amount) //多重背包 { if (cost * amount >= V) CompletePack (cost, weight); int k = 1; while (k < amount) { ZeroOnePack (k * cost, k * weight); amount = amount - k; k = k * 2; } ZeroOnePack (amount * cost, amount * weight); } int main(void) { int t,i; int num = 2; V = 8; int volume[] = {2,4}; int value[] = {100,100}; int count[] = {3,2}; for(i = 0; i <= V; i++) //没有要求把背包装满 { f[i] = 0; } for(i = 0; i < num; i++) MultiplePack(volume[i], value[i], count[i]); printf("%d\n",f[V]); return 0; }
输出方案
一般而言,背包问题是要求一个最优值,如果要求输出这个最优值的方案,可以参照一般动态规划问题输出方案的方法:记录下每个状态的最优值是由状态转移方程的哪一项推出来的,换句话说,记录下它是由哪一个策略推出来的。便可根据这条策略找到上一个状态,从上一个状态接着向前推即可。
还是以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个物品。那么输出方案的伪代码可以这样写(设最终状态为f[N][V]):
i=N
v=V
while(i>0)
if(g[i][v]==0)
print "未选第i项物品"
else if(g[i][v]==1)
print "选了第i项物品"
v=v-c[i]
另外,采用方程的前一项或后一项也可以在输出方案的过程中根据f[i][v]的值实时地求出来,也即不须纪录g数组,将上述代码中的g[i][v]==0改成f[i][v]==f[i-1][v],g[i][v]==1改成f[i][v]==f[i-1][v-c[i]]+w[i]也可。
来源《背包问题九讲》:http://love-oriented.com/pack/