【习题】区间型动态规划

区间型动态规划,即区间 DP,主要用于解决涉及区间的问题。换句话说,这类 DP 问题总是从小的区间转移到大的区间,以区间为子问题。

怎么做?

例题 1: P1775 石子合并

观察题目,我们可以发现,不管前面的石子是怎么合并的,最终都是仅剩的两堆石子合并在一起。对于一段需要合并成一堆的石子区间 [i,j],总是可以找到一个分界点 k(ik<j),让 [i,k][k+1,j] 分别先合并成两堆石子,再将这两堆石子合并,使得区间 [i,k] 合并的代价 + 区间 [k+1,j] 合并的代价 + [i,k] 合并的一堆石子和 [k+1,j] 合并的一堆石子合并的代价 =[i,j] 合并的代价(有点绕),而我们要做的,就是枚举并找到最优的 k

直接定义状态 dpi,j 为将区间 [i,j] 合并的最小代价,则有:

dpi,j=mink=ij1dpi,k+dpk+1,j+

(这样或许好懂一些)

在这里,我们就将小区间的值转移给了大区间,完成了转移。至于合并的代价,将 [i,j] 内的石子全部合并必然意味着所有石子的数量都会相加,就可以用前缀和 sumisumj1 来计算。

完整的状态转移方程:

dpi,j=mink=ij1dpi,k+dpk+1,j+sumisumj1

还有一点,就是 DP 中的初始状态。由于一堆石子(dpi,i)已经是一堆了,所以不需要进行任何操作,代价也自然为 0。由于要取最小值,其余则设为极大值。

答案自然为 dp1,n(整个区间)。

区间 DP 就是这样。状态的设计一般至少有两维,即 dpi,j,代表区间的起点和终点。那么,我们该怎么枚举区间并从小到大转移呢?很简单,我们先枚举区间长度 len,从 2n,这样就保证了区间是从小到大的(区间长度为 1 的是初始情况)。接着,我们再从 1 开始枚举起点 i,但是需要保证区间合法,即 i+len1n。有了区间长度和起点,自然也可以求出终点 j=i+len1。接着,再枚举 k 转移就行啦!

常见的区间 DP 状态:dpi,j 表示区间 [i,j] 的 XXX 的最大值、最小值、方案数。

代码如下:

#include <bits/stdc++.h>
using namespace std;

const int N = 305;

int n, a[N], dp[N][N], s[N];

int main() {
	cin >> n;
	memset(dp, 0x3f, sizeof dp); // 求最小值设极大值
	for (int i = 1; i <= n; i++) {
		cin >> a[i];
		dp[i][i] = 0; // 初始状态
		s[i] = s[i - 1] + a[i]; // 前缀和优化
	}	
	for (int len = 2; len <= n; len++) { // 区间长度
		for (int i = 1; i + len - 1 <= n; i++) { // 起点
			int j = i + len - 1;
			for (int k = i; k <= j - 1; k++) // 枚举 k
				dp[i][j] = min(dp[i][j], dp[i][k] + dp[k + 1][j] + s[j] - s[i - 1]); // 转移
		}
	}
	cout << dp[1][n]; // 答案
	return 0;
}

例题 2: P3146 248 G

又是合并数字,只不过这次要求的东西不一样,那么状态也就自然要进行一些改变了。

1. 状态:dpi,j 表示区间 [i,j] 的数字合并后的最大值。

2. 转移:像石子合并一样,考虑枚举中间点 k。这时候有一个问题:通过样例,我们发现对于某个区间 [i,j],是有可能不会全部合成只剩一个数的。比方说答案的整个区间 [1,n],我们在样例中发现,我们只是将后 3 个数,也就是区间 [2,n] 合并成了 3,而第一个数根本没动过。也就是说,我们如果要将 dpi,kdpk+1,j 合并,还得保证这两个区间合并后的最大值相邻才能合并,但这显然很麻烦,不可行。

但是观察到,真正合并成一个数的一定是一个完整的区间,比如样例的 [2,n],而不一定是不能合成只有一个数的区间 [1,n]。不如给状态加上约束:dpi,j 表示区间 [i,j] 的数字合并成一个数后的最大值。这样,答案便不是 dp1,n 了,而是 max(dpi,j),因为区间 [1,n] 不一定能合并成一个数。

