从朴素 DP 到贪心 + 二分:彻底搞懂最长递增子序列(LIS)
在算法面试中,最长递增子序列(Longest Increasing Subsequence, LIS) 是一道经典题目。它看似简单,却能引出从基础动态规划到高级优化技巧的完整演进。
本文将带你一步步理解 LIS 的两种解法,并重点讲解最优的 O(n log n) 解法——它背后的思想,竟然来自一个纸牌游戏!
🧩 问题回顾
题目链接:300. 最长递增子序列 - 力扣(LeetCode)
给定一个整数数组 nums,找出其中最长严格递增子序列的长度。
子序列不要求连续,但必须保持原有顺序,且严格递增(不能相等)。
示例:
输入: [10,9,2,5,3,7,101,18]
输出: 4
可能的 LIS: [2,3,7,101] 或 [2,3,7,18]
第一步:朴素动态规划(O(n²))—— “逐个拼接”
💡 思路
定义 dp[i] 表示 以 nums[i] 结尾的最长递增子序列的长度。
- 初始化:每个元素自己就是一个长度为 1 的子序列 →
dp[i] = 1 - 转移:对每个
i,遍历所有j < i,若nums[j] < nums[i],则dp[i] = max(dp[i], dp[j] + 1)
✅ 代码
var lengthOfLIS = function(nums) {
const n = nums.length;
if (n === 0) return 0;
const dp = new Array(n).fill(1);
for (let i = 0; i < n; i++) {
for (let j = 0; j < i; j++) {
if (nums[j] < nums[i]) {
dp[i] = Math.max(dp[i], dp[j] + 1);
}
}
}
return Math.max(...dp);
};
⏱️ 时间复杂度:O(n²)
力扣勉强通过...
当 n = 10⁵ 时,会超时 ❌
第二步:优化到 O(n log n) —— 贪心 + 二分查找
朴素 DP 的瓶颈在于:每次都要检查前面所有元素。
但我们真的需要知道“以谁结尾”吗?
不需要!我们只关心:对于每个可能的长度 L,最小的末尾值是多少。
于是,我们维护一个数组 f:
f[k]表示:存在一个长度为k+1的递增子序列,其末尾元素的最小可能值是f[k]。
这个数组有两大优点:
- 严格递增 → 可用二分查找
- 末尾越小越好 → 贪心策略
🔍 算法流程
对每个新数字 x:
- 如果
x > f的最后一个元素 → 可以延长 LIS,f.push(x) - 否则 → 在
f中找到第一个 ≥ x 的位置,用x替换该位置的值(让末尾更小)
✅ 推荐写法(清晰直观)
var lengthOfLIS = function(nums) {
if (nums.length === 0) return 0;
let f = [nums[0]]; // 第一个数自成一个长度为1的序列
const binarySearch = (x) => {
let left = 0, right = f.length;
while (left < right) {
const mid = left + ((right - left) >> 1);
if (f[mid] < x) {
left = mid + 1;
} else {
right = mid;
}
}
return left; // 第一个 >= x 的位置
};
for (let i = 1; i < nums.length; i++) {
if (nums[i] > f[f.length - 1]) {
f.push(nums[i]); // 能延长,就新建一个“堆”
} else {
f[binarySearch(nums[i])] = nums[i]; // 优化某个长度的末尾
}
}
return f.length; // f 的长度就是 LIS 长度!
};
💡 这种写法语义清晰:
push表示新增长度,赋值表示优化已有长度。
📌 其他写法
有些实现会从空数组开始,用一个变量 result 手动维护当前长度:
let top=[];
let result =0;
for(let i =0;i<nums.length;i++)
{
let target =nums[i];
let left =0;
let right =result;
while(left<right){
let mid =left+((right-left)>>1)
if(top[mid]>target)
right=mid;
else if(top[mid]<target)
left=mid+1;
else
right=mid;
}
if(left===result)
result++;
top[left]=target;
}
return result;
逻辑完全等价,但在 JavaScript 中,利用数组的动态特性(如 push)会让代码更简洁、安全。
当然了如果你想理解下面耐心排序的模型,使用这段代码理解也许是一个不错的选择,当你理解了这个纸牌游戏,再回过头看会发现一切都是自然而然
🃏 终极比喻:耐心排序(Patience Sorting)
这个算法的名字来源于一个纸牌游戏:
规则:
- 你有一副打乱的扑克牌(对应
nums)- 从左到右依次摸牌
- 只能把点数小的牌压到点数比它大的牌上,如果有多个牌堆可以选择,则放在最左边满足条件的牌堆上
- 如果没有合适的堆,就新建一堆放在最右边
这样的规则保证了堆顶的牌是有序的,满足子序列递增
神奇结论:最终的堆数 = 最长递增子序列的长度!
详细题解:耐心排序
举个例子:
牌序:[10, 9, 2, 5, 3, 7, 101, 18]
| 摸到 | 牌堆顶部(每堆只看最上面) |
|---|---|
| 10 | [10] |
| 9 | [9] |
| 2 | [2] |
| 5 | [2, 5] |
| 3 | [2, 3] |
| 7 | [2, 3, 7] |
| 101 | [2, 3, 7, 101] |
| 18 | [2, 3, 7, 18] |
✅ 最终有 4 堆 → LIS 长度为 4!
而我们的数组 f 正好记录了每堆的顶部数字,所以 f.length 就是答案。
💡 贪心体现在哪?
总是把新牌放到最左边合适的堆上 —— 这保证了堆顶尽可能小,未来更容易接新牌!
✅ 总结:LIS 解法全景对比
| 方法 | 时间复杂度 | 空间复杂度 | 是否推荐 | 适用场景 |
|---|---|---|---|---|
| 朴素动态规划 | O(n²) | O(n) | ❌ | n ≤ 2000,或需还原具体子序列 |
| 贪心 + 二分查找 | O(n log n) | O(n) | ✅✅✅ | 只要求长度(主流解法) |
🔑 三大核心要点
- 状态压缩:不再记录“以谁结尾”,而是维护
f[k] = 长度为 k+1 的 LIS 的最小末尾。 - 单调性保障:
f数组天然严格递增,使得二分查找成为可能。 - 贪心本质:用更小的末尾“替换”已有值,不改变当前长度,但为未来扩展创造更好条件。
🃏 最佳理解模型:耐心排序
- 每堆顶部构成数组
f - 堆数 =
f.length= LIS 长度 - “放最左边可行堆” 是贪心策略的关键,确保堆数最小且等于 LIS
🎯 终极口诀:
“长度看堆数,序列不用管;末尾越小越好,二分来帮忙。”
掌握这套思想,你不仅能秒杀 LIS,还能举一反三应对许多“最长/最短满足条件子序列”类问题。算法之美,正在于此!