【每日算法】LeetCode 300. 最长递增子序列

对前端开发者而言,学习算法绝非为了"炫技"。它是你从"页面构建者"迈向"复杂系统设计者"的关键阶梯。它将你的编码能力从"实现功能"提升到"设计优雅、高效解决方案"的层面。从现在开始,每天投入一小段时间,结合前端场景去理解和练习,你将会感受到自身技术视野和问题解决能力的质的飞跃。

------ 算法:资深前端开发者的进阶引擎

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²))

这是最直观的思路。

  1. 定义状态dp[i] 表示以 nums[i] 这个数结尾的 最长递增子序列的长度
  2. 状态转移 :对于每一个 i,遍历它之前的所有位置 j (0 <= j < i)。如果 nums[i] > nums[j],说明 nums[i] 可以接在 nums[j] 结尾的子序列后面,形成一个更长的子序列。因此 dp[i] = max(dp[i], dp[j] + 1)
  3. 初始化 :每个位置本身至少可以构成长度为 1 的子序列,所以 dp 数组初始值全为 1。
  4. 最终结果dp 数组中的最大值,即为整个数组的最长递增子序列长度。

3.2 思路二:贪心 + 二分查找 (O(n log n)) 最优解

这个思路非常巧妙,旨在维护一个"潜在"的最优递增子序列。

  1. 核心概念 :维护一个数组 tails,其中 tails[k] 的值代表 长度为 k+1 的所有递增子序列中,末尾元素最小的那个子序列的末尾元素值。

  2. 为什么有效:让子序列的末尾元素尽可能小,这样后续才有更大的机会接上更多的数,从而使序列更长。

  3. 算法流程

    • 遍历 nums 中的每个数 num
    • tails 数组中寻找第一个大于等于 num 的元素的位置。这个过程可以用二分查找
    • 如果找到,就用 num 替换它(因为 num 更小,潜力更大)。如果找不到(即 numtails 中所有元素都大),就将 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 通用解题模板与思路

"最长递增子序列"问题属于"序列型动态规划"的一个经典代表。其通用思考框架如下:

  1. 定义状态 :通常定义为 dp[i],表示 以第 i 个元素(或位置 i)为结尾 的某种最优解(如最长长度、最大和等)。这是解决非连续子序列问题的关键。
  2. 状态转移 :思考如何从 dp[0...i-1] 推导出 dp[i]。通常需要一个内层循环去遍历 i 之前的所有状态,并检查某种条件(如递增、递减、满足某种关系)。
  3. 初始化dp 数组通常有明确的初始值(例如,每个位置至少为1)。
  4. 计算结果 :最终答案有时是 dp 数组中的最大值,有时是最后一个值,需要具体分析。

对于 O(n log n) 的优化解法,其核心思想是:

  • 维护一个有序结构 (如 tails 数组),用于快速定位当前元素应该放置或替换的位置。
  • 利用二分查找将内层循环的 O(n) 优化为 O(log n)。
  • 这种"贪心维护潜力序列 + 二分查找定位"的模式,是优化一类动态规划问题的有效手段。

6.2 类似题目推荐 (LeetCode)

建议按顺序练习,巩固此类问题的解法:

  1. 674. 最长连续递增序列 (简单):连续版本,更简单,可使用滑动窗口。
  2. 354. 俄罗斯套娃信封问题 (困难):二维的 LIS 问题,需要先排序转化为一维 LIS,是本题的经典变体。
  3. 673. 最长递增子序列的个数 (中等):在 LIS 基础上,要求统计个数,需要同时维护长度和数量。
  4. 1964. 找出到每个位置为止最长的有效障碍赛跑路线 (中等):本质是求 LIS,但允许非严格递增(相等也可),可直接套用 O(n log n) 解法。
  5. 1671. 得到山形数组的最少删除次数 (中等):需要正向和反向各求一次 LIS(最长递减子序列)。
相关推荐
ohnoooo92 小时前
251225 算法2 期末练习
算法·动态规划·图论
张较瘦_2 小时前
前端 | 代码可读性 + SEO 双提升!HTML 语义化标签实战教程
前端·html
车队老哥记录生活2 小时前
强化学习 RL 基础 3:随机近似方法 | 梯度下降
人工智能·算法·机器学习·强化学习
似水流年QC2 小时前
前端国际化实战指南:i18n 工程化最佳实践总结
前端
GISer_Jing2 小时前
企业级前端脚手架:原理与实战指南
前端·前端框架
闲看云起2 小时前
LeetCode-day2:字母异位词分组分析
算法·leetcode·职场和发展
熬夜敲代码的小N2 小时前
2026 职场生存白皮书:Gemini Pro 实战使用指南
人工智能·python·ai·职场和发展
非凡ghost2 小时前
Floorp Browser(基于Firefox火狐浏览器)
前端·windows·学习·firefox·软件需求
hpz12232 小时前
XHR和Fetch功能对比表格
前端·网络请求