Skip to content

Latest commit

 

History

History
157 lines (103 loc) · 8.16 KB

最长上升子序列.md

File metadata and controls

157 lines (103 loc) · 8.16 KB

最长上升子序列

0. 概念

最长上升子序列(Longest Increasing Subsequence, LIS)问题就是就是要求从一个给定的序列中找出一个最长的升序子序列,注意这里的子序列不要求连续,和子串(要求连续)的概念是不一样的。另外一个变体就是最长非降序子序列,和 LIS 的区别就是序列中是否可以有相等的数。

⚠️ 注意:对于固定的数组, LIS 序列不一定是唯一的,但是 LIS 的长度是唯一的。

1. 解法

给定序列 nums[] = {2, 7, 1, 5, 6, 4, 3, 8, 9},求 LIS 的长度,下面给出两种解法

解法1:动态规划 O(n^2)

  • 定义 dp[i] 表示以 nums[i] 结尾的最长上升子序列长度。初始化可以为 dp[i] = 1
int lengthOfLIS(vector<int>& nums) {
    if(nums.size() == 0) {
        return 0;
    }
    // dp[i] 表示以 nums[i] 结尾的最长递增子序列的长度
    vector<int> dp(nums.size(), 1);

    int max = 1;
    // 从以 nums[i] 结尾的序列中找最长的递增子序列
    for(int i = 1; i <  nums.size(); i ++) {
        // 只有当 nums[i] 大于前面数值时才可能统计 dp
        for(int j = 0; j <= i-1; j ++) {
            if(nums[i] > nums[j]) {
                if(dp[j] + 1 > dp[i]) {
                    dp[i] = dp[j] + 1;
                }
            }
        }
        max = dp[i] > max ? dp[i] : max;
    }
    return max;
}
  • 时间复杂度:两个 for 循环,时间复杂度为 $O(n^2)$

解法2:贪心+二分 O(nlogn)

这里有一个贪心的思想:对于一个上升子序列而言,显然其结尾的元素越小越好,这样就越有利于后面接上其他元素让子序列更长。

  1. tail[i] 表示长度为 i+1 的 所有 上升子序列尾部元素的最小值
    • tail[0] 表示长度为 1 的所有上升子序列中,结尾元素最小的那一个
    • 假设给定 [10, 9, 2, 5, 3, 7, 101, 18],长度为 2 的所有上升子序列中,结尾元素最小子序列为 [2, 3],因此 tail[1] = 3
    • tail 不是 LIS,只是用于求解 LIS 问题的状态数组,其下标和LIS长度有一个数值为 1 的偏差
  2. 根据贪心的思想,tail 序列一定是严格递增的,可以用反证法证明,参考 liweiwei 题解
  3. 遍历数组 nums 的过程中,维护 tail 数组的定义:和 tail 最后一个元素比较大小
    • nums[i] > tail.back(),直接将 nums[i] 放在 tail 后面(注意这里是 严格大于
    • nums[i] <= tail.back(),在 有序 的 tail 中找到第1个 大于等于 nums[i] 的元素,将其用 nums[i] 替换
      • 如果存在等于 nums[i] 的元素可以没必要替换,因为以 nums[i] 结尾的上升子序列已经存在了
      • 如果存在大于 nums[i] 的元素就要替换,因为这时候找到了一个 结尾更小相同长度 的上升子序列
  4. 最终有序数组 tail 的长度就是 LIS 的长度
int lengthOfLIS(vector<int> &nums) {
	int len = nums.size();
    if (len < 2) return len;

    // tail表示长度为i+1的所有上升子序列的尾部元素最小值
    int tail[len];
    tail[0] = nums[0];
    int end = 0;	// end 表示 tail 最后一个已经赋值的元素的下标
    
    for (int i = 1; i < len; ++ i) {
        if (nums[i] > tail[end]) {	// 情况1:严格大于
            ++ end;
            tail[end] = nums[i];
        } else {	// 情况2:小于就需要二分查找
            // 可以直接调库函数
            // int id = lower_bound(tail, tail+end+1, num[i]) - tail;
            // tail[id] = nums[i];
            
            // 在 [0...end] 中找到第一个大于等于 nums[i] 的元素
            int left = 0, right = end;
            while (left < right) {
                int mid = (right - left) / 2 + left;
                if (tail[mid] < nums[i]) {
                    left = mid + 1;
                } else {
                    right = mid;
                }
            }
            // 这里为了不加入额外的判断,等于的情况也直接替换了
            tail[right] = nums[i];
        }
    }
    return end + 1;
}
  • 时间复杂度:遍历数组 O(n),二分查找 O(logn),总的时间复杂度为 $O(n\log n)$

如果是找最长非递减子序列呢?

  • 二分查找就需要在 tail 中找到第一个 大于 nums[i] 的元素
  • 例如考虑 [7,7,7,7,7,7,7],每次就必须在 tail 中找到 大于 7 的元素,也就是直接加在 tail 后面,最终 tail 和 nums 一样
  • 核心思路就是 「让序列上升得尽可能慢」。不降子序列可以理解为允许 tail 数组中有相同元素,在允许有相同元素的情况下,为了让序列上升得尽可能慢,nums[i] 应当排在和他相同的元素后面,也就是 tail 中的大于 arr[i] 的第一个位置

🔥🔥🔥 小技巧

  • 最长严格递增子序列 需要二分找到 大于或等于 当前元素的元素位置(即 C++ 中的 lower_bound
  • 最长不降子序列 需要二分找到 大于 当前元素的元素位置(即 C++ 中的 upper_bound

2. 相关题目

题目 说明 题解
300. 最长递增子序列 常规的 LIS,按照上面代码直接秒杀 通过
2111. 使数组 K 递增的最少操作次数 分成 k 组,每组中找到最长非递减子序列(LnDS),最小修改次数=每组长度 - LnDS长度 0x3F
使数组 K 严格递增的最少操作次数 分成 k 组,还是和2111一样求 LnDS,但是需要将每个分组减去下标之后求 LnDS 0x3F
1143. 最长公共子序列 求 s1 s2 最长公共子序列,很常规的二DP 负雪明烛

2111 扩展:使数组 K 严格递增的最少操作次数

把每个数减去其下标,然后对所有正整数求最长非降子序列。

举个例子,现在每 k 个数选一个数,假设选出来的数组是 arr = [3,2,4,5,5,6,6]

解释:严格递增说明对应任意的 i > j,有 arr[i] - arr[j] >= i - j,变形得到 arr[i] - i > arr[j] - j,所以构造 b[i] = arr[i] - i,求 b 的最长非递减子序列就可以,每个数减去其下标后就是 [3,1,2,2,1,1,0]

对这个数组中的正整数求最长非降子序列,那就是 [1,1,1] 了,对应原始数组的 [*,2,*,*,5,6,*],这三个数字保留,其余数字修改完成后就是 [1,2,3,4,5,6,7],符合严格递增且均为正整数的要求。

注:上述减去下标的技巧,主要是为了能让保留的数字 之间 可以 容纳 严格递增的数字。否则,若直接按照最长严格递增子序列的求法,会得到例如 [*,2,4,5,*,6,*] 这样的错误结果。

3. 参考