以前也写过01背包问题的博文,但理解并不深刻,前几天在做HDOJ1059这道题的时候,在网上搜了下,原来背包问题远不止01背包问题那么简单,当然,01背包问题是基础。于是参考了网上非常经典的一篇文章:http://love-oriented.com/pack/#sec5
问题定义:
先看一看比较经典的三个背包问题的问题原型。
01背包问题:
有N个物品和一个容量为V的背包。第i个物品的所占容量为c[i],价值为w[i],求解将哪些物品装入背包可使这些物品的总费用不超过背包容量,且使得总价值最大。
完全背包问题:
有N种物品和一个容量为V的背包,每种物品的数量不限。第i种物品的费用是c[i],价值是w[j],求解将哪些物品装入背包可使这些物品的费用总和不超过背包容量,且总价值最大。
多重背包问题:
有N种物品和一个容量为V的背包,第i种物品的数量为num[i],费用是c[i],价值是w[i],求解将哪些物品装入背包可使这些物品的总费用不超过背包容量,且使得总价值最大。
问题分析
01背包问题
01背包问题是最基本的背包问题,后面的完全背包问题和多重背包问题其实都可以转化为01背包问题。
对于01背包问题,其特点是:每一个物品仅有一件,对于每一个物品是否放入相当于一个选择。用子问题定义状态:f[i][v]表示前i件物品放入一个容量为v的背包可以获得的最大价值。于是我们不难得出其状态转移方程:
f[i][v] = max { f[i-1][v] ,f[i-1][v-c[i]] + w[i] }
下图是为了便于理解的一个状态表。
图1 状态表
这是一个经典的状态转移方程,几乎所有的背包问题都可以转化为这个方程求解。然而,此方法的时间和空间复杂度都是(V*N)的,对于时间复杂度我们已经无法再优化了,但对于空间复杂度却可以优化到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]]+w[i]中较大的值,事实上,只要我们在每次主循环中保证v的值是由V…0的顺序来推导f[v]的,就能保证我们在第i次推导f[v]的时候,f[v-c[i]]的值就等于f[i-1][v-c[i]]。其伪代码如下:
for i = 0…N
forv = V…0
f[v]= max { f[v] , f[v-c[i]] + w[i] }
事实上:使用一维数组解01背包问题的程序会在后面被多次用到,所以这里抽象出一个处理一件01背包中的物品的函数。
Void ZeroOnePack( cost, weight )
{
forv=V…cost
f[v]= max { f[v], f[v-cost] }
}
所以01背包问题可以这样写:
Void ZeroOnePackProblem()
{
fori = 1…N
ZeroOnePack(c[i], w[i] );
}
关于初始化细节的问题:
对于01背包问题,往往有两种情况:其一、题目要求“恰好装满背包”的最优解;其二、题目不做这样的要求。
对于第一种情况,我们需将第1次主循环之前的初值f[0]设为0, f[1…V]设为-∞即可。
对于第二种情况,我们可以将第1次主循环之前的初值f[0…V]设为0就行了。
解释是这样的:初始化的f数组事实上就是在没有任何物品可以放入背包时的合法状态。如果要求背包恰好装满,那么此时只有容量为0的背包可能价值为0的nothing“恰好装满”,其它容量的背包均没有合法的解,属于未定义的状态,故它们的值设为-∞。如果背包不用“恰好装满,则容量大于0的背包所装的物品也为0也是合法状态,故设置f[0…V]=0。
完全背包问题
对于完全背包问题,与01背包问题的是每种物品可以选取无限件,但我们仍然可以用01背包问题的思路来解答:
f[i][v] = max {f[i-1][v-k*c[i]]+k*w[i] | 0<=k*c[i]<=v }
我们这里就将01背包问题的基本思路加以改进就得到了很清晰的解法,这足以说明01背包问题的重要性。当然,我们可以看到这个算法的时间复杂度是超过O(V*N)的。
一个简单有效的优化
完全背包问题有一个很简单有效的优化,是这样的:若两件物品i、j满足c[i]<=c[j]且w[i]>=w[j],则将物品j去掉,不用考虑。这个优化的正确性显然:任何情况下都可将价值小费用高得j换成物美价廉的i,得到至少不会更差的方案。对于随机生成的数据,这个方法往往会大大减少物品的件数,从而加快速度。然而这个并不能改善最坏情况的复杂度,因为有可能特别设计的数据可以一件物品也去不掉。
这个优化可以简单的O(N^2)地实现,一般都可以承受。另外,针对背包问题而言,比较不错的一种方法是:首先将费用大于V的物品去掉,然后使用类似计数排序的做法,计算出费用相同的物品中价值最高的是哪个,可以O(V+N)地完成这个优化。
转化为01背包问题求解
既然01背包问题是最基本的背包问题,那么我们可以考虑把完全背包问题转化为01背包问题来解。最简单的想法是,考虑到第i种物品最多选V/c[i]件,于是可以把第i种物品转化为V/c[i]件费用及价值均不变的物品,然后求解这个01背包问题。这样完全没有改进基本思路的时间复杂度,但这毕竟给了我们将完全背包问题转化为01背包问题的思路:将一种物品拆成多件物品。更高效的转化方法是:把第i种物品拆成费用为c[i]*2^k、价值为w[i]*2^k的若干件物品,其中k满足c[i]*2^k<=V。这是二进制的思想,因为不管最优策略选几件第i种物品,总可以表示成若干个2^k件物品的和。这样把每种物品拆成O(log(V/c[i]))件物品,是一个很大的改进。
下面是一个更简单的算法,先看伪代码:
for i = 1…N
forv = 0…V
f[v]= max { f[v] , f[v-c[i]]+w[i] }
不难发现,这个代码,与01背包问题的代码差距仅仅是v的赋值顺序不一样。
解释是这样的:在01问题中,v的赋值顺序是V…0,这是因为要保证第i次循环中的状态f[i][v]是由状态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] }
所以,最后抽象出处理一件完全背包类物品的过程伪代码:
Void CompletePack(cost, weight)
{
forv = cost…V
f[v]= max { f[v], f[v-c[i]] + w[i] }
}
多重背包问题
多重背包问题的一个特点是每种物品的数量有限制,为num[i],但我们可以很容易发现其实多重背包问题与完全背包问题非常类似,因为对于所有的i当num[i]*c[i] > V的时候,其实就是完全背包问题。当然,我们也可以利用一下01背包的思想,得出以下的状态转换方程:
f[i][v] = max { f[i-1][v-k*c[i]] | 0<=k<=num[i] &&k*num[i] 其时间复杂度是O(V*Σn[i))。 当然,同上面的完全背包问题一样,我们同样可以采用二进制的方式来优化算法的时间复杂度。考虑k=1,2,4…,num[i],实际上,当k取这些可能值的时候已经包含了1,2,3,4…,num[i]。我们相当于把num[i]件物品分成消耗为2^k*c[i]的若干件物品。这样算法的时间复杂度就减为O(V*Σlog(n[i]))。 下面是该方法的伪代码: void MultiplePack(cost, weight, amount) { ifcost*amount >= V CompletePack(cost,weight); else k=1; whilek ZeroOnePack(k*cost,k*weight); amount-= amount – k; K= k*2; ZeroOnePack(amount * cost , amount*weight); }