【笔记】区间 DP
指一类求解区间信息的 DP。通常以 \(f_{l,r}\) 表示区间 \([l,r]\) 的状态。
求解过程:枚举区间 \(\rightarrow\) 枚举分割点,将该区间分割为两个小区间 \(\rightarrow\) 合并小区间,转移得到大区间 \(\rightarrow\) 统计答案。
因为在求解大区间前要先得到所有小区间的状态,所以应先枚举区间长度,再枚举区间左端点,得到右端点,最后枚举分割点。
例题
合并石子类
Luogu P1880 [NOI1995] 石子合并
最经典的区间 DP 问题。
对于合并完成一段区间 \([l,r]\) 的状态,要由两个合并完成的子区间求和转移而来,并加上此次合并的贡献。
设 \(f_{l,r}\):将 \([l,r]\) 区间的石子合并为一堆的最大得分,则有:
状态初始值为 \(f_{i,i}=a_i\)。
题目要在环上求解,只需断环为链然后对于从环上任意一点开始长度为 \(n\) 的区间分别求解即可。
时间复杂度 \(O(n^3)\)。
#include<bits/stdc++.h>
using namespace std;
int n;
int a[110],f[110][110],s[110],g[110][110];
int main(){
ios::sync_with_stdio(false);
cin.tie(0),cout.tie(0);
cin>>n;
for(int i=1;i<=n;i++){
cin>>a[i];
a[n+i]=a[i];
s[i]=s[i-1]+a[i];
}
for(int i=n+1;i<=2*n-1;i++) s[i]=s[i-1]+a[i];
for(int i=2*n-1;i>=1;i--){
for(int j=i+1;j<=2*n-1;j++){
f[i][j]=f[i][i]+f[i+1][j];
g[i][j]=g[i][i]+g[i+1][j];
for(int k=i+1;k<j;k++){
f[i][j]=min(f[i][j],f[i][k]+f[k+1][j]);
g[i][j]=max(g[i][j],g[i][k]+f[k+1][j]);
}
f[i][j]+=s[j]-s[i-1];
g[i][j]+=s[j]-s[i-1];
}
}
int ans1=0x3f3f3f3f,ans2=0;
for(int i=1;i<=n;i++){
ans1=min(ans1,f[i][i+n-1]);
ans2=max(ans2,g[i][i+n-1]);
}
cout<<ans1<<endl<<ans2<<endl;
return 0;
}
Luogu P3146 [USACO16OPEN] 248 G
基本上是合并石子的感觉,但是给合并两堆加了两堆必须相等的条件。
那我们就给他加上这个条件再转移,状态里就存 \([l,r]\) 合并成的这一堆有多少个。
因为加了这个条件所以不一定能把 \([1,n]\) 全部合并成一堆,于是就找一个最大的区间即可。
#include<bits/stdc++.h>
using namespace std;
const int N=260;
int n,ans;
int a[N];
int f[N][N];
int main(){
ios::sync_with_stdio(false);
cin.tie(0),cout.tie(0);
cin>>n;
for(int i=1;i<=n;i++){
cin>>a[i];
f[i][i]=a[i];
ans=max(ans,f[i][i]);
}
for(int len=2;len<=n;len++){
for(int i=1;i<=n-len+1;i++){
int j=i+len-1;
for(int k=i;k<j;k++){
if(f[i][k]==f[k+1][j]&&f[i][k]) f[i][j]=max(f[i][j],f[i][k]+1);
}
ans=max(ans,f[i][j]);
}
}
cout<<ans;
return 0;
}
这个题还有一个强化版,但是跟区间 DP 没有什么关系了。
Luogu P1043 [NOIP 2003 普及组] 数字游戏
不同的是合并为一堆变成了合并为 \(m\) 堆,贡献的计算方式也有差别。
那合并为 \(m\) 堆是不是由合并为 \(m-1\) 堆的区间与一个合并为一堆的区间拼起来的,我们可以在枚举区间分割点的时候同时做这个事。
为状态加一维 \(d\) 表示这个区间合并为 \(d\) 堆产生的贡献,转移方程就出来了:
也就是说我们还要多枚举 \(d\),从低到高转移。
最后考虑状态初值,我们预先需要所有的 \(f_{l,r,1}\),也就是区间和取模。
还要注意一点,求最小值的时候可能会出现无穷大出来转移,这时候一乘就特别容易溢出变成一个负数,然后就变成最小值了。所以一定要判一下是不是无穷大在转移。(WA 60pts)
#include<bits/stdc++.h>-
using namespace std;
int n,m,ans1,ans2=1e9;
int a[110],s[110];
int f[110][110][15],g[110][110][15];
int main(){
ios::sync_with_stdio(false);
cin.tie(0),cout.tie(0);
cin>>n>>m;
memset(g,0x3f,sizeof(g));
for(int i=1;i<=n;i++){
cin>>a[i];
a[n+i]=a[i];
}
for(int i=1;i<=2*n;i++) s[i]=s[i-1]+a[i];
for(int i=1;i<=2*n;i++){
for(int j=i;j<=2*n;j++){
f[i][j][1]=((s[j]-s[i-1])%10+10)%10;
g[i][j][1]=((s[j]-s[i-1])%10+10)%10;
}
}
for(int l=2;l<=m;l++){
for(int i=1;i<=2*n;i++){
for(int j=i+1;j<=2*n;j++){
for(int k=i;k<j;k++){
f[i][j][l]=max(f[i][j][l],f[i][k][l-1]*f[k+1][j][1]);
if(g[i][k][l-1]>=0x3f3f3f3f||g[k+1][j][1]>=0x3f3f3f3f) continue;
g[i][j][l]=min(g[i][j][l],g[i][k][l-1]*g[k+1][j][1]);
}
}
}
}
for(int i=1;i<=n;i++){
ans1=max(ans1,f[i][i+n-1][m]);
ans2=min(ans2,g[i][i+n-1][m]);
}
cout<<ans2<<'\n'<<ans1;
return 0;
}
然后这其实还不够优。刚才我们推的时候,就发现枚举区间长度和枚举 \(d\) 干的事情有点重复,都是去搞子区间转移。
于是考虑修改状态 \(f_{i,k}\) 表示前 \(i\) 个数合并为 \(k\) 堆的最大贡献,转移的时候枚举 \([1,i]\) 的分割点。状态转移方程为:
因为 \([1,j]\) 要合并成 \(k-1\) 堆,所以至少得有 \(k-1\) 个元素。
最后是状态初值,我们把所有 \(f_{i,1}\) 都赋上前缀和。
这样求一遍可以 \(O(n^3)\) 求出来指定序列的答案的,但我们把左端点固定为了 \(1\),所以对于这个题求环还得枚举起始点,所以并没有做到优化(
但是下一个题就可以了。
P10967 [IOI 2000] 邮局(原始版)
和上个题很像啊,还是把区间分成两段,一段 \(d-1\) 个邮局,另一段一个。
像上个题一样直接写 \(O(m\times n^3)\) 会有点极限,得益于评测机升级,可以这样写过去了。
不过更优的做法还是写 \(O(m\times n^2)\),思路同上。不同的是,区间修建一个邮局的距离和不能现求,得预处理。
预处理有很多种方法。
首先如果你不会数学方法,你可以去枚举每个区间的邮局放在哪,然后用前缀和 \(O(1)\) 求出来距离和。这样做是 \(O(n^3)\) 的。
for(int i=1;i<=n;i++){
for(int j=i+1;j<=n;j++){
for(int k=i;k<=j;k++){
f[i][j][1]=min(f[i][j][1],s[j]-s[k]-a[k]*(j-k)+a[k]*(k-i)-(s[k-1]-s[i-1]));
}
}
}
然后如果你会数学,你能知道如果区间长度是奇数那么最小距离和一定是选中位数放邮局,如果是偶数那么一定是在中位数两边的任意一个点放邮局。你就可以用 (i+j)/2 算出来中点在哪,然后求出每个区间里的点到邮局有多远。这样做也是 \(O(n^3)\) 的。
因为底下的 DP 就是这个量级,所以以上两种做法也无妨。但考虑到我们这个 DP 还有优化空间(决策单调性),不能让预处理拖后腿,所以得搞一个 \(O(n^2)\) 的预处理。
你发现对于一个区间 \([l,r]\),我们往右拓展他,可能会使这个中位数的点往右移动一个位置(\([l,r]\) 长度为偶),也可能不变(长度为奇)。我们分别观察这两种情况,看看往右拓展会发生什么。
Case 1: \([l,r]\) 长度为偶
拓展前我们选的邮局点是中位数左边那个点 \(mid_l\),一往右拓展邮局点就得到下一个点 \(mid_r\),发现 \([l,mid_l]\) 到邮局点的距离都加了 \(a_{mid_r}-a_{mid_l}\),\([mid_r,r]\) 中的点都加了这么多。注意到这两部分点数量相等,也就是正好抵消,总和不变。
Case 2: \([l,r]\) 长度为奇
下一次邮局点不变,那显然总和也不变。
最后再加上新增点 \(r+1\) 到新邮局点的距离。也就是说,我们可以 \(O(n^2)\) 递推出来。
for(int i=1;i<=n;i++){
for(int j=i+1;j<=n;j++){
d[i][j]=d[i][j-1]+a[j]-a[(i+j)/2];
}
}
最后再赋上状态初值,然后求解。
for(int i=1;i<=n;i++) f[i][1]=d[1][i];
for(int j=2;j<=m;j++){
for(int i=1;i<=n;i++){
for(int k=j-1;k<i;k++){
f[i][j]=min(f[i][j],f[k][j-1]+d[k+1][i]);
}
}
}
cout<<f[n][m];
UVA10304 Optimal Binary Search Tree
二叉搜索树左子节点小于根,右子节点大于根,所以必须按照这个单增序列顺序构造。这样就可以把树映射到区间上处理了。
然后考虑将 \([l,r]\) 区间构造成二叉搜索树的最大贡献。
不难想到要枚举中间哪个点作根。因为根节点深度为 \(0\),所以没有贡献。
然后左右子树都去构造他们最优的二叉搜索树,并且加上整个区间除了根的深度增加 \(1\) 的贡献。
状态转移方程大概长这样:
这个根显然可能是 \(l\) 或者 \(r\),这时候你发现你会用到 \(f_{l,l-1}\) 和 \(f_{r+1,r}\) 这两个状态,初始化时一定要一块赋上 \(0\)。时间复杂度 \(O(n^3)\)。
#include<bits/stdc++.h>
using namespace std;
const int N=260;
int n;
int a[N],s[N];
int f[N][N];
int main(){
ios::sync_with_stdio(false);
cin.tie(0),cout.tie(0);
while(cin>>n){
memset(f,0x3f,sizeof(f));
for(int i=1;i<=n;i++){
cin>>a[i];
s[i]=s[i-1]+a[i];
f[i][i]=f[i][i-1]=f[i+1][i]=0;
}
for(int len=2;len<=n;len++){
for(int i=1;i<=n-len+1;i++){
int j=i+len-1;
for(int k=i;k<=j;k++){
f[i][j]=min(f[i][j],f[i][k-1]+f[k+1][j]+s[j]-s[i-1]-a[k]);
}
}
}
cout<<f[1][n]<<'\n';
}
return 0;
}
取数类
Luogu P2734 [IOI 1996 / USACO3.3] 游戏 A Game
巧妙设置状态:\(f_{l,r}\) 表示先手取完 \([l,r]\) 的得分。相应的,后手的得分即为 \(\sum_{i=l}^{r}a_i-f_{l,r}\)。
然后考虑转移。注意到对于区间 \([l,r]\) 如果我们先手取了 \(l\),那么在区间 \([l+1,r]\) 中我们就是后手了,也就是说先手取 \(l\) 的得分为:
同理我们也可以先手取 \(r\),相同方法转移取最大值即可。
对于这个区间 DP,区间分割点已固定在 \(l\) 或 \(r-1\),所以不需要枚举。时间复杂度 \(O(n^2)\)。
#include<bits/stdc++.h>
using namespace std;
int n;
int a[110],s[110];
int f[110][110];
int main(){
ios::sync_with_stdio(false);
cin.tie(0),cout.tie(0);
cin>>n;
for(int i=1;i<=n;i++){
cin>>a[i];
s[i]=s[i-1]+a[i];
f[i][i]=a[i];
}
for(int l=2;l<=n;l++){
for(int i=1;i<=n-l+1;i++){
int j=i+l-1;
f[i][j]=max(s[j]-s[i]-f[i+1][j]+a[i],s[j-1]-s[i-1]-f[i][j-1]+a[j]);
}
}
cout<<f[1][n]<<' ';
cout<<s[n]-f[1][n];
return 0;
}
补充:空间优化
Luogu P3004 [USACO10DEC] Treasure Chest S
题意不变,数据范围扩大到 \(n\le 5\times10^3\),且空间限制 \(64\) Mib,意味着我们不能开二维数组。
注意到在固定的区间划分下,我们只会用到长度比当前长度小 \(1\) 的区间做转移。我们尝试修改状态定义,令 \(f_{i,len}\) 表示从 \(i\) 开始长度为 \(len\) 的区间,这样我们就完全可以把 \(len\) 这一维压掉,优化空间。
for(int l=2;l<=n;l++){
for(int i=1;i<=n-l+1;i++){
int j=i+l-1;
f[i]=max(s[j]-s[i]-f[i+1]+a[i],s[j-1]-s[i-1]-f[i]+a[j]);
}
}
cout<<f[1];
Luogu P2858 [USACO06FEB] Treats for the Cows G/S
和上个取数博弈的题有些类似,同样是从序列两头取。
状态的转移也同样分为先取 \(l\) 和先取 \(r\) 两种情况。
不同的是贡献与取出顺序有关,只需要对转移式略加修改。
假设我们先取出 \(l\),后面 \([l+1,r]\) 集体往后平移了一位,那对于每个 \(v_i\) 乘的 \(a\) 都加了 \(1\),也就是多了 \(\sum_{i=l+1}^{r}v_i\)。
然后就做完了。
#include<bits/stdc++.h>
#define int long long
using namespace std;
const int N=2010;
int n;
int a[N];
int f[N][N];
int s[N];
signed main(){
ios::sync_with_stdio(false);
cin.tie(0),cout.tie(0);
cin>>n;
for(int i=1;i<=n;i++){
cin>>a[i];
f[i][i]=a[i];
s[i]=s[i-1]+a[i];
}
for(int len=2;len<=n;len++){
for(int i=1;i<=n-len+1;i++){
int j=i+len-1;
f[i][j]=max(f[i][j],f[i][j-1]+s[j-1]-s[i-1]+a[j]);
f[i][j]=max(f[i][j],f[i+1][j]+s[j]-s[i]+a[i]);
}
}
cout<<f[1][n];
return 0;
}
补充:强化版
Luogu P1005 [NOIP 2007 提高组] 矩阵取数游戏
注意到每一行都是上一个题一样的问题,没有本质区别。只需要分别求解每一行最后相加即可。
\(2^i\) 向后平移也很容易,直接给答案 \(\times2\)。
最坑的一点是这题不保证答案在 long long 范围内。一般来说我们要写高精度的,但是现在可以偷懒用 __int128。只需要自己写个快读快写。
#include<bits/stdc++.h>
#define int unsigned long long
using namespace std;
const int N=110;
int n,m;
__int128 ans;
__int128 a[N][N];
__int128 f[N][N];
void print(__int128 x){
if(x<0){
putchar('-');
x=-x;
}
if(x>9) print(x/10);
putchar(x%10+'0');
}
__int128 read(){
__int128 x=0,f=1;
char ch=getchar();
while(ch<'0'||ch>'9'){
if(ch=='-') f=-1;
ch=getchar();
}
while(ch>='0'&&ch<='9'){
x=x*10+(ch-'0');
ch=getchar();
}
return x*f;
}
signed main(){
// ios::sync_with_stdio(false);//不可以关流
// cin.tie(0),cout.tie(0);
cin>>n>>m;
for(int i=1;i<=n;i++){
for(int j=1;j<=m;j++) a[i][j]=read();
}
for(int T=1;T<=n;T++){
for(int i=1;i<=m;i++){
for(int j=1;j<=m;j++){
if(i==j) f[i][j]=a[T][i]*2;
else f[i][j]=0;
}
}
for(int len=2;len<=m;len++){
for(int i=1;i<=m-len+1;i++){
int j=i+len-1;
f[i][j]=max(f[i][j],f[i][j-1]*2+a[T][j]*2);
f[i][j]=max(f[i][j],f[i+1][j]*2+a[T][i]*2);
}
}
ans+=f[1][m];
}
print(ans);
return 0;
}
修改次数类
Luogu P1435 [IOI 2000] 回文字串
令 \(f_{l,r}\) 表示将 \([l,r]\) 变成回文串需要的最少插入次数。
分情况讨论。
Case 1: \(s_l=s_r\)
直接套在 \([l+1,r-1]\) 的外面就行了。\(f_{l,r}=f_{l+1,r-1}\)。
Case 2: \(s_l\neq s_r\)
我们需要在其中一端添加另一端的字符才能保证回文性。有可能是给 \([l+1,r]\) 变成的回文串,左侧接上 \(s_l\) 后再在右侧添加 \(s_l\),也有可能是给 \([l,r-1]\) 变成的回文串,右侧接上 \(s_r\) 后再在左侧添加 \(s_r\)。
最后考虑优化空间,注意到转移时只会用到 \(f_l\) 和 \(f_{l+1}\),可以用取模 \(2\) 滚掉。
时间复杂度 \(O(n^2)\)。
#include<bits/stdc++.h>
using namespace std;
string s;
int n;
int f[2][1010];
int main(){
ios::sync_with_stdio(false);
cin.tie(0),cout.tie(0);
cin>>s;
n=s.size();
s=' '+s;
memset(f,0x3f,sizeof(f));
for(int i=1;i<=n;i++) f[0][i]=f[1][i]=0;
for(int l=2;l<=n;l++){
for(int i=1;i<=n-l+1;i++){
int j=i+l-1;
if(s[i]==s[j]) f[i%2][j]=f[(i+1)%2][j-1];
else f[i%2][j]=min(f[(i+1)%2][j],f[i%2][j-1])+1;
}
}
cout<<f[1][n];
return 0;
}
Codeforces 1114D Flood Fill
那么首先一开始就是同一个连通块的就不用管了,为了方便我们都缩成点。
设 \(f_{l,r}\) 为推平所需要的操作次数。然后继续分类讨论:
Case 1: \(c_l=c_r\)
因为一开始我们就把同一个连通块的都缩了,所以相邻两个一定是不同的。
那任何一个连通块都是从一个点开始逐步往两侧扩大(题目要求了你得一直去染同一个点所在的连通块,不存在同时染两个块到中间会和以减少染色次数的情况),扩大方式就是把颜色染成你要新加入连通块的点的颜色。
这次该扩大到 \([l,r]\) 了,又因为相邻两点颜色一定不同,所以上次的区间 \([l+1,r-1]\) 一定被染成了与 \(l,r\) 不同的颜色。
只需要一次染色把中间染成 \(l,r\) 的颜色即可。
\(f_{l,r}=f_{l+1,r-1}+1\)。
Case 2: \(c_l\neq c_r\)
那就是把 \([l+1,r]\) 染成 \(l\) 颜色或者把 \([l,r-1]\) 染成 \(r\) 颜色了。取一个最小值。
\(f_{l,r}=\min(f_{l+1,r},f_{l,r-1})+1\)。
#include<bits/stdc++.h>
using namespace std;
const int N=5010;
int n,tot;
int c[N];
int f[N][N];
int main(){
ios::sync_with_stdio(false);
cin.tie(0),cout.tie(0);
cin>>n;
int lst=-1;
for(int i=1;i<=n;i++){
int x;
cin>>x;
if(x!=lst){
c[++tot]=x;
lst=x;
}
}
n=tot;
memset(f,0x3f,sizeof(f));
for(int i=1;i<=n;i++) f[i][i]=0;
for(int len=2;len<=n;len++){
for(int i=1;i<=n-len+1;i++){
int j=i+len-1;
if(c[i]==c[j]) f[i][j]=f[i+1][j-1]+1;
else f[i][j]=min(f[i+1][j],f[i][j-1])+1;
}
}
cout<<f[1][n];
return 0;
}

浙公网安备 33010602011771号