这样,保证了区间合并后只会剩一个数字,转移也就很轻松了:

dpi,j=max(dpi,j,dpi,k+1),dpi,k=dpk+1,j

3. 初始状态:区间长度为 1dpi,i 是最小的子问题,而一个数显然无法进行任何操作,故 dpi,i=ai

248 G 区间 DP 代码:

#include <bits/stdc++.h>
using namespace std;

const int N = 250;

int n, a[N], dp[N][N], ans = -1e9;

int main() {
	cin >> n;
	memset(dp, -0x3f, sizeof dp);
	for (int i = 1; i <= n; i++)
		cin >> a[i], dp[i][i] = a[i], ans = max(ans, a[i]);
	for (int len = 2; len <= n; len++) {
		for (int i = 1; i + len - 1 <= n; i++) {
			int j = i + len - 1;
			for (int k = i; k < j; k++)
				if (dp[i][k] == dp[k + 1][j])
					dp[i][j] = max(dp[i][j], dp[i][k] + 1);
			ans = max(ans, dp[i][j]);
		}
	}	
	cout << ans;
	return 0;
}

通过这两道例题,我们可以发现:区间 DP 的转移是没有固定顺序的,而且总是在做合并。

那这时就有人问了:P1090 合并果子也是合并,为什么却是贪心呢?答案是,因为合并果子是任取两堆果子合并,而合并石子,248 G 都是合并相邻的数,有限制,并不是只用贪心就能获得最优解的,所以我们要用区间 DP。

例题 3: P1622 释放囚犯

明明不是合并,却是区间 DP。 —— Weekoder

感觉很像区间 DP,但又不知道怎么写。这时候,我们就要运用逆向思维:把释放囚犯看做抓囚犯,每抓一个囚犯,他旁边的囚犯就都要发肉。初始时,囚犯一共有 Q+1 段。在发肉时,不会给抓进来的囚犯发肉,但在后面会给他发肉。抛开这一点不谈,这不就变成了一个石子合并了吗?

第一部分:初始化。我们定义 numi 为第 i 段囚犯的人数,即石子的数量。我们有 numi=aiai11。这是怎么推出来的呢?一个区间内的人数 aiai1+1 再减去两端要被抓进来的囚犯 aiai1+12=aiai11。由于区间有 Q+1 段,我们可以虚拟一个点 aQ+1=p+1。像石子合并一样,我们还要求出 num 的前缀和 sum。别忘了初始化 dpi,i=0

第二部分:DP。还是和石子合并一样的枚举 leni,不过这次的范围是 Q。注意,此时因为虚拟了最后一个点,Q 已经变为了 Q+1。在石子合并的状态转移方程的基础上,我们还要考虑被抓进去的囚犯后面还是要计算的点,那这样的囚犯有多少个呢?从 i 合并到 j,模拟一下就会发现有 ji 个已经被抓进去的囚犯要发肉,减掉这次抓的囚犯不会发的肉,答案即为 ji1。状态转移方程:

dpi,j=min(dpi,j,dpi,k+dpk+1,j+sumjsumi1+ji1)

下面给出代码:

#include <bits/stdc++.h>
using namespace std;

const int N = 305;

int p, q, a[N], dp[N][N], num[N], sum[N];

int main() {
	cin >> p >> q;
	memset(dp, 0x3f, sizeof dp);
	for (int i = 1; i <= q; i++) 
		cin >> a[i];
	a[++q] = p + 1;
	for (int i = 1; i <= q; i++) 
		num[i] = a[i] - a[i - 1] - 1, sum[i] = sum[i - 1] + num[i], dp[i][i] = 0;
	for (int len = 2; len <= q; len++) {
		for (int i = 1; i + len - 1 <= q; i++) {
			int j = i + len - 1;
			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] += sum[j] - sum[i - 1] + j - i - 1;
		}
	}
	cout << dp[1][q];
	return 0;
}

例题 3: P4170 [CQOI2007] 涂色

这道题也是一个很明显的区间 DP:每次涂色的区域都是区间,而且没有固定的涂色顺序。

