Part 5.2:最长上升子序列 (LIS)
引言:寻找股价的“上升趋势”
想象一下,你是一位股票分析师,拿到了一支股票连续 N 天的收盘价序列,例如 [10, 9, 2, 5, 3, 7, 101, 18]。你的老板想知道,在这段时间里,最长的“持续上涨”周期有多长?
这里的“持续上涨”并不是指连续几天的上涨,而是指你可以从这个序列中挑选出任意多个交易日,只要它们的时间顺序保持不变,且价格一路走高。例如,你可以挑选第 3 天 (价格 2)、第 4 天 (价格 5)、第 6 天 (价格 7)、第 7 天 (价格 101),形成一个长度为 4 的上涨序列 [2, 5, 7, 101]。
这个问题,就是算法领域中著名的最长上升子序列 (Longest Increasing Subsequence, LIS) 问题。
为什么 LIS 如此重要?
- 面试价值:LIS 是 DP 问题中的常青树,几乎是所有一线大厂面试的必考题型。它不仅考察基础的 DP 思维,其 O(n log n) 的优化解法更能区分候选人的思维深度,是展现算法优化能力的绝佳舞台。
- 学术与工程价值:LIS 模型在很多领域都有应用,如生物信息学中的 DNA 序列分析、数据挖掘中的模式识别、以及各种组合优化问题。它也是许多更复杂算法问题的基础(如“俄罗斯套娃信封问题”)。
本文将带你从最直观的 O(n²) DP 解法开始,一步步揭示其瓶颈,并最终引出结合了贪心和二分查找的 O(n log n) 高效解法,让你彻底征服 LIS。
背景知识:子序列 vs. 子串
在开始之前,我们必须明确一个核心概念:子序列 (Subsequence) 和 子串 (Substring) 的区别。
- 子串:必须是原序列中连续的一部分。例如,
[2, 5, 3]是[10, 9, 2, 5, 3, 7, 101, 18]的一个子串。 - 子序列:可以不连续,但必须保持原有的相对顺序。例如,
[2, 5, 7, 101]是原序列的一个子序列,但不是子串。
LIS 求解的是子序列,而不是子串,这给了我们更大的选择自由度。
解法一:O(n²) 动态规划——朴素而强大
这是解决 LIS 问题最经典、最容易想到的 DP 方法。如果你在面试中能写出这个解法,已经证明你具备了基本的 DP 思维。
1. 算法的“直觉”:站在巨人的肩膀上
假设我们要计算以 nums[i] 这个数结尾的最长上升子序列的长度。我们应该怎么做?
一个自然的想法是:向前看。我们遍历 nums[i] 之前的所有数字 nums[j] (其中 j < i)。
- 如果
nums[j]比nums[i]小,这意味着nums[i]可以接在以nums[j]结尾的某个上升子序列的后面,形成一个更长的上升子序列。 - 我们想让这个新的子序列尽可能长,所以我们应该在所有满足
nums[j] < nums[i]的j中,找到那个拥有最长上升子序列的nums[j],然后把它“继承”过来,长度再加 1。
例如,对于序列 [... , 2, 5, 3, 7],当计算以 7 结尾的 LIS 时:
7>2,可以接在以2结尾的 LIS 后面。7>5,可以接在以5结尾的 LIS 后面。7>3,可以接在以3结尾的 LIS 后面。
我们只需要比较 len(LIS_ending_with_2), len(LIS_ending_with_5), len(LIS_ending_with_3) 这三个长度,取其中最大的,然后加 1,就是以 7 结尾的 LIS 长度。
2. 严谨原理:状态定义与状态转移方程
状态定义:
dp[i]表示以nums[i]结尾的最长上升子序列的长度。⚠️ 注意:这个定义至关重要!
dp[i]必须是以nums[i]结尾,而不是指前i个数中的 LIS 长度。状态转移方程:
对于每个i,我们需要遍历所有j < i:
如果nums[j] < nums[i],则nums[i]可以接在以nums[j]结尾的上升子序列后面。此时,我们有一个长度为dp[j] + 1的上升子序列。
我们需要在所有可能的j中找到最大的dp[j]。
因此,状态转移方程为:
**dp[i] = max(dp[j] + 1)**,其中0 ≤ j < i且nums[j] < nums[i]。**初始化 (Base Case)**:
dp数组的所有元素都初始化为1。因为任何一个数字本身都可以构成一个长度为 1 的上升子序列。最终结果:
dp数组中的最大值就是整个序列的 LIS 长度。因为 LIS 必然以序列中的某个数字结尾。
3. 动画式案例
让我们用序列 nums = [10, 9, 2, 5, 3, 7] 来模拟这个过程。
初始化: dp = [1, 1, 1, 1, 1, 1]
i = 0 (nums[0]=10):
dp[0] = 1i = 1 (nums[1]=9): 向前看,没有比 9 小的数。
dp[1] = 1i = 2 (nums[2]=2): 向前看,没有比 2 小的数。
dp[2] = 1i = 3 (nums[3]=5): 向前看…
j=2, nums[2]=2 < 5。可以更新dp[3] = max(dp[3], dp[2]+1) = max(1, 1+1) = 2。dp变为[1, 1, 1, 2, 1, 1]
i = 4 (nums[4]=3): 向前看…
j=2, nums[2]=2 < 3。可以更新dp[4] = max(dp[4], dp[2]+1) = max(1, 1+1) = 2。dp变为[1, 1, 1, 2, 2, 1]
i = 5 (nums[5]=7): 向前看…
j=2, nums[2]=2 < 7。dp[5] = max(1, dp[2]+1) = 2。j=3, nums[3]=5 < 7。dp[5] = max(2, dp[3]+1) = max(2, 2+1) = 3。j=4, nums[4]=3 < 7。dp[5] = max(3, dp[4]+1) = max(3, 2+1) = 3。dp变为[1, 1, 1, 2, 2, 3]
最终 dp 数组: [1, 1, 1, 2, 2, 3]
最终结果: max(dp) = 3。LIS 长度为 3 (例如 [2, 5, 7] 或 [2, 3, 7])。
4. Python 实现 (O(n²))
def length_of_lis_n2(nums):
if not nums:
return 0
n = len(nums)
# dp[i] 表示以 nums[i] 结尾的最长上升子序列的长度
dp = [1] * n
for i in range(n):
for j in range(i):
if nums[j] < nums[i]:
dp[i] = max(dp[i], dp[j] + 1)
return max(dp)
# --- 案例测试 ---
nums = [10, 9, 2, 5, 3, 7, 101, 18]
print(f"O(n^2) 解法, LIS 长度: {length_of_lis_n2(nums)}") # 输出: 45. 复杂度分析
- 时间复杂度:
O(n²)。因为我们有两层嵌套循环,每层都与n相关。 - 空间复杂度:
O(n)。因为我们需要一个dp数组来存储中间状态。
解法二:O(n log n) 贪心 + 二分查找——精妙的优化
O(n²) 的解法在 n 较大时(如 n > 5000)会超时。有没有更高效的方法?答案是肯定的。
1. 算法的“直觉”:让上升子序列“更有潜力”
想象一下,我们正在构建多个不同长度的上升子序列。假设我们有两个长度为 3 的上升子序列:
A = [10, 20, 30]B = [1, 2, 3]
现在来了一个新数字 4。它可以接在 B 后面形成 [1, 2, 3, 4],但不能接在 A 后面。显然,B 比 A “更有潜力”,因为它结尾的数字更小,更容易接纳后续的新数字。
核心思想:如果我们想让上升子序列尽可能长,我们就希望它上升得尽可能缓慢。也就是说,对于相同长度的上升子序列,我们只关心那个结尾数字最小的。
基于这个思想,我们维护一个数组 tails,其中 tails[i] 存储的是所有长度为 i+1 的上升子序列中,结尾数字的最小值。
2. 严谨原理:tails 数组的维护
状态定义:
tails是一个有序数组,tails[i]表示长度为i+1的 LIS 的最小结尾值。算法流程:
我们遍历原数组nums中的每个数字num。- 如果
num比tails数组中所有数字都大,说明num可以接在最长的那个 LIS 后面,形成一个更长的 LIS。我们直接将num追加到tails数组末尾。这使 LIS 的长度增加了 1。 - 如果
num在tails数组的某个范围内,说明它不能延长当前的 LIS。但是,它有可能优化某个长度的 LIS。我们找到tails数组中第一个大于或等于num的数字tails[i],并用num替换它。这相当于我们找到了一个同样长度为i+1,但结尾更小(更有潜力)的上升子序列。
- 如果
由于 tails 数组始终保持有序,查找“第一个大于或等于 num 的数字”这个操作,可以用二分查找来高效完成。
3. 动画式案例
nums = [10, 9, 2, 5, 3, 7, 101, 18]tails = []
- num = 10:
tails为空,追加。tails = [10] - num = 9:
tails中第一个 >=9 的是 10。替换。tails = [9] - num = 2:
tails中第一个 >=2 的是 9。替换。tails = [2] - num = 5:
5>2,追加。tails = [2, 5] - num = 3:
tails中第一个 >=3 的是 5。替换。tails = [2, 3] - num = 7:
7>3,追加。tails = [2, 3, 7] - num = 101:
101>7,追加。tails = [2, 3, 7, 101] - num = 18:
tails中第一个 >=18 的是 101。替换。tails = [2, 3, 7, 18]
遍历结束,tails 数组的长度为 4。所以 LIS 的长度为 4。
⚠️ 重要易错点:最终的
tails数组[2, 3, 7, 18]不是 LIS 本身!真正的 LIS 之一是[2, 3, 7, 101]。tails数组只保证了其长度是正确的。
4. Python 实现 (O(n log n))
def length_of_lis_nlogn(nums):
if not nums:
return 0
# tails[i] 存储长度为 i+1 的 LIS 的最小结尾值
tails = []
for num in nums:
# 二分查找:在 tails 中找到第一个 >= num 的位置
left, right = 0, len(tails)
while left < right:
mid = (left + right) // 2
if tails[mid] < num:
left = mid + 1
else:
right = mid
if left == len(tails):
# num 比所有 tails 中的数都大,追加
tails.append(num)
else:
# 替换
tails[left] = num
return len(tails)
# --- 案例测试 ---
nums = [10, 9, 2, 5, 3, 7, 101, 18]
print(f"O(n log n) 解法, LIS 长度: {length_of_lis_nlogn(nums)}") # 输出: 45. 复杂度分析
- 时间复杂度:
O(n log n)。外层循环遍历n个数字,内层是一个二分查找,其时间复杂度为log k,其中k是tails数组的当前长度 (k <= n)。所以总时间复杂度是O(n log n)。 - 空间复杂度:
O(n)。tails数组最长可能为n。
总结
最长上升子序列 (LIS) 是一个完美的例子,展示了如何从一个朴素的 DP 解法,通过深入思考和巧妙的观察,优化到一个更高效的算法。
本文核心要点
✅ O(n²) 解法 (基础 DP)
- 核心:
dp[i]表示以nums[i]结尾的 LIS 长度。 - 转移:
dp[i] = max(dp[j] + 1)forj < iandnums[j] < nums[i]。 - 优点:思路直观,易于理解和实现。
✅ O(n log n) 解法 (贪心 + 二分)
- 核心:维护一个
tails数组,tails[i]是长度为i+1的 LIS 的最小结尾值。 - 策略:对于新数字
num,如果它能延长 LIS 就追加,否则就去“优化”某个已有长度的 LIS,让其结尾更小。 - 优点:效率极高,是解决 LIS 问题的标准解法。
- 注意:
tails数组本身不是 LIS。
掌握了这两种方法,尤其是 O(n log n) 的解法,你在面试中遇到 LIS 及其变种问题时,将能游刃有余。在下一篇文章中,我们将探讨另一个经典的 DP 问题——**最长公共子序列 (LCS)**。