区间DP详解
FTAC
·
·
算法·理论
哈喽大家好,我是 doooge,今天给大家带来的是 区间DP 的详解。
\Huge \sf 区间DP 详解
1.什么是区间DP
区间DP 是动态规划的一部分。
1.1 dp_{i,j} 的含义
所谓 区间DP,也就是 dp 数组 dp_{i,j} 表示的是一个区间的答案,比如说 dp_{2,5} 表示的就是 [2,5] 这个区间的答案,dp_{3,7} 表示的就是 [3,7] 这个区间的答案。
而且,dp_{i,j} 只能由它的子区间转移而来,也就像下图这样:
是不是很简单?那我们应该用怎样的顺序去遍历 i,j 使得在 [i,j] 的子区间一定在遍历到 [i,j] 前全部推完呢?
1.2 区间DP的遍历方式
我们可以枚举长度遍历。比如说,我们在遍历到 i,j 时,长度在 j-i+1 以下的区间一定会被遍历掉,可以看下图理解:
遍历的代码也很好写:
for(int l=1;l<=n;l++){//遍历长度
for(int i=1;i<=n-l+1;i++){//枚举左端点
int j=i+l-1;//求出右端点
//dp[i][j]=...
}
}
1.3 DP推出的结果在哪
这个其实很好猜,根据我们以往的经验,一维 dp 的答案一般是最后遍历到的第 n 位 dp_n,那么区间DP 的答案应该就是我们最后遍历到的区间,也就是长度最长的区间 dp_{1,n} 这里。
当然,也有特例,当题目要你求所有状态之和时,这时答案就不太可能是 dp_{1,n} 了,而是每个区间的答案之和,用公式表达就是:
\sum_{l=1}^{n} \sum_{i=1}^{n-l+1} dp_{i,i+l-1}
也可以是:
\sum_{i=1}^{n} \sum_{j=i+1}^{n} dp_{i,j}
当然这两个求和公式的本质都是一样的,遍历每个区间,只是遍历方式的不同而已。
2.区间dp的例题
2.1 T1:P1775 石子合并(弱化版)
有 N 堆石子排成一排,每堆石子有一定的质量 m_i。现在要将这 N 堆石子合并成为一堆。每次只能合并相邻的两堆,合并的代价为这两堆石子的质量之和,合并后与这两堆石子相邻的石子将和新堆相邻。试找出一种合理的方法,使总的代价最小,并输出最小代价。
### 2.1.1 思路:
我们先把样例搬过来模拟一下:
**输入 #1**
```
4
2 5 3 1
```
**输出 #1**
```
22
```
我们可以按以下顺序合并:

我们发现,不管怎么合并,$[i,j]$ 合并出来的石子堆的大小都是一样的,也就是,不管怎么合并,这一步合并的方法跟后续的合并没有影响。
比如说,这里有四堆石子 $a,b,c,d$,我们用两个方法先合并 $b,c,d$ 这 $3$ 堆石子,再去合并第 $4$ 堆石子 $a$,情况是这样的:


于是!我们就可以用 $dp_{i,j}$ 表示合并 $[i,j]$ 这几堆石子合并到一起所需的最小代价。那么我们最后的答案就是 $dp_{1,n}$ 了。
那我们的 $dp_{i,j}$ 应该从哪里转移过来呢?我们可以想象 $[i,j]$ 这堆石子怎么合并过来的。
诶!我们发现,$[i,j]$ 这堆石子是通过包含在 $[i,j]$ 里面的两小堆石子合并而来的,那么我们的 $dp_{i,j}$ 应该也是找一个断点 $k$,从 $dp_{i,k}$ 和 $dp_{k+1,j}$ 再加上合并的代价得到的,也就是这样:

