1. 最长递增子序列问题概述
最长递增子序列(Longest Increasing Subsequence,简称LIS)是计算机科学中一个经典的算法问题,也是动态规划领域的入门必修课。给定一个无序的整数序列,我们需要找到其中最长的严格递增子序列的长度。这里的子序列不要求连续,但必须保持原始序列中的相对顺序。
举个例子,对于序列 [10, 9, 2, 5, 3, 7, 101, 18],其最长递增子序列是 [2, 3, 7, 101],长度为4。这个问题看似简单,但在实际应用中却有着广泛的用途,比如基因序列比对、数据压缩、股票分析等领域。
注意:子序列(subsequence)与子串(substring)的区别在于,子序列不要求元素连续,而子串必须是原序列中连续的一段。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 暴力解法与问题分析
2.1 穷举法的思路与局限
最直观的解法是枚举所有可能的子序列,然后检查它们是否是递增的。对于一个长度为n的序列,共有2^n种可能的子序列(每个元素都有选或不选两种选择)。这种方法的时间复杂度是O(2^n),显然无法处理稍大规模的输入。
python复制def lengthOfLIS_bruteforce(nums):
max_len = 0
n = len(nums)
for mask in range(1 << n): # 生成所有可能的子序列
subsequence = []
for i in range(n):
if mask & (1 << i):
subsequence.append(nums[i])
# 检查是否是递增序列
increasing = True
for i in range(1, len(subsequence)):
if subsequence[i] <= subsequence[i-1]:
increasing = False
break
if increasing:
max_len = max(max_len, len(subsequence))
return max_len
2.2 问题特性分析
观察LIS问题,我们可以发现两个关键特性:
-
最优子结构:整个序列的最优解(最长递增子序列)可以由子序列的最优解构造而来。例如,如果我们知道前i-1个元素的LIS长度,那么第i个元素的LIS长度可以通过比较它与前面所有元素的关系来确定。
-
重叠子问题:在递归求解过程中,我们会反复计算相同的子问题。比如计算以第i个元素结尾的LIS时,可能需要计算以多个前面元素结尾的LIS。
这两个特性正是动态规划适用的典型场景。动态规划通过存储子问题的解来避免重复计算,从而将指数级的时间复杂度降低到多项式级别。
3. 动态规划解法详解
3.1 状态定义与转移方程
我们定义dp[i]表示以第i个数字结尾的最长递增子序列的长度。注意这个定义的关键在于"以第i个数字结尾"——这确保了我们可以通过考察前面的所有dp[j](j < i)来计算dp[i]。
状态转移方程如下:
code复制dp[i] = max(dp[j]) + 1, 其中 0 ≤ j < i 且 nums[j] < nums[i]
如果不存在这样的j(即nums[i]比前面所有元素都小),则dp[i] = 1(它自身构成一个长度为1的子序列)。
3.2 完整算法实现
python复制def lengthOfLIS(nums):
if not nums:
return 0
dp = [1] * len(nums)
for i in range(1, len(nums)):
for j in range(i):
if nums[j] < nums[i]:
dp[i] = max(dp[i], dp[j] + 1)
return max(dp)
这个算法的时间复杂度是O(n^2),因为有两层嵌套循环;空间复杂度是O(n),用于存储dp数组。
3.3 算法正确性验证
让我们用示例序列 [10, 9, 2, 5, 3, 7, 101, 18] 来验证算法:
- dp[0] = 1 ([10])
- dp[1] = 1 ([9], 9不比10小)
- dp[2] = 1 ([2])
- dp[3] = max(dp[0], dp[1], dp[2]) + 1 = 2 ([2,5])
- dp[4] = max(dp[2]) + 1 = 2 ([2,3])
- dp[5] = max(dp[2], dp[3], dp[4]) + 1 = 3 ([2,5,7]或[2,3,7])
- dp[6] = max(dp[0...5]) + 1 = 4 ([2,5,7,101]或[2,3,7,101])
- dp[7] = max(dp[0...5]) + 1 = 4 ([2,5,7,18]或[2,3,7,18])
最终结果是max(dp) = 4,与手动分析一致。
4. 优化解法:贪心+二分查找
4.1 O(n log n)算法思路
虽然O(n^2)的解法已经比暴力法好很多,但对于大规模数据(比如n=10^5)仍然不够高效。我们可以通过结合贪心算法和二分查找将时间复杂度优化到O(n log n)。
核心思想是维护一个tails数组,其中tails[i]表示长度为i+1的所有递增子序列中最小的末尾元素。这个数组的一个重要性质是它本身是有序的,因此我们可以使用二分查找来优化插入过程。
4.2 算法实现与解析
python复制import bisect
def lengthOfLIS_optimized(nums):
tails = []
for num in nums:
# 在tails中找到第一个≥num的元素位置
idx = bisect.bisect_left(tails, num)
if idx == len(tails):
tails.append(num)
else:
tails[idx] = num
return len(tails)
这个算法的工作原理是:
- 遍历每个数字
- 如果当前数字大于tails中的所有元素(即可以扩展最长子序列),就将其追加到tails末尾
- 否则,用它替换tails中第一个≥它的元素,这样可以保证tails数组尽可能小,为后续元素提供更大的扩展空间
4.3 算法正确性证明
这个算法看似违反直觉——为什么可以随意替换tails中的元素而不影响最终结果?关键在于我们维护的是"长度为i的子序列的最小末尾",这保证了:
- tails数组始终保持有序
- 当我们用更小的值替换tails中的元素时,不会影响已经计算出的长度,但为后续更长的子序列创造了可能
- 最终tails的长度就是LIS的长度,虽然tails本身不一定是实际的LIS
以序列 [3, 5, 2, 6] 为例:
- 处理3: tails = [3]
- 处理5: tails = [3,5]
- 处理2: tails = [2,5] (用2替换3)
- 处理6: tails = [2,5,6]
最终长度为3,确实是最长递增子序列的长度。
5. 实际应用与变种问题
5.1 实际问题中的应用场景
LIS问题在现实中有许多应用:
- 生物信息学:DNA序列比对,寻找最长的共同子序列
- 金融分析:识别股票价格的最长上涨趋势
- 数据压缩:寻找可以高效编码的有序模式
- 调度问题:安排不冲突的活动序列
5.2 常见变种问题
-
最长非递减子序列:允许相等的元素。只需将条件从nums[j] < nums[i]改为nums[j] <= nums[i]。
-
最长递增子序列的数量:不仅求长度,还要求有多少个这样的最长子序列。这需要额外维护一个count数组。
-
字典序最小的最长递增子序列:当存在多个LIS时,返回字典序最小的那个。这需要在动态规划过程中记录前驱信息。
-
二维LIS问题:比如信封嵌套问题(LeetCode 354),需要先对一维排序,然后在另一维上求LIS。
5.3 输出具体的LIS序列
有时候我们需要输出具体的子序列而不仅仅是长度。这可以通过在动态规划过程中记录前驱节点来实现:
python复制def lengthOfLIS_with_sequence(nums):
if not nums:
return 0, []
dp = [1] * len(nums)
prev = [-1] * len(nums) # 记录前驱索引
for i in range(1, len(nums)):
for j in range(i):
if nums[j] < nums[i] and dp[j] + 1 > dp[i]:
dp[i] = dp[j] + 1
prev[i] = j
max_len = max(dp)
last_idx = dp.index(max_len)
# 回溯构建序列
lis = []
while last_idx != -1:
lis.append(nums[last_idx])
last_idx = prev[last_idx]
return max_len, lis[::-1]
6. 算法比较与选择建议
6.1 时间复杂度对比
| 算法 | 时间复杂度 | 空间复杂度 | 适用场景 |
|---|---|---|---|
| 暴力法 | O(2^n) | O(n) | 仅用于教学演示 |
| 动态规划 | O(n^2) | O(n) | n较小(≤10^3),需要具体序列 |
| 贪心+二分 | O(n log n) | O(n) | n较大(≤10^5),只需长度 |
6.2 选择建议
-
面试场景:通常期望先给出O(n^2)的DP解法,然后优化到O(n log n)版本。能够解释清楚两种解法的区别和优化思路会加分。
-
竞赛场景:直接使用O(n log n)的解法,注意边界条件和实现细节。
-
实际工程:根据数据规模选择。如果n<1000,DP解法更直观;如果n很大,必须使用优化解法。
提示:Python中的bisect模块提供了高效的二分查找实现。在C++中可以使用lower_bound,Java中有Collections.binarySearch。
7. 常见错误与调试技巧
7.1 新手常见错误
-
混淆子序列和子串:忘记子序列可以不连续,错误地寻找连续递增子序列。
-
初始化错误:忘记将dp数组初始化为1(每个元素本身就是一个长度为1的子序列)。
-
边界条件处理:忘记检查空输入的情况,导致数组越界。
-
状态转移条件错误:在比较nums[j]和nums[i]时写错不等号方向(如写成≤而不是<)。
7.2 调试技巧
- 打印中间结果:在DP解法中,打印出每一步的dp数组,观察其变化是否符合预期。
python复制def lengthOfLIS_debug(nums):
dp = [1] * len(nums)
for i in range(1, len(nums)):
print(f"Before processing i={i}, nums[i]={nums[i]}, dp={dp}")
for j in range(i):
if nums[j] < nums[i]:
if dp[j] + 1 > dp[i]:
print(f" Update dp[{i}] from {dp[i]} to {dp[j]+1} because of nums[{j}]={nums[j]}")
dp[i] = dp[j] + 1
print(f"After processing i={i}, dp={dp}\n")
return max(dp) if dp else 0
-
小规模测试:先用小例子(如3-5个元素)手动计算,再与程序输出对比。
-
可视化工具:对于复杂序列,可以画出元素之间的关系图,帮助理解递推关系。
8. 扩展练习与学习资源
8.1 推荐练习题
- LeetCode 300 - 最长递增子序列(基础版)
- LeetCode 673 - 最长递增子序列的个数
- LeetCode 354 - 俄罗斯套娃信封问题(二维LIS)
- LeetCode 491 - 递增子序列(输出所有递增子序列)
8.2 学习资源推荐
- 《算法导论》第15章 - 动态规划
- 《算法竞赛入门经典》第9章 - 动态规划初步
- MIT 6.006 课程讲义 - 动态规划章节
- VisuAlgo 网站的可视化演示 - LIS动画
我在实际刷题和教学中发现,理解LIS问题的关键在于把握"状态定义"的精髓——dp[i]定义为"以第i个元素结尾"的LIS长度,而不是"前i个元素中"的LIS长度。这种定义方式确保了无后效性,是许多动态规划问题的共同特点。另外,对于优化解法,记住tails数组的含义是"长度为i+1的LIS的最小末尾",这个抽象概念需要一些时间才能真正内化理解。
