LeetCode 300:最长递增子序列 —— 题解

👋 欢迎阅读

LeetCode 494 目标和题解封面

🎯 欢迎来到「最长递增子序列」题解之旅! 本文将带你从“寻找最长的严格递增数字序列”这一经典问题出发,深入理解动态规划的核心思想,并掌握如何用贪心 + 二分查找将时间复杂度优化至 O(nlog⁡n)O(nlogn)。

在开始之前,建议你先:

  • 了解题目背景:这是 LeetCode 300 题,给定整数数组 nums,求最长严格递增子序列(LIS) 的长度。子序列不要求连续,但必须保持相对顺序。这是动态规划入门必学的经典题目,也是许多复杂 DP 问题的基础。

  • 准备好环境:建议在本地 IDE 或 LeetCode 在线编辑器中打开代码,边看边运行,亲手验证示例(如 nums = [10,9,2,5,3,7,101,18] 输出 4)。

本文将从问题转化、状态定义、转移方程、初始化、填表顺序到代码实现,层层递进;同时引入进阶优化(贪心 + 二分),让你感受从 O(n2)O(n2) 到 O(nlog⁡n)O(nlogn) 的思维跃迁。即使你对动态规划还不熟悉,我们也会从“以每个位置结尾能接多长”这一直觉出发,让你轻松抓住核心思想——记住每个长度下最小的末尾值,就能贪心地扩展更长的递增序列。现在,让我们一起在数组中寻找最长的上升轨迹吧! 📈🔍


一、题目

300. 最长递增子序列 - 力扣(LeetCode)

二、做题思路

1. 问题分析(前置分析)

本题要求返回数组中最长严格递增子序列的长度。经典动态规划解法为 O(n²),但本题进阶要求 O(n log n)。核心思路是贪心 + 二分:维护一个数组 ret,其中 ret[i] 表示长度为 i+1 的递增子序列的最小末尾元素值。通过让末尾元素尽可能小,为后续元素创造更多延长机会,从而得到最长长度。


2. 贪心策略(核心决策规则)

  • 初始化 ret 数组,将 nums[0] 放入其中。

  • 遍历剩余元素 nums[i]

    • nums[i] > ret.back(),则直接追加到末尾(延长当前最长子序列)。

    • 否则,在 ret二分查找第一个 ≥ nums[i] 的位置,并将其替换为 nums[i](保持 ret 严格递增,同时减小末尾值,为未来更长序列保留潜力)。

  • 最终 ret.size() 即为最长递增子序列长度。


3. 正确性说明(简单版本)

ret 数组记录了各个长度下最小的末尾元素。对于同一个长度,末尾值越小,后续元素越容易接在后面形成更长的递增子序列。因此,每次用新元素替换掉第一个 ≥ 它的位置,相当于用更小的值更新该长度下的最优末尾,而不会丢失任何潜在的更优解。最终 ret 的长度就是全局最长递增子序列的长度。


4. 实现细节(边界防护)

  • 若数组长度为 0,直接返回 0(但题目保证 n ≥ 1)。

  • 使用 vector<int> ret 存储最小末尾值,初始为 nums[0]

  • 二分查找时,leftright 分别指向 ret 的首尾,查找第一个 ≥ nums[i] 的位置。

  • 注意二分边界条件:ret[mid] < nums[i]left = mid+1,否则 right = mid


5. 返回值(目标映射)

返回 ret.size(),即最长严格递增子序列的长度

四、代码

class Solution 
{
public:
    int lengthOfLIS(vector<int>& nums) 
    {
        int n = nums.size();

        // 贪心 + 二分优化:
        // ret[i] 表示长度为 i+1 的递增子序列的“最小末尾元素值”。
        // 维护这个数组,使其保持严格递增。
        vector<int> ret;
        ret.push_back(nums[0]);   // 初始时,长度为1的末尾就是第一个元素

        // 遍历剩余元素
        for (int i = 1; i < n; i++) 
        {
            // 如果当前元素大于 ret 的最后一个元素,则可以直接延长最长子序列
            if (nums[i] > ret.back()) 
            {
                ret.push_back(nums[i]);
            } 
            else 
            {
                // 否则,在 ret 中找到第一个 >= nums[i] 的位置,将其替换为 nums[i]
                // 这样能保证 ret 的单调性,并为未来更长的子序列保留更小的末尾值。
                int left = 0, right = ret.size() - 1;
                while (left < right) 
                {
                    int mid = (left + right) >> 1;
                    if (ret[mid] < nums[i]) 
                    {
                        left = mid + 1;   // 中间值小于目标,向右收缩
                    } 
                    else 
                    {
                        right = mid;      // 中间值 >= 目标,向左收缩
                    }
                }
                // 最终 left == right,且为第一个 >= nums[i] 的位置
                ret[left] = nums[i];
            }
        }

        // ret 的大小即为最长递增子序列的长度
        return ret.size();
    }
};

