区间 DP 学习笔记

本文由 Pretharp 编写,转载需注明出处,禁止用于任何形式的商业用途。

1 引入

1.1 算法相关

区间 DP 是一种通常以区间长度作为拓扑序来转移的 DP。 顾名思义,这种 DP 通常解决一些区间问题,因为没有过多固定的模板,将以例题介绍为主。

2 例题

2.1 例题 1~3

例题 1:[NOI1995] 石子合并

题目大意:

\(n\) 堆构成环形的石子(即 \(1\) 号石子和 \(n\) 号石子相邻),每次可以合并相邻的两堆石子,合并后的石子数为两堆石子数之和,且你将获得与合并后的石子数相等的分数。求石子合并得只剩下一堆时,你获得的分数最大与最小。

分析:

以求最大值为例。

我们不妨设 \(f_{i,j}\) 表示将区间 \([i,j]\) 中的石子全部合并为一堆后的最大得分。我们根据以区间长度为拓扑序作为基础,先枚举区间长度以及起点确定区间,之后也能一并确定出终点。之后,我们将引入区间 DP 最常见的一个转移方式 —— 枚举断点。在区间 \([i,j]\) 下,我们可以枚举 \(k \in [i,j)\)\(f_{i,j}\) 就是由 \(f_{i,k}\)\(f_{k+1,j}\) 转移而来(也就是区间 \([i,j]\) 的答案由 \([i,k]\)\([k+1,j]\) 合并而来),即:

\[f_{i,j}=\max\{f_{i,k}+f_{k+1,j}+\operatorname{sum}(i,j)\} \]

其中 \(\operatorname{sum}(i,j)\) 表示 \([i,j]\) 之间初始的石子数量和。考虑到给定的石子堆是一个环,我们将石子堆复制一遍即可。

代码过于简单,故不给出,有需要可前往洛谷查看题解。

例题 2:[NOIP2006 提高组] 能量项链

题目大意:

给一串有 \(n\) 个珠子的项链(环形,即第 \(1\) 个珠子和第 \(n\) 个珠子相邻),第 \(i\) 个项链有一个 \(a_i\)\(b_i\),保证对于 \(x \in [1,n)\)\(b_x=a_{x+1}\)\(b_n=a_1\)。你可以合并两个相邻的珠子(设其编号为 \(i\)\(i+1\)),合并后你能得到 \(a_i \times b_i \times a_{i+1}\) 点能量,合并后的项链(设其编号为 \(j\))其 \(a_j=a_i,b_j=b_{i+1}\) 。问当项链只剩下一颗珠子时可获得的最大能量。

分析:

依然设 \(f_{i,j}\) 表示将区间 \([i,j]\) 内的所有珠子合并后所获得的能量最大值。枚举需要转移的区间和长度,再枚举断点,那么状态转移方程就是:

\[f_{i,j}=\max\{f_{i,k}+f_{k+1,j}+a_i \times a_{k+1} \times b_j\} \]

不理解建议自己推推。至于环形的话和例题 \(1\) 一样,复制一遍即可。

参考代码:

#include<bits/stdc++.h>
using namespace std;
#define int long long
#define pii pair<int, int>
#define fir first
#define sec second
const int N = 210;
int n, tmp, f[N][N];
pii a[N];
signed main() {
	cin >> n >> tmp;
	a[1].fir = a[n].sec = tmp;
	for(int i = 2; i <= n; i ++) {
		cin >> tmp;
		a[i - 1].sec = a[i].fir = tmp;
	}
	for(int i = n + 1; i <= 2 * n - 1; i ++) {
		a[i] = a[i - n];
	}
	for(int len = 2; len <= 2 * n - 1; len ++) {
		for(int i = 1, j; (j = i + len - 1) <= 2 * n - 1; i ++) {
			for(int k = i; k < j; k ++) {
				f[i][j] = max(f[i][j], f[i][k] + f[k + 1][j] + a[i].fir * a[k + 1].fir * a[j].sec);
			}
		}
	}
	cout << f[1][2 * n - 1] / 2 << endl;
	return 0;
}

例题 3:[SCOI2003] 字符串折叠

题目大意:

给定一个字符串 \(S\),求它折叠后的最短长度。折叠方法如下:

  • \(X(S)\) 表示 \(\begin{matrix}\underbrace{S+S+\ldots+S}\\X个S\end{matrix}\)

例如 \(\texttt{ABABAB}\) 可折叠为 \(\texttt{3(AB)}\)\(\texttt{AABAABCAABAABC}\) 可折叠为 \(\texttt{2(2(AAB)C)}\)

