对前端开发者而言,学习算法绝非为了"炫技"。它是你从"页面构建者"迈向"复杂系统设计者"的关键阶梯。它将你的编码能力从"实现功能"提升到"设计优雅、高效解决方案"的层面。从现在开始,每天投入一小段时间,结合前端场景去理解和练习,你将会感受到自身技术视野和问题解决能力的质的飞跃。
------ 算法:资深前端开发者的进阶引擎
LeetCode 300. 最长递增子序列:从动态规划到最优解
1. 题目描述
给你一个整数数组 nums ,找到其中 最长严格递增子序列 的长度。
子序列 是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。例如,[3,6,2,7] 是数组 [0,3,1,6,2,2,7] 的一个子序列。
示例 1:
输入:nums = [10,9,2,5,3,7,101,18]
输出:4
解释:最长递增子序列是 [2,3,7,101] 或 [2,3,7,18],因此长度为 4。
示例 2:
输入:nums = [0,1,0,3,2,3]
输出:4
示例 3:
输入:nums = [7,7,7,7,7,7,7]
输出:1
2. 问题分析
这个问题要求找到最长的、严格递增的子序列,而不是连续子数组。这是动态规划中的经典问题,也是许多更复杂问题的基础。
核心难点:
- 非连续性:子序列的元素可以不连续,因此不能简单地用滑动窗口。
- 状态依赖 :以
nums[i]结尾的最长递增子序列(LIS)长度,依赖于它前面所有比它小的元素结尾的 LIS 长度。 - 最优解:直观的动态规划解法时间复杂度为 O(n²),存在一种更优的 O(n log n) 解法。
3. 解题思路
3.1 思路一:动态规划 (O(n²))
这是最直观的思路。
- 定义状态 :
dp[i]表示以nums[i]这个数结尾的 最长递增子序列的长度。 - 状态转移 :对于每一个
i,遍历它之前的所有位置j(0 <= j < i)。如果nums[i] > nums[j],说明nums[i]可以接在nums[j]结尾的子序列后面,形成一个更长的子序列。因此dp[i] = max(dp[i], dp[j] + 1)。 - 初始化 :每个位置本身至少可以构成长度为 1 的子序列,所以
dp数组初始值全为 1。 - 最终结果 :
dp数组中的最大值,即为整个数组的最长递增子序列长度。
3.2 思路二:贪心 + 二分查找 (O(n log n)) 最优解
这个思路非常巧妙,旨在维护一个"潜在"的最优递增子序列。
-
核心概念 :维护一个数组
tails,其中tails[k]的值代表 长度为 k+1 的所有递增子序列中,末尾元素最小的那个子序列的末尾元素值。 -
为什么有效:让子序列的末尾元素尽可能小,这样后续才有更大的机会接上更多的数,从而使序列更长。
-
算法流程 :
- 遍历
nums中的每个数num。 - 在
tails数组中寻找第一个大于等于num的元素的位置。这个过程可以用二分查找。 - 如果找到,就用
num替换它(因为num更小,潜力更大)。如果找不到(即num比tails中所有元素都大),就将num添加到tails末尾。 - 最终,
tails的长度就是最长递增子序列的长度。
注意 :
tails数组本身不一定是一个合法的子序列,但其长度是正确的。 - 遍历
4. 代码实现
4.1 动态规划实现 (O(n²))
javascript
/**
* @param {number[]} nums
* @return {number}
*/
var lengthOfLIS = function(nums) {
const n = nums.length;
if (n === 0) return 0;
// 1. 定义 dp 数组并初始化
// dp[i] 表示以 nums[i] 结尾的最长递增子序列的长度
const dp = new Array(n).fill(1);
let maxLen = 1; // 记录全局最大值
// 2. 状态转移
for (let i = 1; i < n; i++) {
// 内层循环:寻找 i 之前所有可能接上的位置 j
for (let j = 0; j < i; j++) {
// 只有当前元素大于之前的元素,才能形成递增关系
if (nums[i] > nums[j]) {
// 状态转移方程:尝试用 dp[j] + 1 更新 dp[i]
dp[i] = Math.max(dp[i], dp[j] + 1);
}
}
// 更新全局最大值
maxLen = Math.max(maxLen, dp[i]);
}
// 3. 返回结果
return maxLen;
};
4.2 贪心 + 二分查找实现 (O(n log n)) 最优解
javascript
/**
* @param {number[]} nums
* @return {number}
*/
var lengthOfLIS = function(nums) {
const n = nums.length;
if (n === 0) return 0;
// tails 数组:tails[k] 存储长度为 k+1 的递增子序列的最小可能末尾值
const tails = [];
// 遍历原数组
for (const num of nums) {
// 1. 在 tails 中寻找第一个大于等于 num 的元素的位置 (二分查找)
let left = 0, right = tails.length;
while (left < right) {
const mid = Math.floor((left + right) / 2);
if (tails[mid] < num) {
left = mid + 1; // num 可能放在右侧
} else {
right = mid; // num 可能放在左侧或当前位置
}
}
const pos = left; // 最终找到的位置
// 2. 根据找到的位置进行更新
if (pos === tails.length) {
// 情况一:num 比 tails 中所有元素都大,可以延长子序列
tails.push(num);
} else {
// 情况二:找到了合适的位置,用更小的 num 替换原来的值
// 这不会改变 tails 的长度,但让这个长度的子序列"潜力"更大
tails[pos] = num;
}
// (调试用) 打印每步 tails 的变化
// console.log(`处理 ${num}: tails = [${tails}]`);
}
// tails 的最终长度即为最长递增子序列的长度
return tails.length;
};
5. 复杂度与优缺点对比
| 实现思路 | 时间复杂度 | 空间复杂度 | 优点 | 缺点 |
|---|---|---|---|---|
| 动态规划 (O(n²)) | O(n²) | O(n) | 思路直观,易于理解和实现。是许多更复杂DP问题的基础。 | 在数据量大时(如 n > 5000)性能较差。 |
| 贪心 + 二分查找 (O(n log n)) | O(n log n) | O(n) | 最优时间复杂度,能高效处理大规模数据(n可达10^5)。 | 思路相对不易理解,tails数组的物理意义需要仔细体会。 |
选择建议:
- 在面试或竞赛中,优先掌握并实现 O(n log n) 的解法,它体现了对问题更深刻的理解和更强的优化能力。
- 对于初学者,可以先从动态规划解法入手,理解问题本质,再进阶学习最优解。
6. 总结与拓展
6.1 通用解题模板与思路
"最长递增子序列"问题属于"序列型动态规划"的一个经典代表。其通用思考框架如下:
- 定义状态 :通常定义为
dp[i],表示 以第 i 个元素(或位置 i)为结尾 的某种最优解(如最长长度、最大和等)。这是解决非连续子序列问题的关键。 - 状态转移 :思考如何从
dp[0...i-1]推导出dp[i]。通常需要一个内层循环去遍历i之前的所有状态,并检查某种条件(如递增、递减、满足某种关系)。 - 初始化 :
dp数组通常有明确的初始值(例如,每个位置至少为1)。 - 计算结果 :最终答案有时是
dp数组中的最大值,有时是最后一个值,需要具体分析。
对于 O(n log n) 的优化解法,其核心思想是:
- 维护一个有序结构 (如
tails数组),用于快速定位当前元素应该放置或替换的位置。 - 利用二分查找将内层循环的 O(n) 优化为 O(log n)。
- 这种"贪心维护潜力序列 + 二分查找定位"的模式,是优化一类动态规划问题的有效手段。
6.2 类似题目推荐 (LeetCode)
建议按顺序练习,巩固此类问题的解法:
- 674. 最长连续递增序列 (简单):连续版本,更简单,可使用滑动窗口。
- 354. 俄罗斯套娃信封问题 (困难):二维的 LIS 问题,需要先排序转化为一维 LIS,是本题的经典变体。
- 673. 最长递增子序列的个数 (中等):在 LIS 基础上,要求统计个数,需要同时维护长度和数量。
- 1964. 找出到每个位置为止最长的有效障碍赛跑路线 (中等):本质是求 LIS,但允许非严格递增(相等也可),可直接套用 O(n log n) 解法。
- 1671. 得到山形数组的最少删除次数 (中等):需要正向和反向各求一次 LIS(最长递减子序列)。