LeetCode 300. 最长递增子序列:两种解法从入门到优化

0 阅读10分钟

LeetCode 经典中等难度题目——300. 最长递增子序列(Longest Increasing Subsequence,简称LIS),是动态规划入门阶段的核心必刷题。该题目存在两种主流解法,一种为基础动态规划解法,另一种为结合二分查找的优化解法,后者可将时间复杂度大幅降低,是面试与笔试中的高频考点,具有重要的学习价值。

本文首先明确题目核心要求:给定一个整数数组 nums,求解其中最长严格递增子序列的长度。

题目对「子序列」的定义为:由数组派生而来的序列,可通过删除(或不删除)数组中的元素,且不改变其余元素的相对顺序得到。例如,[3,6,2,7] 是数组 [0,3,1,6,2,2,7] 的子序列,其核心特征为「不改变元素相对顺序、元素可不连续」。

举例说明:对于数组 nums = [10,9,2,5,3,7,101,18],其最长严格递增子序列为 [2,3,7,101] 或 [2,5,7,101],两种子序列的长度均为 4,因此该数组的最长严格递增子序列长度为 4。

下文将从基础解法到优化解法逐步拆解,结合提供的代码及详细思路进行分析,确保逻辑清晰、易于理解。

解法一:动态规划(DP)—— 入门核心解法

思路核心

动态规划的核心在于「定义状态」与「推导状态转移方程」,针对本题,结合代码细节逐一拆解如下:

  1. 定义状态:设 dp[i] 表示「以 nums[i] 为结尾」的最长严格递增子序列的长度。

  2. 状态转移:对于每个索引 i(取值范围为 0 至 n-1),遍历其之前的所有索引 j(取值范围为 0 至 i-1)。若 nums[j] < nums[i],则说明 nums[i] 可接在 nums[j] 之后,构成更长的严格递增子序列,此时需更新当前最长子序列长度;若不存在满足条件的 j,则 nums[i] 自身构成一个长度为 1 的严格递增子序列(即 dp[i] 的初始值)。

  3. 最终答案:dp 数组中的最大值,即为整个数组的最长严格递增子序列长度。

代码实现(TypeScript)

以下为基础解法的代码实现,附带详细注释,便于理解各步骤的核心作用:

function lengthOfLIS_1(nums: number[]): number {
  const N = nums.length;
  if (N === 0) return 0; // 边界处理:空数组直接返回0,避免后续代码报错
  const dp = new Array(N).fill(1); // 初始化dp数组,每个元素默认值为1(自身构成子序列)
  for (let i = 0; i < N; i++) {
    let res = 1; // 临时存储以nums[i]为结尾的最长子序列长度,初始值为1
    for (let j = 0; j < i; j++) {
      // 遍历所有前序元素,若满足递增条件,则更新最长子序列长度
      if (nums[j] < nums[i]) {
        res = Math.max(dp[j] + 1, res);
      }
    }
    dp[i] = res; // 确定以nums[i]为结尾的最长严格递增子序列长度
  }
  return Math.max(...dp); // 返回dp数组最大值,即目标结果
};

复杂度分析

  • 时间复杂度:O(n²)。算法包含两层嵌套循环,外层循环遍历数组的 n 个元素,内层循环对于每个元素最多遍历 i 次(i 从 0 至 n-1),整体时间复杂度近似为 n²。

  • 空间复杂度:O(n)。仅使用一个长度为 n 的 dp 数组存储状态,空间开销与数组长度线性相关。

小总结

该解法逻辑直观,是动态规划入门阶段的典型案例,核心在于理解「以当前元素为结尾」的状态定义,此类思路同样适用于最长公共子序列等同类动态规划题目。但该解法存在明显局限性:当数组长度 n 较大时(如 n=10⁴),O(n²) 的时间复杂度会超出题目时间限制,因此需进一步优化解法以提升效率。

解法二:动态规划 + 二分查找——优化至 O(nlogn) 复杂度

思路核心

该解法是对基础动态规划解法的核心优化,核心思路为「维护一个单调递增的辅助数组 dp」。需特别注意:此处的 dp 数组与基础解法中的 dp 数组功能完全不同,其作用为存储「长度为 k 的最长严格递增子序列的最小末尾元素」。

结合代码实现,将该思路拆解为以下步骤,便于理解:

  1. 初始化一个空的辅助数组 dp,用于存储「长度为 k 的最长严格递增子序列的最小末尾元素」。

  2. 遍历数组 nums 中的每个元素 num:

    • 若 num 大于 dp 数组的最后一个元素,说明 num 可接在 dp 数组末尾,构成更长的严格递增子序列,直接将 num 加入 dp 数组。

    • 若 num 小于或等于 dp 数组的最后一个元素,说明 num 无法直接接在 dp 数组末尾,但可替换 dp 数组中的某个元素,使 dp 数组保持单调递增,且尽可能使末尾元素更小(便于后续加入新元素以构成更长子序列)。此时通过二分查找,找到 dp 数组中第一个大于或等于 num 的元素位置,将该位置的元素替换为 num。

  3. 最终,辅助数组 dp 的长度,即为原数组的最长严格递增子序列长度。

举个例子理解

