1. 最长递增子序列(LIS)算法深度解析
最长递增子序列(Longest Increasing Subsequence,简称LIS)是动态规划领域的经典问题,也是算法面试中的高频考点。我第一次接触这个问题是在准备校招时,当时被它简洁的定义和巧妙的优化思路深深吸引。经过多年算法竞赛和工程实践的打磨,我发现LIS不仅是理论优美的算法,更在数据处理、生物信息学等领域有广泛应用。
1.1 问题定义与基础解法
给定一个整数数组nums,找到其中最长的严格递增子序列的长度。子序列不要求连续,但必须保持原始顺序。例如对于[10,9,2,5,3,7,101,18],最长递增子序列是[2,3,7,101],长度为4。
最直观的解法是动态规划。定义f[i]表示以nums[i]结尾的LIS长度,状态转移方程为:
python复制def lengthOfLIS(nums):
n = len(nums)
f = [1] * n # 初始化为1,因为每个元素本身就是一个长度为1的子序列
for i in range(n):
for j in range(i):
if nums[j] < nums[i]:
f[i] = max(f[i], f[j] + 1)
return max(f) if f else 0
这个解法的时间复杂度是O(n²),空间复杂度是O(n)。虽然不够高效,但它清晰地展现了LIS问题的核心思想:每个位置的LIS长度取决于前面所有比它小的元素的LIS长度中的最大值加1。
注意:在实际编码时,需要处理空数组的特殊情况。这也是面试中容易忽略的边界条件。
1.2 二分查找优化
当数组长度较大时(比如n>10⁴),O(n²)的解法就会超时。这时我们需要更高效的O(nlogn)解法。这个优化的核心思想是:维护一个数组f,其中f[i]表示长度为i+1的递增子序列的最小末尾元素。
python复制def lengthOfLIS(nums):
f = []
for x in nums:
# 在f中找到第一个≥x的元素位置
l, r = 0, len(f)
while l < r:
mid = (l + r) // 2
if f[mid] >= x:
r = mid
else:
l = mid + 1
if l == len(f):
f.append(x)
else:
f[l] = x
return len(f)
这个算法为什么能工作?关键在于f数组始终保持有序,我们可以用二分查找快速定位。每次处理新元素x时:
- 如果x大于f中所有元素,说明可以形成更长的子序列,直接追加到f末尾
- 否则找到f中第一个≥x的位置并替换,这保证了后续可以形成更长的子序列
实际应用中,这个优化版本可以处理n=10⁵量级的数据。我在处理基因组序列分析时就曾用这个算法高效解决了相似问题。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. LIS变种问题实战
2.1 俄罗斯套娃信封问题(LeetCode 354)
这是LIS的经典变种。给定一批信封的宽度和高度,当一个信封的宽度和高度都大于另一个信封时,可以套在一起,求最多能套多少层。
解法关键:
- 先对宽度升序排序,宽度相同的按高度降序排序
- 然后在高度序列上找LIS
python复制def maxEnvelopes(envelopes):
envelopes.sort(key=lambda x: (x[0], -x[1]))
heights = [h for _, h in envelopes]
return lengthOfLIS(heights)
排序技巧解释:宽度相同时高度降序,可以避免相同宽度的信封被错误地计入序列。我在第一次做这题时就踩了这个坑,调试了很久才发现问题。
2.2 最长数对链(LeetCode 646)
这个问题要求找出可以形成的最长数对链,其中数对[c,d]可以跟在[a,b]后面当且仅当b < c。
虽然看起来像LIS问题,但实际上可以用贪心算法更高效地解决:
python复制def findLongestChain(pairs):
pairs.sort(key=lambda x: x[1])
curr_end, count = float('-inf'), 0
for pair in pairs:
if pair[0] > curr_end:
curr_end = pair[1]
count += 1
return count
这个例子展示了不是所有看似LIS的问题都需要用动态规划解决。理解问题本质比套用模板更重要。
3. 工程实践中的注意事项
3.1 严格递增与非严格递增
标准的LIS要求严格递增(前一个元素必须小于后一个)。如果题目允许相等(非严格递增),需要修改比较条件:
python复制# 修改二分查找中的比较条件
if f[mid] >= x: # 严格递增
if f[mid] > x: # 非严格递增
这个细微差别在实际工程中可能导致完全不同的结果。我曾经在开发一个时间序列分析工具时,就因为忽略了这点导致结果异常。
3.2 内存优化技巧
当处理极大数组时,可以用更节省内存的方式实现:
python复制def lengthOfLIS(nums):
import bisect
f = []
for x in nums:
i = bisect.bisect_left(f, x)
if i == len(f):
f.append(x)
else:
f[i] = x
return len(f)
使用Python内置的bisect模块不仅代码更简洁,而且效率更高。但在某些编程竞赛环境中,可能需要自己实现二分查找。
4. 常见问题与调试技巧
4.1 为什么我的LIS算法结果比预期小?
常见原因:
- 没有正确处理空输入的情况
- 在动态规划解法中,f数组初始化错误(应该是1而非0)
- 在二分查找版本中,比较条件写反了
调试建议:
- 先用小测试用例手动验证
- 打印中间状态(如f数组的变化过程)
4.2 如何重构具体的LIS序列?
标准解法只返回长度,如果需要具体序列,需要额外记录:
python复制def getLIS(nums):
n = len(nums)
f = [1] * n
prev = [-1] * n # 记录前驱节点
for i in range(n):
for j in range(i):
if nums[j] < nums[i] and f[j] + 1 > f[i]:
f[i] = f[j] + 1
prev[i] = j
max_len = max(f)
last = f.index(max_len)
res = []
while last != -1:
res.append(nums[last])
last = prev[last]
return res[::-1]
这个扩展在需要分析具体序列时非常有用,比如在生物信息学中分析DNA序列的保守区域。
5. 进阶挑战与扩展思考
5.1 带权值的LIS问题
如果每个元素有权值,要求找出权值和最大的递增子序列,该如何修改算法?
解法:在动态规划中,将长度改为权值和:
python复制def weightedLIS(nums, weights):
n = len(nums)
f = weights.copy()
for i in range(n):
for j in range(i):
if nums[j] < nums[i]:
f[i] = max(f[i], f[j] + weights[i])
return max(f)
5.2 三维LIS问题
当元素是三维向量时,如何定义和求解LIS?这是一个开放性问题,通常需要根据具体应用场景设计比较规则。
我在处理一些物理仿真数据时遇到过类似问题,最终采用的解决方案是定义优先级:先比较x坐标,再比较y,最后比较z。这种定义虽然不完美,但在特定场景下足够有效。
6. 习题精练与资源推荐
6.1 推荐练习顺序
-
基础模板题:
- LeetCode 300 (标准LIS)
- LeetCode 334 (简化版)
-
经典变种:
- LeetCode 354 (俄罗斯套娃)
- LeetCode 646 (数对链)
-
进阶挑战:
- LeetCode 1671 (山形数组)
- LeetCode 1964 (障碍赛跑)
6.2 学习资源
- 《算法导论》动态规划章节
- 《编程珠玑》中关于算法优化的讨论
- LeetCode讨论区的高质量题解
我在学习LIS算法时,发现结合多种资源比只看一种效果更好。特别是不同作者对同一问题的视角差异,往往能带来新的启发。
最后分享一个实用技巧:当遇到新的序列问题时,先思考它是否可以转化为LIS问题。这种转化思维在解决复杂问题时往往能事半功倍。
