想系统提升编程能力、查看更完整的学习路线,欢迎访问 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分钟口述解法一的DP思路,展示你理解动态规划
- 立即优化到🏆解法二,重点讲解"为什么维护最小末尾值是对的"
- 强调这是LIS问题的经典最优解,时间O(n log n)已达标准
- 手动测试边界用例(全递增、全递减),展示深入理解
🎤 面试现场
模拟面试中的完整对话流程,帮你练习"边想边说"。
面试官:请你解决一下"最长递增子序列"问题。
你:(审题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)
💡 底层原理(选读)
为什么贪心+二分是对的?
贪心正确性:相同长度的递增子序列,末尾值越小,后续能接续的数就越多。维护最小末尾值为后续留最大空间
tails数组的性质:tails本身一定严格递增(长度更长的子序列末尾必然更大),保证二分查找的有效性
替换vs追加:
- 追加:找到更长的子序列(新纪录)
- 替换:用更小的末尾值优化该长度子序列,为未来铺路
时间复杂度推导:
- 外层循环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)
易错点 ⚠️
-
错误:认为tails存储的是真实子序列
- 原因:tails[i]只是长度i+1的子序列的最小末尾,数组本身不一定构成子序列
- 正确理解:tails是辅助数组,用于快速判断和更新长度
-
错误:使用bisect_right而非bisect_left
- 原因:题目要求"严格递增",相等时应替换(保持最小末尾)
- 正确做法:用bisect_left,相等时返回左边界,保证替换而非追加
-
错误:解法一中dp初始化为0
- 原因:每个元素单独都能成长度为1的子序列
- 正确做法:dp = [1] * n
🏗️ 工程实战(选读)
这个算法思想在真实项目中的应用,让你知道"学了有什么用"。
-
场景1:版本控制系统(Git)中寻找"最长公共子序列"来计算差异
- LIS是LCS(最长公共子序列)的特例,广泛用于diff算法
-
场景2:股票分析中寻找"最长上涨趋势"
- 将股价序列视为数组,LIS长度表示最长连续上涨天数
-
场景3:搜索引擎中的"查询建议排序"
- 根据用户输入历史,找出最长的递增频率序列,优先推荐
🏋️ 举一反三
完成本课后,试试这些同类题目来巩固知识:
| 题目 | 难度 | 相关知识点 | 提示 |
|---|---|---|---|
| LeetCode 354. 俄罗斯套娃信封问题 | Hard | LIS变体+排序 | 先按宽度排序,高度求LIS |
| LeetCode 673. 最长递增子序列的个数 | Medium | LIS+计数DP | 额外维护count数组 |
| LeetCode 646. 最长数对链 | Medium | LIS思想+贪心 | 按结尾排序,贪心选择 |
| LeetCode 1964. 找出到每个位置为止最长的有效障碍赛跑路线 | Hard | LIS模板题 | 直接套用本课解法二 |
📝 课后小测
试试这道变体题,不要看答案,自己先想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 学习资料都在这里,后续复习和拓展会更省时间。