最长递增子序列(Longest Increasing Subsequence)题解
题目概括
给定一个整数数组 nums
,返回其最长严格递增子序列的长度。
子序列定义为:通过删除(或不删除)数组中的元素而不改变剩余元素的顺序得到的序列。
例如:数组 [10,9,2,5,3,7,101,18]
的最长递增子序列是 [2,3,7,101]
,长度为 4。
算法思想
动态规划(Dynamic Programming)
核心思想:定义 dp[i]
表示以 nums[i]
结尾的最长递增子序列的长度。
- 对于每个元素
nums[i]
,遍历其之前的所有元素nums[j]
(j < i
) - 若
nums[i] > nums[j]
,则dp[i]
可以继承dp[j]
的最优解 - 最终结果为所有
dp[i]
中的最大值
算法步骤
-
初始化
创建数组dp
(代码中的ans
),长度与nums
相同,初始值全为 0。 -
双层遍历
- 外层遍历:逐个处理每个元素
nums[i]
- 内层遍历:对于当前元素
nums[i]
,遍历其之前的所有元素nums[j]
(j < i
)- 若
nums[i] > nums[j]
,则更新dp[i] = max(dp[i], dp[j])
(继承前面更优的递增链)
- 若
- 外层遍历:逐个处理每个元素
-
状态转移
每个dp[i]
的值最终为继承后的最大值 +1(表示当前元素自身加入子序列)。 -
获取结果
返回dp
数组中的最大值。
具体代码
class Solution:
def lengthOfLIS(self, nums: List[int]) -> int:
dp = [0] * len(nums) # dp[i] 表示以 nums[i] 结尾的最长递增子序列长度
for i in range(len(nums)):
# 遍历 i 之前的所有元素,寻找可以接续的递增序列
for j in range(i):
if nums[i] > nums[j]:
dp[i] = max(dp[i], dp[j])
dp[i] += 1 # 当前元素自身构成长度1,或接续前序序列
return max(dp) if dp else 0
时间复杂度
-
时间复杂度: O ( n 2 ) O(n^2) O(n2)
外层循环遍历 n n n 个元素,内层循环最坏需遍历 i i i 次( i i i 从 0 到 n − 1 n-1 n−1),总操作次数为 1 + 2 + . . . + n − 1 = n ( n − 1 ) 2 1+2+...+n-1 = \frac{n(n-1)}{2} 1+2+...+n−1=2n(n−1)。 -
空间复杂度: O ( n ) O(n) O(n)
dp
数组占用与输入数组等长的空间。
动态规划过程示例
以 nums = [10,9,2,5,3,7,101,18]
为例:
-
d
p
[
0
]
=
1
dp[0] = 1
dp[0]=1(子序列
[10]
) -
d
p
[
1
]
=
1
dp[1] = 1
dp[1]=1(子序列
[9]
,无法接续10) -
d
p
[
2
]
=
1
dp[2] = 1
dp[2]=1(子序列
[2]
) -
d
p
[
3
]
=
max
(
d
p
[
0
]
,
d
p
[
1
]
,
d
p
[
2
]
)
+
1
=
2
dp[3] = \max(dp[0], dp[1], dp[2]) + 1 = 2
dp[3]=max(dp[0],dp[1],dp[2])+1=2(接续2 →
[2, 5]
) -
d
p
[
4
]
=
max
(
d
p
[
2
]
)
+
1
=
2
dp[4] = \max(dp[2]) + 1 = 2
dp[4]=max(dp[2])+1=2(接续2 →
[2, 3]
) -
d
p
[
5
]
=
max
(
d
p
[
2
]
,
d
p
[
3
]
,
d
p
[
4
]
)
+
1
=
3
dp[5] = \max(dp[2], dp[3], dp[4]) + 1 = 3
dp[5]=max(dp[2],dp[3],dp[4])+1=3(接续3 →
[2, 3, 7]
) -
d
p
[
6
]
=
max
(
d
p
[
5
]
)
+
1
=
4
dp[6] = \max(dp[5]) + 1 = 4
dp[6]=max(dp[5])+1=4(接续7 →
[2, 3, 7, 101]
)
最终 max ( d p ) = 4 \max(dp) = 4 max(dp)=4
优化方案(贪心 + 二分查找)
优化背景
传统动态规划解法时间复杂度为 O(n²),当 n ≥ 1e4
时效率较低。通过结合贪心策略和二分查找,可将时间复杂度优化至 O(n log n),适用于处理大规模数据。
算法思想
核心洞察
- 贪心策略:要使得递增子序列尽可能长,需让序列增长得尽可能慢。因此,我们希望每次在递增子序列最后添加的元素尽可能小。
- 维护数组:定义数组
tail
,其中tail[i]
表示长度为i+1
的递增子序列的最小末尾元素。例如:tail[2] = 5
表示所有长度为 3 的递增子序列中,最小的末尾元素是 5。
操作步骤
- 初始化:
tail
数组为空。 - 遍历元素:对每个元素
num
进行如下操作:- 若
num > tail[-1]
,直接加入tail
,表示递增子序列长度增加 1。 - 否则,在
tail
中找到第一个大于等于num
的位置j
,将tail[j]
替换为num
(此操作保证tail
的单调性不变,但可能使未来更长的子序列更容易形成)。
- 若
- 结果:最终
tail
的长度即为最长递增子序列的长度。
正确性证明
关键点
- 替换不影响已有长度:替换
tail[j]
为更小的num
,不会改变当前长度的递增子序列的存在性,但为后续元素提供了更低的“门槛”。 - 单调性保持:
tail
数组始终保持严格递增(可用反证法证明)。
时间复杂度分析
- 遍历元素:外层循环 O(n)
- 二分查找:每次查找 O(log n)
- 总时间复杂度:O(n log n)
具体代码实现
class Solution:
def lengthOfLIS(self, nums: List[int]) -> int:
tail = []
for num in nums:
# 二分查找插入位置
left, right = 0, len(tail)
while left < right:
mid = (left + right) // 2
if tail[mid] < num:
left = mid + 1
else:
right = mid
# 若 num 可扩展序列长度
if left == len(tail): # 说明没有找到>=num的,直接添加
tail.append(num)
else:
tail[left] = num # 贪心替换,降低后续门槛
return len(tail)
详解贪心+二分查找的替换过程
我们以 nums = [3, 5, 6, 2, 3, 7]
为例,逐步演示贪心策略中二分查找替换的操作。
初始化
tail
数组初始为空:tail = []
- 目标:维护
tail
数组,使其始终保持严格递增,且tail[i]
表示长度为i+1
的递增子序列的最小末尾元素。
处理元素 3
- 当前状态:
tail = []
- 操作:直接添加
3
- 结果:
tail = [3]
- 解释:长度为 1 的子序列末尾元素为 3。
处理元素 5
- 当前状态:
tail = [3]
- 比较:
5 > tail[-1] (3)
- 操作:直接添加
5
- 结果:
tail = [3, 5]
- 解释:长度为 2 的子序列末尾元素为 5。
处理元素 6
- 当前状态:
tail = [3, 5]
- 比较:
6 > tail[-1] (5)
- 操作:直接添加
6
- 结果:
tail = [3, 5, 6]
- 解释:长度为 3 的末尾元素为 6。
处理元素 2
(关键步骤)
目标:找到 tail
中第一个大于等于 2
的位置
- 当前状态:
tail = [3, 5, 6]
- 二分查找过程:
- 初始范围:
left = 0
,right = 3
- 第一次中间点:
mid = (0+3)//2 = 1
,检查tail[1] = 5
- 由于
5 > 2
,更新right = mid = 1
- 由于
- 第二次中间点:
mid = (0+1)//2 = 0
,检查tail[0] = 3
- 由于
3 > 2
,更新right = mid = 0
- 由于
- 循环结束,
left = right = 0
- 初始范围:
- 操作:将
tail[0]
替换为2
- 结果:
tail = [2, 5, 6]
- 解释:虽然原序列
[3,5,6]
无法接续2
,但替换后长度为 1 的子序列末尾元素变为更小的2
,为后续元素(如3
)提供了更低的“门槛”。
- 解释:虽然原序列
处理元素 3
(关键步骤)
目标:找到 tail
中第一个大于等于 3
的位置
- 当前状态:
tail = [2, 5, 6]
- 二分查找过程:
- 初始范围:
left = 0
,right = 3
- 第一次中间点:
mid = (0+3)//2 = 1
,检查tail[1] = 5
- 由于
5 > 3
,更新right = mid = 1
- 由于
- 第二次中间点:
mid = (0+1)//2 = 0
,检查tail[0] = 2
- 由于
2 < 3
,更新left = mid + 1 = 1
- 由于
- 循环结束,
left = right = 1
- 初始范围:
- 操作:将
tail[1]
替换为3
- 结果:
tail = [2, 3, 6]
- 解释:替换后长度为 2 的子序列末尾元素变为
3
,比原5
更小,使得后续元素(如7
)更容易接续。
- 解释:替换后长度为 2 的子序列末尾元素变为
处理元素 7
- 当前状态:
tail = [2, 3, 6]
- 比较:
7 > tail[-1] (6)
- 操作:直接添加
7
- 结果:
tail = [2, 3, 6, 7]
- 最终长度为 4,对应子序列
[2, 3, 6, 7]
。
- 最终长度为 4,对应子序列
替换操作的意义
-
降低后续门槛
替换5
为3
后,后续元素只需大于3
(而非原5
)即可形成更长的子序列。 -
保持单调性
每次替换后,tail
数组始终保持严格递增(例如[2,3,6]
严格递增),这使得二分查找始终有效。 -
不改变最长长度
替换操作不会减少当前最长子序列的长度,但会优化未来扩展的可能性。
总结
通过二分查找定位替换位置,贪心策略在每一步都选择当前最优的末尾元素,最终通过维护一个严格递增的 tail
数组,高效求得最长递增子序列的长度。此方法将时间复杂度从 O(n²) 优化至 O(n log n),适用于大规模数据场景。
对比动态规划
方案 | 时间复杂度 | 适用场景 |
---|---|---|
经典动态规划 | O ( n 2 ) O(n^2) O(n2) | n ≤ 1 e 3 n \leq 1e3 n≤1e3 |
贪心 + 二分查找 | O ( n log n ) O(n \log n) O(nlogn) | n ≥ 1 e 4 n \geq 1e4 n≥1e4,大规模数据 |
总结
通过维护一个单调递增的 tail
数组,并结合二分查找快速定位插入位置,该方案在保证正确性的前提下大幅提升了效率。此优化体现了贪心选择性质与高效搜索的结合,是处理最长递增子序列问题的标准优化方案。