外观
从 O(n²) 到 O(nlogn):一文搞懂 LIS 贪心二分
为什么 LIS 是动态规划与二分优化的经典题
最长递增子序列(Longest Increasing Subsequence,简称 LIS)要求在一个数组中,找出元素严格递增、且不要求连续的最长子序列。例如 [10, 9, 2, 5, 3, 7, 101, 18] 的最长递增子序列长度是 4,如 [2, 3, 7, 101]。
这道题之所以高频,是因为它同时覆盖两类核心能力:
- 动态规划建模能力:用
dp[i]表示以第i个元素结尾的 LIS 长度; - 贪心与二分优化能力:维护不同长度下的最小结尾值,把内层线性扫描降为二分查找。
很多同学能背出 tails 数组写法,却不理解为什么“替换”不会破坏真实序列,也不知道如何还原具体方案。本文从 O(n²) 动态规划出发,逐步推导 O(nlogn) 的耐心排序思想。
方案一:动态规划,最直观的 O(n²) 解法
状态定义与转移方程
定义:
text
dp[i]:以 nums[i] 结尾的最长递增子序列长度因为子序列可以接在任意更早且更小的元素后面,所以:
text
当 j < i 且 nums[j] < nums[i] 时:
dp[i] = max(dp[i], dp[j] + 1)每个元素自身构成长度为 1 的子序列,因此 dp 初始值全为 1。
可运行 Python 代码
python
def length_of_lis_dp(nums):
if not nums:
return 0
n = len(nums)
# dp[i] 表示以 nums[i] 结尾的 LIS 长度
dp = [1] * n
answer = 1
for i in range(n):
for j in range(i):
# 严格递增:前一个元素必须小于当前元素
if nums[j] < nums[i]:
dp[i] = max(dp[i], dp[j + 1] if False else dp[j] + 1)
answer = max(answer, dp[i])
return answer
nums = [10, 9, 2, 5, 3, 7, 101, 18]
print(length_of_lis_dp(nums)) # 4上面代码中 dp[j] + 1 是核心转移;刻意保留的 if False 没有实际意义,实际编写时直接写 dp[j] + 1 即可。更清晰的正式版本如下:
python
def length_of_lis_dp_clean(nums):
if not nums:
return 0
dp = [1] * len(nums)
for i in range(len(nums)):
for j in range(i):
if nums[j] < nums[i]:
dp[i] = max(dp[i], dp[j] + 1)
return max(dp)复杂度与适用场景
- 时间复杂度:O(n²),两层循环枚举当前位置和前驱位置;
- 空间复杂度:O(n),需要
dp数组; - 优点:逻辑直观,适合理解子序列类动态规划;
- 缺点:当
n达到10^5时,约10^10次比较,通常会超时。
方案二:耐心排序思想,维护最小结尾值
贪心直觉:结尾越小,未来越容易接元素
假设我们已经有两个长度相同的递增子序列:
text
序列 A:[2, 9]
序列 B:[2, 5]它们长度都为 2,但 [2, 5] 的结尾更小。未来如果出现 7,序列 B 可以扩展为 [2, 5, 7],而序列 A 不能。
因此,对于每一种长度,我们只关心一个信息:能够形成该长度的最小结尾值。
维护数组 tails:
text
tails[k] 表示长度为 k + 1 的递增子序列的最小可能结尾值tails 始终保持严格递增。
处理规则
遍历每个数字 x:
- 如果
x大于tails最后一个元素,说明可以延长最长递增子序列,直接追加; - 否则,在
tails中找到第一个大于等于x的位置,并用x替换它; - 替换不是修改某个真实子序列,而是更新“某长度的最小结尾”。
查找第一个大于等于 x 的位置,正是二分查找中的 lower_bound。
O(nlogn) 贪心二分代码推导
使用 bisect_left 计算长度
Python 标准库中的 bisect_left 对应 lower_bound:返回第一个大于等于目标值的位置。
python
from bisect import bisect_left
def length_of_lis_binary_search(nums):
# tails[len - 1] 表示长度为 len 的递增子序列的最小结尾
tails = []
for x in nums:
pos = bisect_left(tails, x)
if pos == len(tails):
# x 比所有结尾都大,可以延长当前最长序列
tails.append(x)
else:
# 降低某一长度的最小结尾,为未来元素留出空间
tails[pos] = x
return len(tails)
nums = [10, 9, 2, 5, 3, 7, 101, 18]
print(length_of_lis_binary_search(nums)) # 4用数组手动模拟二分过程
以输入 [10, 9, 2, 5, 3, 7, 101, 18] 为例:
text
10 -> tails = [10]
9 -> tails = [9]
2 -> tails = [2]
5 -> tails = [2, 5]
3 -> tails = [2, 3]
7 -> tails = [2, 3, 7]
101 -> tails = [2, 3, 7, 101]
18 -> tails = [2, 3, 7, 18]最终 tails 长度为 4,所以答案是 4。注意最后数组 [2, 3, 7, 18] 恰好也是一个真实 LIS,但在更多数据中,tails 本身不一定等于真实的最长递增子序列,它首先是一张“最小结尾表”。
不用标准库:手写二分边界
python
def length_of_lis_manual_binary_search(nums):
tails = []
for x in nums:
left, right = 0, len(tails)
# 寻找第一个大于等于 x 的位置
while left < right:
mid = (left + right) // 2
if tails[mid] < x:
left = mid + 1
else:
right = mid
if left == len(tails):
tails.append(x)
else:
tails[left] = x
return len(tails)这里使用左闭右开区间 [left, right),循环结束时 left == right,即 lower_bound 位置。
复杂度分析
- 每个元素执行一次二分查找:O(logn);
- 总共处理 n 个元素:O(nlogn);
tails最长为 n:空间复杂度 O(n)。
如何还原一条真实的最长递增子序列
如果题目只要长度,直接返回 len(tails);如果要求输出具体序列,就不能简单认为 tails 一定是答案。可以额外记录每个元素被放入 tails 时对应的长度,再倒推前驱。
python
from bisect import bisect_left
def build_lis(nums):
tails = [] # 最小结尾表
tail_indices = [] # 对应结尾元素在 nums 中的下标
prev = [-1] * len(nums) # 当前元素选择的前驱下标
lengths = [0] * len(nums) # 当前元素能形成的 LIS 长度
for i, x in enumerate(nums):
pos = bisect_left(tails, x)
if pos == len(tails):
tails.append(x)
tail_indices.append(i)
else:
tails[pos] = x
tail_indices[pos] = i
lengths[i] = pos + 1
if pos > 0:
prev[i] = tail_indices[pos - 1]
# 从最终最长长度对应的最后一个位置倒推
last_index = tail_indices[-1]
result = []
while last_index != -1:
result.append(nums[last_index])
last_index = prev[last_index]
result.reverse()
return result
print(build_lis([10, 9, 2, 5, 3, 7, 101, 18]))
# 可能输出:[2, 3, 7, 18]倒推的关键是:当元素占据 tails[pos] 时,它可以接在长度 pos 的最优结尾之后,因此其前驱来自 tail_indices[pos - 1]。
动态规划与贪心二分如何选型
简单决策原则:
- n ≤ 2000,且题目需要统计方案数、带限制转移,动态规划通常更稳;
- n ≥ 10^5,只问最长长度,优先贪心二分;
- 需要还原具体序列时,在二分过程中额外记录前驱;
- 如果题目改成最长非递减子序列,比较条件和二分函数都要同步调整。
避坑总结:高频错误现象与修正方法
1. 把严格递增写成非递减
错误现象: 输入 [2, 2],严格 LIS 长度应为 1,却得到 2。
常见错误是 DP 中写成:
python
if nums[j] <= nums[i]: # 错误:这是非递减
dp[i] = max(dp[i], dp[j] + 1)修正方法: 严格递增必须使用 <,二分优化必须使用 bisect_left。如果题意是非递减,则 DP 改为 <=,二分改为 bisect_right。
2. 误以为 tails 一定是一条真实 LIS
错误现象: 某些替换发生后,tails 中的元素可能来自时间顺序不一致的多个候选序列,直接返回 tails 可能不是合法子序列。
修正方法: 只求长度时,len(tails) 始终可靠;要求具体方案时,记录 prev 和每个位置对应的长度,再从最长位置倒序还原。
3. 手写二分漏掉空数组或边界
错误现象: right 初始化成 len(tails) - 1,空数组时访问越界;或者循环条件写成 left <= right,却没有正确处理插入位置。
修正方法: 推荐固定使用左闭右开写法:
python
left, right = 0, len(tails)
while left < right:
mid = (left + right) // 2这样即使 tails 为空,也会自然得到插入位置 0。
4. 只背模板,不理解替换含义
错误现象: 看到元素没有让 tails 变长,就认为处理无效;实际上替换可能降低某个长度的结尾,为后续更长序列创造条件。
修正方法: 牢记 tails[pos] = x 的含义是“长度 pos + 1 的最小结尾变得更小”,不是“把当前真实 LIS 的某个元素改掉”。
结语
LIS 的 O(n²) 动态规划解决的是“从哪些前驱转移”的问题,而 O(nlogn) 贪心二分解决的是“如何快速维护最优状态”的问题。前者适合打牢状态转移基础,后者体现了耐心排序中按牌堆维护最小牌面的思想。
真正掌握这道题,不只是记住 bisect_left,而是能解释清楚三件事:为什么只保留最小结尾、为什么替换不影响答案长度、以及需要具体方案时如何通过前驱数组还原。
免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。