300.最长递增子序列 - 从暴力到进阶 O(n logn) 解法

2,100 阅读5分钟

题目

给你一个整数数组 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

 

提示:

  • 1 <= nums.length <= 2500
  • -104 <= nums[i] <= 104

 

进阶:

  • 你可以设计时间复杂度为 O(n2) 的解决方案吗?
  • 你能将算法的时间复杂度降低到 O(n log(n)) 吗?

递归

分治思路:

从前往后遍历数组,分别计算以每个数字开头的子序列,记录子序列的长度 len,以及子序列最后一个数 last

对于后续的数字 num 会有两种选择,取其中能组成的最长子序列长度:

  1. 不包含数字 num
  2. 包含数字 num,仅在数字 num 大于 last 的情况下

比如对于[1,5,2,3],计算以 1 开头的子序列,当计算到 5 时,可以包含 5,组成 [1,5],后面的数都不大于 5,则这个序列长度为 2;也可以选择不包含 5,则能组成子序列 [1,2,3],长度为 3,最终我们取 len 为 3.

由此可以写出以下递归程序:

function lengthOfLIS(nums: number[]): number {
  const dfs = (i: number, last: number): number => {
    if (i === nums.length) return 1

    let len = dfs(i + 1, last)
    if (nums[i] > last) len = Math.max(len, dfs(i + 1, nums[i]) + 1)
    return len
  }

  let res = 0
  for (let i = 0; i < nums.length; i++) {
    res = Math.max(res, dfs(i, nums[i]))
  }

  return res
}

其中最外层的循环,可以通过将 last 初始设置为一个极小值来集成到递归中,根据提示最小值为 -10**4 所以可以选择 -1-10**4,不过我习惯使用 -Infinity.这的话因为初始 last 占据了一个位置,所以当退出条件返回 0 而不是 1

function lengthOfLIS(nums: number[]): number {
  const dfs = (i: number, last: number): number => {
    if (i === nums.length) return 0

    let len = dfs(i + 1, last)
    if (nums[i] > last) len = Math.max(len, dfs(i + 1, nums[i]) + 1)
    return len
  }

  return dfs(0, -Infinity)
}
  • 时间复杂度: O(2n)O(2^n)
  • 空间复杂度: O(n)O(n)

记忆化递归

上面的递归在最坏的情况下时间复杂度是 O(2n)O(2^n),可以通过添加缓存来优化时间复杂度.

function lengthOfLIS(nums: number[]): number {
  const cache: { [key: number]: number }[] = new Array(nums.length)
    .fill(0)
    .map(() => ({}))

  const dfs = (i: number, last: number): number => {
    if (i === nums.length) return 0

    if (cache[i][last]) return cache[i][last]

    let len = dfs(i + 1, last)
    if (nums[i] > last) len = Math.max(len, dfs(i + 1, nums[i]) + 1)
    cache[i][last] = len
    return len
  }

  return dfs(0, -Infinity)
}
  • 时间复杂度: O(n2)O(n^2)
  • 空间复杂度: O(n2)O(n^2)

动态规划

将上面的记忆化递归转成动态规划

  • 状态: dp[i] 为一个num -> len的映射,表示以 num 结尾的递增子序列长度
  • 状态转移方程: 对于每一对num,len使用下面公式计算当前数的递增序列长度 curLen
    • nums[i]>num: curLen=max(len,curLen)
    • nums[i] -> curLen
  • 边界: 每个数的 len=1
function lengthOfLIS(nums: number[]): number {
  let res = 1
  const dp = new Array(nums.length).fill(0).map(() => new Map<number, number>())
  for (let i = 0; i < nums.length; i++) {
    dp[i].set(nums[i], 1)
    const pre = dp[i - 1] ?? new Map()
    for (const [num, len] of pre) {
      if (num < nums[i] && dp[i].get(nums[i])! <= len) {
        dp[i].set(nums[i], len + 1)
        res = Math.max(res, len + 1)
      }

      if (num !== nums[i]) dp[i].set(num, len)
    }
  }
  return res
}
  • 时间复杂度: O(n2)O(n^2)
  • 空间复杂度: O(n2)O(n^2)

优化空间

上面的动态规划中,第 i 次的状态只与 i-1 有关,所以可以进行空间优化,用滚动数组的思想,直接用一个 Map 保存状态即可.

function lengthOfLIS(nums: number[]): number {
  let res = 1
  let dp = new Map<number, number>()
  for (let i = 0; i < nums.length; i++) {
    const tmp = new Map<number, number>([[nums[i], 1]])
    for (const [num, len] of dp) {
      if (num < nums[i] && tmp.get(nums[i])! <= len) {
        tmp.set(nums[i], len + 1)
        res = Math.max(res, len + 1)
      }
      if (num !== nums[i]) tmp.set(num, len)
    }
    dp = tmp
  }
  return res
}
  • 时间复杂度: O(n2)O(n^2)
  • 空间复杂度: O(n)O(n)

O(nlogn)O(n logn) 的解决方案

找到更优的状态

到上一步的话,实现了 O(n2)O(n^2) 复杂度的解决方案,不过进阶提示中提示了可以将时间复杂度降低到 O(nlogn)O(n logn).我们可以尝试努力一下.

如果要实现 O(nlogn)O(n logn) 的复杂度的话,就需要对状态转移过程进行分析,找到更优的状态或者状态转移方程.试试看能不能去掉更多不必要的选项,也就是剪枝的思想.

