欢迎来到背包专题第二课——完全背包专题
这篇博客是在动态规划基础题(背包专题第一课):0-1背包的基础上讲解的,如果还没有学过0-1背包,请点击链接先学习0-1背包超详细版。
虽然你已经知道0-1背包的演变过程了,但是在这边我还是要从头开始讲,这样做题会好做许多。
完全背包题目描述:
现在有一个背包容量为m,有n种物品,每种物品可以无限取,第i种物品的体积为w[i],价值为c[i],现在要去一些物品放到背包里,问那些物品的总价值是多少?
老套路——暴搜!
现在的暴搜时间复杂度更加爆炸了,不过......
还是有必要从头开始的,因为这里有一个大家以后做动态规划的题的时候可以用的套路,将会在每一节的最后讲述。
根据0-1背包的思路,我们要枚举每样物品的状态,在0-1背包中,每件物品只能取或不取,但是在完全背包中,物品是无限量的,枚举没有上限,怎么办呢?
简单,题目里还有一个条件,所选物品总体积不能超过背包总容量,所以只要枚举到背包装不下,就可以停下了。
在此,我要恭敬地献上代码:
#include <iostream>
using namespace std;
int w[1005],c[1005];
int solve(int n,int m){ //这边仍然要用倒序,因为回溯是少不了的
if (n==0){ //整个的遍历结束了
return 0;
}
if (m<0)return -2147483647;
int x=solve(n-1,m);
for (int i=1;i*w[n]<=m;i++){
x=max(x,solve(n-1,m-(i*w[n]))+(c[n]*i));
}
return x;
}
int main() {
int n,m;//n表示物品数量,m表示背包容量
scanf("%d%d",&n,&m);
for (int i=1;i<=n;i++){
scanf("%d%d",&w[i],&c[i]);
}
printf("%d\n",solve(n,m));
return 0;
}
我们可以发现,这个程序的时间复杂度为O(),实在是可以炸掉一条街,不过有一个好消息:
套路的已收集!
套路的第一步:打一个暴搜出来找线索。
记忆化搜索
我们又又又又又可以发现,这个代码会有很多重复算的步骤,这里不像上次那样举样例了,大家知道就好。
现在,我们可以令f[i][j]代替solve(i,j)(套路老掉牙)让后再次算到solve(i,j)的时候,就只要从f[i][j]中取数就可以了。
现在,我要再次恭恭敬敬地在下面的空白处附上代码:
#include <iostream>
using namespace std;
int w[1005],c[1005];
int f[1005][1005];
int solve(int n,int m){
if (n==0){
return 0;
}
if (m<0)return -2147483647;
if (f[n][n]!=-1)return f[n][m];
f[n][m]=solve(n-1,m);
for (int i=1;i*w[n]<=m;i++){
f[n][m]=max(f[n][m],solve(n-1,m-(i*w[n]))+(c[n]*i));
}
return f[n][m];
}
int main() {
memset(f,-1,sizeof(f));
int n,m;
scanf("%d%d",&n,&m);
for (int i=1;i<=n;i++){
scanf("%d%d",&w[i],&c[i]);
}
printf("%d\n",solve(n,m));
return 0;
}
复杂度分析:
该程序时间复杂度分析:
该程序空间复杂度分析:
嗯......
还可以,但是已经没法优化了,我们需要找找可以优化的,时间复杂度差不多的写法。
叮咚!
DP套路的已经收集!
第二步:打出搜索的记忆化优化
关键性的一步:递归->递推
我们已经将暴搜改成了时间复杂度更低的记忆化优化后的递归算法,然而,我们却无法再继续优化其空间复杂度或者时间复杂度了,但是这样的时间和空间是一般的题目无法接受的,所以我们要改写成另外一种方式。
递归,它的另一个方向就是递推,递归和递推都是动态规划的一种方式,现在,我们将把一个递归的代码转化成一个递推的代码。
首先,我们要老老实实地转移,不改变时间复杂度和空间复杂度,一改就很容易错有可能只是对于我这种菜的不行的人。
首先,我们不大幅改变f数组的意义,我们令表示循环到第i个物品为止,若背包大小为j,那么可以获得的最大价值是多少。
这次的转移方程先对难找,但是若根据0-1背包的转移方程,我们可以很容易地就推出状态转移方程,由于0-1背包中每个物品每个只能拿一次,所以就少了枚举个数的循环,现在只需要再加一个枚举个数的循环就可以了,然后再转移时,把体积和价值乘一乘就可以了。
代码:
#include <iostream>
using namespace std;
int w[1005],c[1005];
int f[1005][1005];
int main() {
int n,m;
scanf("%d%d",&n,&m);
for (int i=1;i<=n;i++){
scanf("%d%d",&w[i],&c[i]);
}
for (int i=1;i<=n;i++){
for (int j=m;j>=w[i];j--){
for (int k=1;k*w[i]<=j;k++)f[i][j]=max(f[i][j],f[i-1][j-w[i]*k]+c[i]*k);//明显的,k表示拿的物品的数量
}
}
printf("%d\n",f[n][m]);
return 0;
}
明显的,递推的代码长度会比递归短许多。
由于这是递归版的另一种写法,我就不分析时间复杂度和空间复杂度了
成功收集DP套路的
第三步:根据记忆化搜索时记录的方式,定出合理的状态转移方程。
优化空间
前面说过,我们把递归写成递推不是因为我们闲着没事干,而是因为我们要优化时间与空间。
我们先优化比较容易优化的空间吧,0-1背包里面提到过,可以用滚动数组优化空间,虽然这是空完全背包,但本质还是一样的。
我就不细讲了,直接上代码,自己理解!
#include <iostream>
using namespace std;
int w[1005],c[1005];
int f[1005];
int main() {
int n,m;
scanf("%d%d",&n,&m);
for (int i=1;i<=n;i++){
scanf("%d%d",&w[i],&c[i]);
}
for (int i=1;i<=n;i++){
for (int j=m;j>=w[i];j--){
for (int k=1;k*w[i]<=j;k++)f[j]=max(f[j],f[j-w[i]*k]+c[i]*k);
}
}
printf("%d\n",f[m]);
return 0;
}
分析复杂度了:
时间复杂度:
空间复杂度:
我们成功地优化掉了一维空间
优化时间
优化方式我在0-1背包里不小心透露了出来,等会在那边加个链接。
你想,我们是在用滚动数组对吧。
比方说我们现在处理到剩余空间为5的情况,那么剩余空间为4的情况一定是已经处理好了的。
我们在0-1背包里面之所以要倒着来,就是因为我们一个物品只能取一次,而且我们是从剩余空间小的地方转移过来的,我们不能知道剩余空间小的那些状态有没有取过这个物品,所以我们必须从后面开始转移。
那我们再考虑一下,限制0-1背包正序的条件在完全背包中已经被粉碎了。
所以我们可以转移一个已经取过该物品的状态。
所以,我们可以把第二层循环正过来,这样就可以省掉一个第三重循环。
上代码吧:
#include <iostream>
using namespace std;
int w[1005],c[1005];
int f[1005];
int main() {
int n,m;
scanf("%d%d",&n,&m);
for (int i=1;i<=n;i++){
scanf("%d%d",&w[i],&c[i]);
}
for (int i=1;i<=n;i++){
for (int j=w[i];j<=m;j++){ //正序遍历,允许转移已经取过的状态
f[j]=max(f[j],f[j-w[i]]+c[i]);
}
}
printf("%d\n",f[m]);
return 0;
}
复杂度分析:
时间复杂度:
空间复杂度:
OK,整个DP套路都集齐了,我们来整理一下:
第一步:写出暴力(递归)代码。
第二步:写出递归的记忆化搜索。
第三步:根据记忆化的f数组定义,定义出DP的状态。
第四步:优化空间和时间。
好了,我的讲解到此结束,求点赞,求收藏,求关注!