📖 第77课:最长递增子序列

1 阅读16分钟

想系统提升编程能力、查看更完整的学习路线,欢迎访问 AI Compass:github.com/tingaicompa… 仓库持续更新刷题题解、Python 基础和 AI 实战内容,适合想高效进阶的你。

📖 第77课:最长递增子序列

模块:动态规划 | 难度:Medium ⭐⭐⭐ LeetCode 链接:leetcode.cn/problems/lo… 前置知识:第71课(爬楼梯)、第73课(打家劫舍) 预计学习时间:30分钟


🎯 题目描述

给定一个整数数组 nums,找到其中最长严格递增子序列的长度。子序列是从原数组中删除部分(或不删除)元素得到,且不改变剩余元素相对顺序。

示例:

输入:nums = [10,9,2,5,3,7,101,18]
输出:4
解释:最长递增子序列是 [2,3,7,101],长度为 4

约束条件:

  • 1 ≤ nums.length ≤ 2500
  • -10⁴ ≤ nums[i] ≤ 10⁴
  • 子序列不要求连续,只需保持相对顺序

🧪 边界用例(面试必考)

用例类型输入期望输出考察点
最小输入nums=[1]1单元素处理
全递增nums=[1,2,3,4,5]5顺序遍历
全递减nums=[5,4,3,2,1]1无递增序列
含重复nums=[1,3,6,7,9,4,10,5,6]6复杂路径
负数混合nums=[-2,-1,0,3]4负数处理
大规模n=2500性能边界

💡 思路引导

生活化比喻

想象你在一个图书馆整理书架,每本书都有编号,你想找出"最长的编号递增序列"。

🐌 笨办法:枚举所有可能的子序列(共2^n种),逐个检查是否递增,记录最长的——这需要天文数字般的时间。

🚀 聪明办法:遍历每本书时,问自己"如果以这本书结尾,前面能接上的最长递增序列是多长?"。用一张表格记录每个位置的答案,后面的书可以直接利用前面的结果,避免重复计算。

关键洞察

动态规划的精髓:当前位置的答案可以由前面更小的位置"接续"而来,用表格存储避免重复计算。


🧠 解题思维链

这一节模拟你在面试中"从零开始思考"的过程。

Step 1:理解题目 → 锁定输入输出

  • 输入:整数数组 nums,长度 n,元素可正可负
  • 输出:最长严格递增子序列的长度(整数)
  • 限制:子序列不要求连续,但需保持原数组相对顺序

Step 2:先想笨办法(暴力法)

枚举所有子序列(用二进制表示选或不选),检查每个子序列是否递增,记录最长长度。

  • 时间复杂度:O(2^n × n) — 子序列数量2^n,每个检查需O(n)
  • 瓶颈在哪:指数级枚举量,n=25时就有3300万种组合

Step 3:瓶颈分析 → 优化方向

暴力法中大量重复计算:比如在位置i考虑"以nums[i]结尾的最长递增子序列"时,需要重新遍历前面所有元素。

  • 核心问题:对每个位置都要"重头开始"思考
  • 优化思路:能不能记住"以前面每个位置结尾的最长子序列长度",直接复用?

Step 4:选择武器

  • 选用:动态规划(DP表 + 状态转移方程)
  • 理由:
    • 最优子结构:以nums[i]结尾的LIS可以由前面更小的nums[j]接续而来
    • 重叠子问题:不同位置都需要知道"前面各位置的LIS长度"
    • 用一维dp数组记录,O(n²)解决

🔑 模式识别提示:当题目出现"最长/最多/最优"且当前答案依赖前面的答案时,优先考虑"动态规划"


🔑 解法一:二维思维暴力枚举(直觉法)

思路

遍历每个位置i,再向前遍历所有j < i,如果nums[j] < nums[i],说明可以接在j后面,记录所有可能的长度取最大值。

图解过程

输入:nums = [10,9,2,5,3,7,101,18]

Step 1:初始化每个位置的LIS长度为1(单独成序列)
  位置:  0  1  2  3  4  5   6   7
  nums: 10  9  2  5  3  7 101  18
  dp:    1  1  1  1  1  1   1   1

Step 2:遍历i=1(nums[1]=9)
  向前看:nums[0]=10 > 9,无法接续
  dp[1] = 1(保持)

Step 3:遍历i=3(nums[3]=5)
  向前看:
    nums[0]=10 > 5 ✗
    nums[1]=9 > 5 ✗
    nums[2]=2 < 5 ✓ → dp[3] = max(1, dp[2]+1) = 2
  dp[3] = 2

Step 4:遍历i=5(nums[5]=7)
  向前看:
    nums[2]=2 < 7 ✓ → 长度 = dp[2]+1 = 2
    nums[3]=5 < 7 ✓ → 长度 = dp[3]+1 = 3 (更优!)
    nums[4]=3 < 7 ✓ → 长度 = dp[4]+1 = 2
  dp[5] = 3