而 $m_i + m_{i+1} + \cdots + m_j$ 可以用前缀和记录(记为 $s$ 数组),于是 $dp_{i,j}$ 就等于:
$$
\min_{k=i}^{j-1} dp_{i,k}+dp_{k+1,j}+s_j-s_{i-1}
$$
转移代码:
```cpp
for(int k=i;k<j;k++){
dp[i][j]=min(dp[i][j],dp[i][k]+dp[k+1][j]+s[j]-s[i-1]);
}
```
而当长度为 $1$ 的区间由于不需要转移,$dp_{i,i}$ 本来就是 $0$(因为不用合并),所以我们枚举长度 $l$ 的时候直接从 $2$ 开始枚举。
**注意:由于求的是最小代价,所以要将 $dp_{i,j}$ 在转移前初始化为极大值。**
求 $dp$ 数组的代码:
```cpp
for(int l=2;l<=n;l++){//枚举长度
for(int i=1;i<=n-l+1;i++){//枚举起点
int j=i+l-1;//根据起点和长度求出终点
dp[i][j]=1e9;//转移前先初始化成极大值
for(int k=i;k<j;k++){//转移
dp[i][j]=min(dp[i][j],dp[i][k]+dp[k+1][j]+s[j]-s[i-1]);//转移方程如上文所述
}
}
}
```
求 $dp$ 数组的代码已经出来了,那么完整代码也一定不难了吧!
## 2.1.2
代码(已压行):
```cpp
#include<bits/stdc++.h>
using namespace std;
int s[310],dp[310][310];
int main(){
int n;
cin>>n;
for(int i=1;i<=n;i++){
int x;
cin>>x;
s[i]=s[i-1]+x,dp[i][i]=0;
}
for(int l=2;l<=n;l++)for(int i=1;i<=n-l+1;i++){
int j=i+l-1;
dp[i][j]=1e9;
for(int k=i;k<j;k++)dp[i][j]=min(dp[i][j],dp[i][k]+dp[k+1][j]+s[j]-s[i-1]);
}
cout<<dp[1][n]<<endl;
return 0;
}
```
时间复杂度:$O(n^3)$。
## 2.2 [P1435 [IOI 2000] 回文字串](https://www.luogu.com.cn/problem/P1435)
回文词是一种对称的字符串。任意给定一个字符串,通过插入若干字符,都可以变成回文词。此题的任务是,求出将给定字符串变成回文词所需要插入的最少字符数。
比如 $\verb!Ab3bd!$ 插入 $2$ 个字符后可以变成回文词 $\verb!dAb3bAd!$ 或 $\verb!Adb3bdA!$,但是插入少于 $2$ 个的字符无法变成回文词。
**注意**:此问题区分大小写。
记字符串长度为 $l$,$1 \le l \le 1000$。
### 2.2.1 思路
你没听错,这确实是一道 IOI 的题。
咳咳,不扯那么多,我直接来讲这道题用 区间DP 怎么做。
这个时候,聪明的读者就会说了:诶!这题我会!$dp_{i,j}$ 表示让 $s$ 的子串 $s_i$ 到 $s_j$ 变成回文字符串所需要最少的插入字符数。而答案,就是 $dp_{1,n}$ 啦!
那我们的 $dp_{i,j}$ 应该从哪里转移而来呢?可以看下图理解:

- 如果 $s_i=s_j$ 的话,$dp_{i,j}$ 可以从 $dp_{i+1,j-1}$ 转移过来。
- 否则,我们可以从 $dp_{i+1,j}$ 转移过来并在字符串后面添加一个 $s_i$ 来保持字符串回文,也可以从 $dp_{i,j-1}$ 转移过来并在字符串前面添加一个 $s_j$ 来保持字符串回文。由于我们要求最小长度,应该将前两者取最小值作为答案。
转移代码:
```cpp
if(s[i]!=s[j]){//如果不相等
dp[i][j]=min(dp[i][j-1],dp[i+1][j])+1;
}
else{//否则直接从dp[i+1][j-1]转移而来
dp[i][j]=dp[i+1][j-1];
}
```
与上一题一样,长度为 $1$ 的区间不用转移,因为 $1$ 个字符组成的字符串一定是回文的字符串。而长度为 $2$ 的区间可以特殊处理,但是并没有必要,因为只有 $s_i=s_j$ 的情况才可能越界导致访问到 $dp_{i,i+1}$ 这样长度为 $0$ 的区间,但是我们可以将它初始化为 $0$,这样就不用特殊处理了。
**注意:与上题一样,由于求的是最小代价,所以要将 $dp_{i,j}$ 在转移前初始化为极大值。**
### 2.2.2 代码
代码(已压行):
```cpp
#include<bits/stdc++.h>
using namespace std;
int dp[1010][1010];
int main(){
string s;
cin>>s;
for(int l=2;l<=s.size();l++)for(int i=0;i<=s.size()-l;i++){
int j=i+l-1;
if(s[i]!=s[j])dp[i][j]=min(dp[i][j-1],dp[i+1][j])+1;
else dp[i][j]=dp[i+1][j-1];
}
cout<<dp[0][s.size()-1]<<endl;
return 0;
}
```
时间复杂度:$O(n^2)$。
# 3.区间DP 的经典套路题
接下来的题目是 区间DP 的经典套路题,许多 区间DP 题都有这些题的套路。
## 3.1 [P1880 [NOI1995] 石子合并](https://www.luogu.com.cn/problem/P1880)
在一个圆形操场的四周摆放 $N$ 堆石子,现要将石子有次序地合并成一堆,规定每次只能选相邻的 $2$ 堆合并成新的一堆,并将新的一堆的石子数,记为该次合并的得分。
试设计出一个算法,计算出将 $N$ 堆石子合并成 $1$ 堆的最小得分和最大得分。
$N \le 200$。
## 3.1.1 思路
这道题和石子合并唯一的区别就是石子是环形排列。那我们应该怎样解决呢?
我们可以考虑一下环形合并带来的影响。
我们先画一个环:

