动态规划2
文章目录
声明
本篇是动态规划系列的第二章,主要内容是基于经典例题解析区间动态规划的通用解法及其实现方式
本系列基于代码源wls的动态规划系列课程,主要内容包括dp概述、背包、区间dp、树形dp、换根树dp、数位dp、状态压缩dp、概率dp等内容,持续更新,欢迎关注。
所有例题均可在Home - Daimayuan Online Judge的动态规划课程题单中提交。
区间动态规划
例1:石子合并
**
题意解释
例如三堆石子,石子数分别为:1,2,3,则会有两种合并方法
- 先合并前两堆,再合并剩下的两堆。总代价是3 + 6 = 9
- 先合并后两堆,在合并剩下的两堆。总代价是5 + 6 = 11
所以总代价为9
dp分析
考虑最后一步,由于只能相邻的两堆石子合并,所以最后合并的两堆石子其中一堆是第1堆石子到第x堆石子合并的结果,另一堆石子是第x + 1堆石子到第n堆石子合并的结果。
当x确定时,总代价 = 合并第一堆到第x堆的代价 + 合并第x + 1堆到第n堆的代价 + 总石子数(即合并最后两堆的代价)
由于不知道x在哪里总代价最小,所以可以枚举x的位置
但是为了保证在解决一个问题前其所需要的子问题已被解决,所以要把所有区间[l, r]的最小代价都算出来
为了计算区间[i, j]的最小代价,需要枚举分界线k,k大于等于i小于j,因为如果k等于j那么k左边的区间是[i, j],右边的区间从j + 1开始,所以k不能等于j。而如果k等于i,相当于将第i堆石子和[i + 1, j]堆石子合并,是合理的,所以k可以等于i。
所以可以用f[i][j]表示合并第i堆到第j堆石子所需要的最小代价。
f[i][j]就等于对于每一个k所求得的代价的最小值。
那如何保证算区间[i, j]的最小代价时分界线k的左右两个区间的最小代价均已计算过?
只需要将区间按照j - i排序,从小到大进行计算。
这也是区间动态规划的核心!
因为所有能够转移到[i, j]的区间长度一定是比它小的,所以可以保证其所需要的所有子区间的代价最小值均已被计算过
几乎所有区间动态规划都需要枚举区间长度!
代码实现
数据处理
注意到对于每一个区间都需要求第i堆石子到第j堆石子的和即a[i]到a[j]所有元素的和。当涉及到多次使用数组某区间上元素的和时就可以采用前缀和的套路。
前缀和就是指用一个数组s,s[i]记录a[0]到a[i]所有元素的和,这样如果要求a[i]到a[j]的和只需要用s[i] - s[j - 1]即可。
那么前缀和数组该如何求呢?
只需要O(n)的复杂度。方法就是遍历a,s[i] += s[i - 1] + a[i],因为s[i - 1]里存放的就是a[0]到a[i]所有元素的和
那么先定义全局变量,全局变量的好处不再赘述。
int n, a[501], s[501], f[501][501];
输入数据及用前缀和维护数组
cin >> n;
for (int i = 1; i <= n; ++i) {
cin >> a[i];
}
for (int i = 1; i <= n; ++i) {
s[i] += s[i - 1] + a[i];
}
由于f的属性是min,所以将其初始化为无穷大,这样比较的时候才不会出错。
memset(f, 127, sizeof(f));
这也是一个常用的套路。如果是求最大值一般不需要memset,因为答案一般都是正整数,而全局数组f的初值为0。
只需要记住:
- 第二个参数127代表初始化为最大值,具体值由数据类型决定。如果是int的话大概是比2的30次方大,long的话比2的60次方大。
- 如果是128则代表无穷小
- 全局变量初始值为0
然后就是f的最小子问题的值
一般做dp时都会给代表最小子问题的状态赋初值,因为dp的核心是在求解问题时其所需要的子问题均已被解决,所以我们会先给代表最小子问题的状态赋值,然后根据状态转移计算出其它的值。
这道题最小子问题自然是f[i][i]即区间长度为0了,合并第i堆和第i堆石子的代价自然为0,因为无法合并。当然f[i][i]具体是什么含义并不重要,重要的是它是最小子问题,其它状态可以由其转移得到。只要它能够正确的根据转移方程求出具有实际意义的f的值即可。
for (int i = 1; i <= n; ++i) {
f[i][i] = 0;
}
dp运算
根据之前的分析,遍历区间长度和左端点,对于区间内每一个分界点k,求
f[j][k] + f[k + 1][j + i] + s[j + i] - s[j - 1]
的最小值。
for (int i = 1; i < n; ++i) {//区间长度
for (int j = 1; j <= n - i; ++j) {//左端点
for (int k = j; k < j + i; ++k) {
f[j][j + i] = min(f[j][j + i], f[j][k] + f[k + 1][j + i] + s[j + i] - s[j - 1]);
}
}
}
现在这些代码就显得有理有据。
总体代码
#include <bits/stdc++.h>
using namespace std;
int n, a[501], s[501], f[501][501];
int main() {
cin >> n;
for (int i = 1; i <= n; ++i) {
cin >> a[i];
}
for (int i = 1; i <= n; ++i) {
s[i] += s[i - 1] + a[i];
}
memset(f, 127, sizeof(f));
for (int i = 1; i <= n; ++i) {
f[i][i] = 0;
}
for (int i = 1; i < n; ++i) {//区间长度
for (int j = 1; j <= n - i; ++j) {//左端点
for (int k = j; k < j + i; ++k) {
f[j][j + i] = min(f[j][j + i], f[j][k] + f[k + 1][j + i] + s[j + i] - s[j - 1]);
}
}
}
cout << f[1][n];
return 0;
}
基本套路
根据这道题可以总结出区间dp的一般规律
- f[i][j]表示区间i到j上的最优解
- 从小到大求出所有区间的最优解,具体通过从小到大遍历区间长度和左端点实现
这是区间dp的最基本的特征或者套路。
比较特殊的还有一种操作方法:
用k将区间i到j分割为两个部分,通过这两个部分求最优解。这其实也是划分子问题的常用方法。
例2:括号序列
dp分析
按照套路,用f[i][j]表示s[i]到s[j]中最长的合法子序列的长度
那第一种合法的情况是(A),[A]
所以如果s[i]和s[j]匹配,f[i][j]就是f[i + 1][j - 1] + 2和f[i][j]的最大值
第二组合法情况是AB
老方法,枚举分界线k,i到j的最长合法子序列就等于i到k和k + 1到j种最大的那个。即max(f[i][j], f[i][k] + f[k - 1][j])
代码实现
数据处理
int n, f[501][501];
char str[501];
输入
cin >> n;
cin >> (str + 1);
dp运算
按照上述逻辑,遍历区间长度和左端点,如果匹配就计算,然后遍历区间分界点计算
for (int i = 1; i < n; ++i) {
for (int j = 1; j <= n - i; ++j) {
if (str[j] == '(' && str[j + i] == ')' || str[j] == '[' && str[j + i] == ']') {//如果匹配
f[j][j + i] = f[j + 1][j + i - 1] + 2;
}
for (int k = j; k < j + i; ++k) {
f[j][j + i] = max(f[j][k] + f[k + 1][j + i], f[j][j + i]);
}
}
}d
最后输出f[1][n]即可
例3:石子合并2
题目释义
跟石子合并1的区别是这里围成了一圈。
例如有三堆石子顺时针1,3,2
合并方法:
- 先合并前两堆,代价为4,再合并剩下的,代价为6,总代价为10
- 先合并后两堆,代价为5,再合并剩下的,代价为6,总代价为11
- 先合并第一堆和第三堆,代价为3,在河边剩下的,代价为6,总代价为9
所以最小代价为9
dp分析
n堆石子排成一个圆,假设相邻石子之间存在一条边,则圆上一共n条边,每次合并都少一条边。
那么最后一定有一条边没用,因为最后剩下的两堆石子存在两条边相连。
那么可以枚举哪条边没用,剩下的就变成了链上的问题!
dp优化
但是这样做时间复杂度太高
倍增
考虑一条长为2n的链,是由两个a数组连起来的,那么如果i > n的话,a[i] = a[i - n]。即>n位置的i对应了原来的第i - n堆石子
直接在链上做区间dp会发生什么呢?
f[1][n]就相当于从a[1]到a[n]做链式的石子合并,也就是a[1]和a[n]这两堆石子之间的边没用上。
f[2][n + 1]就相当于从a[2]到a[n]再到a[1]做链式的石子合并,也就是a[1]和a[2]之间的边没有用上
以此类推,f[i][n + i - 1]就代表了第i堆石子和第i - 1堆石子之间的边没用上
那么f[i][j]的含义就发生了变化:
f[i][j]表示合并第i堆石子到第j堆石子且不使用第i堆石子和第i - 1堆石子之间的边的代价的最小值
很多题目都可以通过这种倍增的方法将环型问题转化为第i条边不用的链式问题。
代码实现
数据处理
还是使用前缀和
int n, f[501][501], a[1001], s[501];
输入数据,倍增
cin >> n;
for (int i = 1; i <= n; ++i) {
cin >> a[i];
a[n + i] = a[i];//倍增
}
n *= 2;
前缀和
for (int i = 1; i <= n; ++i) {
s[i] = s[i - 1] + a[i];
}
初始化
memset(f, 127, sizeof(f));
for (int i = 1; i <= n; ++i) {
f[i][i] = 0;
}
上述这些操作和石子合并1基本相同
dp运算
运算部分就是链式石子合并,一模一样
for (int i = 1; i < n; ++i) {
for (int j = 1; j <= n - i; ++j) {
for (int k = j; k < j + i; ++k) {
f[j][j + i] = min(f[j][j + i], f[j][k] + f[k + 1][j + i] + s[j + i] - s[j - 1]);
}
}
}
输出
根据f[i][j]的含义,我们之前说的“枚举哪条边没用,选n种情况种最小的”就变成了从f[i]到f[i + n/2 - 1]里找到最小值(此时的n已经乘2)。因为f[i][n + i - 1]代表的就是第i堆石子和第i - 1堆石子之间的边没用上。
int ans = 1 << 30;
for (int i = 1; i <= n / 2; ++i) {
ans = min(ans, f[i][i + n / 2 - 1]);
}
cout << ans;
最后就得到了正确的结果