动态规划(Dynamic programming)是一种神奇的喵。
动态规划的基本性质以及几个重要概念
忘记过去的人,必定要重蹈覆辙。 ——乔治·桑塔亚纳
几个性质
几个重要概念
接下来我们会用一道题目来看一下这些定义是什么。
简单例题
IOI1994 数字三角形 简单来说,给定一个由数组成的三角形(即除第一层只有一个数外,每一层的数个数都比上一层多1),需要你求出从第一层到最后一层的所经过的最大的和。 例如这个三角形,从 $7→3→8→7→5$ 的路径产生了最大权值。
这道题很简单,所以只在此处明确一下以上的概念。 下文中的 dp
数组未经优化,是二维数组,并且思路是从上往下进行计算。
状态:dp[i][j]
表示从上往下走到第 i
行第 j
列所能得到的最大权值。
状态转移方程:dp[i][j] = max(dp[i-1][j], dp[i-1][j-1]) + a[i][j]
初始状态定义:dp[1][1] = a[1][1]
(但其实定不定义无所谓的啦)
喵你已经掌握了这些基本概念了,接下来我们会各种形态的DP进行深入剖析喵
各种动态规划的形态 线性DP 这一种DP构造最简单,代表性问题是LIS(最长上升子序列)和LCS(最长公共子序列)。(因为连续序列太简单,所以不在此处讨论)
最长公共子序列(LCS) 我们定义 f[i][j]
为第一个数组 i
之前与第二个数组 j
之前的数能构成的最长公共子序列的长度。于是,稍加推导,可以得出状态转移方程:
$f_{i,j}=\begin{cases}f_{i-1,j-1}+1&\mathrm{if}\;s[i]=t[j]\\max(f_{i-1,j},f_{i,j-1})&\mathrm{otherwise.}\end{cases}$
(这应该看得懂吧。。。)
接下来考虑初始状态,很明显,根本不需要定义初始状态。
接下来就是考验码力的时候了(好像也不怎么考验?
P.S. 如何提升码力?多做模拟题,尤其是大模拟,成为码力小王子!(我都没做
* So, end?
* Yeah, it’s easy. Isn’t it?
最长上升子序列(LIS) 请注意 ,LIS题目中还有很多类型的,例如求方案数或是判断一个数是否一定属于它所在的数组的LIS中。
我们定义 f[i]
表示前 i
个元素的最长上升子序列的长度。更新时,使用前面的所有合法状态来更新当前状态,容易写出转移方程:
$ f_{i}=\mathrm{max}(f_{j}\;|\;j<i\;\mathrm{and}\;A_j<A_i) $
这种方法的时间复杂度是 $O(n^2)$ ,并且我们还有第二种方法,它的时间复杂度是 $O(n\log n)$ 的。
首先我们定义一个数组 $d$ ,表示最长上升子序列,$len$ 为这个最长上升子序列的长度,所以 $d_{len}$ 就是这个最长上升子序列的最后一个元素。
我们初始化状态:$d_1=a_1, len=1$
接下来我们枚举 $i$ 从 $2$ 到 $n$ 每次更新 $d$ 数组。考虑接下来添加一个元素 $a_i$:
完成!
背包问题 事实上在这个子问题下还有很多类问题,但是这类问题很简单,也肯定有人比我写得好,就不在此处说明了。
记忆化搜索 很难说它属于DP,但是它的思想却是实打实的DP。我们可以把这个词分为两块来理解:记忆化+搜索。还记得 Part 1 引用的那句话吗,记忆化搜索就是为了防止搜索时出现重复状态增加运行时间而出现的。具体而言,它在搜索过程中记录了每一步得到的答案,而下次这个同样的情况被搜索到时,将不会继续搜索,而是会把之前记录的答案直接返回。
这类问题有很多,包括后文中提到的 数位DP 的例题也是使用了记忆化搜索的。
解题步骤
先看这道题能不能用记忆化搜索(这要保证搜索时产生的状态并不多)。
如果可以,那么应该如何定义状态,需要记录的到底有什么(例如搜索时产生的步数信息就 一般 不用记录)。
我下次复用这个信息的时候要注意什么。
区间DP 这类题目算是 玩的挺花 的题,各种变形出其不意攻其不备,并且状态的定义会比其他类型的题目灵活许多,因此对于这些题,最好的方法不是记模型,而是理解这种思路。因此,这个板块我们需要结合例题来看。
最经典的区间DP例题——石子合并(弱化版)
一条路上有许多堆石子,每堆石子都有一个质量参数,现在要将它们合并成一堆,每次合并的代价是这两堆石子的质量之和,需要求出合并成一堆的总代价的最小值。
题意很简单,现在我们来考虑状态的定义:$f_{i,j}$ 表示合并第 $i$ 到第 $j$ 堆石子所花费的代价的最小值。
接下来我们考虑转移:从 $i$ 到 $j$ 的石子可以把它分成两半来考虑,每次转移只需要增加两边的代价,也就是 $f_{i,j}=\mathrm{min}(f_{i,k}+f_{k+1,j}\;|\;i \leq k\leq j-1)$ (注意这个 $-1$ 哈)
那么,问题来了,$f$ 想要转移,前提是那些转移来的值是已经被处理过的,否则转了跟转了一样(sssss),所以,我们需要一种枚举方法,来保证转移的合法性。
这里你可以参考 floyd 的转移方法(事实上有人说 floyd 也是DP),我们只需要先枚举区间长度,再枚举起点,最后用前两个信息推出区间终点,我们考虑一下为什么这么做可以满足上面的性质,因为我们每次转移来的两个区间的长度一定是比当前区间的长度短的,而我们的区间长度又是从小到大枚举,自然而然转移来的状态一定是已经处理过的了。
另外,我们每次加的代价值,不能 再拿一个循环来枚举并累加代价,这里我们需要预处理一个前缀和以便我们在 $O(1)$ 的时间内的到代价值。
最后是初始状态,$f$ 数组首先肯定要赋值成极大值(这里注意,不建议将其赋值成 INT_MAX
或 LLONG_MAX
,这样 $f$ 数组一加就直接爆 int
或 long long
,虽然这道题可以这么赋值),然后对于对角线上的元素 $f_{i,i}$ 要赋值成 $0$ 。
思考过程结束,代码如下。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 #include <iostream> #include <climits> using namespace std;int n, a[301 ];int dp[301 ][301 ];int c[301 ];int main () { cin >> n; for (int i = 0 ; i <= n; i++) { for (int j = 0 ; j <= n; j++) { dp[i][j] = INT_MAX; } } for (int i = 1 ; i <= n; i++) { dp[i][i] = 0 ; } for (int i = 1 ; i <= n; i++) { cin >> a[i]; c[i] = c[i - 1 ] + a[i]; } for (int len = 2 ; len <= n; len++) { for (int i = 1 ; i <= n; i++) { int j = i + len - 1 ; if (j <= n) { for (int k = i; k < j; k++) { dp[i][j] = min (dp[i][j], dp[i][k] + dp[k + 1 ][j] + c[j] - c[i - 1 ]); } } } } cout << dp[1 ][n]; }
这道题事实上可以使用四边形不等式优化。但是我也忘了qwq。