插入类型 DP 学习笔记
插入类型 DP
形式
-
多为
n 个元素无法重复使用,需要给定一个排列,满足一定条件或是求有多少个排列满足一定条件。 -
-
满足一些函数图像,类似于波浪函数,且答案与每个波浪和波浪的顶点有关(函数的
x 坐标为下标,y 坐标为下标上数的值)。
满足以上三个条件的 DP 大部分是插入类型的 DP。
引入
先来看一道例题:
- 对于一个正整数序列
a ,长度为n(n \ge 3) ,如果对于所有的2 \le i \le n-1 ,都有a_{i-1}+a_{i+1}\ge 2a_i ,就称这个序列是“美丽的”。现在给你另一个正整数序列b ,问你有多少种排列这个序列的方式使得这个序列是美丽的。(n \le 100 )
这道例题看似无从下手,但是我们把式子变换一下可以发现:
即差分递增,差分递增有什么好处呢?把所有满足条件的
有了这个性质,我们按
于是设
注意观察:这个 DP 没有后效性,且能够顺利转移,满足子问题包含的性质。
综上,可以
实践
容易看出,引子是一个水题,因为我们还没有牵扯到其它的限制,只是规定元素不能重复选,接下来我们看一下这道题:
CEOI2016-kangaroo
这道题就满足了上面三条形式:
-
-
2 \le n \le 2\times 10^3 -
波浪函数,每个波浪的长度为
1
这个时候,考虑怎么转换已经没有用了,因为它并不满足类似于差分递增这种规律,使得有一个单调性在里面,所以我们按照这三条形式的最后一条,即波浪进行入手。
先看下面一幅图:
(如图,这是
那么我们观察到这个函数图像有
这种类型的 DP,有几个要素:
- 确定元素添加顺序
- 确定状态的转移
- 确定对于
s,t 的特判
我们一个问题一个问题解决。
确定元素添加顺序
没什么好说的,既然是排列,那就要从
确定状态转移
因为必须在
什么叫段数,请看这幅图:
这里,就是把
因为我们的
考虑转移,因为我们选择了
综上,DP 方程就可以出来了:
总述一下,这种类型的题其实大部分都是对段进行 DP,然后考虑转移和特判,之后就可以过了。
小结
此类 DP 被称为 “插入DP” 或者是 “连续段DP”,主要都是依据段数来转移状态。
模型:
不同之处:有些题目固定了左右两端点,有些题目没有固定左右两端点。
依据题目的不同要求,大部分题目中的段是可以
设计转移时通常 按照一定顺序插入,且知道了这个顺序就知道了题目中要求的函数的值(函数的值是根据数的一定顺序决定的) 且 每个元素只有合并两段、接续一段、新开一段等操作,这样才能更好的帮助我们维护 DP 数组。(有些时候元素插入在一段的左/右边的结果不一样,需要再开一维)
推这种 DP 式子的时候,我们会发现有些情况可能会缠在一起,让人分不清楚。这里要着重说一下:每个状态其实都是在为后面的状态作准备。
比如:
明明可以写为接在一个段的后面,DP 方程中偏要写为新开一个段。这就是因为新开一个段能够保证这个元素的左右两边都是比它大的数,如果是接续一个段,那么只能保证一端比它大,一端比它小。
所以,我们对于新开一个段和接续一个段的状态会不会重复的问题,只需要考虑它们最后形成的状态会不会重复就行了,而并不需要考虑当前的形态相不相同。例如:
最后,特别要注意整个序列两个端点需要特别判断,处理这种类型的方法有两种:
- 在转移过程中就把贡献去掉/加上。(多为题目固定了左右两端点)
- 多开两维数组记录两个端点的贡献或一些信息。(多为题目没有固定左右两端点)
习题
- CEOI2016-kangaroo(例题)
- AT_abc209_f-Deforestation
- AT_dp_t-permutation
- [COCI2021-2022#2] Magneti
- CF1515E-Phoenix and Computers
- CF704B-Ant Man
- [JOI Open 2016] 摩天大楼
- [ZJOI2012]波浪
部分习题讲评
CF704B-Ant Man
初探题面
这道题我们可以先转换一下题意:
让
那么就可以将
由这个公式看出:权值与下标的大小相关,只要确定了下标的大小,那么这个权值基本上就确定了。
所以,按照
状态设计
还是像例题一样,设
但是与例题不同的是,这里的每一段可以是
那么我们就可以开始 dp 了。
首先考虑一般情况(
(以下状态设计均考虑费用提前计算技巧)
所以对于
注意,这些式子是怎么推导出来的!
- 因为贡献只与两个数的大小有关
- 两个数的大小这么来判断:比
i 先填的数一定比i 小,比i 后填的数一定比i 大。
根据这两点,权值和方程就能很轻松写出来了。
至于
细节
1、对于以
2、对于以
3、为什么我们合并两个段不需要额外记录是不是
4、为什么考虑加在某个段的前面的时候不需要判断
5、做这类题目时,一定要把
综上,这道题目就做完了,为了避免一些特殊情况,代码采用从前往后的方式 DP。(从后往前也可以)
#include<bits/stdc++.h>
#define ll long long
#define N 5005
using namespace std;
ll n,i,j,x[N],a[N],b[N],c[N],d[N],s,t,dp[N][N];
int main(){
ios::sync_with_stdio(false);
cin>>n>>s>>t;
for(i=1;i<=n;i++) cin>>x[i];
for(i=1;i<=n;i++) cin>>a[i],a[i]+=x[i];
for(i=1;i<=n;i++) cin>>b[i],b[i]-=x[i];
for(i=1;i<=n;i++) cin>>c[i],c[i]+=x[i];
for(i=1;i<=n;i++) cin>>d[i],d[i]-=x[i];
memset(dp,0x3f,sizeof(dp));
dp[0][0] = 0;
for(i=1;i<=n;i++){
for(j=0;j<i;j++){
if(i==s){
if(j>=(i>t)){
if(j) dp[i][j] = min(dp[i][j],dp[i-1][j]+c[i]);
dp[i][j+1] = min(dp[i][j+1],dp[i-1][j]+d[i]);
}
}
else if(i==t){
if(j>=(i>s)){
if(j) dp[i][j] = min(dp[i][j],dp[i-1][j]+a[i]);
dp[i][j+1] = min(dp[i][j+1],dp[i-1][j]+b[i]);
}
}
else{
if(j<((i>t)+(i>s))) continue;
if(j>=2) dp[i][j-1] = min(dp[i][j-1],dp[i-1][j]+a[i]+c[i]);
dp[i][j+1] = min(dp[i][j+1],dp[i-1][j]+b[i]+d[i]);
if(j>(i>t)) dp[i][j] = min(dp[i][j],dp[i-1][j]+a[i]+d[i]);
if(j>(i>s)) dp[i][j] = min(dp[i][j],dp[i-1][j]+b[i]+c[i]);
}
}
}
cout<<dp[n][1]<<endl;
}
[JOI Open 2016] 摩天大楼
初探题面
首先,看到绝对值想到分类讨论,即为:
那么又是根据大小关系来决定权值大小了,所以考虑插入 DP。
第一种方法
状态推导
像上一道题一样,这里的状态因为没有段长的硬性要求,所以
设
注意到最开始是不用
再发现,如果我们不记录开始和结尾数值的话,很难维护,所以我们要 用尽可能小的空间传递更多的信息。
- 如果我们能够确定第一个位置是否已经被填了,那么就不存在其它特判情况了,即在填的时候特判一下就可以了。(结尾同理)
根据上面那一条发现的性质,我们对 DP 状态进行修改,设
这个转移方程一确定,那么事情就简单多了。
转移方程
首先,明确一下状态的后效性如何去除(因为
看下面这幅图:
(其中横轴为元素的位置,纵轴为元素的值)
容易看出这样的总的权值和是:
在我们按照大小顺序插入的前提下考虑分开:
这样我们就可以得知,每个极小值会被减去两次(但是如果是在序列开头或末尾只会被减一次),每个极大值会被加两次(但是如果是在序列开头或末尾只会被加一次)。
即遇到极大值看它是不是在开头或者末尾,如果在,贡献就会一份,否则为两份,极小值同理。
综上,我们就把
以下状态设计均为从大往小插入考虑
然后,熟悉的分类讨论:
- 对于
i ,它合并了两个段,段数少1
因为
(为什么是加
- 对于
i ,它新开了一个段,段数多1
因为
- 对于
i ,它延续了某个段,并接在该段的左/右边,段数不变
我们以左边为例,因为
加在右边同理,由此我们推导完了整个 DP,但是实现过程中还要注意一下转移时的细节:左右端点固定后权值是多少?有多少个段可以插入等。
这种方法常数很大,同时不利于优化,但是个人认为思维跟 kangaroo 差不多,而且更好想一些。
第二种方法
此处不再赘述,详见:Afewsuns 的博客-[JOI Open 2016]摩天大楼题解。
注:[ZJOI2012] 波浪 和此题十分相像,主要是处理精度问题和小数的“快速输出”,那道题可能会卡常,推荐使用第二种方法。
此处给出第一种方法的代码:
#include<bits/stdc++.h>
#define ll long long
#define mod 1000000007
using namespace std;
ll n,i,j,k,l,a[105],dp[2][105][8005][2][2],ans;
bool cmp(ll a,ll b){return a>b;}
void add(ll &a,ll b){
a += b;
a %= mod;
}
int main(){
ios::sync_with_stdio(false);
cin>>n>>l;
for(i=1;i<=n;i++) cin>>a[i];
if(n==1){
cout<<1<<endl;
return 0;
}
sort(a+1,a+n+1,cmp);
dp[0][0][0][0][0] = 1;
for(i=1;i<=n;i++){
for(j=0;j<=i;j++) for(k=0;k<=6000;k++) dp[i&1][j][k][0][0]=dp[i&1][j][k][1][0]=dp[i&1][j][k][0][1]=dp[i&1][j][k][1][1]=0;
for(j=1;j<=i;j++){
for(k=0;k<=6000;k++){
//MERGE
add(dp[i&1][j][k][0][0],j*dp[(i-1)&1][j+1][k+2*a[i]][0][0]);
add(dp[i&1][j][k][0][1],j*dp[(i-1)&1][j+1][k+2*a[i]][0][1]);
add(dp[i&1][j][k][1][0],j*dp[(i-1)&1][j+1][k+2*a[i]][1][0]);
add(dp[i&1][j][k][1][1],j*dp[(i-1)&1][j+1][k+2*a[i]][1][1]);
if(k>=2*a[i]){
//NEW
add(dp[i&1][j][k][0][0],j*dp[(i-1)&1][j-1][k-2*a[i]][0][0]);
add(dp[i&1][j][k][1][0],(j-1)*dp[(i-1)&1][j-1][k-2*a[i]][1][0]);
add(dp[i&1][j][k][0][1],(j-1)*dp[(i-1)&1][j-1][k-2*a[i]][0][1]);
if(j-1>1) add(dp[i&1][j][k][1][1],(j-2)*dp[(i-1)&1][j-1][k-2*a[i]][1][1]);
}
if(k>=a[i]){
//NEW
add(dp[i&1][j][k][1][0],dp[(i-1)&1][j-1][k-a[i]][0][0]);
add(dp[i&1][j][k][0][1],dp[(i-1)&1][j-1][k-a[i]][0][0]);
add(dp[i&1][j][k][1][1],dp[(i-1)&1][j-1][k-a[i]][0][1]+dp[(i-1)&1][j-1][k-a[i]][1][0]);
}
//LEFT
add(dp[i&1][j][k][0][0],j*dp[(i-1)&1][j][k][0][0]);
if(j>1) add(dp[i&1][j][k][1][0],(j-1)*dp[(i-1)&1][j][k][1][0]);
add(dp[i&1][j][k][1][0],dp[(i-1)&1][j][k+a[i]][0][0]);
add(dp[i&1][j][k][0][1],j*dp[(i-1)&1][j][k][0][1]);
if(j>1) add(dp[i&1][j][k][1][1],(j-1)*dp[(i-1)&1][j][k][1][1]+dp[(i-1)&1][j][k+a[i]][0][1]);
else add(dp[i&1][j][k][1][1],dp[(i-1)&1][j][k+a[i]][0][1]);
//RIGHT
add(dp[i&1][j][k][0][0],j*dp[(i-1)&1][j][k][0][0]);
add(dp[i&1][j][k][1][0],j*dp[(i-1)&1][j][k][1][0]);
if(j>1) add(dp[i&1][j][k][0][1],(j-1)*dp[(i-1)&1][j][k][0][1]);
add(dp[i&1][j][k][0][1],dp[(i-1)&1][j][k+a[i]][0][0]);
if(j>1) add(dp[i&1][j][k][1][1],(j-1)*dp[(i-1)&1][j][k][1][1]+dp[(i-1)&1][j][k+a[i]][1][0]);
else add(dp[i&1][j][k][1][1],dp[(i-1)&1][j][k+a[i]][1][0]);
}
}
}
for(i=0;i<=l;i++) ans=(ans+dp[n&1][1][i][1][1])%mod;
cout<<ans<<endl;
}
[ABC209F] Deforestation
初探题面
这道题有点类似于前面提到的“凸”这道例题,但是它没有叫你计算最小的花费是多少,而是计算有多少种方案能够达到最小的花费。
遇到这种类型的题目,首先要明确在什么条件下能够达到最小的花费。
因为任意一个
- 如果先选
i ,权值为:a_{i-1}+a_i+a_{i+1}+a_{i+1}+a_{i+2} 。 - 如果先选
i+1 ,权值为:a_i+a_{i+1}+a_{i+2}+a_i+a_{i-1} 。
用第一个式子减去第二个式子得:
所以当
很明显的,对于每组
状态设计
我们发现如果按照这个关系减出来的图其实是一个 TAG,不好维护插入顺序。因此我们只能考虑从
因为
说明一下,这里的第
对于
对于
对于
注:
- 第一个转移方程从
j 开始循环到i 是因为如果i-1 在1 \sim i-1 的第j 个位置,那么i 就可以插入到第j 个位置的前面使得i 在i-1 的前面并且相对位置也是j 。 - 第二个转移方程循环到
j-1 的道理同上。 - 这种方法为什么能不重不漏,是因为两个不同的最终操作序列一定有一个
i 在1\sim i 中的相对位置不同;两个相同的最终操作序列一定满足每一个i 在1\sim i 中的相对位置相同。 - 初始化 DP 的时候需要注意
1 在1\sim 1 中的相对位置只有1 这一个。
综上,代码就可以写出来了。
#include<bits/stdc++.h>
#define ll long long
#define mod 1000000007
using namespace std;
ll n,a[4005],dp[4005][4005],m[4005][4005],i,j;
int main(){
ios::sync_with_stdio(false);
cin>>n;
for(i=1;i<=n;i++) cin>>a[i];
dp[1][1] = 1;
for(i=1;i<=n;i++) m[1][i]=1;
for(i=2;i<=n;i++){
for(j=1;j<=i;j++){
if(a[i]==a[i-1]) dp[i][j]=m[i-1][i-1];
if(a[i]>a[i-1]) dp[i][j]=((m[i-1][i-1]-m[i-1][j-1])%mod+mod)%mod;
if(a[i]<a[i-1]) dp[i][j]=m[i-1][j-1];
}
for(j=1;j<=n;j++) m[i][j]=(m[i][j-1]+dp[i][j])%mod;
}
cout<<m[n][n]<<endl;
}
CF1515E-Phoenix and Computers
初探题面
一看就知道是插入 DP,但是如何设计状态令人十分难为。
还是想用
根据上面的总结,我们知道,只要这个状态所产生的的最终的所有的状态不会与另外一个不同的状态(同一阶段)所产生的不同的所有的状态相重合,那么这种做法就会做到不重不漏。
而且题目要求如果
注意:这里的未知数指的是中间一定有超过一个电脑,相当于中间有
考虑这种方法会不会导致状态有重叠,答案是不会。
因为考虑最终的操作序列,肯定最后合并成了一段,合并成了一段就不存在某两段之间至少有
既然都推到这里了,那么 DP 方程就出来了。
首先,单独形成一段,
然后加在某段的两边(这里的左右边是一样的,故不分开讨论):
最后连接连段,如果两段中间的未知数等于
最后注意一下,这次枚举的顺序就是开机的顺序了,并不是电脑编号的顺序,因为题目的自动与手动是按照开机的顺序以及位置决定的,与电脑编号没有关系。
#include<bits/stdc++.h>
#define ll long long
using namespace std;
ll n,mod,i,j,dp[405][405];
int main(){
cin>>n>>mod;
dp[0][0] = 1;
for(i=0;i<n;i++){
for(j=0;j<n;j++){
dp[i+1][j+1] = (dp[i+1][j+1]+dp[i][j]*(j+1))%mod;
dp[i+1][j] = (dp[i+1][j]+dp[i][j]*2*j)%mod;
dp[i+2][j] = (dp[i+2][j]+dp[i][j]*2*j)%mod;
if(j>=2){
dp[i+2][j-1] = (dp[i+2][j-1]+dp[i][j]*(j-1)*2)%mod;
dp[i+3][j-1] = (dp[i+3][j-1]+dp[i][j]*(j-1))%mod;
}
}
}
cout<<dp[n][1]<<endl;
}
总结
总结其实都写在例题和习题讲评里面了,复制粘贴一遍其实没有用,重要的是去理解,并且收获自己的感受,这样才能让做题的思路更加敏捷精确。这就是插入 DP,一个非常巧妙但是很难理解的 DP 类型。