依然设计状态 dpi,j 表示将区间 [i,j] 涂色至目标颜色的最短涂色次数。显然,初始状态为 dpi,i=1,也就是一格直接涂色即可。

考虑转移。这时候,就出现不一样的东西了:分类讨论。首先考虑对于一个区间 [i,j],如果 si=sj,该如何转移?既然 si=sj,那就代表我可以在涂 sj 的时候顺便把 si 也涂了,那么花费就相当于是 dpi+1,j。反过来想,我也可以在涂 si 的时候顺便把 sj 涂了,那么花费就是 dpi,j1。两者取较小值即可。

可能这个时候就会有爱思考的同学说了:既然 si=sj,那么为什么不能在涂区间 [i,j] 的时候先全部涂 si(sj) 的颜色,然后再涂 [i+1,j1],也就是 dpi+1,j1+1 呢?我尝试后发现,这样只能拿 50 分,因为这并不是最优的。可以这样考虑:在 [i+1,j1] 中有一个 sk,它等于 si 或者 sj。此时 si=sj,在 [i+1,j1] 内又有一个 sk=si=sj,那为什么不能直接将 sisj 在涂 sk 的时候一起顺便涂了呢?所以,这个时候就不需要将 dpi+1,j1+1,答案可直接取 dpi+1,j1。我又尝试了枚举这个 sk,但还是只得了 60 分。综合以上所述,min(dpi+1,j,dpi,j1) 考虑的更全面,状态更优。

接着,就是另一种情况:sisj。根据刚刚的思路,我们可以发现,sisj 是整个区间的“底色”。于是,我们只需要枚举断点 k(ik<j)[i,k] 内的底色为 si[k+1,j] 内的底色为 sj。状态转移方程为 dpi,j=min(dpi,k+dpk+1,j)

这还有一个技巧,就是字符串的下标是从 0 开始的。如何让下标从 1 开始呢?其实,我们只需要在字符串前面补一个字符就行了,即 s = '#' + s

完整代码如下:

#include <bits/stdc++.h>
using namespace std;

const int N = 55;

string s;
int n, dp[N][N];

int main() {
	cin >> s;
	n = s.size();
	s = '#' + s;
	memset(dp, 0x3f, sizeof dp);
	for (int i = 1; i <= n; i++) dp[i][i] = 1;
	for (int len = 2; len <= n; len++) {
		for (int i = 1; i + len - 1 <= n; i++) {
			int j = i + len - 1;
			if (s[i] == s[j]) dp[i][j] = min(dp[i][j - 1], dp[i + 1][j]);
			else {
				for (int k = i; k <= j - 1; k++)
					dp[i][j] = min(dp[i][j], dp[i][k] + dp[k + 1][j]);
			}
		}
	}
	cout << dp[1][n];
	return 0;
}

区间 DP 的特征

好了,做了这些题目,我们是时候该总结一下区间 DP 的一些套路和特征了。从上面这些例题中,可以发现:

  • 从左往右,从右往左递推会得到不同的结果;
  • 区间 DP 通常是合并类问题或者拆分类问题,或者处理两端类问题;
  • 状态转移要么是枚举中间断点,要么是枚举 2 个端点。

接下来,让我们继续来看一道处理两端的问题。

例题 4: P2858 [USACO06FEB] Treats for the Cows G/S

由于每次只能拿两端的零食,很符合处理两段类问题,考虑区间 DP。

定义 dpi,j 为将区间 [i,j]所有零食都拿走的前提下区间 [i,j] 能产生的最大价值。那么,答案显然为 dp1,n

初始状态是什么?dpi,i 表示其他零食都拿完了,只剩第 i 个零食了,那么当时肯定是最后一天,第 n 天。显然,产生的价值为 ai×n

既然是两端类问题,那么状态肯定也只能从 [i+1,j][i,j1] 转移而来。其实很简单:要么拿零食 i,要么拿零食 j。前者的价值为 ai×+dpi+1,j,后者的价值为 aj×+dpi,j1。重点是,如何求出当前天数?一共有 n 天,剩下的还有当前区间的长度 len=ji+1 天,算一下容易得到当前天数为 nlen+1。记 days=nlen+1,状态转移方程为:

