前言
前面在动态规划详解一文种讲解了几个动态规划的基本问题。最近在工作中碰到了背包问题,写了一下基于动态规划的算法,决定写一篇文章总结一下。
3种背包问题的比较
背包问题:有N种物品和一个容量为b的背包,第i种物品的重量为,价值为,如何选择放入的物品使得总价值最大。
根据每件物品的数量限制,就分为3种背包问题,定义如下:
- 01背包:每种物品只有一件,要么选择放入,要么选择不放入
- 完全背包:每种物品的数量有无穷多个
- 多重背包:每种物品都有数量限制,第i种物品的最大数量为
三种背包问题都有一个共同的限制,那就是背包容量,背包的容量是有限的,这便限制了物品的选择,而三种背包问题的共同目的,便是让背包中的物品价值最大。
不同的地方在于物品数量的限制,01背包问题中,每种物品只有一个,对于每种物品而言,便只有选和不选两个选择。完全背包问题中,每种物品有无限多个,所以可选的范围要大很多。在多重背包问题中,每种物品都有各自的数量限制。
因此问题归结为如下的线性规划:
目标函数:
约束条件:
完全背包问题
对于完全背包问题,上述约束条件已经足够。
因此我们首先来分析完全背包问题的状态转移方程,也就是建立递推关系式。
首先,我们来界定子问题:由参数k和y来界定,其中
- k表示对0,1,...k号物品的选择
- y表示背包总重量不超过y
当k=n-1,y=b就是我们的原始问题。
优化函数:装前k种物品,总重量不超过y,背包达到的最大价值。
那么考虑第k种物品是否装入时,有两种情况:
- 第k种物品不装入,那么
- 第k种物品装入1个以上,那么
因此,我们得到递推关系式:
初始条件为:
有了初始条件和递推关系,采用自上而下填表法,就可以很容易地写出代码了。
举个例子,输入如下:b=10,
的计算表如下:
其中,第一行根据初始条件直接计算得到,然后利用递推公式,依次增大k和y,往下填表。
解的追踪
的表格(在代码中就是二维数组)计算出来后,就是要求解的最大价值。接下来,就需要确定达到最大价值时,xi的取值。根据递推公式,我们容易得到伪代码如下:
嗯,完美解决。时间复杂度即填表耗时O(N * b)
,这里用了一个二维数组来存储子问题的解,所以空间复杂度为O(N * b)。
下面给出一个Delphi实现的代码:
function Knapsack(W, V: TList<Integer>; Wmax: Integer; Xout: TList<Integer>): Integer;
var
i, j, k: Integer;
Vmax: Integer;
Vky: array of array of Integer;//子问题最大价值记录表
begin
if (W.Count <> V.Count) or (W.Count = 0) then
raise Exception.Create('Error Input');
//初始化
SetLength(Vky, W.Count);
for i := 0 to W.Count - 1 do
begin
SetLength(Vky[i], Wmax + 1);
Vky[i][0] := 0;
end;
for i := 1 to Wmax do
if i >= W[0] then
Vky[0][i] := (i div W[0]) * V[0]; // 完全背包(物品数量无穷)
//自下而上填表
for i := 1 to W.Count - 1 do
begin
k := i - 1;
for j := 1 to Wmax do
if j < W[i] then//装不进去
Vky[i][j] := Vky[k][j]
else
begin
Vmax := Vky[i][j - W[i]] + V[i];//用i号物品>=1个时的最大价值
Vky[i][j] := Max(Vky[k][j], Vmax);
end;
end;
//追踪解
Result := Vky[W.Count - 1][Wmax];
Xout.Clear;
Xout.Capacity := W.Count;
for I := 0 to W.Count - 1 do
Xout.Add(0);
j := Wmax;
for I := W.Count - 1 downto 1 do
while Vky[i][j] > Vky[i - 1][j] do
begin //i号物品被使用
j := j - W[i];
Xout[i] := Xout[i] + 1;
end;
if j >= W[0] then
Xout[0] := j div W[0];
end;
Tips:完全背包问题有一个很简单有效的优化,是这样的:若两件物品i、j满足w[i] <= w[j]且vl[i] >= v[j],则将物品j去掉,不用考虑。这个优化的正确性显然:任何情况下都可将价值小费用高的j换成物美价廉的i,得到至少不会更差的方案。对于随机生成的数据,这个方法往往会大大减少物品的件数,从而加快速度。然而这个并不能改善最坏情况的复杂度,因为有可能特别设计的数据可以一件物品也去不掉。
————————————————
01背包问题
对于01背包问题,还要加上一个约束条件,即每种物品只有一件:
同理,考虑第k种物品是否装入时,有两种情况:
- 第k种物品不装入,那么
- 第k种物品装入1个,那么
注意第二种情况相对与完全背包发生了变化,因为物品最多只有一件,因此的下标由k变成了k-1。
因此,递推公式如下:
初始条件为:
同样的,对于上面的例子,此时的计算表如下:
对于01背包解的追踪,比较简单:从表格的最优解处,开始逆向追踪,如果,表示使用了k号物品,否则k号物品未使用。
转化成代码如下:
function Knapsack01(W, V: TList<Integer>; Wmax: Integer; Xout: TList<Integer>): Integer;
var
i, j, k: Integer;
Vmax: Integer;
Vky: array of array of Integer;//子问题最大价值记录表
begin
if (W.Count <> V.Count) or (W.Count = 0) then
raise Exception.Create('Error Input');
//初始化
SetLength(Vky, W.Count);
for i := 0 to W.Count - 1 do
begin
SetLength(Vky[i], Wmax + 1);
Vky[i][0] := 0;
end;
for i := 1 to Wmax do
if i >= W[0] then
Vky[0][i] := V[0];
//Vky[0][i] := (i div W[0]) * V[0]; 完全背包(物品数量无穷)
//自下而上填表
for i := 1 to W.Count - 1 do
begin
k := i - 1;
for j := 1 to Wmax do
if j < W[i] then//装不进去
Vky[i][j] := Vky[k][j]
else
begin
Vmax := Vky[k][j - W[i]] + V[i];//用i号物品时的最大价值
Vky[i][j] := Max(Vky[k][j], Vmax);
end;
end;
//追踪解
Result := Vky[W.Count - 1][Wmax];
Xout.Clear;
Xout.Capacity := W.Count;
for I := 0 to W.Count - 1 do
Xout.Add(0);
j := Wmax;
for I := W.Count - 1 downto 1 do
if Vky[i][j] <> Vky[i - 1][j] then
begin //i号物品被使用
j := j - W[i];
Xout[i] := 1;
end;
if j >= W[0] then
Xout[0] := 1;
end;
多重背包问题
由于01背包问题也有数量限制(每种物品限制一件),我们可以考虑把完全背包问题转化为01背包问题来解。最简单的想法是,考虑到第i种物品最多选件,于是可以把第i种物品转化为件费用及价值均不变的物品,然后求解这个01背包问题。但这样做,如果物品重量较小,空间复杂度将很高!
更高效的转化方法是:把第i种物品拆成费用为、价值为的若干件物品,其中k满足w[i]*2^k<b。这是二进制的思想,因为不管最优策略选几件第i种物品,总可以表示成若干个2^k件物品的和。这样把每种物品拆成件物品,是一个很大的改进。
例如:7:1、2、4;
9:1、2、2、4;
10:1,2、3、4
唯一的问题是如何拆分物品。事实上,只需要列出数字2~10的拆分,那么任意数字的拆分都很容易做到了,只需将个位、十分位、百分位、千分位...分别拆分即可。
做这样的拆分后,时间复杂度就成了。问题完全转化为了01背包,相信读者完全可以写出代码,这里就不再给出了。