LeetCode 300. 最长递增子序列|从 DP O (n²) 到贪心 + 二分 O (n log n)

一、题目到底在问什么

给你一个整数数组 nums,找到其中最长严格递增子序列的长度。

⚠️ 核心关键词:子序列 ≠ 子数组

  • 子数组:要求元素在原数组中连续
  • 子序列:只要求保持原数组的先后顺序,中间可以跳过元素

举个例子: nums = [10, 9, 2, 5, 3, 7, 101, 18] 合法的最长递增子序列:

  • 2 → 5 → 7 → 101,长度 4
  • 2 → 3 → 7 → 101,长度 4
  • 2 → 3 → 7 → 18,长度 4

它们都不连续,但顺序和原数组一致,都是有效答案。


二、O (n²) 动态规划解法

1. 最关键:dp i 到底是什么

动态规划的核心不是写循环,而是定义状态

这道题的标准定义:

dp i 表示:以 nums i 作为结尾的最长递增子序列的长度

❌ 不是 "前 i 个数字里的最长递增子序列长度" ✅ 必须是「以 nums i 结尾」,这个定义是状态转移的基础。

举个例子: nums = [10, 9, 2, 5]

  • dp[0] = 1:以 10 结尾的最长子序列就是 [10],长度 1
  • dp[1] = 1:前面 10 > 9,没法接,只能自己 [9],长度 1
  • dp[2] = 1:前面 10、9 都比 2 大,只能自己 [2],长度 1
  • dp[3] = 2:前面 2 < 5,可以接成 [2,5],所以 dp[3] = dp[2] + 1 = 2

2. 为什么要往前遍历所有 j

当我们计算 dp[i] 时,需要看:前面有哪些数字可以接在 nums i 前面

也就是遍历所有 j = 0 ~ i-1

  • 如果 nums[j] < nums[i]:说明以 nums[j] 结尾的递增子序列,后面可以再接一个 nums[i]
  • 此时候选长度就是 dp[j] + 1

因为前面可能有多个 j 都满足条件,所以要取最大值dp[i] = Math.max(dp[i], dp[j] + 1)

这一行就是整道题最核心的状态转移。

3. 完整例子一步步推导

nums = [10, 9, 2, 5, 3, 7] 为例: 初始:每个数字自己都是长度为 1 的子序列 dp = [1, 1, 1, 1, 1, 1]

  1. i=0(numsi=10) :前面没有元素,dp[0] = 1
  2. i=1(numsi=9) :前面 10 > 9,没法接,dp[1] = 1
  3. i=2(numsi=2) :前面 10、9 都比 2 大,dp[2] = 1
  4. i=3(numsi=5)
    • j=0:10 > 5,不满足
    • j=1:9 > 5,不满足
    • j=2:2 < 5,候选长度 dp[2]+1=2
    • dp[3] = 2
  5. i=4(numsi=3)
    • 只有 j=2 满足 2 < 3,候选长度 2
    • dp[4] = 2
  6. i=5(numsi=7)
    • j=2:2 < 7 → 候选 2
    • j=3:5 < 7 → 候选 dp[3]+1=3
    • j=4:3 < 7 → 候选 dp[4]+1=3
    • 取最大值,dp[5] = 3

最终 dp = [1, 1, 1, 2, 2, 3],答案是 dp 数组的最大值 3

4. 状态转移方程

java 复制代码
if (nums[j] < nums[i]) {
    dp[i] = Math.max(dp[i], dp[j] + 1);
}

翻译成人话: 如果前面的 nums j 比当前 nums i 小,那么当前数字就可以接在以 nums j 结尾的递增子序列后面,形成更长的子序列。 因为有多个 j 可选,所以取最大的那个长度。

5. 为什么 dp 初始值全是 1

因为每个数字自身,至少都能构成长度为 1 的递增子序列。 哪怕整个数组是降序的,每个数字自己也是一个长度为 1 的子序列。

6. 完整 AC 代码

java 复制代码
import java.util.Arrays;

class Solution {
    public int lengthOfLIS(int[] nums) {
        int n = nums.length;
        int[] dp = new int[n];
        // 初始化:每个元素自身都是长度为1的子序列
        Arrays.fill(dp, 1);
        int ans = 1;

        for (int i = 0; i < n; i++) {
            // 枚举i前面所有元素
            for (int j = 0; j < i; j++) {
                // nums[i]可以接在nums[j]后面
                if (nums[j] < nums[i]) {
                    dp[i] = Math.max(dp[i], dp[j] + 1);
                }
            }
            // 更新全局最大值
            ans = Math.max(ans, dp[i]);
        }
        return ans;
    }
}