dpi,j=max(dpi+1,j+ai×days,dpi,j1+aj×days)

代码如下:

#include <bits/stdc++.h>
using namespace std;

const int N = 2005;

int n, a[N], dp[N][N];

int main() {
	cin >> n;
	for (int i = 1; i <= n; i++) cin >> a[i], dp[i][i] = a[i] * n;
	for (int len = 2; len <= n; len++) {
		for (int i = 1; i + len - 1 <= n; i++) {
			int j = i + len - 1, days = n - len + 1;
			dp[i][j] = max(dp[i + 1][j] + a[i] * days, dp[i][j - 1] + a[j] * days);
		} 
	}
	cout << dp[1][n];
	return 0;
}

例题 5: P2890 [USACO07OPEN] Cheapest Palindrome G

首先看到题目,这是一道区间 DP 吗?是的,因为我可以通过操作来从小的回文串变为大的回文串。如果是这样,那我们必须要意识到一点:删除一个字符等同于在相对位置添加一个字符。既然是这样,那么我们只需要在两种操作的代价中取较小值即可,即为 vali

第一步,初始状态是什么?一个字符本身就是回文串,我们显然有 dpi,i=0

第二步,怎么转移?还是分类讨论:如果 si=sj,那么 i,j 这一对位置本身就是回文的了,与涂色不同,我们只需要让 [i+1,j1] 回文即可,dpi,j=min(dpi,j,dpi+1,j1)。但还要考虑越界的情况,即当 len=2 时,区间 [i+1,j1] 是不合法的,dpi,j 直接等于 0

如果 sisj,又该如何转移呢?肯定不是枚举断点,因为这和回文串没有关系,两段回文串拼在一起不一定是一个回文串。考虑两端转移。如果我已经让 dpi,j1 回文了,那 j 怎么办?要么删掉它,要么在对面补一个,我们肯定选择代价较小的 valsj。那么就是 dpi,j1+valsj。反过来,另一个端点就是 dpi+1,j+valsi,两者取较小值即可。完整的状态转移方程:

dpi,j=min(dpi,j,dpi+1,j1),(si=sj,len>2)dpi,j=0,(si=sj,len=2)dpi,j=min(dpi+1,j+valsi,dpi,j1+valsj),(sisj)

代码如下:

#include <bits/stdc++.h>
using namespace std;

const int N = 2e3 + 5;

int n, m, dp[N][N], val[130];

string s;

int main() {
	memset(dp, 0x3f, sizeof dp);
	cin >> n >> m >> s;
	s = '#' + s;
	for (int i = 1; i <= n; i++) {
		char ch;
		int x, y;
		cin >> ch >> x >> y;
		val[ch] = min(x, y);
	}
	for (int i = 1; i <= m; i++) dp[i][i] = 0;
	for (int len = 2; len <= m; len++) {
		for (int i = 1; i + len - 1 <= m; i++) {
			int j = i + len - 1;
			if (s[i] == s[j]) {
				if (len == 2) dp[i][j] = 0;
				else dp[i][j] = min(dp[i][j], dp[i + 1][j - 1]);
			}
			else
				dp[i][j] = min(dp[i + 1][j] + val[s[i]], dp[i][j - 1] + val[s[j]]);
		}
	}
	cout << dp[1][m];
	return 0;
}

例题 6: P1435 [IOI2000] 回文字串

与上一道题几乎一样,只是代价变为了 1,少了一些理解上的困难(就因为这个,从蓝题变成了黄题)。这里就不提供代码了,供读者练习。

还有一点要注意的是,我们一般会用 n 来代表字符串的长度,即 n = s.size(),方便写区间 DP。但是我们还有一个操作 s = '#' + s,让字符串的下标从 1 开始。n 的赋值一定要写在修改 s 的前面,不然 n 就会多 1,导致我复制的上一题的代码都调了 10 分钟

例题 7: P4302 [SCOI2003] 字符串折叠

首先,这道题为什么是一个区间 DP 呢?很明显,我们折叠的顺序是不固定的,而且可以把折叠看做一次合并。

定义状态 dpi,j 表示将区间 [i,j] 折叠后的最短长度。一个字符不需要折叠,我们有初始状态 dpi,i=1