以数组 nums = [10,9,2,5,3,7,101,18] 为例,结合上述思路逐步分析辅助数组 dp 的变化过程:

  • 遍历元素 10:dp 数组为空,直接将 10 加入 dp → dp = [10]

  • 遍历元素 9:9 ≤ 10,通过二分查找找到 dp 数组中第一个大于或等于 9 的位置(索引 0),将该位置元素替换为 9 → dp = [9]

  • 遍历元素 2:2 ≤ 9,通过二分查找找到 dp 数组中第一个大于或等于 2 的位置(索引 0),将该位置元素替换为 2 → dp = [2]

  • 遍历元素 5:5 > 2,将 5 加入 dp 数组 → dp = [2,5]

  • 遍历元素 3:3 ≤ 5,通过二分查找找到 dp 数组中第一个大于或等于 3 的位置(索引 1),将该位置元素替换为 3 → dp = [2,3]

  • 遍历元素 7:7 > 3,将 7 加入 dp 数组 → dp = [2,3,7]

  • 遍历元素 101:101 > 7,将 101 加入 dp 数组 → dp = [2,3,7,101]

  • 遍历元素 18:18 ≤ 101,通过二分查找找到 dp 数组中第一个大于或等于 18 的位置(索引 3),将该位置元素替换为 18 → dp = [2,3,7,18]

最终 dp 数组的长度为 4,与预期结果一致。需重点说明:辅助数组 dp 本身并非最长严格递增子序列,其长度仅与最长严格递增子序列的长度相等,这是该优化解法的核心易错点。

代码实现(TypeScript)

以下为优化解法的代码实现,附带详细注释,拆解二分查找的核心逻辑:

function lengthOfLIS_2(nums: number[]): number {
  const N = nums.length;
  if (N === 0) return 0; // 边界处理:空数组直接返回0
  const dp: number[] = []; // 辅助数组,存储长度为k的最长严格递增子序列的最小末尾元素
  for (const num of nums) {
    // 情况1:num大于dp数组最后一个元素,直接加入,构成更长子序列
    if (dp.length === 0 || num > dp[dp.length - 1]) {
      dp.push(num);
    } else {
      // 情况2:通过二分查找,找到第一个≥num的位置并替换,保证dp数组单调递增且末尾元素最小
      let [l, r] = [0, dp.length - 1];
      let res = 0; // 存储替换索引,初始值设为0作为兜底
      while (l <= r) {
        const mid = (l + r) >> 1; // 等价于Math.floor((l+r)/2),位运算效率更高
        if (num <= dp[mid]) {
          r = mid - 1; // 向左缩小查找范围,寻找更靠前的≥num的元素
          res = mid; // 更新替换索引
        } else {
          l = mid + 1; // 向右缩小查找范围,当前mid位置元素小于num,不满足替换条件
        }
      }
      dp[res] = num; // 替换对应位置元素,维持dp数组的单调递增特性
    }
  }
  return dp.length; // dp数组的长度即为最长严格递增子序列的长度
};

复杂度分析

  • 时间复杂度:O(nlogn)。遍历数组的时间复杂度为 O(n),每次遍历中执行二分查找的时间复杂度为 O(logk)(其中 k 为当前 dp 数组的长度,最大为 n),因此整体时间复杂度为 O(nlogn)。

  • 空间复杂度:O(n)。辅助数组 dp 的最大长度为 n,即当原数组本身为严格递增数组时,dp 数组长度与原数组长度一致。

小总结

该解法的核心在于理解辅助数组的含义——其并非存储最长严格递增子序列本身,而是存储「长度为 k 的最长严格递增子序列的最小末尾元素」。通过贪心策略(使末尾元素尽可能小,便于后续加入新元素)与二分查找(快速定位替换位置)的结合,将时间复杂度从 O(n²) 优化至 O(nlogn),适用于处理大规模数组。在面试中,能够写出该优化解法,可充分体现对算法思路的深入理解与优化能力。

两种解法对比及适用场景

解法时间复杂度空间复杂度适用场景
动态规划(解法一)O(n²)O(n)数组长度较小(n ≤ 1000)、入门阶段理解动态规划思路
DP + 二分查找(解法二)O(nlogn)O(n)数组长度较大(n > 1000)、面试优化要求、追求高效算法

刷题技巧及易错点提醒

1. 边界处理不可遗漏:当输入数组为空时,需直接返回 0,该细节易被忽略,可能导致测试用例执行失败(对应代码中 if (N === 0) return 0; 语句)。

2. 二分查找细节需注意:循环条件需设为 l ≤ r,替换索引 res 的初始值及更新时机需准确把控,避免出现定位错误(例如,当 num 小于 dp 数组中所有元素时,res 保持初始值 0,替换 dp 数组第一个元素)。

3. 两种解法的本质区别:基础动态规划解法关注「以每个元素为结尾的最长严格递增子序列」,可明确获取具体子序列;优化解法关注「最小末尾元素」,仅能得到子序列长度,无法直接获取具体子序列,但效率更优。

4. 代码优化细节:优化解法中使用的位运算 (l + r) >> 1,等价于 Math.floor((l + r)/2),但运行效率更高,在面试中合理使用此类优化,可体现代码编写的专业性。

本题的两种解法基本覆盖了最长递增子序列的核心考点,建议先掌握基础动态规划解法,理解动态规划的核心思路,再逐步学习优化解法,体会贪心策略与二分查找的结合技巧。对于最长非递减子序列、最长递增子数组等同类变形题目,可借鉴本文思路进行求解。