300. 最长上升子序列
题目来源:力扣(LeetCode)https://leetcode-cn.com/problems/longest-increasing-subsequence
题目
给定一个无序的整数数组,找到其中最长上升子序列的长度。
示例:
输入: [10,9,2,5,3,7,101,18]
输出: 4
解释: 最长的上升子序列是 [2,3,7,101],它的长度是 4。
说明:
-
可能会有多种最长上升子序列的组合,你只需要输出对应的长度即可。
-
你算法的时间复杂度应该为 O ( n 2 ) O(n^2) O(n2) 。
进阶: 你能将算法的时间复杂度降低到 O ( n log n ) O(n \log n) O(nlogn) 吗?
解题思路
思路:动态规划、二分查找
在这里,题目要求找到给定无序数组中最长上升子序列的长度。
看示例部分,我们可以发现,这里要求返回的序列并非要求要连续的。
动态规划
这里我们用动态规划的方法来解决这问题。
状态定义
设 dp[i]
为以 nums[i]
结尾的上升子序列的长度。
状态转移方程
在这里,我们要求 dp[i]
之前,需要先求得 dp[0...i-1]
的值。
然后只要 nums[i]
的值严格大于 i
前面的某个数,那么 num[i]
便可以跟在这个数后面,形成更长的子序列。
也就是说 dp[i]
就等于 i
前面严格小于 num[i]
中 dp
值最大 + 1。
那么状态方程如下:
d
p
[
i
]
=
m
a
x
(
d
p
[
j
]
)
+
1
dp[i]=max(dp[j])+1
dp[i]=max(dp[j])+1
其中
0
≤
j
<
i
0\leq j < i
0≤j<i,并且
n
u
m
[
i
]
>
n
u
m
[
j
]
num[i] > num[j]
num[i]>num[j]。
状态初始化
因为单独一个数字也能算作是一个长度为 1 的上升序列,那么先将 dp
数组初始化为 1。
注意:
这里不能直接返回 dp
数组的最后一个状态值,这里要求返回最长的上升子序列,那么最终要返回 dp
数组中的最大值。
具体的代码实现如下:
class Solution:
def lengthOfLIS(self, nums: List[int]) -> int:
if not nums:
return 0
n = len(nums)
dp = [1] * n
for i in range(n):
for j in range(i):
if nums[i] > nums[j]:
dp[i] = max(dp[i], dp[j]+1)
# 返回 dp 数组最大值
return max(dp)
复杂度分析:
- 时间复杂度:
O
(
N
2
)
O(N^2)
O(N2),
N
N
N 为数组
nums
的长度。两层遍历都为线性时间 O ( N ) O(N) O(N)。 - 空间复杂度:
O
(
N
)
O(N)
O(N),额外使用长度为
N
N
N 的
dp
数组。
动态规划+二分查找
进阶部分,希望时间复杂度能够降到 O ( N log N ) O(N \log N) O(NlogN)。下面就前面的方法进行优化。
我们可以观察示例中的上升子序列,如果需要获得更长的子序列,那么子序列上升的幅度越小越好。
也就是,当上升子序列中最后的元素越小,那么与后面的元素连接形成更长的子序列的可能性更大。
状态定义
这里要重新定义状态。设 tail[i]
表示长度为 i+1
子序列尾部元素值。
状态转移方程
在这里 tail
列表一定是要严格递增的。这里用反证法来证明:
证明: 当 i < j i < j i<j 时,都有 t a i l [ i ] < t a i l [ j ] tail[i] < tail[j] tail[i]<tail[j]。
反证法:
假设 i < j i<j i<j 时,存在 t a i l [ i ] > = t a i l [ j ] tail[i]>= tail[j] tail[i]>=tail[j]。
这里 t a i l [ i ] tail[i] tail[i] 表示长度为 i + 1 i+1 i+1 子序列尾部元素值, t a i l [ j ] tail[j] tail[j] 同理。
也就是说,上面的假设中,较短子序列中的尾部元素值 要大于 较长子序列中的尾部元素值 。
现在,将 较长子序列 从尾部开始删减元素,直至长度等于 较短子序列 的长度( i + 1 i+1 i+1)。假设在这个缩减后长度为 i + 1 i+1 i+1 的序列尾部元素值为 t a i l [ k ] tail[k] tail[k],那么此时 t a i l [ k ] < t a i l [ j ] tail[k] < tail[j] tail[k]<tail[j] ,也就是说长度为 i + 1 i+1 i+1 的子序列的尾部元素值更小,那么这就跟原假设 t a i l [ i ] > = t a i l [ j ] tail[i]>= tail[j] tail[i]>=tail[j] 矛盾。
故,当 i < j i < j i<j 时,都有 t a i l [ i ] < t a i l [ j ] tail[i] < tail[j] tail[i]<tail[j]。
转移方程
设 length
为 tail
数组的长度。遍历数组 nums
,假设当前元素为nums[i]
,这里可能出现的情况:
- 若
nums[i]
大于tail
数组尾部元素,那么可以将num[i]
直接放到tail
数组尾部,更新长度length
,令length +1
。 - 若不存在上面的情况,那么则需要通过二分查找在
tail
数组中找到第一个比nums[i]
大的元素,用nums[i]
替换。
状态初始化
初始化 tail
数组值为 0。
令 tail[0]=nums[0]
,初始长度 length
为 1。
具体的代码实现如下:
class Solution:
def lengthOfLIS(self, nums: List[int]) -> int:
n = len(nums)
# 处理数组为空的情况
if not nums:
return n
# 一般情况,初始化 tail 数组值都为 0
tail = [0] * n
# 令 tail[0] = nums[0],length 长度为 1
tail[0] = nums[0]
length = 1
# 开始遍历
for i in range(1, n):
# 如果出现 nums[i] 大于 tail 数组末尾元素
# 将 nums[i] 放到末尾,更新 length
if nums[i] > tail[length-1]:
tail[length] = nums[i]
length+=1
continue
# nums[i] 不大于 tail 数组末尾元素时
# 利用二分查找在 tail 数组索引为 [0 , length) 之间的元素找到第一个大于 nums[i] 的元素
# 将其替换为 nums[i]
left = 0
right = length-1
while left < right:
mid = left + (right-left) // 2
if nums[i] > tail[mid]:
left = mid + 1
else:
right = mid
tail[left] = nums[i]
return length
复杂度分析:
- 时间复杂度:
O
(
N
log
N
)
O(N\log N)
O(NlogN)。
N
N
N 为
nums
数组的长度,遍历nums
数组需要 O ( N ) O(N) O(N)。遍历查找元素使用二分查找需要 O ( log N ) O(\log N) O(logN),所以总时间为 O ( N log N ) O(N\log N) O(NlogN)。 - 空间复杂度:
O
(
N
)
O(N)
O(N)。
tail
数组的开销。
欢迎关注
公众号 【书所集录】
如有错误,烦请指出,欢迎指点交流。