【习题】区间型动态规划
区间型动态规划,即区间 DP,主要用于解决涉及区间的问题。换句话说,这类 DP 问题总是从小的区间转移到大的区间,以区间为子问题。
怎么做?
例题
观察题目,我们可以发现,不管前面的石子是怎么合并的,最终都是仅剩的两堆石子合并在一起。对于一段需要合并成一堆的石子区间
直接定义状态
(这样或许好懂一些)
在这里,我们就将小区间的值转移给了大区间,完成了转移。至于合并的代价,将
完整的状态转移方程:
还有一点,就是 DP 中的初始状态。由于一堆石子(
答案自然为
区间 DP 就是这样。状态的设计一般至少有两维,即
常见的区间 DP 状态:
代码如下:
#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;
}
例题
又是合并数字,只不过这次要求的东西不一样,那么状态也就自然要进行一些改变了。
但是观察到,真正合并成一个数的一定是一个完整的区间,比如样例的
这样,保证了区间合并后只会剩一个数字,转移也就很轻松了:
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。
例题
明明不是合并,却是区间 DP。 —— Weekoder
感觉很像区间 DP,但又不知道怎么写。这时候,我们就要运用逆向思维:把释放囚犯看做抓囚犯,每抓一个囚犯,他旁边的囚犯就都要发肉。初始时,囚犯一共有
第一部分:初始化。我们定义
第二部分:DP。还是和石子合并一样的枚举
下面给出代码:
#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;
}
这道题也是一个很明显的区间 DP:每次涂色的区域都是区间,而且没有固定的涂色顺序。
依然设计状态
考虑转移。这时候,就出现不一样的东西了:分类讨论。首先考虑对于一个区间
可能这个时候就会有爱思考的同学说了:既然
接着,就是另一种情况:
这还有一个技巧,就是字符串的下标是从 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 通常是合并类问题或者拆分类问题,或者处理两端类问题;
- 状态转移要么是枚举中间断点,要么是枚举
个端点。
接下来,让我们继续来看一道处理两端的问题。
例题
由于每次只能拿两端的零食,很符合处理两段类问题,考虑区间 DP。
定义
初始状态是什么?
既然是两端类问题,那么状态肯定也只能从
代码如下:
#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;
}
例题
首先看到题目,这是一道区间 DP 吗?是的,因为我可以通过操作来从小的回文串变为大的回文串。如果是这样,那我们必须要意识到一点:删除一个字符等同于在相对位置添加一个字符。既然是这样,那么我们只需要在两种操作的代价中取较小值即可,即为
第一步,初始状态是什么?一个字符本身就是回文串,我们显然有
第二步,怎么转移?还是分类讨论:如果
如果
代码如下:
#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;
}
与上一道题几乎一样,只是代价变为了
还有一点要注意的是,我们一般会用 n = s.size()
,方便写区间 DP。但是我们还有一个操作 s = '#' + s
,让字符串的下标从 ,导致我复制的上一题的代码都调了 10 分钟。
首先,这道题为什么是一个区间 DP 呢?很明显,我们折叠的顺序是不固定的,而且可以把折叠看做一次合并。
定义状态
如何转移?我们可以先按常规的思路来:枚举断点,折叠后的字符串是可以拼在一起的,
然后,我们再考虑折叠。枚举
折叠后的字符串分为
- 两个括号;
- 折叠的数字;
- 折叠的字符串;
两个括号的长度明显为
答案即为
下面给出完整代码,请参考代码自行理解:
#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;
}
字符串由短扩展到长,可以视为按长度划分子问题,考虑区间 DP;
定义
代码:
#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;
}
例题
可以发现,关灯的路灯总是一个区间,而且正在不断扩大,考虑区间 DP。而且只设计
此题难度较大!!
代码:
#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;
}
可以先预处理,将颜色相同的区间合并成一个,就可以进行较为简单的区间 DP 了。
根据前面的经验,相信读者不难推出状态转移方程:
轻松 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;
}
结语
这应该算是一本习题册了吧,里面包含了
完.
本文作者:Weekoder
本文链接:https://www.cnblogs.com/Weekoder/p/18240223
版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 2.5 中国大陆许可协议进行许可。
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】凌霞软件回馈社区,博客园 & 1Panel & Halo 联合会员上线
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】博客园社区专享云产品让利特惠,阿里云新客6.5折上折
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步