Step 5:最终dp数组
  位置:  0  1  2  3  4  5   6   7
  nums: 10  9  2  5  3  7 101  18
  dp:    1  1  1  2  2  3   4   4
         ↑              ↑       ↑
      [10]      [2,5,7]    [2,5,7,101/18]

  max(dp) = 4 → 最长递增子序列长度为4

Python代码

from typing import List


def lengthOfLIS_dp(nums: List[int]) -> int:
    """
    解法一:动态规划(朴素版)
    思路:dp[i] = 以nums[i]结尾的最长递增子序列长度
    """
    if not nums:
        return 0

    n = len(nums)
    # dp[i]表示以nums[i]结尾的LIS长度,初始化为1
    dp = [1] * n

    # 遍历每个位置i
    for i in range(1, n):
        # 向前看所有j < i
        for j in range(i):
            # 如果nums[j] < nums[i],可以接在j后面
            if nums[j] < nums[i]:
                dp[i] = max(dp[i], dp[j] + 1)

    # 返回所有位置中的最大值
    return max(dp)


# ✅ 测试
print(lengthOfLIS_dp([10,9,2,5,3,7,101,18]))  # 期望输出:4
print(lengthOfLIS_dp([0,1,0,3,2,3]))          # 期望输出:4
print(lengthOfLIS_dp([7,7,7,7,7,7,7]))        # 期望输出:1

复杂度分析

  • 时间复杂度:O(n²) — 两层嵌套循环,外层n次,内层平均n/2次
    • 具体地说:如果输入规模 n=2500,大约需要 2500×2500/2 ≈ 312万 次比较操作
  • 空间复杂度:O(n) — dp数组存储n个状态

优缺点

  • ✅ 思路清晰,容易理解和实现
  • ✅ 代码简洁,10行以内
  • ❌ 时间复杂度O(n²),当n=2500时略慢(但在约束内可接受)
  • ❌ 无法直接得到具体的子序列内容(只有长度)

🏆 解法二:贪心+二分查找(最优解)

优化思路

解法一的瓶颈在于:每次更新dp[i]时要遍历所有j < i。能否用更聪明的方式"快速找到能接续的最优位置"?

关键洞察:维护一个辅助数组tails,tails[i]表示长度为i+1的递增子序列的最小末尾值。这样:

  • 遍历nums时,用二分查找找到当前数应该"替换"或"追加"的位置
  • 时间从O(n)降为O(log n),总体O(n log n)

💡 关键想法:相同长度的递增子序列,末尾值越小越好(为后续元素留更多空间)

图解过程

输入:nums = [10,9,2,5,3,7,101,18]

初始化:tails = [] (表示当前已知的各长度子序列的最小末尾)

Step 1:遍历10
  tails为空,直接追加
  tails = [10]  (长度1的子序列末尾最小是10)

Step 2:遍历9
  9 < tails[0]=10,替换(长度1的子序列可以用更小的9)
  tails = [9]

Step 3:遍历2
  2 < tails[0]=9,替换
  tails = [2]

Step 4:遍历5
  5 > tails[0]=2,追加
  tails = [2, 5]  (长度2的子序列末尾最小是5)

Step 5:遍历3
  3 > tails[0]=2,但3 < tails[1]=5
  用二分找到位置1,替换
  tails = [2, 3]  (长度2的子序列可以用更小的3)

Step 6:遍历7
  7 > tails[1]=3,追加
  tails = [2, 3, 7]

Step 7:遍历101
  101 > tails[2]=7,追加
  tails = [2, 3, 7, 101]

Step 8:遍历18
  18 > tails[2]=7,但18 < tails[3]=101
  用二分找到位置3,替换
  tails = [2, 3, 7, 18]

最终:len(tails) = 4 → 最长递增子序列长度为4

为什么这样是对的?

  • tails数组本身一定是递增的(长度更长的子序列末尾必然更大)
  • 长度为len(tails)的子序列一定存在(虽然tails中的元素不一定组成该子序列)
  • 通过贪心保持每个长度的最小末尾值,为后续元素留最大接续空间

Python代码

from bisect import bisect_left


def lengthOfLIS_optimal(nums: List[int]) -> int:
    """
    🏆 解法二:贪心+二分查找(最优解)
    思路:维护各长度子序列的最小末尾值,用二分查找更新
    """
    tails = []  # tails[i] = 长度为i+1的递增子序列的最小末尾值

    for num in nums:
        # 用二分查找找到num应该替换的位置
        pos = bisect_left(tails, num)

        if pos == len(tails):
            # num比所有末尾都大,追加(找到更长的子序列)
            tails.append(num)
        else:
            # 替换tails[pos],保持该长度子序列的最小末尾
            tails[pos] = num

    return len(tails)