我们考虑可能会跨界(也就包括 $5$ 的石子堆合并到包括 $1$ 的石子堆的情况,有以下几种:





这五种合并方式会导致越界。
那我们应该如何解决越界的情况呢?如果我们把一个合并的环环拉成一条线的话:

发现了吗!如果我们把整个 $1 \thicksim n$ 的数组复制两边,再把区间长度限制到 $n$ 的话,就能解决环形的问题了:

所以!代码也就很简单了!
### 3.1.2 代码
代码:
```cpp
#include <bits/stdc++.h>
using namespace std;
int dp[210][210],dp2[210][210],a[210],s[210];
int main(){
int n,ans1=1e9,ans2=-1e9;
cin>>n;
for(int i=1;i<=n;i++){
cin>>a[i];
s[i]=s[i-1]+a[i];//前缀和
a[n+i]=a[i];//求n+1到2n的a数组
}
for(int i=n+1;i<=2*n;i++){//求n+1到2n的前缀和
s[i]=s[i-1]+a[i-n];
}
for(int l=2;l<=n;l++){//长度最大只有n
for (int i=1;i<=2*n-l+1;i++){
int j=i+l-1;
dp[i][j]=1e9;//求最小值
for (int k=i;k<=j-1;k++){//同石子合并弱化版
dp[i][j]=min(dp[i][j],dp[i][k]+dp[k+1][j]);
}
dp[i][j]+=s[j]-s[i-1];
}
}
for(int l=2;l<=n;l++){//求最大值
for(int i=1;i<=2*n-l+1;i++){
int j=i+l-1;
for(int k=i;k<=j-1;k++){
dp2[i][j]=max(dp2[i][j],dp2[i][k]+dp2[k+1][j]);
}
dp2[i][j]+=s[j]-s[i-1];
}
}
for(int i=1;i<=n;i++){//找答案
ans1=min(ans1,dp[i][n+i-1]);
ans2=max(ans2,dp2[i][n+i-1]);
}
cout<<ans1<<endl<<ans2<<endl;
return 0;
}
```
## 3.2 [[NOIP 2003 提高组] 加分二叉树](https://www.luogu.com.cn/problem/P1040)
设一个 $n$ 个节点的二叉树 $\text{tree}$ 的中序遍历为$(1,2,3,\ldots,n)$,其中数字 $1,2,3,\ldots,n$ 为节点编号。每个节点都有一个分数(均为正整数),记第 $i$ 个节点的分数为 $d_i$,$\text{tree}$ 及它的每个子树都有一个加分,任一棵子树 $\text{subtree}$(也包含 $\text{tree}$ 本身)的加分计算方法如下:
$\text{subtree}$ 的左子树的加分 $\times$ $\text{subtree}$ 的右子树的加分 $+$ $\text{subtree}$ 的根的分数。
若某个子树为空,规定其加分为 $1$,叶子的加分就是叶节点本身的分数。不考虑它的空子树。
试求一棵符合中序遍历为 $(1,2,3,\ldots,n)$ 且加分最高的二叉树 $\text{tree}$。要求输出
1. $\text{tree}$ 的最高加分。
2. $\text{tree}$ 的前序遍历。
$1 \le n < 30$。
### 3.2.1 思路
这道题一眼看上去并不是 区间DP,其实不然。
题目要求我们建立一颗中序遍历为 $1,2,\cdots,n$ 的树,我们可以看看中序遍历为 $1,2,\cdots,n$ 的树有什么性质。

我们发现,每一个节点的左儿子的编号都会小于本节点的编号,每一个节点的右儿子的编号都会大于本节点的编号。于是,$dp_{i,j}$ 就能表示 $i$ 到 $j$ 的节点按中序遍历的顺序为 $i,i+1,\cdots,j$ 所得到的树的最大得分。
$dp_{i,j}$ 怎么转移呢?我们可以看看上面的图片。
我们可以发现,在 $i,j$ 区间内建成的树,如果让 $k$ 作为根,那么这两个子树一定就是 $i,k-1$ 和 $k+1,j$,得分就是 $dp_{i,k-1} \times dp_{k+1,j}+d_k$。我们只要取最大值就行了。
所以转移代码就是:
```cpp
for(int k=i+1;k<j;k++){
if(dp[i][k-1]*dp[k+1][j]+a[k]>dp[i][j]){
dp[i][j]=dp[i][k-1]*dp[k+1][j]+a[k];
}
}
```
但是,如果区间长度为 $2$,此时必然只有一个子树,但题目中说了 `若某个子树为空,规定其加分为 1`,于是我们就可以加一个 `while` 特判。
那我们应该如何输出最终的建成的树呢?我们可以建立一个前驱数组 $fa$,$fa_{i,j}$ 表示 $i,j$ 这棵树是的根是 $fa_{i,j}$,也就是把这棵树分成了 $i,fa_{i,j}-1$,$fa_{i,j}+1,j$ 这两个部分,然后递归求解。
题目要求前序输出这棵树,我们就可以先输出这棵树的根 $fa_{i,j}$,然后在 DFS 递归 $i,fa_{i,j}-1$ 和 $fa_{i,j}+1,j$ 这两棵子树,如果 $i=j$ 就代表递归到叶子结点了,直接输出 $i$ 即可。
输出代码:
```cpp
void print(int l,int r){
if(l==r){//如果是叶子节点
cout<<l<<' ';//直接输出
return;
}
cout<<fa[l][r]<<' ';//先输出根节点
if(fa[l][r]!=l)print(l,fa[l][r]-1);//递归左节点
if(fa[l][r]!=r)print(fa[l][r]+1,r);//递归右节点
return;
}
```
于是!这道题基本上就解决了!
### 3.2.2 代码
代码(已压行):
```cpp
#include<bits/stdc++.h>
using namespace std;
int a[50],dp[50][50],fa[50][50];
void print(int l,int r){
if(l==r){
cout<<l<<' ';
return;
}
cout<<fa[l][r]<<' ';
if(fa[l][r]!=l)print(l,fa[l][r]-1);
if(fa[l][r]!=r)print(fa[l][r]+1,r);
return;
}
int main(){
int n;
cin>>n;
for(int i=1;i<=n;i++)cin>>a[i],dp[i][i]=a[i],fa[i][i]=i;
for(int l=2;l<=n;l++)for(int i=1;i<=n-l+1;i++){
int j=i+l-1;
dp[i][j]=max(dp[i+1][j]+a[i],dp[i][j-1]+a[j]);
fa[i][j]=(dp[i+1][j]>dp[i][j-1]?i:j);
for(int k=i+1;k<j;k++)if(dp[i][k-1]*dp[k+1][j]+a[k]>dp[i][j]){
dp[i][j]=dp[i][k-1]*dp[k+1][j]+a[k];
fa[i][j]=k;
}
}
cout<<dp[1][n]<<endl;
print(1,n);
return 0;
}
```
## 3.3 关路灯
某一村庄在一条路线上安装了 $n$ 盏路灯,每盏灯的功率有大有小(即同一段时间内消耗的电量有多有少)。老张就住在这条路中间某一路灯旁,他有一项工作就是每天早上天亮时一盏一盏地关掉这些路灯。
为了给村里节省电费,老张记录下了每盏路灯的位置和功率,他每次关灯时也都是尽快地去关,但是老张不知道怎样去关灯才能够最节省电。他每天都是在天亮时首先关掉自己所处位置的路灯,然后可以向左也可以向右去关灯。
现在已知老张走的速度为 $1m/s$,每个路灯的位置(是一个整数,即距路线起点的距离,单位:$m$)、功率($W$),老张关灯所用的时间很短而可以忽略不计。
请你为老张编一程序来安排关灯的顺序,使从老张开始关灯时刻算起所有灯消耗电最少(灯关掉后便不再消耗电了)。
$1 \le n \le 200$。
### 3.3.1 思路
我们知道老张关灯的时间不计,那么如果有一连串的灯,他应该全部关掉,如下图:

如果我们只关掉了部分的灯,那么为了关掉剩余的灯,我们就要折返重新关掉一遍,这样就多出来两站灯的功耗,所以这样肯定不是最优的。
我们还可以发现,如果把一条路径上的灯全关掉,关掉会形成一个区间。于是,我们可以用 区间DP 来解决这道题,$dp_{i,j}$ 表示关掉 $i,j$ 区间的灯所需要的最小功耗。
那么我们应该怎样计算功耗呢?请看下图:

相信大家应该都懂了吧。那 $dp_{i,j}$ 应该从哪里转移而来呢?这似乎成了个问题,因为我们不知道关完 $i,j$ 的灯后我们在哪里。

现在面临着两个选择:
1. 增加一个维度表示位置,但是前面的推论可能推倒重来
2. 直接不演了 
额...还是选 $1$ 吧。我们考虑多加一个维度 $dp_{i,j,0}$ 或 $dp_{i,j,1}$ 表示关完 $i,j$ 区间的灯最后停在左边或停在右边的答案。这样转移就好写多了:
我们先考虑 $dp_{i,j,0}$,也就是关完 $i,j$ 的灯停在左边的转移。

不难看出,$dp_{i,j,0}$ 是由 $dp_{i+1,j,0}$ 从 $i+1$ 走到 $i$ 来的,也可以从 $dp_{i+1,j,1}$ 从 $j$ 走到 $i$ 来的。那么这时所消耗的功耗就是 $a_1 + a_2 + \cdots + a_i$ 和 $a_{j+1} + \cdots + a_n$ 相加在乘上时间(也就是距离,用 $c$ 数组表示) $c_{i+1} - c_i$,如果我们用前缀和记录就是 $(s_n - s_j + s_i) \times (c_j - c_i)$。
$dp_{i,j,1}$ 同理,这边也就不过多赘述了。而然答案的话也很好猜,就是 $\min(dp_{1,n,0},dp_{1,n,1})$。
如果你看懂了,写出代码应该就不难了吧
### 3.3.2 代码
代码(已压行):
```cpp
#include<bits/stdc++.h>
using namespace std;
int a[110],b[110],s[110],dp[110][110][5];
int main(){
int n,t,sum=0;
cin>>n>>t;
for(int i=1;i<=n;i++)cin>>a[i]>>b[i],s[i]=s[i-1]+b[i];
memset(dp,0x3f,sizeof(dp));
dp[t][t][0]=dp[t][t][1]=0;
for(int l=2;l<=n;l++)for(int i=1;i<=n;i++){
int j=i+l-1;
dp[i][j][0]=min(dp[i+1][j][0]+(s[n]-s[j]+s[i])*(a[i+1]-a[i]),dp[i+1][j][1]+(s[n]-s[j]+s[i])*(a[j]-a[i]));
dp[i][j][1]=min(dp[i][j-1][1]+(s[n]-s[j-1]+s[i-1])*(a[j]-a[j-1]),dp[i][j-1][0]+(s[n]-s[j-1]+s[i-1])*(a[j]-a[i]));
}
cout<<min(dp[1][n][0],dp[1][n][1])<<endl;
return 0;
}
```
# 4.作业
1. T1:[P2858 [USACO06FEB] Treats for the Cows G/S](https://www.luogu.com.cn/problem/P2858),难度:$2/5$。
2. T2:[P9325 [CCC 2023 S2] Symmetric Mountains](https://www.luogu.com.cn/problem/P9325),难度:$2.5/5$。
3. [P3146 [USACO16OPEN] 248 G](https://www.luogu.com.cn/problem/P3146),难度:$3/5$。
4. [P1063 [NOIP 2006 提高组] 能量项链](https://www.luogu.com.cn/problem/P1063),难度:$3.5/5$。
5. [P4805 [CCC 2016] 合并饭团](https://www.luogu.com.cn/problem/P4805),难度:$4/5$。
6. [P4302 [SCOI2003] 字符串折叠](https://www.luogu.com.cn/problem/P4302),难度:$4.5/5$。
# 5.闲话
希望大家能喜欢我的文章,点个赞吧 QwQ
蒟蒻不才,膜拜大佬。如果文章有错字等问题,请在评论区@我。