zoukankan      html  css  js  c++  java
  • 线性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))的决策集合为({jmid 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 eq 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_{1cdots i})(B_{1cdots j})的最长个公共子序列之长(此时这个子序列不一定包含(A_i,B_j)!)。此时的(i,j)可以看作是两个扫描数组的指针。

    (i,j)想向后扩展时,有以下情况:

    • (A_{i+1}=B_{j+1})。此时(i,j)均往后跳一位,并让序列的长度(+1)
    • (A_{i+1} eq 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) = egin{cases} F(i-1,j-1)+1 & A_i = B_j\ max{F(i-1,j),F(i,j-1)} & A_i eq B_j end{cases} ]

    另外,这道题的阶段划分依据叫做“已经处理的前缀长度”。这里并没有直接指明到底是哪一个前缀,因此任意选择一个序列即可。

    最长公共上升子序列LCIS

    LIS和LCS的结合。求序列(A_i,B_i)最长的,单调递增的LCS。

    注意到第一题的状态保证了“取结尾”,而第二题却没有。为了降低时间复杂度,这里我们应该采用“半保留”的设计方法。
    (F(i,j))表示扫描到前缀子序列(A_{1cdots 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) = egin{cases} F(i-1,j) & A_i eq 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))

  • 相关阅读:
    Linux ansible 常用模块二
    Linux之ansible 常用模块
    flask websocket实现用例
    flask--上下文原理
    python Django
    python 并发编程 锁 / 信号量 / 事件 / 队列(进程间通信(IPC)) /生产者消费者模式
    并发编程 process 模块的方法及运用 僵尸与孤儿
    python 并发编程 操作系统 进程 并发.并行 及 同步/异步,阻塞/非阻塞
    python 网络编程粘包解决方案2 + ftp上传 + socketserver
    第一节 机器学习基础
  • 原文地址:https://www.cnblogs.com/LinearODE/p/11589127.html
Copyright © 2011-2022 走看看