dp类相关问题(1):区间dp
线性动态规划(dp)简介
线性动态规划(Linear Dynamic Programming)是动态规划中最基础且常见的形式,其核心思想是通过状态转移方程将问题分解为若干线性递推的子问题。通常适用于问题具有“线性结构”或“顺序依赖性”,例如最长上升子序列(LIS)、最大子数组和等问题。通过存储子问题的解(即状态表),避免重复计算,实现高效求解。(对动态规划存有疑问的同学可前往动态规划入门:从记忆化搜索到动态规划处查看)
因此,本章节笔者将从具体问题处分析着手,带读者一步一步理解dp问题的含义和解法。(算法原理与动态规划一脉相承,读者在结束笔者所推导的状态转移方程等内容之后,可以自行尝试写出算法原理以及算法)
区间dp
区间dp也是线性dp的一种,它用区间的左右端点来描述状态,通过小区间的解来推导出大区间的解。
因此,区间dp的核心思想是将大区间划分成小区间,它的状态转移方程通常依赖于区间的划分点。
常见的划分点的方式有两个:
- 基于区间的左右端点,分情况讨论;
- 基于区间上某一点,划分成左右区间讨论;
回文字串:
题目描述:回文词是一种对称的字符串。任意给定一个字符串,通过插入若干字符,都可以变成回文词。此题的任务是,求出将给定字符串变成回文词所需要插入的最少字符数。
比如 Ab3bd 插入 2 个字符后可以变成回文词 dAb3bAd 或 Adb3bdA,但是插入少于 2 个的字符无法变成回文词。
注意:此问题区分大小写。题目出处:回文字串。
在此题中,假设我们需要将 [i, j] 区间的子串变成回文字串,那么我们可以首先判断 s[i] 和 s[j] 这两个位置的元素是否相等,如果相等,那么我们可以两端都再向内收缩一个,即判断 s[i+1] 和 s[j-1] 是否相等,且 f[i][j] = f[i+1][j-1]
依次类推即可(这里 f[i][j] 表示将字符串 [i, j] 区间中的子串变成回文串的最小插入次数)。但如果 s[i] != s[j]
时,我们就要移动 i 或 j 的位置,得到 f[i+1][j] 和 f[i][j-1],因为此时得到的 f[i+1][j] 和 f[i][j-1] 的大小是其变成回文字串需要插入的最少次数,那么我们就可以很容易的推导出 f[i][j] = min(f[i+1][j], f[i][j-1]) + 1
就为我们将 [i, j] 区间的子串变成回文字串的最小值。
至此,状态转移方程就推导结束了,但是这里还有一个需要注意的点 —— 填表顺序。显然,当我们使用之前动态规划从上往下从左往右的填写方式时,我们会发现实现起来十分的繁琐,一会上一会下,所以,这里笔者向你们介绍一种小妙招 —— 用枚举长度的方式进行填表。即第一层for
循环枚举长度,第二层for
循环枚举左端点,每个右端点可以通过左端点的位置和字符串长度推导出来。这样,可以保证当我们枚举 [i, j] 区间子串时 [i+1][j] 、[i+1][j-1] 和 [i][j-1] 位置的值肯定已经得到了。
算法原理:
- 状态表示:f[i][j]表示:将字符串[i,j]区间中的子串变成回文串的最小插入次数
- 状态转移方程:s[i] = s[j]:两个字符相等时 ->
f[i][j] = f[i+1][j-1] s[i] != s[j]
两个字符不相等时 ->f[i][j] = min(f[i+1][j], f[i][j-1]) + 1
- 初始化:长度为1的位置
- 填表顺序:第一层for循环枚举区间长度,第二层for循环枚举区间左端点
代码实现
int main()
{string s; cin >> s;int n = s.size();s = ' ' + s; // 为了方便处理,前面加一个空格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])f[i][j] = f[i + 1][j - 1];else f[i][j] = min(f[i + 1][j], f[i][j - 1]) + 1;}cout << f[1][n] << endl;return 0;
}
石子合并:
在一个圆形操场的四周摆放 N 堆石子,现要将石子有次序地合并成一堆,规定每次只能选相邻的 2 堆合并成新的一堆,并将新的一堆的石子数,记为该次合并的得分。
试设计出一个算法,计算出将 N 堆石子合并成 1 堆的最小得分和最大得分。题目出处石子合并
看到这样一个问题,有过数据结构基础的读者第一反应肯定是哈夫曼编码,可以轻轻松松的将这题解决,但当我们再仔细看一眼题目,会发现这并不可行,因为题目规定了,每次只能选相邻的2对石子合成新的一堆,因此,我们需要换一种方法。这时,就可以考虑考虑区间dp了。
因为是圆形操场,因此,这里首先要考虑的是一个环形问题,对于环形问题的处理,我们有一个常用的技巧 —— 复写(也称为倍增)。这个技巧要求我们在原有数组的基础上,再开辟一个大小相同的数组紧跟在它的后面,这样,当我们取任意一个数时,只需要向后寻找 n(数组个数)个,即可得到一个以这个数为起点的完整的数组,可以减少我们不断取模等操作的时间。
接着,我们来分析一下状态转移方程,由于在前面已经提到过,区间dp的分析依赖一个分割点 k,我们将区间分为两个部分,分别为 [i, k] 和 [k+1, j]。这样,我们就可以得到状态转移方程为 f[i][j] = min(所有情况下的f[i][k] + f[k+1][j] + sum[i][j](sum为[i, j]区间的和))
。
这里我们使用的填表顺序依然为上文提到的用枚举长度的方式填表,不清楚为什么的朋友可以在仔细分析一下题目,具体原因与上一题基本一致。
最小得分解释清楚后,笔者就不再赘述最大得分的内容了。接下来算法原理中状态转移方程也是以最小为例。
算法原理:
- 状态表示:f[i][j]表示的是将区间[i,j]内的所有石子合并成一堆的最小代价
- 状态转移方程:
x = f[i][k] + f[k+1][j] + sum[i][j]
f[i][j] = min(f[i][j], x)
- 初始化:
f[i][i] = 0
- 填表顺序:第一层for循环枚举区间长度,第二层for循环枚举区间左端点
代码实现:
int s[N * 2];
int f[N][N];//最小得分
int g[N][N];//最大int main()
{cin >> n;for (int i = 1; i <= n; i++){cin >> s[i];s[i + n] = s[i];//倍增}int m = n + n;//前缀和:用于求取sum[i][j]for (int i = 1; i <= m; i++)s[i] += s[i - 1];//初始化memset(f, 0x3f, sizeof f);memset(g, -0x3f, sizeof g);for (int i = 1; i <= m; i++)f[i][i] = g[i][i] = 0;for (int len = 2; len <= n; len++){for (int i = 1; i + len - 1 <= n; i++){int j = i + len - 1;int t = s[j] - s[i - 1];//枚举分割点for (int k = i; k < j; k++){//[i,k],[k+1,j]f[i][j] = min(f[i][j], f[i][k] + f[k + 1][j] + t);g[i][j] = max(g[i][j], g[i][k] + g[k + 1][j] + t);}}}//更新结果int ret1 = 0x3f3f3f, ret2 = -0x3f3f3f;for (int i = 1; i <= n; i++){ret1 = min(ret1, f[i][i + n - 1]);ret2 = max(ret2, f[i][i + n - 1]);}cout << ret1 << endl << ret2;return 0;
}
谢谢阅读!如果觉得对您有益,请留下一个宝贵的赞。