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)—— 入门核心解法
思路核心
动态规划的核心在于「定义状态」与「推导状态转移方程」,针对本题,结合代码细节逐一拆解如下:
-
定义状态:设 dp[i] 表示「以 nums[i] 为结尾」的最长严格递增子序列的长度。
-
状态转移:对于每个索引 i(取值范围为 0 至 n-1),遍历其之前的所有索引 j(取值范围为 0 至 i-1)。若 nums[j] < nums[i],则说明 nums[i] 可接在 nums[j] 之后,构成更长的严格递增子序列,此时需更新当前最长子序列长度;若不存在满足条件的 j,则 nums[i] 自身构成一个长度为 1 的严格递增子序列(即 dp[i] 的初始值)。
-
最终答案: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 的最长严格递增子序列的最小末尾元素」。
结合代码实现,将该思路拆解为以下步骤,便于理解:
-
初始化一个空的辅助数组 dp,用于存储「长度为 k 的最长严格递增子序列的最小末尾元素」。
-
遍历数组 nums 中的每个元素 num:
-
若 num 大于 dp 数组的最后一个元素,说明 num 可接在 dp 数组末尾,构成更长的严格递增子序列,直接将 num 加入 dp 数组。
-
若 num 小于或等于 dp 数组的最后一个元素,说明 num 无法直接接在 dp 数组末尾,但可替换 dp 数组中的某个元素,使 dp 数组保持单调递增,且尽可能使末尾元素更小(便于后续加入新元素以构成更长子序列)。此时通过二分查找,找到 dp 数组中第一个大于或等于 num 的元素位置,将该位置的元素替换为 num。
-
-
最终,辅助数组 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),但运行效率更高,在面试中合理使用此类优化,可体现代码编写的专业性。
本题的两种解法基本覆盖了最长递增子序列的核心考点,建议先掌握基础动态规划解法,理解动态规划的核心思路,再逐步学习优化解法,体会贪心策略与二分查找的结合技巧。对于最长非递减子序列、最长递增子数组等同类变形题目,可借鉴本文思路进行求解。