那具体要怎么做呢?

  1. 可以通过对公式进行推导.
  2. 通过具体的示例,去看其一步步的转移过程找的规律.

对我来说第二种方法会更容易一些.

如果是一些简单的状态转移,可以直接在纸上画出来,一些比较复杂带状态,我比较推荐直接用程序打印出来,下面是nums = [10,9,2,5,3,7,101,18]这个示例的状态转移过程.其中有缩进的是每个 Map 中存储的状态,使用num: len的格式,使用->表示发生的转移.

i: 0 num: 10
i: 1 num: 9
    10: 1
i: 2 num: 2
    9: 1
    10: 1
i: 3 num: 5
    2: 1  ->  5: 2
    2: 1
    9: 1
    10: 1
i: 4 num: 3
    5: 2
    2: 1  ->  3: 2
    2: 1
    9: 1
    10: 1
i: 5 num: 7
    3: 2  ->  7: 3
    3: 2
    5: 2
    2: 1
    9: 1
    10: 1
i: 6 num: 101
    7: 3  ->  101: 4
    7: 3
    3: 2
    5: 2
    2: 1
    9: 1
    10: 1
i: 7 num: 18
    101: 4
    7: 3  ->  18: 4
    7: 3
    3: 2
    5: 2
    2: 1
    9: 1
    10: 1

我们看到i: 5 num: 7这一步的状态,其中是从3: 2 -> 7: 3,这没什么问题,7 比 3 大,所以可以将 7 接在之前的 3 后面组成更长的递增子序列,但这里还有另外一个组合5: 2也同样可以转移到7: 3的状态,看到这是不是想到什么了?

对于长度同样都是 2 的3: 25: 2来说,3: 2是一个更优的选择: 因为对于大于 5 的数来说,3: 25: 2能起到同样的作用,提供两个单位的长度,但是对于 4 和 5 来说,3: 2能组成更长的增长子序列,而5: 2却不行,所以当我们发现同时存在3: 25: 2时,可以直接淘汰掉5: 2.也就是说存在同样的长度 len 时,只保留最小的 num 即可.

这就不难想出可以在之前代码的基础上,每次遍历时根据 len 排序,只取相同 len 中 num 最小的那一对映射.

我们通过上面的输出可以发现 len 是从 1 开始慢慢递增的(这也很容易理解,我们都是从前往后一个个数添加的,也是随着更大的数添加进来组成递增序列,len 在不断变大),这样我们可以使用一个以 len 为下标的数组作为 dp 数组,会更方便,而 dp 中记录的则是每个 len 最小的 num.因为 len 是从 1 开始的,而数组的索引是从 0 开始的,所以 len 对应的都是索引 j+1.

定义好状态之后,再来看看状态如何进行转移.依旧从示例nums = [10,9,2,5,3,7,101,18]入手,看看新的状态的转移过程(我手动去算的一个过程):

i: 0 num: 10
    0: 10
i: 1 num: 9
    0: 10 -> 0: 9
i: 2 num: 2
    0: 9 -> 0: 2
i: 3 num: 5
    0: 2
    -> 1: 5
i: 4 num: 3
    0: 2
    1: 5 -> 1: 3
i: 5 num: 7
    0: 2
    1: 3
    -> 2: 7
i: 6 num: 101
    0: 2
    1: 3
    2: 7
    -> 3: 101
i: 7 num: 18
    0: 2
    1: 3
    2: 7
    3: 101 -> 3: 18

这个过程中,我们每次将当前数 num 跟 dp 中的数从 0 开始对比,如果发现 num 比较小,则直接替换掉 dp 中对应的数(因为后面的数都会比这个数大,所以可以直接跳出循环),而如果 num 比较大,则继续跟下一位进行比较,当进行到最后一位时,也就是 num 比 dp 中所有的数都大,说明 num 能直接作序列最后的一个数,所以在 dp 最后添加上 num 即可.

最终这个 dp 数组就是我们要找最长递增子序列,所以直接返回 dp 数组长度即可.

function lengthOfLIS(nums: number[]): number {
  let dp: number[] = [Infinity]

  for (let i = 0; i < nums.length; i++) {
    for (let j = 0; j < dp.length; j++) {
      if (dp[j] >= nums[i]) {
        dp[j] = nums[i]
        break
      }

      dp[j + 1] = Math.min(nums[i], dp[j + 1] ?? Infinity)
    }
  }

  return dp.length
}
  • 时间复杂度: O(n2)O(n^2)
  • 空间复杂度: O(n)O(n)

二分查找优化

上面的优化虽然速度会快很多,但在极端情况下,也还只是 O(n2)O(n^2) 的时间复杂度,比如像 [1,2,3,4,5] 这样的例子,

对于里面的一层循环实际上我们是在 dp 数组中,找一个大于 num 的最小数,而 dp 数组是一个递增的数组,所以可以通过二分查找来优化成 O(nlogn)O(n logn)

function lengthOfLIS(nums: number[]): number {
  let dp: number[] = [Infinity]

  for (let i = 0; i < nums.length; i++) {
    let [left, right] = [0, dp.length]
    while (left < right) {
      let mid = (left + right) >> 1
      if (dp[mid] === nums[i]) {
        right = mid
        break
      } else if (dp[mid] < nums[i]) {
        left = mid + 1
      } else {
        right = mid
      }
    }
    dp[right] = nums[i]
  }

  return dp.length
}
  • 时间复杂度: O(nlogn)O(n logn)
  • 空间复杂度: O(n)O(n)