题目
给你一个整数数组 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 会有两种选择,取其中能组成的最长子序列长度:
- 不包含数字 num
- 包含数字 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)
}
- 时间复杂度:
- 空间复杂度:
记忆化递归
上面的递归在最坏的情况下时间复杂度是 ,可以通过添加缓存来优化时间复杂度.
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)
}
- 时间复杂度:
- 空间复杂度:
动态规划
将上面的记忆化递归转成动态规划
- 状态: 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
}
- 时间复杂度:
- 空间复杂度:
优化空间
上面的动态规划中,第 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
}
- 时间复杂度:
- 空间复杂度:
的解决方案
找到更优的状态
到上一步的话,实现了 复杂度的解决方案,不过进阶提示中提示了可以将时间复杂度降低到 .我们可以尝试努力一下.
如果要实现 的复杂度的话,就需要对状态转移过程进行分析,找到更优的状态或者状态转移方程.试试看能不能去掉更多不必要的选项,也就是剪枝的思想.
那具体要怎么做呢?
- 可以通过对公式进行推导.
- 通过具体的示例,去看其一步步的转移过程找的规律.
对我来说第二种方法会更容易一些.
如果是一些简单的状态转移,可以直接在纸上画出来,一些比较复杂带状态,我比较推荐直接用程序打印出来,下面是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: 2
和5: 2
来说,3: 2
是一个更优的选择: 因为对于大于 5 的数来说,3: 2
和5: 2
能起到同样的作用,提供两个单位的长度,但是对于 4 和 5 来说,3: 2
能组成更长的增长子序列,而5: 2
却不行,所以当我们发现同时存在3: 2
和5: 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
}
- 时间复杂度:
- 空间复杂度:
二分查找优化
上面的优化虽然速度会快很多,但在极端情况下,也还只是 的时间复杂度,比如像 [1,2,3,4,5]
这样的例子,
对于里面的一层循环实际上我们是在 dp 数组中,找一个大于 num 的最小数,而 dp 数组是一个递增的数组,所以可以通过二分查找来优化成
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
}
- 时间复杂度:
- 空间复杂度: