算法:线性 DP 学习总结
线性 DP 是一种基础的 DP,但它常常以一种令人意想不到的方式出现在比赛中。本蒟蒻被它弄得措不及防,于是痛定思痛,写下了这篇文章。
动态规划简介
定义
动态规划是一种通过把原问题分解为相对简单的子问题的方式求解复杂问题的方法。
由于动态规划并不是某种具体的算法,而是一种解决特定问题的方法,因此它会出现在各式各样的数据结构中,与之相关的题目种类也更为繁杂。
基本性质
-
最优子结构。
-
无后效性(重中之重):已经求解的子问题,不会再受到后续决策的影响。
-
子问题重叠。
如果有大量的重叠子问题,我们可以用空间将这些子问题的解存储下来,避免重复求解相同的子问题,从而提升效率。这是大部分 DP 优化的常用方法。
一般解法
对于一个能用动态规划解决的问题,一般采用如下思路解决:
- 将原问题划分为若干阶段,每个阶段对应若干个子问题,提取这些子问题的特征(称之为状态);
- 寻找每一个状态的可能决策,或者说是各状态间的相互转移方式(用数学的语言描述就是状态转移方程)。
- 按顺序求解每一个阶段的问题。
(以上均来自 oi-wiki)。
线性 DP 常被归类于“基础动态规划”,但事实上它相当灵活,不比树形 DP 和状压 DP 这些简单多少。
接下来进入正文。
线性 DP
递推是动态规划的引入,我们从这里开始。
递推
首先,我们来看一个经典案例:求解斐波那契数列第 \(n\) 项。
斐波那契数列规律:\(F_n=F_{n-1}+F_{n-2}\)。
之前大家都学过递归的解法,即如下代码:
void F(int n){
if(n==1||n==2)return 1;
return F(n-1)+F(n-2);
}
这很好理解,但是时间复杂度是 \(O(2^n)\),太慢了!
于是我们考虑优化,于是有了线性递推解法。由于它的执行过程是线性的,所以这也是最初步的线性 DP。它的状态转移方程就是 \(F_n=F_{n-1}+F_{n-2}\)。
代码是简短的,循环一遍求每个 \(F_i\) 就好了,时间复杂度同样是线性复杂度 \(O(n)\)。
这个问题后来又有更优秀的递推方法(矩阵),可以优化至 \(O(\log{n})\),这是后话。
那么,数楼梯、过河卒等题目的实现就可以大大降低复杂度了。
但是,还有很多东西也是“线性 DP”。序列上的问题自然就出现了。
最长上升子序列(LIS)
给定一个长为 \(n\) 的序列 \(a\),求最长上升子序列长度。
子序列和子串不一样,它允许不连续,所以不能贪心。
我们会发现,一个位置的最长上升子序列长度可以由上一个数值比它小的位置的最长上升子序列长度“转移”而来,所以我们关心上一个比它小的位置的数值大小。
那就规定 \(f_i\) 为 \(i\) 为终点的最长上升子序列长度。可以写出转移方程 \(f_i=\max{f_j+1}\),对于每个 \(1\le j<i,a_j<a_i\)。
转移复杂度显然 \(O(n^2)\)。
那如果 \(n\) 的范围是 \(2 \times 10^5\),这个效率是不是就不够看了?
考虑优化,我们注意到 \(f_i\) 转移所需要的一定是数值最大的那个符合条件的 \(f_j\),所以可以令 \(f_i\) 为所有的长度为 \(i\) 的不下降子序列的末尾元素的最小值,变量 \(len\) 为子序列的长度进行解题。
我们考虑维护这个 \(f\) 序列。
- 当遇到比它末尾大的数时,直接插入。
- 否则,找到第一个大于等于它的元素,替换掉。由于序列单调递增,使用二分可以做到 \(O(\log{n})\) 的查找次数。
答案即为序列长度。但是切记 \(f\) 序列不是要求的最长上升子序列!它本质上只是在维护递推时每个长度不同的子问题的值!
总复杂度 \(O(n \log{n})\),可以通过。
这个优化好好理解下,下面要用。
最长公共子序列(LCS)
求序列 \(a\) 和 \(b\) 的最长公共子序列长度。
两个序列作比较,容易想到设二维状态 \(f_{i,j}\) 表示 \(a\) 序列以 \(i\) 为结尾,\(b\) 序列以 \(j\) 为结尾时的最长公共子序列长度。
我们会发现,这个状态只和 \(f_{i-1,j-1},f_{i-1,j},f_{i,j-1}\) 有关(可以在草稿纸上画示意图手玩一下)。那状态转移方程呼之欲出了!
别急,来看看答案要怎么转移上去。
-
\(a_i=b_j\),答案就是 \(f_{i-1,j-1}+1\),因为此时有贡献,其它两个状态不参与转移。
-
\(a_i\ne b_j\),答案应该是 \(f_{i-1,j},f_{i,j-1}\) 中的最大值,因为没有新贡献,\(f_{i-1,j-1}\) 没有办法参与转移。
代码直接依照上面来实现就好了。
这个问题的启示:转移决策可能需要分类讨论。这就很考验心思的细腻了。
但是,这居然也能优化!
当然,这道模板题有一个性质:两个序列长度相同且都是 \(1,2,…,n\) 的排列。基于此进行优化。
我们考虑记录 \(a\) 中每个数的下标,用 \(pos_{a_i}\) 表示。此时,\(pos\) 序列一定是单调递增的。
我们对应到 \(b\) 中,发现 \(\{pos_{b_i}\}\) 并不严格递增。那么,\(\{pos_{b_i}\}\) 的递增部分一定会在 \(\{pos_{a_i}\}\) 中出现。
于是,我们求 \(\{pos_{b_i}\}\) 的最长上升子序列就是正解。用之前的优化方法可以做到 \(O(n\log{n})\)。
相当神奇,建议自己去试一试。
正确性证明(可能不是很严谨):\(\{pos_{b_i}\}\) 的最长上升子序列可能有多种,但是 \(a\) 与 \(b\) 的公共部分一定是下标吻合率最高的,自然求 \(\{pos_{b_i}\}\) 的最长上升子序列长度就是正解。
最长上升公共子序列(LCIS)
这其实是一道习题。
求长度为 \(n\) 的 \(a\) 序列和长度为 \(m\) 的 \(b\) 序列的 LCIS。
综合以上所述,可以想到设 \(f_{i,j}\) 为以 \(a\) 序列的第 \(i\) 项为结尾,以 \(b\) 序列的第 \(j\) 项为结尾的 LCIS。转移方程也很好列,如下所示:
- \(a_i=b_j\),\(f_{i,j}=\max (f_{i-1,j},f_{i,k}+1)\),对于每个 \(1\le k<j,b_k<b_j\)。
- \(a_i\ne b_j\),\(f_{i,j}=f_{i-1,j}\)。
这里转移时忽视 \(a\) 序列,不影响正确性,因为 \(a\) 与 \(b\) 必须协同一致,没有必要做两遍转移。
容易发现复杂度是 \(O(n^3)\)。
考虑优化,不难发现因为 \(f_{i,j}\ge f_{i,k}+1\),所以求得的答案是满足 \(a_i\ne b_k\) 的 \(f_{i−1,k}+1\) 的前缀最大值。
求解前缀最大值使用递推即可,减掉一重循环,复杂度降为 \(O(n^2)\),可以通过本题。
0/1 背包问题
题意简述:有 \(n\) 个物品,所占空间为 \(v_i\),价值为 \(w_i\),用总空间为 \(V\) 的背包能装的物品价值总和最大为多少。
容易设状态 \(f_{i,j}\) 表示选到 \(i\) 物品,占空间 \(j\) 时的最大价值和。
那么,当 \(j-v_i\ge0\) 时,\(f_{i,j}=\max(f_{i-1,j},f_{i-1,j-v_i}+w_i)\)。否则 \(f_{i,j}=f_{i-1,j}\)。递推解决即可。
可以过,但我们有更优的方式。
我们去掉第一维状态,那转移方程就变为当 \(j-v_i\ge0\) 时,\(f_{j}=\max(f_{j},f_{j-v_i}+w_i)\)。否则 \(f_{j}=0\)。
但是这时有一个问题需要注意,那就是此时内层循环如果还是正着来,就会出现一个物品多次放入的情况(手玩试试)。所以,内层循环要倒着来。
这样空间复杂度就变为 \(O(V)\)。
完全背包问题
完全背包正解就是优化后的 \(\text{0/1}\) 背包解法。
后记
希望能给大家带来帮助。
这里有一道补充题可以试试。

浙公网安备 33010602011771号