动态规划初步
01背包
模板题(AcWing.2)
有 \(n\) 件物品和一个容量是 \(V\) 的背包。每件物品只能使用一次。
第 \(i\) 件物品的体积是 \(vi\),价值是 \(wi\)。
求解将哪些物品装入背包,可使这些物品的总体积不超过背包容量,且总价值最大。
思路:
-
表示状态
设
f[i][j]
表示只考虑前 \(i\) 个物品,总共体积不大于 \(j\) 时的最大价值。 -
循环范围
\(i\) 表示物品数量,物品最少 1 个,物品最多 \(n\) 个,因此 \(i\) 的循环范围是 1~\(n\) 。
\(j\) 表示总共体积,体积最小是 0 ,体积最大是 \(V\) ,因此 \(j\) 的循环范围是 0~\(V\) 。 -
状态转移
由于每个状态是由前一个状态推来的,所以要决定当前物品是选还是不选。
如果选这个物品不优,那么就不选它,所以每个状态一定不会比之前的任何一个状态更差。
然后可以得出:如果不选当前物品,则价值为
f[i-1][j]
如果选当前物品:
要给这个物品在 \(j\) 个单位的空间中腾出位置,还得保证留下的物品时最优的。
留下的物品的总价值就存在f[i-1][j-v[i]]
最后,还要加上选中物品的价值w[i]
。
综上所述,如果选当前物品,则价值为f[i-1][j-v[i]]+w[i]
然后求出它们的较大值,存在
f[i][j]
现在可以列出转移方程:f[i][j]=max(f[i][j],f[i-1][j-v[i]]+w[i]);
代码
#include <bits/stdc++.h>
#define ll long long
#define N 1005
using namespace std;
inline ll read()
{
ll x=0,f=1;char c=getchar();
while(!isdigit(c)){if(c=='-') f=-f;c=getchar();}
while(isdigit(c)){x=x*10+c-48;c=getchar();}
return (f==1)?x:-x;
}//卡常
ll n=read(),V=read(),v[N],w[N],f[N][N];
int main()
{
for(ll i=1;i<=n;i++) v[i]=read(),w[i]=read();
for(ll i=1;i<=n;i++)
{
for(ll j=0;j<=V;j++)
{
f[i][j]=f[i-1][j];
if(j>=v[i]) f[i][j]=max(f[i][j],f[i-1][j-v[i]]+w[i]);
}//判断j是否不小于v[i],如果小于就肯定不能选当前物品,则f[i][j]=f[i-1][j]
}
cout << f[n][V];
return 0;
}
线性DP
线性 DP 是一个大类,属于简单 DP ,没有模板,这里给一些题的题解。
最长上升子序列II(AcWing.896)
数据加强版的最长上升子序列不能直接DP,还得二分(其实有点像贪心)
思路
-
状态表示
\(f_i\) 表示长度为 \(i\) 的最长上升子序列,末尾最小的数字。(长度为 \(i\) 的最长上升子序列所有结尾中,结尾最小的) 即长度为 \(i\) 的子序列末尾最小元素是什么。 -
状态转移
对于每一个 \(w_i\) , 如果大于 \(f_{ans-1}\) (下标从0开始,cnt长度的最长上升子序列,末尾最小的数字),那就 \(ans\)++,当前末尾最小元素为 \(w_i\) 。
若 \(w_i\) 小于等于 \(f_{ans-1}\) ,说明不会更新当前的长度,但之前末尾的最小元素要发生变化,找到第一个大于或等于 \(w_i\),更新以那时候末尾的最小元素。
\(f_i\) 一定以一个单调递增的数组,所以可以用二分法来找第一个大于或等于 \(w_i\) 的数字。
代码
#include <bits/stdc++.h>
#define ll long long
#define N 100005
using namespace std;
inline ll read()
{
ll x=0,f=1;char c=getchar();
while(!isdigit(c)){if(c=='-') f=-f;c=getchar();}
while(isdigit(c)){x=x*10+c-48;c=getchar();}
return (f==1)?x:-x;
}//卡常
ll n=read(),l,r,ans,mid,a[N],b[N];
int main()
{
ios::sync_with_stdio(0);cout.tie(0);
b[0]=-1e9+5;
for(ll i=1;i<=n;i++) a[i]=read();
for(ll i=1;i<=n;i++)
{
l=0,r=ans;
while(l<r)
{
mid=l+r+1>>1;
if(b[mid]<a[i]) l=mid;
else r=mid-1;
}
ans=max(ans,r+1);
b[r+1]=a[i];
}
cout << ans;
return 0;
}
最短编辑距离(AcWing.902)
说两句闲话:
- 其实是一道字符串DP
- 其实这道题我的最大收获是搞清楚了
cin >> s+1;
或scanf("%s",s+1);
的必要条件是char s[N];
而不是string s
,怪不得之前总CE。我居然到现在才知道
思路
-
表示状态
设f[i][j]
表示 \(a\) 的前 \(i\) 个字符转换成 \(b\) 的前 \(j\) 个字符所需的最少步骤。 -
循环范围
\(i\) 表示 \(a\) 的字符,\(a\) 的最大下标为 \(n\),因此 \(i\) 的循环范围是 1~\(n\) 。
\(j\) 表示 \(b\) 的字符,\(b\) 的最大下标为 \(m\),因此 \(j\) 的循环范围是 1~\(m\) 。 -
状态转移
f[i][j]
的取值是Min{f[i][j-1]+1
,f[i][j-1]+1
,f[i-1][j-1]+1
}其中,
f[i][j-1]+1
表示通过删字符的最小步骤;
f[i][j-1]+1
表示通过增字符的最小步骤;
f[i-1][j-1]+1
表示通过改字符的最小步骤。可得,转移方程为:
f[i][j]=min(f[i][j-1],f[i][j-1])+1; if(a[i]==b[j]) f[i][j]=min(f[i][j],f[i-1][j-1]); else f[i][j]=min(f[i][j],f[i-1][j-1]+1);
代码
#include <bits/stdc++.h>
#define ll long long
#define N 1005
using namespace std;
inline ll read()
{
ll x=0,f=1;char c=getchar();
while(!isdigit(c)){if(c=='-') f=-f;c=getchar();}
while(isdigit(c)){x=x*10+c-48;c=getchar();}
return (f==1)?x:-x;
}//卡常
ll n,m,maxn,f[N][N];
char a[N],b[N];
void init()
{
for(ll i=0;i<=maxn;i++)
{
if(i<=n) f[i][0]=i;
if(i<=m) f[0][i]=i;
}/*更好理解的写法:
for(ll i=0;i<=n;i++) f[i][0]=i;//如果b的长度为0,a要转换成b只能通过删去i个字符
for(ll i=0;i<=m;i++) f[0][1]=i;//如果a的长度为0,a要转换成b只能通过添加i个字符
*/}
int main()
{
n=read();cin >> a+1;
m=read();cin >> b+1;
maxn=max(n,m);
init();//初始化
for(ll i=1;i<=n;i++)
{
for(ll j=1;j<=m;j++)
{
f[i][j]=min(f[i][j-1],f[i][j-1])+1;
if(a[i]==b[j]) f[i][j]=min(f[i][j],f[i-1][j-1]);//如果a[i]等于b[j]那就不需要再操作
else f[i][j]=min(f[i][j],f[i-1][j-1]+1);//否则,就还需操作一次
}
}
cout << f[n][m];
return 0;
}
方格取数(luogu.P1004)
思路
-
状态表示
设f[i][j][i2][j2]
表示:
第一条路走到第 \(i\) 行,第 \(j\) 列;第二条路走到第 \(i2\) 行,第 \(j2\) 列能得到的最大价值。 -
循环范围
枚举都是在 \(n\)×\(n\)的方阵中,因此\(i\),\(j\),\(i2\),\(j2\)的循环范围都是 1~\(n\)。 -
状态转移
和数字三角形一样,不过还是写一下。\(x\):
f[i-1][j][i2-1][j2]
表示从走到上面一格能收获的最大价值。
f[i-1][j][i2][j2-1]
表示对于第一条路走到上面一格,对于第二条路走到左边一格能收获的最大价值。\(y\):和 \(x\) 差不多,看代码自己理解。
当然,要取最大值,还得加上
a[i][j]
和a[i2][j2]
的价值。
还有,如果路径重复,只能取一个价值。
可得,转移方程为:x=max(f[i-1][j][i2-1][j2],f[i-1][j][i2][j2-1]); y=max(f[i][j-1][i2-1][j2],f[i][j-1][i2][j2-1]); f[i][j][i2][j2]=max(x,y)+a[i][j]; if(i!=i2||j!=j2) f[i][j][i2][j2]+=a[i2][j2];
代码
#include <bits/stdc++.h>
#define ll long long
#define N 15
using namespace std;
inline ll read()
{
ll x=0,f=1;char c=getchar();
while(!isdigit(c)){if(c=='-') f=-f;c=getchar();}
while(isdigit(c)){x=x*10+c-48;c=getchar();}
return (f==1)?x:-x;
}//卡常
ll n=read(),a[N][N],f[N][N][N][N];
void workF(ll i,ll j,ll i2,ll j2)
{
ll x,y;
x=max(f[i-1][j][i2-1][j2],f[i-1][j][i2][j2-1]);
y=max(f[i][j-1][i2-1][j2],f[i][j-1][i2][j2-1]);
f[i][j][i2][j2]=max(x,y)+a[i][j];
if(i!=i2||j!=j2) f[i][j][i2][j2]+=a[i2][j2];
}
int main()
{
while(1)
{
ll x=read(),y=read(),val=read();
if(!x&&!y&&!val) break;
a[x][y]=val;
}
for(ll i=1;i<=n;i++)
for(ll j=1;j<=n;j++)
for(ll i2=1;i2<=n;i2++)
for(ll j2=1;j2<=n;j2++)
workF(i,j,i2,j2);
cout << f[n][n][n][n];
return 0;
}
- 有一道\({\color{#FADB14}【普及/提高-】\ }\)的[CSP-J2020]方格取数比这道\({\color{#52C410}【普及+/提高】\ }\)的[NOIP2000 提高组]方格取数还难得多。
传纸条(luogu.P1006)
思路
和方格取数差不多,只需改读入和循环范围。
代码
#include <bits/stdc++.h>
#define ll long long
#define N 55
using namespace std;
inline ll read()
{
ll x=0,f=1;char c=getchar();
while(!isdigit(c)){if(c=='-') f=-f;c=getchar();}
while(isdigit(c)){x=x*10+c-48;c=getchar();}
return (f==1)?x:-x;
}//卡常
ll n=read(),m=read(),a[N][N],f[N][N][N][N];
void workF(ll i,ll j,ll i2,ll j2)
{
ll x,y;
x=max(f[i-1][j][i2-1][j2],f[i-1][j][i2][j2-1]);
y=max(f[i][j-1][i2-1][j2],f[i][j-1][i2][j2-1]);
f[i][j][i2][j2]=max(x,y)+a[i][j];
if(i!=i2||j!=j2) f[i][j][i2][j2]+=a[i2][j2];
}
int main()
{
for(ll i=1;i<=n;i++)
for(ll j=1;j<=m;j++)
a[i][j]=read();
for(ll i=1;i<=n;i++)
for(ll j=1;j<=m;j++)
for(ll i2=1;i2<=n;i2++)
for(ll j2=1;j2<=m;j2++)
workF(i,j,i2,j2);
cout << f[n][m][n][m];
return 0;
}
青蛙过河(luogu.P1244)
思路
1.若有 \(k\) 个荷叶,没有石墩,则最多有 \(k+1\) 个青蛙。所以 \(f_0=k+1\) 。
2.若有 \(k\) 个荷叶,1 个石墩,则只需要使石墩上承载最多的青蛙。进一步分析,我们只需要将石墩当做对岸,这样就变成1的情况了。所以 \(f_1=f_0+k+1\) 。
3.若有 \(k\) 个荷叶,2 个石墩,则需要先让石墩 1 作为对岸,叠完后再让石墩 2 作为对岸。所以 \(f_2=f_1+f_0+k+1\) 。
继续往下推,得到转移方程:f[h]=f[0]+f[1]+f[2]+···+f[h-1]+k+1
代码
#include <bits/stdc++.h>
#define ll long long
#define N 1000005
using namespace std;
inline ll read()
{
ll x=0,f=1;char c=getchar();
while(!isdigit(c)){if(c=='-') f=-f;c=getchar();}
while(isdigit(c)){x=x*10+c-48;c=getchar();}
return (f==1)?x:-x;
}//卡常
ll h=read(),k=read(),f[N];
int main()
{
f[0]=k+1;
for(ll i=1;i<=h;i++)
{
f[i]+=f[0];
for(ll j=0;j<i;j++) f[i]+=f[j];
}
cout << f[h];
return 0;
}
用小学数学化简一下,可得:
#include <bits/stdc++.h>
#define ll long long
using namespace std;
inline ll read()
{
ll x=0,f=1;char c=getchar();
while(!isdigit(c)){if(c=='-') f=-f;c=getchar();}
while(isdigit(c)){x=x*10+c-48;c=getchar();}
return (f==1)?x:-x;
}//卡常
ll h=read(),k=read();
int main()
{
cout << (k+1)*(1<<h);
return 0;
}
NASA的食物计划(luogu.P1507)
思路
可以看做是升级版的01背包问题。
-
状态表示
设f[i][j][k]
表示只考虑前 \(i\) 个物品,体积不超过 \(j\),质量不超过 \(k\) 的最大价值。
然后会发现,\(i\) 这一层其实没有必要,就可以用二维数组存了。 -
循环范围
虽然在状态表示中 \(i\) 可以不要,但在循环中 \(i\) 还是必不可少的。
\(i\) 表示考虑前多少个物品,物品最多有 \(n\) 个,因此 \(i\) 的循环范围是 1~\(n\) 。
\(j\) 表示可用的体积,体积最大为H,最小为h[i]
,因此 \(j\) 的循环范围是h[i]
~\(H\) 。
\(k\) 表示可用的质量,体积最大为T,最小为t[i]
,因此 \(k\) 的循环范围是t[i]
~\(T\) 。 -
状态转移
见01背包详解
然后,除了体积需要腾出来,质量也得腾出来。
这样一来,余下物品的最大体积就是f[j-h[i]][k-t[i]]
最后,加上价值(卡路里)ka[i]
可得,转移方程为:
f[j][k]=max(f[j][k],f[j-h[i]][k-t[i]]+ka[i]);
代码
#include <bits/stdc++.h>
#define ll long long
#define N 505
using namespace std;
inline ll read()
{
ll x=0,f=1;char c=getchar();
while(!isdigit(c)){if(c=='-') f=-f;c=getchar();}
while(isdigit(c)){x=x*10+c-48;c=getchar();}
return (f==1)?x:-x;
}//卡常
ll H=read(),T=read(),n=read(),h[N],t[N],ka[N],f[N][N];
int main()
{
for(ll i=1;i<=n;i++)
{
h[i]=read();
t[i]=read();
ka[i]=read();
}
for(ll i=1;i<=n;i++)
for(ll j=H;j>=h[i];j--)
for(ll k=T;k>=t[i];k--)
f[j][k]=max(f[j][k],f[j-h[i]][k-t[i]]+ka[i]);
//升级01背包
cout << f[H][T];
return 0;
}