数据范围:\(|S| \le 100\)

分析:

\(f_{i,j}\) 表示将 \([i,j]\) 折叠后的最小长度。先考虑当前区间不折叠时的状态转移方程:

\[f_{i,j}=\min\{f_{i,k}+f_{k+1,j}\} \]

接下来考虑有折叠的。考虑到无法确定最短循环节折叠后是否为子问题最优,以及无法直接确定最短循环节长度,不妨干脆枚举循环节长度,然后暴力判断是否合法。因此对于每一个 \(l\),若其为子串 \([i,j]\) 中一个合法的循环节长度,有状态转移方程:

\[f_{i,j}=\min\{f_{i,i+l-1}+\operatorname{num}(len \div l) + 2\} \]

其中 \(len\) 当前需要转移的区间长度,\(\operatorname{num}(len \div l)\) 表示折叠后 \(X(S)\)\(X\) 这个数字的长度,还要加上 \(2\) 是括号长度。

参考代码:

#include<bits/stdc++.h>
using namespace std;
const int N = 110;
int n, f[N][N], num[N];
string s;
bool canFold(int l, int r, int len) { // canFold() 判断在区间 [l,r] 中是否存在一个长度为 len 的循环节。
	for(int i = l + len; i <= r; i ++) {
		if(s[i] != s[i - len]) {
			return 0;
		}
	}
	return 1;
}
signed main()
{
	cin >> s, n = s.size();
	s = ' ' + s;
	memset(f, 0x3f, sizeof f);
	for(int i = 1; i <= n; i ++) {
		f[i][i] = 1;
		num[i] = num[i / 10] + 1;
	}
	for(int len = 2; len <= n; len ++) {
		for(int i = 1, j; (j = i + len - 1) <= n; i ++) {
			for(int k = i; k < j; k ++) {
				f[i][j] = min(f[i][j], f[i][k] + f[k + 1][j]);
			}
			for(int l = 1; l <= len / 2; l ++) {
				if(len % l || !canFold(i, j, l)) {
					continue;
				}
				f[i][j] = min(f[i][j], f[i][i + l - 1] + num[len / l] + 2); 
			}
		}
	} 
	return cout << f[1][n] << endl, 0;
}

小结:

以上都是区间 DP 中最基础的题目,主要考察设计状态,相关练习参见「3 习题」部分中的前两道题。

2.2 例题 4~5

前面的题都还算好想,接下来我们将状态变得更加复杂。

例题 4:[洛谷 P1220] 关路灯

题目大意:

\(n\) 盏路灯,第 \(i\) 盏路灯位于 \(pos_i\) ,每秒消耗 \(cos_i\) 点电,一开始你位于编号为 \(c\) 的路灯处。接下来你可以以每秒一个单位长度的速度向前或者向后移动,当你碰到了一盏路灯时其将会立刻关闭(停止消耗电量),问将所有路灯关闭后最小消耗总电量。

分析:

方便起见,这一道题目中所说的位置为路灯的编号。

区间 DP,但是简简单单的 \(f_{i,j}\) 表示将 \([i,j]\) 内的所有路灯关闭并不能直接统计出答案。假设当前关闭了 \([i,j]\) 内的所有路灯,接下来你要移动到 \(i-1\) 的路灯,那么你有可能是从 \(i\) 处移动到 \(i-1\),也有可能是在 \(j\) 处掉头回来,故无法统计答案。既然如此,我们不妨将状态增加一维,设 \(f_{i,j,0/1}\) 表示将区间 \([i,j]\) 内所有路灯关闭,且停留在 \(i\) 处或者 \(j\) 处的最小代价,那么 \(f_{i,j,0}\) 就是从 \(f_{i+1,j,0}\)(顺便把 \(i\) 关了)和 \(f_{i+1,j,1}\)(从 \(j\) 掉头回来)转移而得。从 \(f_{i+1,j,0}\) 转移而来的状态转移方程:

