此文为自己复习理解用,本人菜鸡一枚,如有不当之处请指正
首先看一道例题
这个问题我们可能首先会想到用递归解决,但如果有n种硬币,需要m元(键入),用递归解决就不合适了,这时就需要我们的动态规划算法了,它与递归之间的区别主要在于dp将计算过的结果存了下来,而递归则进行了许多重复计算。
附c++递归代码
#include<iostream>
#include<algorithm>
using namespace std;
int dg(int x)
{
if(x<=0) return 0;
int res=0x3f3f3f3f;
if(x>=2) res=min(res,dg(x-2)+1);
if(x>=5) res=min(res,dg(x-5)+1);
if(x>=7) res=min(res,dg(x-7)+1);
return res;
}
int main()
{
cout<<dg(27);
return 0;
}
对于这道题来说,dp其实采取了几乎一样的策略
dp的做题技巧
1.找状态
2.考虑边界和初始条件
3.状态转移方程
4.考虑计算顺序
放到这个题目来看:
1.状态:我们设一个f数组,f[x]表示x枚硬币的最优划分方案
2.边界和初始条件:x>0 , f[0]=0
3.状态转移方程:f[x]=min(f[x-2]+1,f[x-5]+1,f[x-7]+1);
4.计算顺序:因为我们已经知道f[0]的值,故采取0-27的顺序将更加方便
附dp c++代码
#include<iostream>
#include<algorithm>
#include<cstring>
using namespace std;
int f[1010];
int main()
{
memset(f,0x3f,sizeof f);
f[0]=0;
for(int i=1;i<=27;i++)
{
if(i>=2) f[i]=min(f[i],f[i-2]+1);
if(i>=5) f[i]=min(f[i],f[i-5]+1);
if(i>=7) f[i]=min(f[i],f[i-7]+1);
}
cout<<f[27];
return 0;
}
接下来我们思考一个进阶题目——有n种硬币,硬币面额需键入,有m元钱,思考最少的硬币组合
我们还是先找状态:f[x]为x元时的最佳硬币组合
初始情况:f[0]=0 边界:x>0
状态转移方程:我们设硬币面额存在a数组中,那么 f[x]=min(f[x-a[1]],...,f[x-a[n]])+1
计算顺序:从小到大
#include<cstdio>
#include<algorithm>
#include<cstring>
using namespace std;
int f[1010],a[1010],n,m;
int main()
{
scanf("%d%d",&n,&m);
for(int i=1;i<=n;i++)
scanf("%d",&a[i]);
memset(f,0x3f,sizeof f);
f[0]=0;
for(int i=1;i<=m;i++)
{
for(int j=1;j<=n;j++)
{
if(i>=a[j]) f[i]=min(f[i],f[i-a[j]]+1);
}
}
printf("%d",f[m]);
return 0;
}
到这里,dp入门内容基本完成,接下来我们进入背包问题
首先是最基础的0/1背包问题,题目如下,它的特点是每种物品只有一件,这样在选择时只能选择0件(不选)或者选一件
样例数据:
输入 输出:14
3 6
2 5
3 8 我们用一个表格帮助理解
4 9 每一行开头的数x表示选前x个物品,每一列开头的数y表示物品的总体积不超过y
物品价值 | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
1 | 0 | 0 | 5 | 5 | 5 | 5 | 5 |
2 | 0 | 0 | 5 | 8 | 8 | 13 | 13 |
3 | 0 | 0 | 5 | 8 | 9 | 13 | 14 |
通过表格和对题意的理解,我们得到状态为:f[i][j]表示选前i件物品,总体积不超过j的情况下,背包中物品的最大价值
初始条件:如表格所示,f[x][0]和f[0][x]的值都为0 边界:x,y>0
状态转移方程:通过表格和对题意的理解,我们可知,每一次选择,我们都有选和不选两种选项,不选的话,f[i][j]=f[i-1][j],如果选,f[i][j]=f[i-1][j-w[i]]+p[i]
综上:f[i][j]=max(f[i-1][j],f[i-1][j-w[i]]+p[i])
计算顺序:从小到大
#include<stdio.h>
#include<string.h>
#include<algorithm>
using namespace std;
int f[110][10010],P[110],W[110],n,w;
int main()
{
scanf("%d%d",&n,&w);
for(int i=1;i<=n;i++)
{
scanf("%d%d",&W[i],&P[i]);
}
memset(f,0,sizeof f);
for(int i=1;i<=n;i++)
{
for(int j=1;j<=w;j++)
{
if(j-W[i]>=0)
f[i][j]=max(f[i-1][j],f[i-1][j-W[i]]+P[i]);
else
f[i][j]=f[i-1][j];
}
}
printf("%d",f[n][w]);
return 0;
}
再思考,现在我们的n只是不超过100而已,如果不超过10000,再采取刚才的策略,空间这第一关都过不了,这该怎么办?
我们通过观察刚才的表格和代码发现,f[i][j]的值都是从f[i-1][j]或f[i-1][j-W[i]]过来的,也就是说每一行的数据都是从上一行的数据经过处理后得到的,我们可以以此为突破点
接下来介绍——滚动数组 顾名思义,这个数组是滚动的,存完一行之后,下一行可以将原来的数据覆盖掉,以此来减少开数组而占用的空间,而这个数组怎么开呢?很简单,只需要开一个一维数组即可,大小为w+1,在本题里为f[101]即可
那具体操作是什么?其实跟原来的二维数组差不多,只需将f[i][j]中表示行的i删去,变成f[j]即可,这样的话,状态转移方程就变成了f[j]=max(f[j],f[j-W[i]]+P[i]),但我们还需要思考一件事
物品价值 | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
1 | 0 | 0 | 5 | 5 | 5 | 5 | 5 |
2 | 0 | 0 | 5 | 8 | 8 | 13 | 13--16 |
3 | 0 | 0 | 5 | 8 | 9 | 13 | 14 |
我们再来观察这个表格,看1、2行,当j=3时,f[j]取f[j-W[i]]+P[i],这样的话f[3]由5变成了8,而当j=6时,f[j]本来应该取5+8=13,现在却变成了8+8=16,出现了错误
再来思考,怎样解决这个问题呢?我们只需让j从后往前循环,就不会出现前面的数将f数组的值覆盖,后面的数出现错误的情况,我们再用表格模拟一下,发现的确是这样的
物品价值 | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
1 | 0 | 0 | 5 | 5 | 5 | 5 | 5 |
2 | 0 | 0 | 5 | 8 | 8 | 13 | 13 |
3 | 0 | 0 | 5 | 8 | 9 | 13 | 14 |
#include<stdio.h>
#include<string.h>
#include<algorithm>
using namespace std;
int f[10010],P[110],W[110],n,w;
int main()
{
scanf("%d%d",&n,&w);
for(int i=1;i<=n;i++)
{
scanf("%d%d",&W[i],&P[i]);
}
memset(f,0,sizeof f);
for(int i=1;i<=n;i++)
{
for(int j=w;j>=1;j--)
{
if(j-W[i]>=0)
f[j]=max(f[j],f[j-W[i]]+P[i]);
else
f[j]=f[j];
}
}
printf("%d",f[w]);
return 0;
}
好的,现在我们进入完全背包,下附例题:
完全背包与0/1背包的不同之处在于每一件物品都可以选择无数次,其他事项都和0/1背包一致,所以我们可以延用0/1背包的状态,初始情况,边界,计算顺序,对于状态转移方程稍加变动即可
再把我们的表格拿出来并稍加改变
状态:f[i][j]:在前i个物品中,选总体积不超过j的物品所能达到的最大价值
样例: 输入3 6 2 5 3 8 4 9
物品价值 | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
1 | 0 | 0 | 5 | 5 | 10 | 10 | 15 |
2 | 0 | 0 | 5 | 8 | 10 | 13 | 16 |
3 | 0 | 0 | 5 | 8 | 9 | 13 | 16 |
我们再来分析一下:对于每一件物品,我们有这几种选择:选0件,选1件,选2件,...,选n件
其中n*W[i]<=w,那对于状态转移方程的变化,我们也有了头绪
原方程:f[i][j]=max(f[i-1][j],f[i-1][j-V[i]]+C[i])
现方程:f[i][j]=max(f[i-1][j],f[i-1][j-n*V[i]]+n*C[i])
解释一下:跟0/1背包一样, 对于一件物品,我们可以选择不选它,就有了max函数里的第一个参数f[i-1][j],跟0/1背包的区别在于,一件物品,只要我选择的件数×该物品的体积不超过j,在这个范围里我可以任意选择,所以我们可以加一重循环,让循环变量k从1开始,循环条件为k*V[i]<=j,k++
初始条件:如表格所示,f[0][x]=0,f[x][0]=0 边界:j>0
计算顺序:从小到大
#include<cstdio>
#include<algorithm>
#include<cstring>
using namespace std;
int f[110][50010],V[110],C[110],n,v;
int main()
{
scanf("%d%d",&n,&v);
for(int i=1;i<=n;i++)
{
scanf("%d%d",&V[i],&C[i]);
}
memset(f,0,sizeof f);
for(int i=1;i<=n;i++)
{
for(int j=1;j<=v;j++)
{
for(int k=0;k*V[i]<=j;k++)
{
f[i][j]=max(f[i][j],f[i-1][j-k*V[i]]+k*C[i]);
}
}
}
printf("%d",f[n][v]);
return 0;
}
因为本题数据范围过大,上面的代码会被卡掉部分分,所以跟0/1背包一样,接下来我们思考如何优化
又是和0/1背包一致,我们根据上面的代码,又一次发现每一行的数据都是从上一行的数据而来,如此我们便可以采取和0/1背包相似的策略——将f数组降成一维,把f[i][j]变成f[j]
如此,状态转移方程变成了:f[j]=max(f[j],f[j-k*V[i]]+k*C[i])
但是我们的优化还没停止,刚才我们只是减小了空间复杂度,对于例题,我们的时间复杂度依然过高,怎么办呢?我们继续来观察表格
物品价值 | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
第一次 | 0 | 0 | 5 | 5 | 10 | 10 | 15 |
第二次 | 0 | 0 | 5 | 8 | 10 | 13 | 16 |
第三次 | 0 | 0 | 5 | 8 | 9 | 13 | 16 |
我们可以发现,对于每一个f[j],都可以从上一次的f[j]和本次的f[j-V[i]]变来(从f[j]和f[j-V[i]]+C[i] 中取最大值),那我们是不是可以删去第三重循环k啊!
我们再来思考,这一次,第二重循环还需要倒序进行吗?答案是:不需要了,我们再来观察表格
物品价值 | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
第一次 | 0 | 0 | 5 | 5 | 10 | 10 | 15 |
第二次 | 0 | 0 | 5 | 8 | 10 | 13 | 16 |
第三次 | 0 | 0 | 5 | 8 | 9 | 13 | 16 |
之前说过,对于每一个f[j],都是从f[j]和f[j-V[i]]+C[i]中取最大值后得到的,也就是说我们正序进行就可以,如果倒序进行反而会出现错误
#include<cstdio>
#include<algorithm>
#include<cstring>
using namespace std;
int f[50010],V[110],C[110],n,v;
int main()
{
scanf("%d%d",&n,&v);
for(int i=1;i<=n;i++)
{
scanf("%d%d",&V[i],&C[i]);
}
memset(f,0,sizeof f);
for(int i=1;i<=n;i++)
{
for(int j=1;j<=v;j++)
{
if(j-V[i]>=0)
f[j]=max(f[j],f[j-V[i]]+C[i]);
else
f[j]=f[j];
}
}
printf("%d",f[v]);
return 0;
}
that's all, thanks for watching!