参考资料
-
背包九讲
著名的背包问题讲义,大而全面,包含各种背包问题的变形和大量优化方法,影响了无数后来者。缺点是说得不甚明白,省略了大量推理,如果对背包问题不够熟悉很难看懂里面的结论是怎么来的。
最适合已经搞懂背包问题过后,用来提纲挈领,作为复习资料或参考手册使用。
如果对背包问题及其变形不甚了了,需要搜索大量的相关资料配合食用。 -
https://zhuanlan.zhihu.com/p/93857890
知乎@SMON写的《动态规划之背包问题系列》。思路清晰易懂,排版也对阅读有不小帮助,适合初学者。
本文的主要内容
本文讲解背包问题中最基本的01背包问题。我们将从0-1背包问题出发,从一个第一次见到背包问题的“纯新手”的角度出发,利用动态规划的思路来分析问题,分解子问题,求解状态转移方程,根据状态转移方程来设计程序伪码,并分析动态规划解法的优点。后续还会有新的文章讨论背包问题的变种。
1. 01背包问题描述
共有N件物品 I 1 , … , I N I_1,…,I_N I1,…,IN,每件物品 i 的重量为w(i),价值为v(i)。
现在使用承重上限为W的背包,如何挑选物品才能使背包内物品的总价值最大。
这里背包内每件物品的数量只能为0(不装)或1(装且只装一件),因此称为0-1背包问题。
2. 子问题与状态转移方程
每个物品的重量和价值实际上可以随意指定而不影响解法,因此背包问题只有两个条件约束其问题规模:物品数量、承重上限。用(m,w)来表示0-1背包问题:对第1~m件物品,要求选出总重量不超过w的、价值最大的物品序列。其解为物品集合I(m,w)、价值总和v(m,w)。减小m和w即可得到它的子问题。
下面我们来思考如何用子问题的解求原问题的解。对问题 (m,w) ,只考虑第m个物品是否包含在最优解中,容易想到
逐条分析:
-
m在最优解中 : v ( m , w ) = v ( m − 1 , w − w ( m ) ) + v ( m ) v(m,w)=v(m-1,w-w(m))_+v(m) v(m,w)=v(m−1,w−w(m))+v(m)
由于m在最优解中,从I(m,w)中取出m后,剩下的物品只在1~m中,总重量减少了w(m),总价值减少了v(m)。 -
m不在最优解中 : v ( m , w ) = v ( m − 1 , w ) v(m,w)=v(m-1,w) v(m,w)=v(m−1,w)
(m,w) 的最优解应该是 (m-1,w) 的最优解。
v(m,w) 的最终值应该是上述2种情况中的最大值,即
v
(
m
,
w
)
=
max
(
v
(
m
−
1
,
w
−
w
(
m
)
)
+
v
(
m
)
,
v
(
m
−
1
,
w
)
)
v(m,w)=\max(v(m-1,w-w(m))+v(m) , v(m-1,w))
v(m,w)=max(v(m−1,w−w(m))+v(m),v(m−1,w))
这就是0-1背包问题的状态转移方程。
同时可以证明:如果 v(m−1,w−w(m))+v(m)>v(m−1,w) 则 m∈I(m,w)。
3. 基本解法
我们已经求得状态转移方程 v(m,w)=max(v(m−1,w−w(m))+v(w) , v(m−1,w)) ,下面来考虑一些边界情况。
1. 显然,m和w都不能是负数
2. m=0或w=0的情况。“m=0”代表“不装东西”,“w=0”代表“总重量不超过0”。对∀w≥0,v(0,w)=0。对∀m≥0,I(m,0)=∅。
这些边界情况将告诉我们应该怎样去初始化状态表。
现在,我们得到完整的 v(m,w) 求解公式 :
v
(
m
,
w
)
=
{
max
(
v
(
m
−
1
,
w
−
w
(
m
)
)
+
v
(
m
)
,
v
(
m
−
1
,
w
)
)
m
>
=
1
,
w
>
=
w
(
m
)
v
(
m
−
1
,
w
)
m
>
=
1
,
w
<
w
(
m
)
0
m
=
0
或
w
=
0
v(m,w)= \begin{cases} \max(v(m-1,w-w(m))+v(m), v(m-1,w))& m>=1, w>=w(m)\\ v(m-1,w) & m>=1, w<w(m)\\ 0 &m=0 或 w=0 \end{cases}
v(m,w)=⎩⎪⎨⎪⎧max(v(m−1,w−w(m))+v(m),v(m−1,w))v(m−1,w)0m>=1,w>=w(m)m>=1,w<w(m)m=0或w=0
且
当
v
(
m
,
w
)
取
v
(
m
−
1
,
w
−
w
(
m
)
)
+
v
(
m
)
时
,
I
(
m
,
w
)
=
I
(
m
−
1
,
w
−
w
(
m
)
)
∪
{
m
}
当 v(m,w) 取 v(m-1,w-w(m))+v(m) 时,I(m,w)=I(m-1,w-w(m)) \cup \{m\}
当v(m,w)取v(m−1,w−w(m))+v(m)时,I(m,w)=I(m−1,w−w(m))∪{m}
使用这个结论可求任意 v(m,w) 和 I(m,w) 。
至此,我们的思路和分冶法的思路是一样的,但是动态规划比分冶法多一个考虑:在计算子问题时存在重复过程,如果每算出一个子问题就把结果存起来,后面再算到这个问题时直接读结果,可以节省大量的计算时间。为了保存每个子问题的解,设计一张N∗W的表,将v(m,w)填写在对应位置。这里我们想到的是用空间换时间来简化求解过程,后面我们还将进一步优化这个存储方案。
下面给出一个具体例子,我将首先用二叉搜索树的形式展现单纯的分冶法可能会造成的计算冗余,然后演示如何在搜索解的同时使用备忘来避免这些冗余。
(这里题目中所有物品重量都不超过承重上限。如果有单个就超重的物品,显然可以先将它剔除,后面的算法中我们忽略这种超重情况。)
(如果W的值非常大,超过了所有物品重量之和,那么显然应该把所有物品都放进背包。)
3.1. 递归求解
如果采用递归方式实现,递归栈内容可以用这个树形结构表示,每个节点表示一个特定规模的问题,执行的操作是选出其子节点中的较大者(叶子结点直接赋值为0)。
分冶算法在每个非叶子结点处求子节点的值,最差的情况,这是一棵高度为N的完全二叉树,时间复杂度为 O ( 1 + 2 + 2 2 + … + 2 N ) = O ( 2 N ) O(1+2+2^2+…+2^N)=O(2^N) O(1+2+22+…+2N)=O(2N) 。
动态规划算法则在每次求子节点结果之前先查找之前是否求过该子问题,如果求过则直接读取结果,如果没求过则在求解之后记录该子问题的解。即对于需要重复求解的问题,我们只在第一次进行求解操作;从第二次开始以它为根节点的子树都不用再求解,且只需读取该结点的结果而无需再向下进行递归。此时我们最多需要求解N*W个子问题的解,即时间复杂度为O(NW)。这种方法称为带备忘的递归。
我们用“重叠子问题”来描述这种子问题被多次求解的现象。重叠子问题的性质保证了动态规划在时间上优于普通的分冶方法(一个是平方级,一个是指数级)。
int w[N]; //物品重量
int v[N]; //物品价值
int memo[N][W]; //存储中间结果的备忘
for n=0…N , w=0…W
memo[n][w]=-1;
int 01knpsack_recur(int m, int w)
{
if (m==0 || w==0)
{
memo[m][w]=0;
return 0;
}
if (w < w[m])
{
if (memo[m-1][w]==-1)
{
memo[m-1][w]=01knapsack(m-1,w);
}
return memo[m-1][w];
}
else
{
if (memo[m-1][w]==-1)
{
memo[m-1][w]=01knapsack(m-1,w);
}
if (memo[m-1][w-w[m]]==-1)
{
memo[m-1][w-w[m]]=01knapsack(m-1,w-w[m]);
}
return max(memo[m-1][w-w[m]]+v[m], memo[m-1][w]);
}
}
3.2. 打表求解
- 每个单元格根据递归式填写值。
- 由于父问题的解取决于子问题的解,按照(0,0)→(N,W)的顺序填完整个表格就能在(N,W)得到原问题的解。
int w[N]; //物品重量
int v[N]; //物品价值
int f[N+1][W+1]; //中间过程,每个子问题的最大价值
int 01knapsack_tbl(int N, int W)
{
//可以在声明变量时将数组f置为全0,省略这里的初始化
for w=0…W
f[0][w]=0;
for n=1…N
f[n][0]=0;
for n=1…N
{
for w=1…W
{
if (w<w[n])
f[n][w]=f[n-1][w];
else
f[n][w]=max(f[n-1][w-w[n]]+v[n], f[n-1][w]);
}
}
return f[N][W];
}
3.3. 比较两种方法
逐行填表过程对应递归树从叶子节点向根节点递推的过程,这两种方法对应的是两个推理方向,通常称递归为自顶向下,称打表为自底向上。
可以看出,b中的子问题只是c中表格的一部分,带备忘的递归在有时候可以规避一些(打表方法要求解的)不必要的子问题,但是要注意递归调用本身的开销。总地来说,两种方法的时间复杂度都是 O(NW) 。而从空间复杂度看,保存备忘还是需要表格来记录,同时递归栈还导致额外的开销。对于打表法,下面我们还将进一步优化方案。
3.4. 求I(m,w)
如果要求I(m,w),还需要同时维护一张表记录状态转移的过程,以便用回溯法重现当时放入最优解的物品。
如果v(m,w)=v(m−1,w−w(m)+v(m)则填(m−1,w−w(m)),如果v(m,w)=v(m−1,w)则填(m−1,w),如果两者都满足则需都填写。为了简便,用a表示(m−1,w−w(m)),b表示(m−1,w),c表示两者都可能,空格”表示“该路径已回溯完毕”。
回溯时,从(N,W)开始,根据表格内容找到前一个状态,直到回溯完整个路径。如果表格内是a,说明在这个状态下最优解中加入了一个物品,在回溯过程中把这些物品记录在栈中,回溯完成时栈中就得到最优解方案。
4. 打表法优化空间开销
观察状态转移方程,我们能够得到结论:只要(m−1,w−w(m))和(m−1,w)的解确定,(m,w)的解也随之确定。也就是说,我们填表格的第m行时,只需要m-1行的值,再之前的数据实际上是冗余的,因此,考虑将N*W的表格压缩。
一个简单的想法是压缩为连续的两行A A-1,A中是正在填的第m行,A-1中是“依据”第m-1行,填完m行后将它原样复制到A-1中,然后再在A中填m+1行。这样将原来N*W的空间开销降为2W,减小了一个量级。(尽管“复制”这一操作可以优化掉,但这不是我们讨论的重点,下面只用一行的方法开销会更小。)
进一步思考,能否只用一行。只使用一行的困难在于“正填写的行”与“所依据的行”需要放在同一行数组中,我们担心填写行为会覆盖掉待填单元格所依据的数据。再次对照状态转移方程,填写第(i,j)格时,使用的是(i−1,j)和(i−1,j−w(i)),即原表格中上方和左上方的数据,那么它的值不会被前一行右侧的数据影响到。也就是说,当我们只用一行表格时,如果从右到左地填写,就能够避免覆盖待使用数据的问题。
同时,w<w[n] 的情形下,不用再更新(m,w),那么内层循环可以调整循环次数为当 w=W…w[n] 。这样处理可以减少一些循环次数从而节省时间。
int f[W+1]={0};
int 01knapsack_tbl_1L(int N, int W)
{
for n=1…N
{
for w=W…w[n]
f[w]=max(f[w-w[n]]+v[n], f[w]);
}
return f[N][W];
}
需要注意的是,上面这个优化只能用于求最大价值的表,用来记录具体取物品方案的表是无法缩小的。
这种压缩完整表格的方法称为滚动数组法。滚动数组只存储决定下一组状态的数据,并实时更新。支持这样做的原因在于本问题中的过程状态具有无后效性。
5. “刚好装满”
前面的问题只要求背包内物品不超重,现在,我们要求背包刚好被装满。
重新描述子问题:(m,w)表示从前m个物品中选出重量和刚好为w的、价值最大的物品序列。同样考虑第m个物品是否在最优解中:如果m∈I(m,w),那么v(m,w)=v(m−1,w−w(m));如果m∉I(m,w),且(m−1,w)存在合法解,那么v(m,w)=v(m−1,w)。
“刚好装满”的要求导致某些情况下问题可能没有合法解。实际上,当前的合法解一定是从之前的合法状态推得的,即
(
m
,
w
)
存
在
合
法
解
⇔
{
m
∈
I
(
m
,
w
)
,
且
(
m
−
1
,
w
−
w
(
m
)
)
存
在
合
法
解
m
∉
I
(
m
,
w
)
,
且
(
m
−
1
,
w
)
存
在
合
法
解
(m,w)存在合法解 ⇔ \begin{cases} m∈I(m,w),且(m−1,w−w(m))存在合法解 \\ m∉I(m,w),且(m−1,w)存在合法解 \end{cases}
(m,w)存在合法解⇔{m∈I(m,w),且(m−1,w−w(m))存在合法解m∈/I(m,w),且(m−1,w)存在合法解
这个结论可以用反证法证明,思路与2中证明类似。
现在,我们在状态表中需要能够区别出该子问题是否有合法解,可以设计为:初始化的时候,所有子问题都是未知的,假设它们都没有合法解,然后在求解子问题过程中把有合法解的单元格赋值成问题的解。不妨先使用一个特殊符号 ⋇ 来表示非法解,则前面的 v(m,w) 求解可表示为:
v
(
m
,
w
)
=
{
max
(
v
(
m
−
1
,
w
−
w
(
m
)
)
+
v
(
m
)
,
v
(
m
−
1
,
w
)
)
v
(
m
−
1
,
w
−
w
(
m
)
)
≠
⋇
,
v
(
m
−
1
,
w
)
≠
⋇
v
(
m
−
1
,
w
−
w
(
m
)
)
+
v
(
m
)
v
(
m
−
1
,
w
−
w
(
m
)
)
≠
⋇
,
v
(
m
−
1
,
w
)
=
⋇
v
(
m
−
1
,
w
)
v
(
m
−
1
,
w
−
w
(
m
)
)
=
⋇
,
v
(
m
−
1
,
w
)
≠
⋇
⋇
v
(
m
−
1
,
w
−
w
(
m
)
)
=
⋇
,
v
(
m
−
1
,
w
)
=
⋇
v(m,w)= \begin{cases} \max(v(m−1,w−w(m))+v(m) , v(m−1,w)) & v(m−1,w−w(m))≠⋇, v(m−1,w)≠⋇ \\ v(m−1,w−w(m))+v(m) & v(m−1,w−w(m))≠⋇, v(m−1,w)=⋇ \\ v(m−1,w) & v(m−1,w−w(m))=⋇, v(m−1,w)≠⋇ \\ ⋇ & v(m−1,w−w(m))=⋇, v(m−1,w)=⋇ \end{cases}
v(m,w)=⎩⎪⎪⎪⎨⎪⎪⎪⎧max(v(m−1,w−w(m))+v(m),v(m−1,w))v(m−1,w−w(m))+v(m)v(m−1,w)⋇v(m−1,w−w(m))=⋇,v(m−1,w)=⋇v(m−1,w−w(m))=⋇,v(m−1,w)=⋇v(m−1,w−w(m))=⋇,v(m−1,w)=⋇v(m−1,w−w(m))=⋇,v(m−1,w)=⋇
为了化简这个表达,我们可以寻找这样的 ⋇ ,它使得上面这四个式子都可以表示为状态转移方程 v(m,w)=max(v(m−1,w−w(m))+v(m) , v(m−1,w)) 。即⋇ 应该满足
{
v
(
m
,
w
)
=
max
(
⋇
+
C
3
,
C
2
)
=
C
2
v
(
m
−
1
,
w
−
w
(
m
)
)
=
⋇
,
v
(
m
−
1
,
w
)
=
C
2
v
(
m
,
w
)
=
max
(
C
1
+
C
3
,
⋇
)
=
C
1
+
C
3
v
(
m
−
1
,
w
−
w
(
m
)
)
=
C
1
,
v
(
m
−
1
,
w
)
=
⋇
v
(
m
,
w
)
=
max
(
⋇
+
C
3
,
⋇
)
=
⋇
v
(
m
−
1
,
w
−
w
(
m
)
)
=
⋇
,
v
(
m
−
1
,
w
)
=
⋇
\begin{cases} v(m,w)=\max(⋇+C3, C2)=C2 & v(m−1,w−w(m))=⋇, v(m−1,w)=C2 \\ v(m,w)=\max(C1+C3 , ⋇)=C1+C3 & v(m−1,w−w(m))=C1, v(m−1,w)=⋇ \\ v(m,w)=\max(⋇+C3 , ⋇)=⋇ & v(m−1,w−w(m))=⋇ , v(m−1,w)=⋇ \end{cases}
⎩⎪⎨⎪⎧v(m,w)=max(⋇+C3,C2)=C2v(m,w)=max(C1+C3,⋇)=C1+C3v(m,w)=max(⋇+C3,⋇)=⋇v(m−1,w−w(m))=⋇,v(m−1,w)=C2v(m−1,w−w(m))=C1,v(m−1,w)=⋇v(m−1,w−w(m))=⋇,v(m−1,w)=⋇
容易想到令⋇=−∞,在实现时则取一个足够小的负数。
下面来考虑边界。当m=0时,背包里没有东西,而“没有东西”只能刚好装满“重量为0”;当w=0时,“总重量为0”只能由“没有东西”刚好装满。即v(0,0)=0,v(0,w)=−∞(w>0),v(m,0)=−∞(m>0)。
现在得到完整的求解公式
v
(
m
,
w
)
=
{
max
(
v
(
m
−
1
,
w
−
w
(
m
)
)
+
v
(
m
)
,
v
(
m
−
1
,
w
)
)
m
≥
1
,
w
≥
w
(
m
)
v
(
m
−
1
,
w
)
m
≥
1
,
w
<
w
(
m
)
0
m
=
w
=
0
−
∞
m
≠
w
且
(
m
=
0
或
w
=
0
)
v(m,w)=\begin{cases} \max(v(m−1,w−w(m))+v(m) , v(m−1,w)) & m≥1, w≥w(m) \\ v(m−1,w) & m≥1, w<w(m) \\ 0 & m=w=0 \\ −∞ & m≠w 且 (m=0 或 w=0) \end{cases}
v(m,w)=⎩⎪⎪⎪⎨⎪⎪⎪⎧max(v(m−1,w−w(m))+v(m),v(m−1,w))v(m−1,w)0−∞m≥1,w≥w(m)m≥1,w<w(m)m=w=0m=w且(m=0或w=0)
可以观察到,这个公式和不要求刚好装满的情况非常相似,不同点只在于对边界情况的处理。
在实际编程中,只需要在初始化阶段,将 v(0,0) 赋值为0,其他赋值为一个足够小的负数,其余不变。