【leetcode】最长递增子序列

124 阅读5分钟

什么是最长递增子序列

最长递增子序列指的是:在一个给定的数值序列中,找到一个子序列,使得这个子序列元素的数值依次递增,并且这个子序列的长度尽可能地大。最长递增子序列中的元素在原序列中不一定是连续的

题目描述

leetcode:leetcode.cn/problems/lo…

给你一个整数数组 nums ,找到其中最长严格递增子序列的长度。 子序列 是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。例如,[3,6,2,7] 是数组 [0,3,1,6,2,2,7] 的子序列

测试用例:

示例 1:

输入: nums = [10,9,2,5,3,7,101,18]
输出: 4
解释: 最长递增子序列是 [2,3,7,101],因此长度为 4 。

示例 2:

输入: nums = [0,1,0,3,2,3]
输出: 4

示例 3:

输入: nums = [7,7,7,7,7,7,7]
输出: 1

思路

动态规划(Dynamic Programming)

定义一个辅助数组dp,初始长度为输入数组长度,子项用来存储以当前索引数值为结尾的最长递增子序列长度

很显然,dp[0]=1

当nums[1]>nums[0]时,dp[1]=max(dp[0])+1

当nums[2]>nums[0]且nums[2]>nums[1]时,dp[2]=max(dp[0],dp[1])+1

以此类推,可以得到,dp[i]=max(dp[0]....dp[i-1])+1

最后长度为n的nums的LIS=max(dp[0]....dp[n-1]);

总结下,就是遍历nums,然后遍历当前索引前面的元素,若当前元素大于前面所有的元素,那么dp[i]为max(dp[j])+1

/**
 * 动态规划
 * @param {*} arr
 * @returns
 * 时间复杂度:O(n^2)
 * 空间复杂度:O(n)
 */

function getLongestSequenceLength(arr) {
  // 存储以当前元素结尾的最长递增子序列长度
  const resultDp = new Array(arr.length).fill(1);
  for (let i = 0; i < arr.length; i++) {
    for(let j = 0; j < i; j++) {
      // 必须满足当前元素大于前面的元素
      if (arr[i] > arr[j]) {
        resultDp[i] = Math.max(resultDp[i], resultDp[j] + 1);
      }
    }
  }
  return Math.max(...resultDp);
}

贪心+二分

可以注意到,动态规划的时间复杂度为O(n^2),采用贪心+二分,可将复杂度降为O(nlogn)

想要让子序列尽可能地长,上升趋势肯定要尽可能地慢,所以我们引入辅助数组tails,其中 tails[i] 表示长度为 i + 1 的递增子序列的最小末尾元素

遍历nums,根据以下规则更新tails:

  • 若num大于tails的最大值,则直接将nu追加到tails
  • 若num小于tails的最大值,则在tails中找到第一个大于num的元素,并用num替换
  • 若num等于tails的最大值,则不更新tails

基于以上思路,假设我们有一个输入数组 nums = [10, 9, 2, 5, 3, 7, 101, 18],我们将逐步解释如何找到最长递增子序列

初始化:tails=[]

开始遍历:

  1. 输入10,tails为空,添加10,tails=[10]
  2. 输入9,9小于tails的最大中10,找到第一个大于9的数并替换,tails=[9]
  3. 输入2,2小于tails的最大中9,tails=[2]
  4. 输入5,5大于tails的最大值2,追加,tails=[2,5]
  5. 输入3,3小于tails的最大值5,找到第一个大于3的并替换,tails=[2,3]
  6. 输入7,7大于tails的最大值3,追加,tails=[2,3,7]
  7. 输入101,101大于tails的最大值7,追加,tails=[2,3,7,101]
  8. 输入18,18大于tails的最大值101,找到第一个大于18的并替换,tails=[2,3,7,18] 结束遍历

最终tails的长度为4,所以LIS=4

来编写代码:

function getLongestSequenceLength2(arr) {
  const tails = [arr[0]];
  for(let i = 0; i < arr.length; i++) {
    const last = tails.length - 1;
    // 如果当前元素大于最后一个元素,则直接添加到最后
    if(arr[i] > tails[last]) {
      tails.push(arr[i]);
    } else if(arr[i] < tails[last]) {
      // 如果当前元素小于最后一个元素,则找到第一个大于当前元素的元素,替换
      const index = tails.findIndex(item => item > arr[i]);
      index >= 0 && (tails[index] = arr[i]);
    }
  }
  return tails.length;
}

可以观察到,在寻找第一个大于当前元素的索引时,可以采用二分法来优化下

/**
 * 贪心+二分:
 * 思想:定义一个辅助数组,遍历原数组,如果当前元素大于辅助数组的最后一个元素,则直接添加到最后,否则找到第一个大于当前元素的元素,替换
 * @param {*} arr
 * @returns
 * 时间复杂度:O(nlogn)
 * 空间复杂度:O(n)
 */
function getLongestSequenceLength2(arr) {
  const tails = [arr[0]];
  for(let i = 0; i < arr.length; i++) {
    const last = tails.length - 1;
    // 如果当前元素大于最后一个元素,则直接添加到最后
    if(arr[i] > tails[last]) {
      tails.push(arr[i]);
    } else if(arr[i] < tails[last]) {
      // 如果当前元素小于最后一个元素,则找到第一个大于当前元素的元素,替换
      const index = findIdxByBinary(tails, arr[i]);
      index >= 0 && (tails[index] = arr[i]);
    }
  }
  return tails.length;
}

const findIdxByBinary = (arr, target) => {
  let left = 0;
  let right = arr.length - 1;
  let index = arr.length;
  while(left <= right) {
    const middle = Math.floor((left + right) / 2);
    // 如果中间元素等于目标元素,则直接返回
    if(arr[middle] === target) {
      return -1;
    }
    // 如果中间元素大于目标元素,则向左查找
    if(arr[middle] > target) {
      right = middle - 1;
      index = middle;
    }
    // 如果中间元素小于目标元素,则向右查找
    if(arr[middle] < target) {
      left = middle + 1;
    }
  }
  return index;
}