如何转移?我们可以先按常规的思路来:枚举断点,折叠后的字符串是可以拼在一起的,dpi,j=min(dpi,j,dpi,k+dpk+1,j)

然后,我们再考虑折叠。枚举 k 表示以 [i,k] 为周期进行折叠。首先要判断,如果区间长度 len 不是折叠长度 ki+1 的倍数,则不能折叠,直接跳过。然后,我们还需要检查整个区间 [i,j] 的字符串是否以 [i,k] 的字符串为周期,才能折叠。这里写一个判断函数即可。若可以折叠,则状态转移。

折叠后的字符串分为 3 部分:

  1. 两个括号;
  2. 折叠的数字;
  3. 折叠的字符串;

两个括号的长度明显为 2。折叠的数字为 len÷(ki+1),可以写一个函数返回数字的长度。折叠的字符串长度不能直接取 ki+1,考虑到折叠嵌套的情况,应取 dpi,k。总结一下,状态转移方程为:

dpi,j=min(dpi,j,dpi,k+2+getlen(len÷(ki+1)))

答案即为 dp1,n

下面给出完整代码,请参考代码自行理解:

#include <bits/stdc++.h>

using namespace std;

const int N = 105;

int n, dp[N][N];

string s;

bool check(int l, int r, int len) {
	string tmp = s.substr(l, len);
	for (int i = l; i + len - 1 <= r; i += len)
		if (tmp != s.substr(i, len))
			return 0;
	return 1;
}

int getlen(int x) {
	string tmp = to_string(x);
	return tmp.size();
}

int main() {
	memset(dp, 0x3f, sizeof dp);
	cin >> s;
	n = s.size();
	s = '#' + s;
	for (int i = 1; i <= n; i++) dp[i][i] = 1;
	for (int len = 2; len <= n; len++) {
		for (int i = 1; i + len - 1 <= n; i++) {
			int j = i + len - 1;
			for (int k = i; k < j; k++)
				dp[i][j] = min(dp[i][j], dp[i][k] + dp[k + 1][j]);
			for (int k = i; k <= j; k++) {
				int l = k - i + 1;
				if (len % l) continue;
				if (check(i, j, l))
					dp[i][j] = min(dp[i][j], dp[i][k] + 2 + getlen(len / l));
			}
		}
	}	
	cout << dp[1][n];
	return 0;
}

例题 7: P4290 [HAOI2008] 玩具取名

字符串由短扩展到长,可以视为按长度划分子问题,考虑区间 DP;

定义 dpi,j,0/1/2/3 表示区间 [i,j] 是否能通过 W,I,N,G 变换而来。还可以定义 yesc,c1,c2 表示字符 c 是否可以用 c1,c2 组合而来。那么状态转移方程就可以枚举 c,c1,c2,如果 [i,k] 能变为 c1[k+1,j] 能变为 c2,而且 c1c2 能变为 c,那 dpi,j,c=True。最后输出即可,还是有点难度的。

代码:

#include <bits/stdc++.h>

using namespace std;

const int N = 205;

int a[4], num[130], n;

bool yes[4][4][4], dp[N][N][4];

string s, tmp = "WING";

int main() {
	cin >> a[0] >> a[1] >> a[2] >> a[3];
	num['W'] = 0, num['I'] = 1, num['N'] = 2, num['G'] = 3;
	for (int c = 0; c < 4; c++) {
		for (int i = 1; i <= a[c]; i++) {
			char c1, c2;
			cin >> c1 >> c2;
			yes[c][num[c1]][num[c2]] = 1;
		}
	}
	cin >> s;
	n = s.size();
	s = '#' + s; 
	for (int i = 1; i <= n; i++) dp[i][i][num[s[i]]] = 1;
	for (int len = 2; len <= n; len++) {
		for (int i = 1; i + len - 1 <= n; i++) {
			int j = i + len - 1;
			for (int k = i; k < j; k++)
				for (int c = 0; c < 4; c++)
					for (int c1 = 0; c1 < 4; c1++)
						for (int c2 = 0; c2 < 4; c2++)
							dp[i][j][c] |= dp[i][k][c1] && dp[k + 1][j][c2] && yes[c][c1][c2];
		}
	}
	bool flag = 1;
	for (int c = 0; c < 4; c++) 
		if (dp[1][n][c])
			cout << tmp[c], flag = 0;
	if (flag) cout << "The name is wrong!";  
	return 0;
}