\[f_{i,j,0}=\min\{f_{i+1,j,0}+(pos_{i+1}-pos_i)\times(\operatorname{sum}(1,n)-\operatorname{sum}(i+1,j)\} \]

其中 \((pos_{i+1}-pos_i)\times(\operatorname{sum}(1,n)-\operatorname{sum}(i+1,j))\) 就是从 \(i+1\) 走到 \(i\) 时没有关掉的路灯耗得的电量,\(\operatorname{sum}(x,y)\) 表示 \(\sum\limits_{i=x}^y cos_i\)。从 \(f_{i+1,j,1}\) 而来的转移方程:

\[f_{i,j,0}=\min\{f_{i+1,j,1}+(pos_{j}-pos_i)\times(\operatorname{sum}(1,n)-\operatorname{sum}(i+1,j)\} \]

\(f_{i,j,1}\) 就是从 \(f_{i,j-1,0}\)\(f_{i,j-1,1}\) 转移而来,与前面的类似,这里就不再赘述了。最后答案就是 \(\min\{f_{1,n,0},f_{1,n,1}\}\)

参考代码:

#include<bits/stdc++.h>
using namespace std;
#define pii pair<int, int>
#define fir first
#define sec second
const int N = 55;
int n, c, f[N][N][2], sum[N]; 
pii a[N];
signed main() {
	cin >> n >> c;
	for(int i = 1; i <= n; i ++) {
		cin >> a[i].fir >> a[i].sec;
		sum[i] = sum[i - 1] + a[i].sec;
	}
	memset(f, 0x3f, sizeof f);
	f[c][c][0] = f[c][c][1] = 0;
	for(int len = 2; len <= n; len ++) {
		for(int i = 1, j; (j = i + len - 1) <= n; i ++) {
			int cps1 = sum[n] - (sum[j] - sum[i]), cps2 = sum[n] - (sum[j - 1] - sum[i - 1]); // cost per second
			f[i][j][0] = min(
				f[i + 1][j][0] + (a[i + 1].fir - a[i].fir) * cps1, 
				f[i + 1][j][1] + (a[j].fir - a[i].fir) * cps1
			);
			f[i][j][1] = min(
				f[i][j - 1][1] + (a[j].fir - a[j - 1].fir) * cps2,
				f[i][j - 1][0] + (a[j].fir - a[i].fir) * cps2
			);
		}
	}
	return cout << min(f[1][n][0], f[1][n][1]) << endl, 0;
}

例题 5:[SCOI2007] 压缩

题目大意:

给定一个仅包含小写字母的字符串,你可以通过在其中添加大写字母 \(\texttt{M}\)\(\texttt{R}\) 以对字符串进行压缩,压缩规则如下:

  • 对于每一个大写字母 \(\texttt{R}\),它会将它左边第一个字符到其左边第一个 \(\texttt{M}\)(如果没有就到字符串开始之处)的子串复制一遍,如 \(\texttt{bcdcdcdcd}\) 可以压缩为 \(\texttt{bMcdRR}\)

求压缩后的字符串的最小长度。

分析:

其实不难,前提是先把题目搞清楚。这道题不像 [SCOI2003] 字符串折叠 可以随意嵌套,因为 \(\texttt{M}\)\(\texttt{R}\) 并不像括号那样可以随意匹配,举例(字符串中的空格为方便阅读,实际并不存在):

字符串 \(\texttt{a ab ab a ab ab}\) 如果先折叠为 \(\texttt{a MabR a MabR}\),再折叠为 \(\texttt{a MabRR}\),那么应该先展开为 \(\texttt{a MababR}\),再展开为 \(\texttt{a abab abab}\),很显然是错误的。因为在第二次压缩后的第二个 \(\texttt{R}\) 如果像括号匹配一样,不会与第一个 \(\texttt{M}\) 匹配,而是复制从字符串开始之处到第二个 \(\texttt{R}\) 的子串,可事实并非如此。

不难发现字符串具体如何正确折叠只与是否包含 \(\texttt{M}\) 有关,所以我们可以设计状态 \(f_{i,j,0/1}\) 表示在区间 \([i,j]\) 内,不包含或者包含 \(\texttt{M}\) 的最小折叠长度。还要想清楚的一点,对于子问题 \(f_{i,j,0/1}\),我们的状态是基于在 \(i-1\) 处有一个 \(\texttt{M}\),洛谷上很多题解没有讲清楚这样做为什么是合法的,其实读者可以理解为对于任意一个子问题 \(f_{i,j,0/1}\) 都是在 \(i\) 处有一个 \(\texttt{M}\) 的,只不过考虑到对于整个字符串而言,有一个隐形的 \(\texttt{M}\) 在整个字符串之前,考虑到区间 DP 是以区间长度作为拓扑序转移,所以外层的 \(\texttt{M}\) 并不会影响内层的复制匹配,故无后效性,即答案合法。如果还是不理解可以往后看看状态理解。

先考虑如果当前区间内的子串可以折叠:

\[f_{i,j,0}=\min\{f_{i,mid,0}+1\} \]

\(mid\) 是区间 \([i,j]\) 的中点。接下来考虑不折叠,老样子,枚举断点,对于 \(f_{i,j,0}\),有:

\[f_{i,j,0}=\min\{f_{i,k.0}+(j-k)\} \]

其中 \(f_{i,k,0}\) 好理解,那么 \((j-k)\) 呢?在 \([i,k]\) 中,区间的开头都有一个 \(\texttt{M}\),所以满足对状态的定义,但是对于 \([k+1,j]\),区间开头如果有 \(\texttt{M}\),就不符合 \(f_{i,j,0}\) 中区间内不存在 \(\texttt{M}\) 的定义,故此。接下来看对于 \(f_{i,j,1}\)

\[f_{i,j,1}=\min\{\min\{f_{i,k,0}+f_{i,k,1}\}+\min\{f_{k+1,j,0},f_{k+1,j,1}\}+1\} \]

首先前半部分(\(\min\{f_{i,k,0}+f_{i,k,1}\}\))好理解,那么后半部分不同于子问题 \(f_{i,j,0}\) 的是,区间内是可以出现 \(\texttt{M}\) 的,同时这个 加上的\(1\) 也就是 \(\texttt{M}\) 的长度。最后答案就是 \(\min\{f_{1,n,0},f_{1,n,1}\}\),为什么不用减去 \(1\) 是因为对于子问题 \(f_{1,1,0/1}\) 中是取不到含有 \(\texttt{M}\) 的情况的,理由显然。

参考代码:

#include<bits/stdc++.h>
using namespace std;
const int N = 55;
int n, f[N][N][2];
string s;
bool checkSame(int l, int r) { // 判断前后是否相同。 
	if((r - l + 1) % 2) {
		return 0;
	}
	for(int i = l; i <= (l + r >> 1); i ++) {
		if(s[i] != s[i + (r - l + 1 >> 1)]) {
			return 0;
		}
	}
	return 1;
}
signed main() {
	cin >> s;
	n = s.size(), s = 'P' + s;
	memset(f, 0x3f, sizeof f);
	for(int i = 1; i <= n; i ++) {
		f[i][i][0] = 1, f[i][i][1] = 2; // f(i,i,1) 中含有一个 M,所以初始长度为 2。 
	}
	for(int len = 2; len <= n; len ++) {
		for(int i = 1, j; (j = i + len - 1) <= n; i ++) {
			if(checkSame(i, j)) {
				f[i][j][0] = min(f[i][j][0], f[i][i + j >> 1][0] + 1);
			}
			for(int k = i; k < j; k ++) {
				f[i][j][0] = min(f[i][j][0], f[i][k][0] + j - k);
				f[i][j][1] = min(f[i][j][1], min(f[i][k][0], f[i][k][1]) + min(f[k + 1][j][0], f[k + 1][j][1]) + 1);
			}
		}
	}
	return cout << min(f[1][n][0], f[1][n][1]) << endl, 0;
}

3 习题

3.1 对于例题 1~3 的习题

  1. [CQOI2007] 涂色

    \(f_{i,j}\) 表示 \([i,j]\) 的最优答案,其它与例题 1~3 几乎没有区别。

  2. [NOIP2003 提高组] 加分二叉树

    同上。

3.2 对于例题 4~5 的习题

  1. [HNOI2010] 合唱队

    \(f_{i,j,0/1}\) 表示对于合唱队 \([i,j]\) 中最后一人从左边或者右边进来的方案数。

  2. [CF149D] Coloring Brackets

    \(f_{i,j,x,y}\) 表示对于区间 \([i,j]\) 且两端的颜色 \(x\)\(y\) 的情况下的答案,需要分类讨论一下两端的括号是否匹配。

  3. [ZOJ3469] Food Delivery

    与前面讲过的关路灯类似。

  4. [HDU2476] String painter

    可以考虑一个中间状态(类似于双向 BFS),比如空串。原 OJ 的题目链接似乎炸了,故给出 Vjudge 的链接,现在已不支持提交(原链接)。

对于有代表性的、值得刷刷的题目笔者已经将自己所知道的在本文放出,更多的题目多为重复的套路,故不放出。如果有需要,读者可前往洛谷AcWing 等 OJ 自行搜索题目。本文如有任何不当之处请联系笔者(Luogu uid:612663),谢谢!

posted @ 2023-09-28 20:52  Pretharp  阅读(149)  评论(0)    收藏  举报