7. DP 四步总结(面试必背)

碰到动态规划题,先强迫自己回答四个问题:

  1. dp 数组代表什么? dp i = 以 nums i 结尾的最长递增子序列长度
  2. 初始值是什么? dp i = 1,每个数字自身就是一个子序列
  3. 状态怎么转移? 若 nums j < nums i,则 dp i = max (dp i, dp j + 1)
  4. 最终答案是什么? max (dp i),不是最后一个位置的值

复杂度:

  • 时间:O (n²),两层循环
  • 空间:O (n),dp 数组

三、O (n log n) 贪心 + 二分优化解法

1. 核心思想:tails 数组是什么

我们不再关心每个位置具体的 LIS 长度,而是维护一个贪心数组 tails

定义:

tails i 表示:长度为 i+1 的递增子序列中,结尾元素尽可能小的那个值

比如:

  • tails 0:长度为 1 的递增子序列,最小的结尾值
  • tails 1:长度为 2 的递增子序列,最小的结尾值
  • tails 2:长度为 3 的递增子序列,最小的结尾值

2. 为什么 "结尾越小越好"(贪心本质)

这是整个算法的灵魂。

假设现在有两个长度都是 3 的递增子序列:

  • 1 → 4 → 9
  • 1 → 4 → 6

哪个更有潜力继续往后接数字? 显然是第二个:结尾是 6,后面来个 7 就能接成 1→4→6→7;而结尾是 9 的话,7 就接不上了。

结论:对于相同长度的递增子序列,结尾越小,未来扩展能力越强。 所以我们只保留「结尾最小」的那个,这就是贪心的核心。

3. 完整例子一步步推导

还是用经典数组:nums = [10, 9, 2, 5, 3, 7, 101, 18] 初始:tails = [],有效长度 size = 0

  1. 数字 10 :数组为空,直接加入 tails = [10],size = 1 含义:长度 1 的子序列,最小结尾是 10

  2. 数字 9 :9 < 10 长度 1 的子序列,结尾 9 比 10 更优,替换掉 10 tails = [9],size 不变,还是 1

  3. 数字 2 :2 < 9 同理替换,长度 1 的最优结尾变成 2 tails = [2],size = 1

  4. 数字 5 :5 > tails 最后一个元素 2 可以直接接在后面,形成更长的子序列 tails = [2, 5],size = 2 含义:长度 2 的子序列,最小结尾是 5

  5. 数字 3 :3 < 5,不能直接接在后面 但 3 可以替换长度 2 的结尾,让结尾更小 tails = [2, 3],size 不变,还是 2 含义:长度 2 的子序列,最优结尾从 5 优化成 3

  6. 数字 7 :7 > 3,直接接在后面 tails = [2, 3, 7],size = 3

  7. 数字 101 :101 > 7,直接接在后面 tails = [2, 3, 7, 101],size = 4

  8. 数字 18 :18 < 101,不能直接接 替换长度 4 的结尾,18 比 101 更优 tails = [2, 3, 7, 18],size 不变,还是 4

最终 size = 4,也就是最长递增子序列的长度为 4。

4. 规律总结:每个数字的两种情况

对于遍历到的每个数字 num:

  1. num 比 tails 最后一个元素大:直接追加到末尾,LIS 长度 +1
  2. num 小于等于 tails 最后一个元素:在 tails 中找到第一个 >= num 的位置,替换它

5. 为什么可以二分查找

因为 tails 数组一定是严格递增的

原因很简单: 长度为 2 的递增子序列,结尾一定比长度 1 的结尾大; 长度为 3 的递增子序列,结尾一定比长度 2 的结尾大。

所以 tails 天然有序,就可以用二分查找快速定位替换位置,把 O (n) 的查找优化成 O (log n)。

6. 二分到底在找什么

我们要找的是:tails 中第一个大于等于 num 的元素的位置(lower_bound)。

找到这个位置后,用 num 替换掉这个位置的元素,完成 "优化结尾" 的操作。

7. 完整 AC 代码

java 复制代码
class Solution {
    public int lengthOfLIS(int[] nums) {
        int[] tails = new int[nums.length];
        int size = 0; // tails 中有效元素的数量

        for (int num : nums) {
            int left = 0;
            int right = size;
            // 二分查找:第一个 >= num 的位置
            while (left < right) {
                int mid = left + (right - left) / 2;
                if (tails[mid] < num) {
                    left = mid + 1;
                } else {
                    right = mid;
                }
            }
            // 替换/新增元素
            tails[left] = num;
            // 如果插在末尾,说明长度增加
            if (left == size) {
                size++;
            }
        }
        return size;
    }
}