例题 8: P1220 关路灯

可以发现,关灯的路灯总是一个区间,而且正在不断扩大,考虑区间 DP。而且只设计 dpi,j 不太够,因为老张一定在关灯的路灯的两个端点,还得知道是从哪里来的:设 dpi,j,0/1 表示将区间 [i,j] 的灯关闭并且老张最后站在 ij 的最小电量。有状态转移方程:

dpi,j,0=min(dpi+1,j,1+(wjwi)×(sumnsumj+sumi),dpi+1,j,0+(wi+1wi)×(sumnsumj+sumi))

dpi,j,1 以此类推。

此题难度较大!!

代码:

#include <bits/stdc++.h>

using namespace std;

const int N = 55;

int n, c, dp[N][N][2], w[N], a[N], sum[N];

int main() {
	cin >> n >> c;
	for (int i = 1; i <= n; i++)
		cin >> w[i] >> a[i], sum[i] = sum[i - 1] + a[i];
	memset(dp, 0x3f, sizeof dp);
	for (int i = 1; i <= n; i++)
		dp[i][i][0] = dp[i][i][1] = abs(w[c] - w[i]) * (sum[n] - a[c]);
	for (int len = 2; len <= n; len++) {
		for (int i = 1; i + len - 1 <= n; i++) {
			int j = i + len - 1;
			dp[i][j][0] = min(dp[i + 1][j][1] + (w[j] - w[i]) * (sum[n] - sum[j] + sum[i]), dp[i + 1][j][0] + (w[i + 1] - w[i]) * (sum[n] - sum[j] + sum[i]));
			dp[i][j][1] = min(dp[i][j - 1][1] + (w[j] - w[j - 1]) * (sum[n] - sum[j - 1] + sum[i - 1]), dp[i][j - 1][0] + (w[j] - w[i]) * (sum[n] - sum[j - 1] + sum[i - 1]));
		}
	}
	cout << min(dp[1][n][0], dp[1][n][1]);
	return 0;
}

例题 9: CF1114D Flood Fill

可以先预处理,将颜色相同的区间合并成一个,就可以进行较为简单的区间 DP 了。

根据前面的经验,相信读者不难推出状态转移方程:

dpi,j=0(i=j or (ai=aj and ji+1=2))dpi,j=min(dpi,j,dpi+1,j1+1)(ai=aj)dpi,j=min(dpi+1,j+1,dpi,j1+1)(aiaj)

轻松 A 掉此题。()

代码:

#include <bits/stdc++.h>

using namespace std;

const int N = 5005;

int len, n, a[N], dp[N][N];

int main() {
    memset(dp, 0x3f, sizeof dp);
    cin >> len;
    for (int i = 1; i <= len; i++) 
        cin >> a[i];
    int n = unique(a + 1, a + 1 + len) - a - 1;
    for (int i = 1; i <= n; i++) dp[i][i] = 0;
    for (int len = 2; len <= n; len++) {
        for (int i = 1; i + len - 1 <= n; i++) {
            int j = i + len - 1;
            if (a[i] == a[j]) {
                if (len == 2) dp[i][j] = 0;
                else dp[i][j] = min(dp[i][j], dp[i + 1][j - 1] + 1);
            }
            else
                dp[i][j] = min(dp[i + 1][j] + 1, dp[i][j - 1] + 1);
        }
    }
    cout << dp[1][n];
    return 0;
}

结语

这应该算是一本习题册了吧,里面包含了 9 道区间 DP 的题目,有基础的模板题,也有困难的挑战思维题。可能不会讲解的特别详细,毕竟是习题册,还请谅解。

完.

本文作者:Weekoder

本文链接:https://www.cnblogs.com/Weekoder/p/18240223

版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 2.5 中国大陆许可协议进行许可。

posted @   Weekoder  阅读(13)  评论(0编辑  收藏  举报
点击右上角即可分享
微信分享提示
评论
收藏
关注
推荐
深色
回顶
收起