概念解释
什么是0/1背包?这指的是假设有若干个物体,对于第 个,所带有的信息包括价值
,所占用的体积为
,背包总共是
的体积,一共
个物体。求怎样选物体,使得总价值有最值。
我们给出模版题。luogu.P1048 [NOIP 2005 普及组] 采药
规律提炼
可以考虑一下几个思路。
- 可以考虑DFS。由于通常DFS的时间复杂度为阶乘级别的,所以考虑空间换时间,即记忆化搜索,并且恰当剪枝。设计状态
表示当前处理到了第
个物体,所用空间为
,所有的价值和为
;
- 自然可以想到DP。怎么DP?实际上与DFS的状态设计是一样的。
DP的转移过程
详细计算过程
算法分析
状态转移方程的讨论
- 对于记忆化DFS,O(N!) 级别,空间复杂度是
;
- DP,双重for循环,时间复杂度在n的规模较大时远远优于O(N!) ,空间复杂度也是
。
思考具体的细节。怎样初始化?不选肯定价值就是0. 表示处理到了第
个物体,剩余可用空间为
这时的总价值。对于每个物体,都可以选或不选。而对于每个物体,更需要考虑能不能选(剩余的空间够不够)。
- 够。那么
。也就是选了这个物体和不选这个物体的max;
- 不够。那么直接继承上一轮的答案即可。
所以最终的答案就存储在 。这样的算法时空复杂度均为
。这样看来似乎还可以更优化。
优化时空复杂度
注意观察上面的那张表。可以观察到,每一次DP的转移都是从上一行或者同一列的左侧转移而来。从状态转移方程来看,对于每一个 ,在转移时,都只与上一行有关。所以,由于程序的顺序特性,在每一次转移时,都会保存上一次转移时的答案,所以直接消去关于
的这一维,成功地将空间复杂度降至
。时间复杂度已至瓶颈,现阶段无法更优。
参考程序
给出DP的程序。(未优化)
#include<iostream>
using namespace std;
struct node
{
int t,p;
}a[105];
int dp[1005][1005];
int main()
{
int t,m;
cin>>t>>m;
for(int i=1;i<=m;i++) cin>>a[i].t>>a[i].p;
for(int i=1;i<=m;i++)
for(int j=0;j<=t;j++)
{
if(j<a[i].t) dp[i][j]=dp[i-1][j];
else dp[i][j]=max(dp[i-1][j-a[i].t]+a[i].p, dp[i-1][j]);
}
cout<<dp[m][t];
return 0;
}
下面是优化后的程序。
#include<iostream>
using namespace std;
struct node
{
int t,p;
}a[105];
int dp[1005][1005];
int main()
{
int t,m;
cin>>t>>m;
for(int i=1;i<=m;i++) cin>>a[i].t>>a[i].p;
for(int i=1;i<=m;i++)
for(int j=a[i].t;j<=t;j++)
dp[j]=max(dp[j],dp[j-a[i].t]+a[i].p);
cout<<dp[t];
return 0;
}
细节实现
我们仍旧关注DP的实际更新过程,这里有两个地方需要特别注意。
-
初始化问题。题目中常见有这两种问法:“不超过最大容量”与“恰好装填满”。有区别,在于不超过时全部初始化为0即可,但是恰好装满则需要全部初始化为-INF。当
时也认为有意义,背包价值为0;因为不要求刚好装满,那么其他任何容量的背包都有一个合法解“什么都不装”,这个解的价值为0,所以初始时状态的值也就全部为0了。此外,背包容量为
时,认为背包被价值为0的物品(nothing)恰好填满;因为要求恰好装满,其它容量都是未知能否装满的不确定状态,我们把这种不确定是否合法的状态都初始化为无穷小。换句话说,除了0号单元(结合表看)在memset初始化以后归零,其余不可,关注实际意义,若
,则不存在一种情况在一开始就使得背包“被装满”。道理是很显然的。
-
循环边界问题。由于数组不可越界(程序特性)且空间是不能为负的(实际意义),j-a[i].t必须是非负的,这要求
,所以循环时可以从边界开始,当然加上if选择结构判断也是等效的。
-
更新顺序问题。优化后的代码内层循环时是从大到小逆序更新。为什么?注意一点,我们是由第i-1次循环的两个状态推出第i个状态的,而且W >W - w[i],则对于第i次循环,背包容量只有当W..0循环时,才会先处理背包容量为W的状况,后处理背包容量为W-w[i] 的情况。
具体来说,由于在执行W时,还没执行到W - w[i],因此,f[W - w[i]]保存的还是第i-1次循环的结果。即在执行第i次循环且背包容量为W时,此时的f[W]存储的是 f[i - 1][W] ,此时f[W-w[i]]存储的是f[i - 1][W-w[i]]。
相反,如果在执行第i次循环时,背包容量按照0..W的顺序遍历一遍,来检测第i件物品是否能放。此时在执行第i次循环且背包容量为W时,此时的f[W]存储的是 f[i - 1][W] ,但是,此时f[W-w[i]]存储的是f[i][W-w[i]]。
因为,W >W - w[i],第i次循环中,执行背包容量为W时,容量为W - w[i]的背包已经计算过,即f[W - w[i]]中存储的是f[i][W - w[i]]。即,对于01背包,按照增序枚举背包容量是不对的。事实上前文也提及过,这就是先保存了上一次更新的结果,要依靠上一次的结果更新本次。这其实也就是DP的最优子结构性质和无后效性的一个体现,并且在0-1背包中,每个物体只能取或不取两种状态,不能重复计算。为什么要强调这个?因为在后面的完全背包中是可以做出相同的优化且可以进行增序遍历的,具体的到时候再说。
总结归纳
0/1背包问题是最简单的背包问题。