[线性dp] aw895最长上升子序列(知识理解+重要模板题+最长上升子序列模型+LCS转化LIS)

8 篇文章 0 订阅
5 篇文章 0 订阅

0. 前言

LIS(Longest Increasing Subsequence) 最长上升子序列 。

一个数的序列 bi,当 b1 < b2 < … < bS 的时候,我们称这个序列是上升的。对于给定的一个序列 (a1, a2, …, aN),我们可以得到一些上升的子序列 (ai1, ai2, …, aiK),这里 1 <= i1 < i2 < … < iK <= N

比如,对于序列 (1, 7, 3, 5, 9, 4, 8),有它的一些上升子序列,如(1, 7), (3, 4, 8) 等等。这些子序列中最长的长度是 4,比如子序列(1, 3, 5, 8)

要求: 对于给定的序列,求出最长上升子序列的长度。


密切相关:[线性dp] aw895最长上升子序列(知识理解+重要模板题+最长上升子序列模型+LCS转化LIS) 详看下方的知识理解,有用!


1. LIS 模板题

895. 最长上升子序列

在这里插入图片描述

重点: 线性 dpLIS 问题及优化

思路:

  • 状态定义:
    • f[i] 所有以 a[i] 结尾的上升子序列长度的最大值
  • 状态转移:
    • 分类依据:倒数第二个数是哪个数,可将状态分类为:
      • a[i] > a[j] 时,有 f[i] = max(f[i], f[j] + 1)j = 0, 1, 2,...,i-1
  • 边界处理及初始化
    • 每次将 f[i] 首先初始化为 1,因为前段若小于 i 的数,则 i 自己当做子序列开头
  • 时间复杂度:
    • O ( n 2 ) O(n^2) O(n2),状态 n n n × \times × 转移数量 n n n

dp O ( n 2 ) O(n^2) O(n2) 代码:

#include <iostream>
#include <algorithm>

using namespace std;

const int N = 1005;

int n;
int a[N], f[N];

int main() {
    cin >> n;
    
    for (int i = 0; i < n; ++i) cin >> a[i];
    
    int ans = 1;
    for (int i = 0; i < n; ++i) {
        f[i] = 1;
        for (int j = 0; j < i; ++j) {
            if (a[i] > a[j]) f[i] = max(f[i], f[j] + 1);
        }
        ans = max(ans, f[i]);
    }
    cout <<ans << endl;;
    return 0;
}

顺便可以输出转移路径,即输出最长上升子序列的数值。

这也是很经典的记录 dp 中间转移过程并输出的方法,很有利于理解中间转移的方式和方法。再进行逆向回推,写代码就要写的通透。

逆序输出最长上升子序列的数值代码:

#include <iostream>
#include <algorithm>

using namespace std;

const int N = 1005;

int n;
int a[N], f[N], g[N];

int main() {
    cin >> n;
    
    for (int i = 0; i < n; ++i) cin >> a[i];
    
    int ans = 1;
    for (int i = 0; i < n; ++i) {
        f[i] = 1;
        g[i] = 0;       // 表示它只有一个数
        for (int j = 0; j < i; ++j) {
            if (a[i] > a[j]) 
                if (f[i] < f[j] + 1) {
                    f[i] = max(f[i], f[j] + 1);
                    g[i] = j;       //记录下是从哪里转移过来的
                }
        }
    }
    
    int k = 1;                      
    for (int i = 1; i <= n; ++i)    // 找到f[i]数组最大值,即最长上升子序列长度
        if (f[k] < f[i])
            k = i;                  // k保存f[i]最大值的下标
    
    cout << f[k] << endl;           // f[k]即为最长上升子序列的最大长度
    
    for (int i = 0, len = f[k]; i < len; ++i) {   // 逆序输出方案
        cout << a[k] << ' ';
        k = g[k];
    }
    return 0;
}

2. LIS 贪心 O ( n l o g n ) O(nlogn) O(nlogn) 求解

熟悉 LIS 问题的,其实还应该知道本题还有 dp + 二分的优化。即当数据量大的时候, O ( n 2 ) O(n^2) O(n2) 的时间复杂度是不太行的…可进行优化到 O ( n l o g n ) O(nlogn) O(nlogn)

