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 < inums[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]

  1. i = 0 (nums[0]=10): dp[0] = 1

  2. i = 1 (nums[1]=9): 向前看,没有比 9 小的数。dp[1] = 1

  3. i = 2 (nums[2]=2): 向前看,没有比 2 小的数。dp[2] = 1

  4. i = 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]
  5. 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]
  6. i = 5 (nums[5]=7): 向前看…

    • j=2, nums[2]=2 < 7dp[5] = max(1, dp[2]+1) = 2
    • j=3, nums[3]=5 < 7dp[5] = max(2, dp[3]+1) = max(2, 2+1) = 3
    • j=4, nums[4]=3 < 7dp[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)}") # 输出: 4

5. 复杂度分析

  • 时间复杂度: 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 后面。显然,BA “更有潜力”,因为它结尾的数字更小,更容易接纳后续的新数字。

核心思想:如果我们想让上升子序列尽可能长,我们就希望它上升得尽可能缓慢。也就是说,对于相同长度的上升子序列,我们只关心那个结尾数字最小的。

基于这个思想,我们维护一个数组 tails,其中 tails[i] 存储的是所有长度为 i+1 的上升子序列中,结尾数字的最小值

2. 严谨原理:tails 数组的维护

  • 状态定义tails 是一个有序数组,tails[i] 表示长度为 i+1 的 LIS 的最小结尾值。

  • 算法流程
    我们遍历原数组 nums 中的每个数字 num

    1. 如果 numtails 数组中所有数字都大,说明 num 可以接在最长的那个 LIS 后面,形成一个更长的 LIS。我们直接将 num 追加tails 数组末尾。这使 LIS 的长度增加了 1。
    2. 如果 numtails 数组的某个范围内,说明它不能延长当前的 LIS。但是,它有可能优化某个长度的 LIS。我们找到 tails 数组中第一个大于或等于 num 的数字 tails[i],并用 num 替换它。这相当于我们找到了一个同样长度为 i+1,但结尾更小(更有潜力)的上升子序列。

由于 tails 数组始终保持有序,查找“第一个大于或等于 num 的数字”这个操作,可以用二分查找来高效完成。

3. 动画式案例

nums = [10, 9, 2, 5, 3, 7, 101, 18]
tails = []

  1. num = 10: tails 为空,追加。tails = [10]
  2. num = 9: tails 中第一个 >=9 的是 10。替换。tails = [9]
  3. num = 2: tails 中第一个 >=2 的是 9。替换。tails = [2]
  4. num = 5: 5 > 2,追加。tails = [2, 5]
  5. num = 3: tails 中第一个 >=3 的是 5。替换。tails = [2, 3]
  6. num = 7: 7 > 3,追加。tails = [2, 3, 7]
  7. num = 101: 101 > 7,追加。tails = [2, 3, 7, 101]
  8. 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)}") # 输出: 4

5. 复杂度分析

  • 时间复杂度: O(n log n)。外层循环遍历 n 个数字,内层是一个二分查找,其时间复杂度为 log k,其中 ktails 数组的当前长度 (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) for j < i and nums[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)**。