DP 习题(一)
朴素 DP
[ABC301F] Anti-DDoS
题意
定义形如 DDoS
的序列为类 DDoS 序列,其中 DD
表示两个相同的任意大写字母,o
表示任意小写字母,S
表示任意大写字母。
给定一个由大小写字母和 ?
组成的序列 \(S\),问有多少种将 ?
替换为大小写字母的方案可以使 \(S\) 不含有任何一个类 DDoS 子序列,答案对 \(998244353\) 取模。
\(4 \le \left|S\right| \le 3 \times 10^5\)。
解法
这是上一道例题的变式。
这一道题因为是对不含类 DDoS 子序列的方案计数,所以为了方便,我们设 \(f_{i,j}\) 是前 \(i\) 位中没有类 DDoS 子序列中的前 \(j+2\) 位的方案数。显然答案就是 \(f_{n,2}\)。
首先我们考虑如何计算 \(f_{i,0}\),即使前 \(i\) 位中不含两个相同大写字母的方案数。考虑假设前 \(i\) 位有 \(m\) 个 ?
,有 \(k\) 种大写字母。注意到如果这 \(k\) 种大写字母的总个数不为 \(k\),那么此时方案数一定为 \(0\)。否则我们可以在 \(m\) 个 ?
选取 \(0\sim k\) 个选择大写字母,其余选择小写字母,这样我们可以列出式子:
然后考虑如何计算 \(f_{i,1}\) 和 \(f_{i,2}\)。对于不存在 DDo
的方案数,我们发现如果一个位置是大写字母,那么这里我们就只需要保证之前不存在 DDo
就行了;而如果一个位置是小写字母,我们这里则要保证之前不存在 DD
;如果是 ?
的话,等于说这里任意小写或大写字母都可以填,于是有转移式:
对 \(f_{i,2}\) 的转移类似。
这样我们就在 \(O(n|\Sigma|)\) 的时间复杂度内解决了此题。
代码
#include<bits/stdc++.h>
using namespace std;
string s;
#define int long long
int f[300005][5];
int frac[300005],ifrac[300005],_26[300005];
const int mod=998244353;
int ksm(int a,int b){
if(!b)return 1;
return (b&1?a:1)*ksm(a*a%mod,b/2)%mod;
}
int vis[27],lftc=26;
int A(int a,int b){
if(a<b)return 0;
return frac[a]*ifrac[a-b]%mod;
}
int C(int a,int b){
if(a<b)return 0;
return frac[a]*ifrac[a-b]%mod*ifrac[b]%mod;
}
signed main(){
ios::sync_with_stdio(0);
cin.tie(0),cout.tie(0);
cin>>s;
int siz=s.size();
frac[0]=_26[0]=1;
int lim=max(26ll,siz);
for(int i=1;i<=lim;i++)frac[i]=frac[i-1]*i%mod,_26[i]=_26[i-1]*26%mod;
ifrac[lim]=ksm(frac[lim],mod-2);
for(int i=lim-1;i>=0;i--)ifrac[i]=ifrac[i+1]*(i+1)%mod;
int cntq=0;
f[0][0]=f[0][1]=f[0][2]=1;
for(int i=1;i<=siz;i++){//DD
if(s[i-1]=='?')cntq++;
else if(s[i-1]>='A'&&s[i-1]<='Z'){
if(vis[s[i-1]-'A'+1])break;
else vis[s[i-1]-'A'+1]=1,lftc--;
}
int &p=f[i][0];
for(int j=min(lftc,cntq);j>=0;j--){
p=(p+C(cntq,j)*A(lftc,j)%mod*_26[cntq-j]%mod)%mod;
// cout<<j<<" "<<cntq<<" "<<lftc<<" "<<C(cntq,j)<<" "<<A(lftc,j)<<" "<<frac[26]<<" "<<p<<"\n";
}
}
// cout<<"\n";
for(int i=1;i<=siz;i++){//DDo
if(s[i-1]=='?')f[i][1]=(26ll*f[i-1][0]%mod+26ll*f[i-1][1]%mod)%mod;
else if(s[i-1]>='a'&&s[i-1]<='z')f[i][1]=f[i-1][0];
else f[i][1]=f[i-1][1];
}
for(int i=1;i<=siz;i++){
if(s[i-1]=='?')f[i][2]=(26ll*f[i-1][1]%mod+26ll*f[i-1][2]%mod)%mod;
else if(s[i-1]>='a'&&s[i-1]<='z')f[i][2]=f[i-1][2];
else f[i][2]=f[i-1][1];
}
cout<<f[siz][2];
return 0;
}
P2224 [HNOI2001] 产品加工
题意
某加工厂有 A、B 两台机器,来加工的产品可以由其中任何一台机器完成,或者两台机器共同完成。由于受到机器性能和产品特性的限制,不同的机器加工同一产品所需的时间会不同,若同时由两台机器共同进行加工,所完成任务又会不同。
某一天,加工厂接到 \(n\) 个产品加工的任务,每个任务的工作量不尽一样。
你的任务就是:已知每个任务在 A 机器上加工所需的时间 \(t_1\),B 机器上加工所需的时间 \(t_2\) 及由两台机器共同加工所需的时间 \(t_3\),请你合理安排任务的调度顺序,使完成所有 \(n\) 个任务的总时间最少。
\(1\leq n\leq 6\times 10^3\),\(0\leq t_1,t_2,t_3\leq 5\)。
解法
和上一道题有一些相似之处。
注意到题面没有说非要按顺序完成这些任务,直接按顺序加入元素显然可能会导致等待,这样不同顺序会有不同的 DP 结果,所以我们需要一种不导致等待或者可以按某个顺序 DP 的 DP 方式。
考虑假设这个时候我们已经通过 DP 算出了一个前 \(i\) 个元素的调度顺序。
这个时候我们对 A 或 B 设置一个单独的任务,并不会产生额外的工作时间变化。
但是我们对 A,B 设置一个一起做的任务,我们发现此时 B 就需要被迫等待 A 做完剩下的才能和 A 一起做。这个时候我们把这个任务插到开头,发现就没有这个等待的时间了,这时因为没有多余时间,所以一定最优。
因为最优情况下一定没有等待时间,所以原问题就变成了有一些任务,选一些给 A 做,选一些给 B 做,再选一些让它们一起做,求两个机器运作的时间的最大值的最小值,这样每个元素都是独立的,加入时不受前面或后面元素的影响,这样就能 DP 了。
所以我们设 \(f_{i,j}\) 为前 \(i\) 个元素中,A 机器运行了 \(j\) 时间,B 机器运行的最小时间。转移是简单的,就讨论这个任务是由 A 做还是由 B 做还是一起做。有:
这样我们能在 \(O(nV)\) 时间复杂度内解决这个问题,其中 \(V=5n\)。
代码
#include<bits/stdc++.h>
using namespace std;
int n;
const int M=3e4;
int dp[2][M+5];
int main(){
ios::sync_with_stdio(0);
cin.tie(0),cout.tie(0);
int now=1,ed=0,t1,t2,t3;
memset(dp[0],0x3f,sizeof dp[0]);
dp[0][0]=0;
cin>>n;
for(int i=1;i<=n;i++){
cin>>t1>>t2>>t3;
memset(dp[now],0x3f,sizeof dp[now]);
for(int j=0;j<=M;j++){
if(t1&&j>=t1)dp[now][j]=min(dp[now][j],dp[ed][j-t1]);
if(t2)dp[now][j]=min(dp[now][j],dp[ed][j]+t2);
if(t3&&j>=t3)dp[now][j]=min(dp[now][j],dp[ed][j-t3]+t3);
}
swap(now,ed);
}
int ans=1e9;
for(int i=0;i<=M;i++)ans=min(ans,max(i,dp[ed][i]));
cout<<ans;
return 0;
}
区间 DP
P2470 [SCOI2007] 压缩
题意
给一个由小写字母组成的字符串,我们可以用一种简单的方法来压缩其中的重复信息。压缩后的字符串除了小写字母外还可以(但不必)包含大写字母R与M,其中M标记重复串的开始,R重复从上一个M(如果当前位置左边没有M,则从串的开始算起)开始的解压结果(称为缓冲串)。
bcdcdcdcd
可以压缩为 bMcdRR
,下面是解压缩的过程:
已经解压的部分 | 解压结果 | 缓冲串 |
---|---|---|
b | b | b |
bM | b | . |
bMc | bc | c |
bMcd | bcd | cd |
bMcdR | bcdcd | cdcd |
bMcdRR | bcdcdcdcd | cdcdcdcd |
\(n\leq 50\)。
解法
和上道题一样的压缩字符串类的题。
其实这题可以加一个输出方案,这样的话这题就是一个作者认为非常好的例题。
因为这题要处理 M
,所以我们可以设 \(f_{i,j}\) 为区间 \([i,j]\) 可以用 R
字符压缩的最短长度。
这里就有两个转移,第一个是 \(f_{i,j}\gets f_{i,i+\frac{j-i+1}{2}-1}+1\),压缩一半。
第二个是合并两个区间,我们有 \(f_{i,j}\gets f_{i,k}+(j-k+1)\),因为第二个区间不能有 R
。
然后我们考虑把所有区间的 \(f\) 用另外一个 DP 合并起来,设 \(g_{i}\) 为前 \(i\) 个元素的压缩后最短长度,显然有 \(g_{i}\gets g_j+f_{j+1,i}+1\),\(1\) 是给前面加的 M
加的。
这两个方程式都很像能 DP 优化的样子,如果胡出来这个优化的可以私信作者。
UPD:作者胡了一个 \(O(n^2)\) 的扫描一遍 + 单调栈的做法,但是这题 \(n\leq 50\) 随便过。
最后答案就是 \(g_{1,n}-1\)。
代码
#include<bits/stdc++.h>
using namespace std;
char s[55],*ss=s+1;
int dp[55][55];
int f[55];
#define ull unsigned long long
ull hsh[55];
const ull base=179;
ull _b[55];
ull gethsh(int l,int r){
return hsh[r]-hsh[l-1]*_b[r-l+1];
}
int main(){
ios::sync_with_stdio(0);
cin.tie(0),cout.tie(0);
cin>>ss;
int n=strlen(ss);
_b[0]=1;
for(int i=1;i<=n;i++)hsh[i]=hsh[i-1]*base+s[i]-'a'+1,_b[i]=_b[i-1]*base;
for(int i=1;i<=n;i++)f[i]=1e9;
memset(dp,0x3f,sizeof dp);
for(int i=1;i<=n;i++)dp[i][i]=1;
for(int i=2;i<=n;i++){
for(int j=1;j<=n-i+1;j++){
for(int k=j;k<j+i-1;k++){
dp[j][j+i-1]=min(dp[j][j+i-1],dp[j][k]+(j+i-1)-k);
}
if(i%2==0){
if(gethsh(j,j+i/2-1)==gethsh(j+i/2,j+i-1))dp[j][j+i-1]=min(dp[j][j+i-1],dp[j][j+i/2-1]+1);//...R
}
}
}
// cerr<<dp[1][4]<<" "<<gethsh(1,2)<<" "<<gethsh(3,4)<<"\n";
for(int i=1;i<=n;i++){
for(int j=0;j<i;j++){
f[i]=min(f[i],f[j]+dp[j+1][i]+1);
}
}
cout<<f[n]-1;
return 0;
}
P3592 [POI2015] MYJ
题意
有 \(n\) 家洗车店从左往右排成一排,每家店都有一个正整数价格 \(p_i\)。有 \(m\) 个人要来消费,第 \(i\) 个人会驶过第 \(a_i\) 个开始一直到第 \(b_i\) 个洗车店,且会选择这些店中最便宜的一个进行一次消费。但是如果这个最便宜的价格大于 \(c_i\),那么这个人就不洗车了。请给每家店指定一个价格,使得所有人花的钱的总和最大。
\(n\leq 50,m\leq 4000\)。
解法
和上一道题基本一样,而且要输出方案,所以是选做。
离散化 \(c_i\),设 \(f_{l,r,p}\) 为区间 \([l,r]\) 的定价都不小于 \(p\) 的时候,对于行驶区间完全包含于 \([l,r]\) 的人的花费最大值。
考虑随意指定这个区间的一个位置,钦定其定价为 \(p\),因为其余定价都不小于 \(p\),所以行驶跨过该位置的人如果要花费就可以在这里花费。所以我们有:
其中 \(cnt_{pos,p}\) 表示行驶区间在 \([l,r]\) 内且跨越 \(pos\) 位置又能接受 \(p\) 价格的人的数量。
这个方程式显然不完整,因为它只包含了这个区间有定价为 \(p\) 的点的情况。如果整个区间都是 \(>p\) 的定价,那么我们完全可以从 \(f_{l,r,p+1}\) 转移过来。这样的转移就完整了。
最后一个问题就是如何计算 \(cnt_{pos,p}\)。我们在枚举 \(l,r,pos\) 的时候我们可以枚举每一个人计算,不难发现每个人都是接受一个价格前缀的,所以我们可以差分。这样我们可以在 \(O(m)\) 解决这个问题。
输出方案的话同样记录 \(f_{l,r,p}\) 是从哪个位置还是从 \(f_{l,r,p+1}\) 转移过来,最后 DFS 一遍即可。
代码
#include<bits/stdc++.h>
using namespace std;
int n,m;
int cval[4005],ccnt;
int l[4005],r[4005],c[4005];
int f[55][55][4005],ans[55],o[55][55][4005];
int tmp[4005];
const int SIG=1e8;
void getans(int l,int r,int val){
if(l>r)return;
if(o[l][r][val]==0){
for(int i=l;i<=r;i++)ans[i]=cval[val];
return;
}
else if(o[l][r][val]==SIG)getans(l,r,val+1);
else{
ans[o[l][r][val]]=cval[val];
getans(l,o[l][r][val]-1,val);
getans(o[l][r][val]+1,r,val);
}
return;
}
int main(){
ios::sync_with_stdio(0);
cin.tie(0),cout.tie(0);
cin>>n>>m;
for(int i=1;i<=m;i++)cin>>l[i]>>r[i]>>c[i],cval[++ccnt]=c[i];
sort(cval+1,cval+ccnt+1);
ccnt=unique(cval+1,cval+ccnt+1)-cval-1;
for(int i=1;i<=m;i++){
c[i]=lower_bound(cval+1,cval+ccnt+1,c[i])-cval;
}
for(int i=1;i<=n;i++){
for(int j=1;j<=n-i+1;j++){
for(int k=j;k<=j+i-1;k++){
memset(tmp,0,sizeof tmp);
for(int p=1;p<=m;p++)if(l[p]>=j&&l[p]<=k&&r[p]>=k&&r[p]<=j+i-1)tmp[c[p]]++;
for(int p=ccnt;p>=1;p--)tmp[p]+=tmp[p+1];
for(int p=1;p<=ccnt;p++){
if(f[j][j+i-1][p]<tmp[p]*cval[p]+f[j][k-1][p]+f[k+1][j+i-1][p]){
f[j][j+i-1][p]=tmp[p]*cval[p]+f[j][k-1][p]+f[k+1][j+i-1][p];
o[j][j+i-1][p]=k;
}
}
}
for(int p=ccnt;p>=1;p--){
if(f[j][j+i-1][p]<f[j][j+i-1][p+1]){
f[j][j+i-1][p]=f[j][j+i-1][p+1];
o[j][j+i-1][p]=SIG;
}
}
}
}
cout<<f[1][n][1]<<"\n";
getans(1,n,1);
for(int i=1;i<=n;i++)cout<<ans[i]<<" ";
return 0;
}
背包 DP
P1941 [NOIP2014 提高组] 飞扬的小鸟
题意
Flappy Bird 是一款风靡一时的休闲手机游戏。玩家需要不断控制点击手机屏幕的频率来调节小鸟的飞行高度,让小鸟顺利通过画面右方的管道缝隙。如果小鸟一不小心撞到了水管或者掉在地上的话,便宣告失败。
为了简化问题,我们对游戏规则进行了简化和改编:
游戏界面是一个长为 \(n\),高为 \(m\) 的二维平面,其中有 \(k\) 个管道(忽略管道的宽度)。
小鸟始终在游戏界面内移动。小鸟从游戏界面最左边任意整数高度位置出发,到达游戏界面最右边时,游戏完成。
小鸟每个单位时间沿横坐标方向右移的距离为 \(1\),竖直移动的距离由玩家控制。如果点击屏幕,小鸟就会上升一定高度 \(x\),每个单位时间可以点击多次,效果叠加;如果不点击屏幕,小鸟就会下降一定高度 \(y\)。小鸟位于横坐标方向不同位置时,上升的高度 \(x\) 和下降的高度 \(y\) 可能互不相同。
小鸟高度等于 \(0\) 或者小鸟碰到管道时,游戏失败。小鸟高度为 \(m\) 时,无法再上升。
现在,请你判断是否可以完成游戏。如果可以,输出最少点击屏幕数;否则,输出小鸟最多可以通过多少个管道缝隙。
\(n\leq 10000,m\leq 1000\)。
解法
直接从左到右扫一遍,如果遇到管道那就设置强制不可达。
特判下降和最高点的转移,中间的转移枚举同余系然后扫一遍中间的数就行了。背包的转移是简单的。
不是依赖性背包。
代码
#include<bits/stdc++.h>
using namespace std;
int dp[2][1005];
int n,m,k;
int upo[10005],downo[10005];
int pos[10005],L[10005],R[10005];
int id[10005];
int main(){
ios::sync_with_stdio(0);
cin.tie(0),cout.tie(0);
cin>>n>>m>>k;
for(int i=1;i<=n;i++)cin>>upo[i]>>downo[i];
for(int i=1;i<=k;i++)cin>>pos[i]>>L[i]>>R[i],id[pos[i]]=i;
int now=1,ed=0;
dp[ed][0]=1e9;
int cnt=0;
for(int i=1;i<=n;i++){
memset(dp[now],0x3f,sizeof dp[now]);
for(int j=1;j<m;j++)dp[now][m]=min(dp[now][m],dp[ed][j]+(m-j+(upo[i]-1))/upo[i]);
dp[now][m]=min(dp[now][m],dp[ed][m]+1);
for(int j=m-downo[i];j>=1;j--)dp[now][j]=min(dp[now][j],dp[ed][j+downo[i]]);
for(int j=1;j<=upo[i];j++){
int tmp=1e9;
for(int k=j;k<m;k+=upo[i]){
dp[now][k]=min(dp[now][k],tmp+1);
tmp=min(tmp+1,dp[ed][k]);
}
}
if(id[i]){
for(int j=1;j<=L[id[i]];j++)dp[now][j]=1e9;
for(int j=R[id[i]];j<=m;j++)dp[now][j]=1e9;
cnt++;
}
bool flag=0;
for(int j=1;j<=m;j++)if(dp[now][j]<1e8)flag=1;
if(!flag)return cout<<0<<"\n"<<cnt-1,0;
swap(now,ed);
}
int ans=1e9;
for(int i=1;i<=m;i++){
ans=min(dp[ed][i],ans);
}
cout<<1<<"\n"<<ans;
return 0;
}
[ABC240G] Teleporting Takahashi
题意
在一个空间直角坐标系中移动,每步可以沿着坐标轴正/负方向移动一个单位的长度。
给定 \(N,X,Y,Z\) ,求:
恰好 \(N\) 步,从点 \((0,0,0)\) 走到点 \((X,Y,Z)\) 的方案数。
答案对 \(998244353\) 取模。
\(N\leq10^7,|X|,|Y|\leq 10^7\)。
解法
不是 DP,只是对上一道题的转化的练习。
和上一道题的想法相似,不过作者比较唐,认为 \(2\times 3\) 能被化成 \(a^b(a,b>1)\) 的形式然后想了半天。
然后注意到 \(N\leq 10^7\),所以我们枚举某一维走 \(k\) 步,然后把剩下两维化成 \((x+y,x-y)\) 的形式来使得其互不限制。
互不限制之后问题可以转化为有 \(n-k\) 次操作,每次可以动一步或者不动,组合数秒了。注意枚举每一个 \(k\) 时我们是在整个过程中选了 \(k\) 步出来,这里还有一个组合数要乘。
时间复杂度 \(O(N)\)。
代码
#include<bits/stdc++.h>
using namespace std;
int n,x,y,z;
const int mod=998244353;
#define ll long long
ll frac[10000007],ifrac[10000007];
ll ksm(ll a,ll b){
if(b==0)return 1;
return (b&1?a:1)*ksm(a*a%mod,b/2)%mod;
}
ll cho(ll a,ll b){
if(a<b)return 0;
return frac[a]*ifrac[b]%mod*ifrac[a-b]%mod;
}
ll calc(ll a,ll x){
if(a<x||(a+x)&1)return 0;
return cho(a,(x+a)/2);
}
int main(){
ios::sync_with_stdio(0);
cin.tie(0),cout.tie(0);
cin>>n>>x>>y>>z;
frac[0]=1;
for(int i=1;i<=n;i++)frac[i]=frac[i-1]*i%mod;
ifrac[n]=ksm(frac[n],mod-2);
for(int i=n-1;i>=0;i--)ifrac[i]=ifrac[i+1]*(i+1)%mod;
x=abs(x),y=abs(y),z=abs(z);
ll ans=0;
for(int i=z;i<=n;i++){
int lft=n-i;
ans=(ans+calc(i,z)*cho(n,i)%mod*calc(lft,x+y)%mod*calc(lft,abs(x-y))%mod)%mod;
}
cout<<ans;
return 0;
}
状压 DP
P3959 [NOIP2017 提高组] 宝藏
题意
原来的题意真的有点史,有点让人看不懂。
给你一张带权图,要求你求一个有根树,其中每条树边的代价为所连接两点最大深度乘上边权,钦定根的深度为 \(0\),那么求最小代价。
点数 \(1\leq n\leq 12\)。
解法
和上一题相似,我们这道题同样能轻易得到一个用三进制状压的做法。
我们每次记录哪些点被选过且不是深度为 \(i\) 的叶子节点,哪些点被选过且是深度为 \(i\) 的叶子结点,哪些点没有选过,这个东西显然可以用三进制状压。然后枚举深度转移,每次选一个对应二进制数的超集来连其最近的叶子节点即可,复杂度可以证明是 \(O(4^n\operatorname{poly}(n))\) 的,有点卡,而且后面的 \(n\) 的指数我的实现至少为 \(2\),应该是过不了。
然而这里我们其实可以不用记录的这么细,注意到我们只需要包含最优解的一个 DP 就行了。考虑如果我们只记录哪些点被选,哪些点没被选,在深度为 \(i\) 时一律把加入的边边权乘上 \(i+1\),我们发现如果我们此时连到非叶子上,一定是不优于之前当那个非叶子是叶子时就连上的。所以这个 DP 能 DP 出最优解,同时不会 DP 出比看似比最优解更优,但是达不到的解。这时我们就把复杂度降到了 \(O(3^n\operatorname{poly}(n))\) 的复杂度,可以通过。
代码
#include<bits/stdc++.h>
using namespace std;
int f[15][1<<12];
int dis[15][15],n,m;
long long x;
int y,z;
int mdis[15][1<<12];
int main(){
ios::sync_with_stdio(0);
cin.tie(0),cout.tie(0);
cin>>n>>m;
for(int i=1;i<=n;i++)for(int j=i+1;j<=n;j++)dis[i][j]=dis[j][i]=1e9;
for(int i=1;i<=m;i++){
cin>>x>>y>>z;
dis[x][y]=dis[y][x]=min(dis[x][y],z);
}
memset(f,0x3f,sizeof f),memset(mdis,0x3f,sizeof mdis);
for(int i=1;i<(1<<n);i++){
for(int j=0;j<n;j++){
for(int k=1;k<=n;k++){
if((1<<j)&i)mdis[k][i]=min(mdis[k][i],dis[k][j+1]);
}
}
}
for(int i=1;i<=n;i++)f[0][1<<(i-1)]=0;
int ans=1e9;
for(int i=1;i<=n;i++){
for(int j=1;j<(1<<n);j++){
int o=((1<<n)-1)^j;
for(int k=o;k;k=(k-1)&o){
x=0;
for(int t=1;t<=n;t++)if((1<<(t-1))&k)x+=mdis[t][j];
f[i][j|k]=min((long long)f[i][j|k],x*i+f[i-1][j]);
}
}
ans=min(ans,f[i][(1<<n)-1]);
}
if(ans==1e9)ans=0;
cout<<ans;
return 0;
}