8. 逐行代码拆解

  1. int\[\] tails = new intnums.length 开辟最大长度的数组,最坏情况整个数组递增,全部用上。

  2. int size = 0 记录 tails 中有效元素的数量,同时也是当前的 LIS 长度。 tails 数组只有 [0, size-1] 是有效部分。

  3. 二分查找部分

    • 搜索范围:左闭右开 [left, right)
    • tails[mid] < num:说明 mid 位置太小,目标一定在右边,left = mid + 1
    • 否则:mid 可能是目标,左边可能还有更早的,right = mid
    • 最终 left == right,就是第一个 >= num 的位置
  4. tailsleft = num 统一写法:

    • left < size:替换对应位置的元素,优化结尾
    • left == size:在末尾新增元素,扩展长度
  5. if (left == size) size++ 如果插入位置刚好是有效长度的末尾,说明这个数字比所有尾部都大,LIS 长度 +1。

9. 常见误区

❌ 误区:tails 数组最终就是最长递增子序列本身。 ✅ 纠正:tails 记录的是不同长度子序列的最小尾部值 ,它的元素顺序和原数组的子序列路径不一定对应。 tails 的长度等于 LIS 的长度,但 tails 本身不是具体的 LIS 序列。 如果题目要求输出具体的 LIS 序列,还需要额外记录前驱下标和路径。

10. 贪心正确性证明

核心逻辑:对相同长度的递增子序列,保留最小结尾,不会损失未来形成更长子序列的可能性。

假设存在一个更长的子序列,没有用我们保留的最小结尾,而是用了更大的结尾。 那么把那个更大的结尾换成我们保留的最小结尾,依然能形成同样长度的子序列,并且还能继续往后扩展。 因此贪心策略不会错过最优解。


四、两种解法对比

表格

解法 核心思想 时间复杂度 空间复杂度 适用场景
动态规划 DP 枚举所有前驱,计算以每个元素结尾的 LIS 长度 O(n²) O(n) 数据量小、容易理解、面试基础版
贪心 + 二分 维护各长度的最小结尾,二分替换优化 O(n log n) O(n) 数据量大、最优解、面试进阶版

五、面试口述标准回答

基础版(DP)

我用动态规划来做。定义 dp i 表示以 nums i 结尾的最长递增子序列长度,初始每个位置都是 1。 对于每个 i,遍历它前面所有的 j,如果 nums j < nums i,就用 dp j+1 更新 dp i 的最大值。 最终答案是 dp 数组的最大值。时间复杂度 O (n²),空间 O (n)。

进阶版(贪心 + 二分)

可以进一步优化到 O (n log n)。 我用贪心维护一个 tails 数组,tails i 表示长度为 i+1 的递增子序列中最小的结尾元素。 对于每个数字,如果它比当前所有尾部都大,就扩展 LIS 长度;否则用二分查找找到第一个大于等于它的位置并替换。 因为相同长度的子序列,结尾越小未来越容易扩展,所以保留最小结尾不会损失最优解。 tails 数组始终有序,因此每次二分是 O (log n),总时间 O (n log n),空间 O (n)。


六、最终核心总结

  1. DP 版是基础,核心是「以 i 结尾」的状态定义 + 往前枚举所有前驱
  2. 贪心版是优化,核心是「相同长度保留最小结尾」的贪心策略
  3. 二分的前提是 tails 天然有序,找的是第一个 >= num 的位置(lower_bound)
  4. 面试先讲 DP 再讲优化,体现思维的递进性
相关推荐
叠层归一研究院1 小时前
缝合维度:时空3+1结构的拓扑起源
人工智能·算法·机器学习·agi
YXXY3131 小时前
FloodFill算法
算法
OPEN-F1 小时前
C++入门教程:数组、字符串与指针入门
数据结构·c++·算法
林森lsjs1 小时前
零基础吃透二叉树:定义、遍历与高频算法 —数据结构柒
java·开发语言·数据结构·算法·二叉树
船厂电气自动化ai大模型1 小时前
AI大模型与数学·第56课 快速傅里叶变换FFT:DFT高效优化算法,图像、音频、扩散模型工程加速核心工具
数据结构·人工智能·深度学习·算法·机器学习
203号居民1 小时前
【无标题】LeetCode hot 100 —54. 螺旋矩阵
算法
ai产品老杨2 小时前
越界检测算法接入 AI 视频分析平台的流程与误报优化指南
人工智能·算法·音视频
bluesky9612222 小时前
malloc 和 realloc C/C++
c语言·c++·算法
M78佐菲2 小时前
Linux学习笔记:进程回收、exec函数簇与线程
linux·笔记·学习·算法