线性DP
Posted linearode
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了线性DP相关的知识,希望对你有一定的参考价值。
很多问题往往会给出一个序列或者一个数表,让你对其进行划分,或者选出其中的某个最优子集。这一类问题往往适合使用线性DP。
线性DP是一种非常常见的DP。它往往以状态内的其中一个维度划分阶段。接下来,我将给出几个非常重要的转移方程。
最长上升(下降)子序列LIS
已知一个序列\(A_i\)。现在我希望从这个序列中从左往右选出若干个元素,使得这些元素组成的子序列元素大小单调递增。求这样序列的最大长度。
我们尝试设计状态表示这个最大高度。不难发现,只要\(A_i < A_j,i<j\),\(A_j\)就可以和\(A_i\)合并。这个过程和\(A_i\)以前的元素没有直接的关系。
于是我们尝试设\(F(i)\)为以\(A_i\)为结尾的,从\(A_1 \sim A_i\)中选出的LIS。不难发现这样一个转移关系:
\[
F(i) = \max_j < i, A_j < A_i\F(j)\ + 1
\]
也就是说,我们以前\(i\)个序列划分阶段,如果有\(A_j < A_i, j < i\),那么答案就可以从\(F(j)\)转移到\(F(i)\)。
初始值\(F(1) = 1\)。
上面这个做法的时间复杂度为\(O(N^2)\),但我们可以通过以下两种方式做到\(O(N \log N)\)。
树状数组维护最大值
树状数组不能维护区间最大值,但可以维护前缀和后缀最大值。一个状态\(F(i)\)的决策集合为\(\j\mid j < i, A_j < A_i\\)。我们可以在\(A_i\)的值域上建立树状数组,保存结尾在\([0,A_i]\)范围内的最长上升子序列长度。由于我们每次按输入顺序查询,并更新之,我们自动满足了\(i < j\)的条件。
贪心+二分
并不能算是标准做法,但是非常巧妙。它并没有直接设状态表示最长上升子序列的长度,而是设\(F(i)\)表示当最长上升子序列的长度为\(i\)时,这个子序列末尾的最小值。
有一个比较显然的贪心策略:当两个上升子序列长度一样时,我们应该保留结尾较小者,因为它更有可能接纳更长的上升组序列。
核心代码:
inline int bs(int num)
int l = 0, r = maxlen + 1;
while(l < r)
int mid = (l + r) >> 1;
if(F[mid] > num)
r = mid;
else
l = mid + 1;
return l;
int main()
N = qr(1);
RP(i, 1, N) A[i] = qr(1);
RP(i, 1, N)
int pos = bs(A[i]);
if(pos > maxlen)
maxlen = pos;
F[pos] = A[i];
else
F[pos] = A[i];
printf("%d", maxlen);
return 0;
可以看出,尽管DP的速度相较搜索而言相当快了,但也可以通过其他算法进行更深层的优化。
最长公共子序列LCS
给定两个序列\(A_i\),\(B_i\),现在我们要求找出一个序列\(C\),使得\(C\)既是\(A\)的子序列,又是\(B\)的子序列。请你最大化序列\(C\)长度。
从上一题来看,一个比较直观的想法是设\(F(i,j)\)表示以\(A_i\)结尾,以\(B_j\)结尾的最长公共子序列。当\(A_i \neq B_j\)时,直接令\(F(i,j)=0\)。当\(A_i = B_j\)时,有转移方程 \(F(i,j) = \max_k < i, l < j, A_k = A_i, B_l = B_j\F(k,l)\+1\)。
这个方程有两个比较明显的问题。首先,\(F(i,j)\)这个状态是不是过度冗余?很大一部分的\(F(i,j)=0\),这样会浪费大量的空间。其次,时间复杂度过高,达到了\(O(N^4)\)。如果用链表也许可以减少时间,但还是减少不了多少。
考虑这样一种做法:设\(F(i,j)\)表示前缀序列\(A_1\cdots i\)和\(B_1\cdots j\)的最长个公共子序列之长(此时这个子序列不一定包含\(A_i,B_j\)!)。此时的\(i,j\)可以看作是两个扫描数组的指针。
当\(i,j\)想向后扩展时,有以下情况:
- \(A_i+1=B_j+1\)。此时\(i,j\)均往后跳一位,并让序列的长度\(+1\)。
- \(A_i+1\neq B_j+1\)。此时指针是不能同时跳的。根据DFS的思想,我们会作如下尝试:
- 尝试让\(i\)往后跳一次,搜索\((i+1,j)\)这个状态
- 尝试让\(j\)往后跳一次,搜索\((i,j+1)\)这个状态
- 返回两次尝试的最大值
综上,当\(A_i+1=B_i+1\)时,\(F(i,j)\)可以直接转移到\(F(i+1,j+1)\),并增加一个贡献。反之,\(F(i,j)\)应该转移到\(F(i,j+1)\)或者\(F(i+1,j)\),并取其中的最大值。
把上面的每个方程反过来写,写成被转移状态关于转移状态的表达式,把\(i+1,i\)改成\(i,i-1\),就可以得到转移方程:
\[
F(i,j) = \begincases
F(i-1,j-1)+1 & A_i = B_j\\max\F(i-1,j),F(i,j-1)\ & A_i \neq B_j
\endcases
\]
另外,这道题的阶段划分依据叫做“已经处理的前缀长度”。这里并没有直接指明到底是哪一个前缀,因此任意选择一个序列即可。
最长公共上升子序列LCIS
LIS和LCS的结合。求序列\(A_i,B_i\)最长的,单调递增的LCS。
注意到第一题的状态保证了“取结尾”,而第二题却没有。为了降低时间复杂度,这里我们应该采用“半保留”的设计方法。
设\(F(i,j)\)表示扫描到前缀子序列\(A_1\cdots i\),以\(B_j\)结尾的LCIS。
当\(i\)指针向后移一位时,前缀子串会多出来一个新的数\(A_i+1\)。对于这个数,我们有两种方案:
- 不尝试匹配这个数。此时\(j\)不动,直接把答案转移给\(F(i+1,j)\)。
- 尝试匹配这个数。此时\(j\)往后跳,找到一个位置\(k\),使得\(B_k = A_i+1, B_j < B_k\)。尝试更新这个状态\(F(i+1,k)\)。注意到可能有多个数匹配到这个位置\(k\),因此不能直接赋值。
把这两种方案写成填表法的形式,把上面的\(j\)分别替换成\(j-1\)和\(k\),就得到:
\[
F(i,j) = \begincases
F(i-1,j) & A_i \neq B_j\\max_k < j, B_k < B_j\F(i-1,k)\ + 1 & A_i = B_j
\endcases
\]
这个转移的时间复杂度是\(O(N^3)\)的,其中这里只能以\(A_i\)的前缀子序列长度划分阶段。但它还有优化的空间。
注意到在任意时刻,如果我们需要\(O(N)\)枚举\(F(i,j)\)的决策集合,那么必然有\(A_i = B_j\)。因此,当\(A_i = B_j\)时,转移方程还可以写成这个样子:
\[
F(i,j) = \max_k < j, B_k < A_i\F(i-1,k)\+1
\]
在每个阶段,\(A_i\)的值都是固定的;在当前阶段,对决策集合的限制条件只有\(k < j\)。因此,当前\(F(i,j)\)计算完之后,\(j < j' = j+\Delta j\),那么\(F(i,j)\)就会立刻被加到\(F(i,j')\)的决策集合中!
这就是时间复杂度过大的根源。我们花费了额外\(O(N^2)\)的时间来重复扫描这个决策集合,而这个集合实际上是“开源”的,可以供当前阶段的所有状态使用。
因此,我们只需要用一个变量\(F_m\)来维护\(\max_B_j < A_i\F(i,j)\\)。每当计算完一个状态\(F(i,j)\),我们就可以拿它来更新\(F_m\)。这样时间复杂度就降为了\(O(N^2)\)。
以上是关于线性DP的主要内容,如果未能解决你的问题,请参考以下文章