最长递增子序列(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(n2)O(n^2)O(n2)
外层循环遍历 nnn 个元素,内层循环最坏需遍历 iii 次(iii 从 0 到 n−1n-1n−1),总操作次数为 1+2+...+n−1=n(n−1)21+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] 为例:
- dp[0]=1dp[0] = 1dp[0]=1(子序列
[10]) - dp[1]=1dp[1] = 1dp[1]=1(子序列
[9],无法接续10) - dp[2]=1dp[2] = 1dp[2]=1(子序列
[2]) - dp[3]=max(dp[0],dp[1],dp[2])+1=2dp[3] = \max(dp[0], dp[1], dp[2]) + 1 = 2dp[3]=max(dp[0],dp[1],dp[2])+1=2(接续2 →
[2, 5]) - dp[4]=max(dp[2])+1=2dp[4] = \max(dp[2]) + 1 = 2dp[4]=max(dp[2])+1=2(接续2 →
[2, 3]) - dp[5]=max(dp[2],dp[3],dp[4])+1=3dp[5] = \max(dp[2], dp[3], dp[4]) + 1 = 3dp[5]=max(dp[2],dp[3],dp[4])+1=3(接续3 →
[2, 3, 7]) - dp[6]=max(dp[5])+1=4dp[6] = \max(dp[5]) + 1 = 4dp[6]=max(dp[5])+1=4(接续7 →
[2, 3, 7, 101])
最终 max(dp)=4\max(dp) = 4max(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(n2)O(n^2)O(n2) | n≤1e3n \leq 1e3n≤1e3 |
| 贪心 + 二分查找 | O(nlogn)O(n \log n)O(nlogn) | n≥1e4n \geq 1e4n≥1e4,大规模数据 |
总结
通过维护一个单调递增的 tail 数组,并结合二分查找快速定位插入位置,该方案在保证正确性的前提下大幅提升了效率。此优化体现了贪心选择性质与高效搜索的结合,是处理最长递增子序列问题的标准优化方案。



2211

被折叠的 条评论
为什么被折叠?