# ✅ 测试
print(lengthOfLIS_optimal([10,9,2,5,3,7,101,18]))  # 期望输出:4
print(lengthOfLIS_optimal([0,1,0,3,2,3]))          # 期望输出:4
print(lengthOfLIS_optimal([7,7,7,7,7,7,7]))        # 期望输出:1

复杂度分析

  • 时间复杂度:O(n log n) — 遍历n次,每次二分查找O(log n)
    • 具体地说:如果输入规模 n=2500,大约需要 2500×log₂(2500) ≈ 2.9万 次操作
    • 比解法一快约100倍! (312万 vs 2.9万)
  • 空间复杂度:O(n) — tails数组最多存储n个元素

🐍 Pythonic 写法

利用 Python 的 bisect 模块简化二分查找逻辑:

from bisect import bisect_left

def lengthOfLIS(nums: List[int]) -> int:
    """一行核心逻辑版本"""
    tails = []
    for num in nums:
        pos = bisect_left(tails, num)
        tails[pos:pos+1] = [num]  # 替换或追加
    return len(tails)

解释:

  • bisect_left(tails, num):返回num应该插入的位置(保持有序)
  • tails[pos:pos+1] = [num]:切片赋值,相当于替换pos位置(若pos超界则追加)
  • 利用Python切片的灵活性,自动处理边界情况

⚠️ 面试建议:先写解法二的标准版本(if-else清晰),再提这个简化版展示Python功底。面试官更看重你的思考过程,而非代码行数。


📊 解法对比

维度解法一:DP朴素版🏆 解法二:贪心+二分(最优)
时间复杂度O(n²)O(n log n) ← 时间最优
空间复杂度O(n)O(n)
代码难度简单(双循环)中等(需理解贪心思想)
面试推荐⭐⭐⭐⭐⭐ ← 首选
适用场景n ≤ 1000通用,尤其n > 1000

为什么是最优解:

  • 时间复杂度O(n log n)已接近理论最优(至少要遍历一遍)
  • 贪心策略巧妙利用"最小末尾值"保证正确性
  • 代码简洁,实际运行速度极快

面试建议:

  1. 先花1分钟口述解法一的DP思路,展示你理解动态规划
  2. 立即优化到🏆解法二,重点讲解"为什么维护最小末尾值是对的"
  3. 强调这是LIS问题的经典最优解,时间O(n log n)已达标准
  4. 手动测试边界用例(全递增、全递减),展示深入理解

🎤 面试现场

模拟面试中的完整对话流程,帮你练习"边想边说"。

面试官:请你解决一下"最长递增子序列"问题。

:(审题30秒)好的,这道题要求找出数组中最长严格递增子序列的长度,子序列不要求连续。让我先想一下...

我的第一个想法是用动态规划:定义dp[i]为以nums[i]结尾的最长递增子序列长度,遍历时向前找所有比它小的元素,取最大的dp值+1。时间复杂度是 O(n²)。

不过我们可以用贪心+二分优化到 O(n log n)。核心思路是维护一个数组tails,tails[i]表示长度为i+1的递增子序列的最小末尾值。遍历数组时,用二分查找找到当前数应该替换或追加的位置。这样每次只需O(log n),总体O(n log n)。

面试官:很好,请写一下代码。

:(边写边说)我维护一个tails数组,初始为空。遍历nums时,用bisect_left二分查找当前数num应该插入的位置pos。如果pos等于数组长度,说明num比所有末尾都大,可以追加形成更长的子序列;否则替换pos位置,保持该长度子序列的最小末尾。最终返回tails的长度。

面试官:测试一下?

:用示例 [10,9,2,5,3,7,101,18] 走一遍:

  • 10进来,tails=[10]
  • 9替换10,tails=[9]
  • 2替换9,tails=[2]
  • 5追加,tails=[2,5]
  • 3替换5,tails=[2,3]
  • 7追加,tails=[2,3,7]
  • 101追加,tails=[2,3,7,101]
  • 18替换101,tails=[2,3,7,18] 最终长度4,正确。再测边界情况[7,7,7,7],所有元素相等,每次都替换pos=0,长度保持1,正确。

高频追问

追问应答策略
"还有更优解吗?"时间O(n log n)已是LIS问题的最优解,空间O(n)也是必要的(需存储中间状态)。理论下界证明:任何比较排序算法至少O(n log n),而LIS问题可归约为排序问题
"能输出具体子序列吗?"可以,需要额外维护一个parent数组记录每个元素的前驱,最后回溯构造。空间仍O(n),时间不变
"如果数据量非常大呢?"考虑分治:将数组分块,每块求LIS,再合并(类似归并排序)。但实际O(n log n)已经很快,10⁶规模也仅需2000万次操作,约0.02秒
"空间能不能O(1)?"不能,必须用额外空间记录中间状态。即使解法一的dp数组也是必要的