思路:

  • 状态定义:
    • f[i] 表示长度为 i 的最长上升子序列,末尾最小的数字,即长度为 i 的子序列末尾最小元素。
    • 这样相当于处理得到了长度为 1、长度为 2、长度为 3…的最长上升子序列,且 f[i] 均保存它们的最小值,那么当枚举到下一个数 t 的时候,如果 t>f[2]t<f[3],那么 t 一定可以可以与长度为 2 的子序列构成一个新的长度为 3 的上升子序列。同时,它也必定可以与长度为 1 的 f[1] 构成一个新的长度为 2 的上升子序列。显然,与长度为 2 的子序列构成一个长度为 3 的子序列是满足最长子序列定义的。故 f[i] 数组是具有单调性的(如果不具备单调性,反证法简单可证伪)。那么现在我们针对一个新的数字,去寻找它所能构成的最长子序列的时候,就可以利用 f 数组的单调性进行二分查找到对应的 i 位置,但是一定需要记得更新 f[i+1] 保存的最小值,将其更新为当前值。因为这个数字与 i 位置的几个数字将构成一个 i+1 长度的最长上升子序列,且二分查找时,f[i+1] 肯定大于当前数的,所以需要更新。(比较白话,顺着思路写了自己的理解
  • 状态转移:
    • a[i] > f[cnt -1] 时,这里的下标从 0 开始,cnt 指的是 cnt 长度的最长上升子序列。f[cnt-1] 就是下标从 0 开始,cnt 长度的最长上升子序列末尾最小的数字。如果当前数比它大,那么就 cnt + 1,即最长上升子序列长度 +1,更新末尾最小元素为 w[i]
    • 如果 a[i]<=f[cnt-1] 时,说明不会更新当前长度,但之前末尾的最小元素需要变化,找到第一个大于等于 a[i],用于更新末尾最小元素
  • 边界处理及初始化
    • 每次将 f[i] 首先初始化为 1,因为前段若小于 i 的数,则 i 自己当做子序列开头
  • 时间复杂度:
    • O ( n l o g n ) O(nlogn) O(nlogn)

这里的贪心就是将所有长度为 k 的上升子序列拿出来,看看 x 能不能更新最短的 f[k] 若不能更新,则 x 无法接到任意一个长度为 k 的子序列后面。如果能更新,则让 x 更新所有长度为 k 的子序列中最短的哪一个,这就是贪心的来更新。


3. LCS 转 LIS 条件及分析

保留上述思路,虽然看着很绕… 以下思路为 2021 年 5 月 15 日更新。

LCS 求最长公共子序列中有一个序列元素是不重复的时候, LCS 问题就可以转化为 LIS 问题,进而使用 O ( n l o g n ) O(nlogn) O(nlogn) 的做法进行求解。

具体转化就是将 b 数组的各个元素搞一个对应的 c 数组,a[c[i]] = b[i],即 c 数组存对应位置上 b 数组在 a 数组出现的下标。在此要求 a 数组是元素各不重复的数组,这样才能保证映射的单一性。

这样,在 c 数组存的所有下标中的 LIS,就是对应的 a b 数组的 LCS可从任意一个上升子序列都是公共子序列,任意一个公共子序列都是上升子序列这两方面进行证明。 也比较容易证明,注意下标自然而然是递增的这个条件!

这样,做了映射之后,就是贪心求 LIS 问题的范畴了。

上部分状态定义是没有问题的,但是状态转移中讲的不够清楚,为了做对比,就不删除了。

状态转移:

  • 考虑一个新数 x 是怎么更新 f[i] 数组的。
  • f[i] 表示长度为 iLIS 中结尾的最小值,我们可以找到 f[k],其是严格小于 x 中最大的一个,且由于 f 数组的严格单调递增性,f[k+1] 一定大于等于 x
  • 那么从 f[k+1] 开始至以后,x 都不能作为它们的结尾,因为它们自身的结尾最小值都已经大于 x 了,若将 x 作为它们的结尾则破坏了单调性。
  • x 可以做 k 之前的任意长度的结尾,但是实际上如果 x 做了 f[1~k-1] 之间的结尾,还不如让 f[k] 去做 f[1~k-1] 之间的结尾,这样还能保证 f[1~k-1] 在长度不变的情况下结尾最小。
  • 所以 x 就只能做 f[k] 的结尾,实际上就是将 x 接到长度为 kLIS 后面,实际上就是将 f[k+1]=x 作为状态更新。
  • 在此要明确,原有的 f[k+1] 一定是大于等于 x 的,这也是 b 数组可以重复的原因,它并不影响正确答案。

边界及技巧:

  • 因为我们每次都要二分一个比 x 小的最大的一个元素,若 x 本身是最小元素的话,就需要特殊处理一个边界,比较麻烦,故我们可以特殊插入一个比所有元素都小的元素。
  • 但其实在此根本不需要设立设个边界值,因为 f[0] 根本没有被用到,甚至这个 f[0] 设置为任意值均可 ac。找到了比 x 最大的一个元素则为正常情况,若所有的 f[i] 都小于 x 的话,二分最后停留位置就是 l=r=0,改变的是 f[r+1]=f[1]=x,跟 f[0] 没有关系。甚至由 f[0] 改变了整个的严格单调递增性也无所谓,因为二分停留位置的下一个一定是第一个大于等于 x 的位置,或者说是第一个空闲位置。 也可以将 f[0] 改变单调性的情况分类讨论。
  • 但是给二分问题加边界确是很常见的一种方式,尤其在使用 set<> 的时候,很有用。

练手题:

终极 O ( n l o g n ) O(nlogn) O(nlogn) 代码:

#include <iostream>
#include <algorithm>

using namespace std;

const int N = 1e5+5;

int n;
int a[N];
int q[N];

int main() {
    cin >> n;
    
    for (int i = 0; i < n; ++i) cin >> a[i];
    
    int cnt = 0;
    q[0] = -2e9;	// 可由可无
    for (int i = 0; i < n; ++i) {
        int l = 0, r = cnt;
        while (l < r) {
            int mid = l + r + 1>> 1;
            if (q[mid] < a[i]) l = mid;
            else r = mid - 1;
        }
        cnt = max(cnt, r + 1);
        q[r + 1] = a[i];
    }
    cout << cnt << endl;
    return 0;
}
  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Ypuyu

如果帮助到你,可以请作者喝水~

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值