线性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) = \begin{cases}F(i-1,j-1)+1 & A_i = B_j\\\max\{F(i-1,j),F(i,j-1)\} & A_i \neq B_j\end{cases}\]
另外,这道题的阶段划分依据叫做“已经处理的前缀长度”。这里并没有直接指明到底是哪一个前缀,因此任意选择一个序列即可。

最长公共上升子序列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) = \begin{cases}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\end{cases}\]
这个转移的时间复杂度是\(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)\)

计算机