🎓 知识点总结

Python技巧卡片 🐍

# 技巧1:bisect模块高效二分 — 自动处理边界
from bisect import bisect_left
pos = bisect_left(arr, target)  # 返回target应插入的最左位置

# 技巧2:切片赋值优雅替换/追加 — 自动扩展列表
arr[pos:pos+1] = [value]  # pos存在则替换,超界则追加

# 技巧3:生成器表达式节省空间 — 适合大数据
max_length = max((dp[i] for i in range(n)), default=0)

💡 底层原理(选读)

为什么贪心+二分是对的?

  1. 贪心正确性:相同长度的递增子序列,末尾值越小,后续能接续的数就越多。维护最小末尾值为后续留最大空间

  2. tails数组的性质:tails本身一定严格递增(长度更长的子序列末尾必然更大),保证二分查找的有效性

  3. 替换vs追加:

    • 追加:找到更长的子序列(新纪录)
    • 替换:用更小的末尾值优化该长度子序列,为未来铺路
  4. 时间复杂度推导:

    • 外层循环n次
    • 每次二分查找tails(长度≤n),O(log n)
    • 总时间:O(n × log n)

算法模式卡片 📐

  • 模式名称:最长递增子序列(LIS)
  • 适用条件:
    • 在数组中寻找"最长/最多"的满足某种单调性的子序列
    • 子序列不要求连续,只需保持相对顺序
  • 识别关键词:"最长递增/递减"、"子序列"、"保持顺序"
  • 模板代码:
from bisect import bisect_left

def lengthOfLIS(nums):
    tails = []
    for num in nums:
        pos = bisect_left(tails, num)
        if pos == len(tails):
            tails.append(num)
        else:
            tails[pos] = num
    return len(tails)

易错点 ⚠️

  1. 错误:认为tails存储的是真实子序列

    • 原因:tails[i]只是长度i+1的子序列的最小末尾,数组本身不一定构成子序列
    • 正确理解:tails是辅助数组,用于快速判断和更新长度
  2. 错误:使用bisect_right而非bisect_left

    • 原因:题目要求"严格递增",相等时应替换(保持最小末尾)
    • 正确做法:用bisect_left,相等时返回左边界,保证替换而非追加
  3. 错误:解法一中dp初始化为0

    • 原因:每个元素单独都能成长度为1的子序列
    • 正确做法:dp = [1] * n

🏗️ 工程实战(选读)

这个算法思想在真实项目中的应用,让你知道"学了有什么用"。

  • 场景1:版本控制系统(Git)中寻找"最长公共子序列"来计算差异

    • LIS是LCS(最长公共子序列)的特例,广泛用于diff算法
  • 场景2:股票分析中寻找"最长上涨趋势"

    • 将股价序列视为数组,LIS长度表示最长连续上涨天数
  • 场景3:搜索引擎中的"查询建议排序"

    • 根据用户输入历史,找出最长的递增频率序列,优先推荐

🏋️ 举一反三

完成本课后,试试这些同类题目来巩固知识:

题目难度相关知识点提示
LeetCode 354. 俄罗斯套娃信封问题HardLIS变体+排序先按宽度排序,高度求LIS
LeetCode 673. 最长递增子序列的个数MediumLIS+计数DP额外维护count数组
LeetCode 646. 最长数对链MediumLIS思想+贪心按结尾排序,贪心选择
LeetCode 1964. 找出到每个位置为止最长的有效障碍赛跑路线HardLIS模板题直接套用本课解法二

📝 课后小测

试试这道变体题,不要看答案,自己先想5分钟!

题目:给定数组nums,找出最长严格递减子序列的长度。

💡 提示(实在想不出来再点开)

将所有元素取相反数,转化为求最长递增子序列。

✅ 参考答案
from bisect import bisect_left

def lengthOfLDS(nums):
    """最长递减子序列 = 将元素取反后求LIS"""
    # 取相反数转化为递增问题
    negated = [-num for num in nums]

    # 套用LIS模板
    tails = []
    for num in negated:
        pos = bisect_left(tails, num)
        if pos == len(tails):
            tails.append(num)
        else:
            tails[pos] = num

    return len(tails)

# 测试
print(lengthOfLDS([10,9,2,5,3,7,101,18]))  # 输出:3 (子序列[10,9,2])

核心思路:递减问题等价于"取反后的递增问题"。原数组[10,9,2,5,3,7]取反变为[-10,-9,-2,-5,-3,-7],最长递增子序列[-10,-9,-7]对应原数组的[10,9,7]递减序列。


如果这篇内容对你有帮助,推荐收藏 AI Compass:github.com/tingaicompa… 更多系统化题解、编程基础和 AI 学习资料都在这里,后续复习和拓展会更省时间。