五、流程图

六、正确性说明(详细版)

步骤 1:符号与问题建模
+----------------------------------------------------+
|  输入数组 nums,长度 n                             |
|  定义 tails 数组:tails[i] 表示所有长度为 i+1      |
|  的递增子序列中,最后一个元素的最小值。             |
|  例如 nums = [7,3,8,4,7,2,14,13]:                |
|  长度1最小末尾:2(而非7或3)                     |
|  长度2最小末尾:4(如[3,4]或[2,4])              |
|  长度3最小末尾:7(如[3,4,7]或[2,4,7])          |
|  长度4最小末尾:13(如[3,4,7,13]或[2,4,7,13])   |
|  贪心策略:维护 tails 数组,使其严格递增,        |
|  且每个位置的值尽可能小。                          |
+----------------------------------------------------+
                    |
                    v
+----------------------------------------------------+
|  操作规则:遍历 nums,对每个元素 x:              |
|  ① 若 x > tails.back(),则直接追加,长度+1;      |
|  ② 否则,在 tails 中二分找到第一个 ≥ x 的位置,   |
|     将其替换为 x(因为更小的末尾值更优)。         |
+----------------------------------------------------+
  • 核心思想:要得到最长递增子序列,只需关心每个长度下最小的末尾元素值。末尾越小,越有利于后续扩展。

  • 贪心实质:每读入一个新元素,要么延长当前最长序列(若比所有末尾都大),要么替换掉某个长度的最小末尾,使其变得更小,从而为未来创造更多可能。


步骤 2:关键性质 —— 替换操作的合理性(反证与单调性)
+----------------------------------------------------+
|  性质 1:tails 数组严格递增。                       |
|  证明:若 tails[i] ≥ tails[i+1],则长度为 i+1 的   |
|  子序列末尾更小,取其前 i 个元素即可得到长度为 i  |
|  且末尾 ≤ tails[i+1] < tails[i] 的子序列,矛盾。    |
+----------------------------------------------------+
                     |
                     v
+----------------------------------------------------+
|  性质 2:替换操作(将第一个 ≥ x 的位置替换为 x)   |
|  保持 tails 的单调性,并且不会使任何长度的          |
|  最小末尾变大,只会变小或不变。                     |
|  反证:若替换后破坏了单调性,则与二分查找位置矛盾。|
+----------------------------------------------------+
                     |
                     v
+----------------------------------------------------+
|  性质 3(贪心选择):当 x > tails.back() 时,       |
|  直接追加是最优的,因为此时 x 可以延长所有已有序列,|
|  得到更长的新序列。否则,替换某个位置为更小值,     |
|  不会减少未来可扩展的长度。                         |
+----------------------------------------------------+

详细论证(结合反证法)

  • 单调性:假设 tails 不是严格递增,则存在 i 使得 tails[i] >= tails[i+1]。由于 tails[i+1] 是长度为 i+2 的子序列的最小末尾,取其前 i+1 个元素构成的长度为 i+1 的子序列,其末尾 ≤ tails[i+1],又因为 tails[i+1] <= tails[i],则得到长度为 i+1 的子序列末尾 ≤ tails[i],这与 tails[i] 是长度为 i+1 的最小末尾矛盾。故严格递增成立。

  • 替换的合理性:当 x 不大于 tails.back() 时,它不能延长最长序列,但可以“优化”某个较短长度的末尾。我们找到第一个 tails[pos] >= x 的位置,将 tails[pos] 替换为 x。因为 x 小于等于原值,替换后该长度的最小末尾变小(或不变),且由于 pos 是第一个满足条件的位置,替换后仍保持递增。这个操作不会使任何长度的最小末尾变大,因此不会丢失最优解。

  • 反证:若存在一个最优解,其长度更长,但贪心算法未能达到该长度,则说明在某个步骤中贪心做出了错误替换。但替换操作只是将末尾变小,且保持所有长度可选,不可能阻断后续扩展。因此贪心选择是安全的。


步骤 3:归纳证明 —— 扫描完毕 tails 的长度即为答案
+----------------------------------------------------+
|  初始:tails 为空,处理第一个元素 x,追加。        |
|  假设处理完前 i 个元素后,tails 正确地表示          |
|  当前所有长度下的最小末尾值。                       |
+----------------------------------------------------+
                     |
                     v
+----------------------------------------------------+
|  处理第 i+1 个元素 x:                            |
|  - 若 x > tails.back(),则存在长度为 L 的子序列,  |
|    末尾小于 x,故可延长为 L+1,追加 x 是正确的。   |
|  - 否则,替换第一个 ≥ x 的位置,使得该长度的       |
|    末尾更小,且不改变其他长度,归纳假设保持。       |
+----------------------------------------------------+
                     |
                     v
+----------------------------------------------------+
|  最终 tails 的长度 L 即为最长递增子序列的长度。     |
|  因为 tails 中的每个位置都对应一个存在的最短末尾,  |
|  而任何长度大于 L 的递增子序列必然需要一个比       |
|  tails[L-1] 更大的末尾,但扫描完毕所有元素后        |
|  不存在这样的元素,故 L 是最大值。                  |
+----------------------------------------------------+

详细论证

  • 归纳假设:在扫描完前 i 个元素后,tails 数组满足:tails[j] 是长度为 j+1 的递增子序列的最小末尾值,并且数组严格递增。

  • 处理新元素 x

    • x > tails.back(),则当前最长长度 L 的子序列末尾小于 x,可以接上 x 形成长度为 L+1 的子序列,因此 tails.push_back(x) 正确。

    • 否则,x 不能延长最长序列,但可以优化某个较短长度。二分查找第一个 tails[pos] >= x,替换为 x。因为 x 更小,该长度的最小末尾被更新,且保持递增。这不会破坏归纳假设。

  • 最终:循环结束后,tails 的长度即为扫描过程中能达到的最长递增子序列长度。因为任何更长的子序列,其末尾值必须大于等于 tails.back() 且出现于数组之后,但算法遍历完所有元素,若存在则会触发追加,矛盾。

🎯 闭幕

动态规划学习路径图

🎉 恭喜你完成了「最长递增子序列」问题的学习!

为了巩固知识并进一步拓展,建议你:

🚀 动手实践
在 LeetCode 上提交代码,尝试不同的测试用例。

💡 深入思考

  • 本题使用 贪心 + 二分 优化,核心是维护数组 ret,其中 ret[i] 表示长度为 i+1 的递增子序列的 最小末尾元素。为什么这样维护能保证得到最长长度?如果直接替换某个位置的值,会不会“破坏”已经构造的子序列?

  • nums[i] 大于 ret.back() 时,直接追加到末尾;否则用二分找到第一个 大于等于 nums[i] 的位置并替换。为什么是“大于等于”而不是“大于”? 如果数组中存在重复元素(如 [2,2]),使用“大于等于”会得到长度 1(正确),若用“大于”则会错误地得到 2,你能理解其中的区别吗?

  • 本题也可以用 动态规划dp[i] 表示以 nums[i] 结尾的最长递增子序列长度),时间复杂度 O(n^2)。在 n <= 2500 时两者都能通过,但当 n 很大时(比如 10^5),贪心+二分的优势就体现出来了。你能说出 O(n log n) 相比 O(n^2) 提升在哪里吗?

  • ret 数组虽然记录的是“最小末尾值”,但它 并不是真正的子序列。如果题目要求输出具体的最长递增子序列,贪心+二分还能直接给出吗?为什么?

📚 延伸挑战

  • 将题目改为 最长非递减子序列(允许相等元素递增),你只需要修改哪一处比较条件(> 改为 >=)?动手改改并验证 [2,2,2] 的结果是否为 3

  • 如果要求 最长递减子序列,只需将 nums 取反或修改比较符号,你能快速写出相应的二分代码吗?

  • 尝试用 动态规划 实现本题,并对比两种方法在代码复杂度和运行速度上的差异。

如果你觉得本文对你有所帮助,欢迎:

👍 点赞 / 收藏
👤 关注作者,获取更多题解
💬 留言交流你的疑问或优化思路

祝你在 算法之路 上越走越稳,早日攻克每一道难题!下次见 🚀✨

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值