区间DP详解

哈喽大家好,我是 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\) 堆石子合并成为一堆。每次只能合并相邻的两堆,合并的代价为这两堆石子的质量之和,合并后与这两堆石子相邻的石子将和新堆相邻。试找出一种合理的方法,使总的代价最小,并输出最小代价。

\(N \le 300\)\(m_i \le 1000\)

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} \]

转移代码:

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\) 数组的代码:

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

代码(已压行):

#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] 回文字串

回文词是一种对称的字符串。任意给定一个字符串,通过插入若干字符,都可以变成回文词。此题的任务是,求出将给定字符串变成回文词所需要插入的最少字符数。

比如 \(\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\) 来保持字符串回文。由于我们要求最小长度,应该将前两者取最小值作为答案。

转移代码:

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 代码

代码(已压行):

#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] 石子合并

在一个圆形操场的四周摆放 \(N\) 堆石子,现要将石子有次序地合并成一堆,规定每次只能选相邻的 \(2\) 堆合并成新的一堆,并将新的一堆的石子数,记为该次合并的得分。

试设计出一个算法,计算出将 \(N\) 堆石子合并成 \(1\) 堆的最小得分和最大得分。

\(N \le 200\)

3.1.1 思路

这道题和石子合并唯一的区别就是石子是环形排列。那我们应该怎样解决呢?

我们可以考虑一下环形合并带来的影响。

我们先画一个环:

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





这五种合并方式会导致越界。

那我们应该如何解决越界的情况呢?如果我们把一个合并的环环拉成一条线的话:

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

所以!代码也就很简单了!

3.1.2 代码

代码:

#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 提高组] 加分二叉树

设一个 \(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\)。我们只要取最大值就行了。

所以转移代码就是:

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\) 即可。

输出代码:

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 代码

代码(已压行):

#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 代码

代码(已压行):

#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,难度:\(2/5\)
  2. T2:P9325 [CCC 2023 S2] Symmetric Mountains,难度:\(2.5/5\)
  3. P3146 [USACO16OPEN] 248 G,难度:\(3/5\)
  4. P1063 [NOIP 2006 提高组] 能量项链,难度:\(3.5/5\)
  5. P4805 [CCC 2016] 合并饭团,难度:\(4/5\)
  6. P4302 [SCOI2003] 字符串折叠,难度:\(4.5/5\)

5.闲话

希望大家能喜欢我的文章,点个赞吧 QwQ

蒟蒻不才,膜拜大佬。如果文章有错字等问题,请在评论区@我。

posted @ 2025-05-19 11:13  doooge  阅读(115)  评论(0)    收藏  举报