面试必会!最长递增子序列的最优解法详解

63 阅读5分钟

从朴素 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]

这个数组有两大优点:

  1. 严格递增 → 可用二分查找
  2. 末尾越小越好 → 贪心策略

🔍 算法流程

对每个新数字 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
  • 从左到右依次摸牌
  • 只能把点数小的牌压到点数比它大的牌上,如果有多个牌堆可以选择,则放在最左边满足条件的牌堆上
  • 如果没有合适的堆,就新建一堆放在最右边

这样的规则保证了堆顶的牌是有序的,满足子序列递增 image.png 神奇结论:最终的堆数 = 最长递增子序列的长度! 详细题解:耐心排序

举个例子:

牌序:[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)✅✅✅只要求长度(主流解法)

🔑 三大核心要点

  1. 状态压缩:不再记录“以谁结尾”,而是维护 f[k] = 长度为 k+1 的 LIS 的最小末尾
  2. 单调性保障f 数组天然严格递增,使得二分查找成为可能。
  3. 贪心本质:用更小的末尾“替换”已有值,不改变当前长度,但为未来扩展创造更好条件。

🃏 最佳理解模型:耐心排序

  • 每堆顶部构成数组 f
  • 堆数 = f.length = LIS 长度
  • “放最左边可行堆” 是贪心策略的关键,确保堆数最小且等于 LIS

🎯 终极口诀
“长度看堆数,序列不用管;末尾越小越好,二分来帮忙。”

掌握这套思想,你不仅能秒杀 LIS,还能举一反三应对许多“最长/最短满足条件子序列”类问题。算法之美,正在于此!