【LeetCode Hot100 刷题日记 (87/100)】300. 最长递增子序列 —— 数组、动态规划、二分查找、贪心📘

1 阅读6分钟

📌 题目链接:300. 最长递增子序列 - 力扣(LeetCode)

🔍 难度:中等 | 🏷️ 标签:数组、动态规划、二分查找、贪心

⏱️ 目标时间复杂度:O(n log n) (进阶要求)

💾 空间复杂度:O(n)


在算法面试中,最长递增子序列(Longest Increasing Subsequence, LIS) 是一道经典中的经典。它不仅考察你对 动态规划(DP) 的掌握,还引出了 贪心策略 + 二分查找 的高级优化技巧——这正是大厂高频考点!

本题看似简单,但其背后蕴含的状态设计思想、单调性维护、以及如何从 O(n²) 优化到 O(n log n) ,是理解许多后续难题(如俄罗斯套娃信封、安排会议等)的关键基础。

💡 面试高频点

  • 能否写出 O(n²) 的 DP 解法?
  • 是否知道存在 O(n log n) 的优化解法?
  • 能否解释 d[i] 数组的含义及其单调性?
  • 能否手写二分查找边界处理?

🧠 题目分析

给定一个整数数组 nums,找出其中严格递增最长子序列的长度。

  • 子序列 ≠ 子数组:不要求连续,但必须保持原顺序。
  • 严格递增a < b < c,不能相等(如 [7,7,7] 的 LIS 长度为 1)。
  • 数据规模:n ≤ 2500 → O(n²) 可过,但进阶要求 O(n log n)。

⚙️ 核心算法及代码讲解

本题有两种主流解法:

✅ 方法一:动态规划(DP)—— O(n²)

📌 核心思想

定义 dp[i] 表示nums[i] 结尾的最长递增子序列的长度。

❗关键点:必须以 nums[i] 结尾,这样才能保证状态转移的合法性。

状态转移方程:

dp[i] = max{ dp[j] + 1 } ,其中 j < i 且 nums[j] < nums[i]

若找不到这样的 j,则 dp[i] = 1(仅包含自己)。

最终答案:max(dp[0], dp[1], ..., dp[n-1])

💻 C++ 代码(带行注释)

vector<int> dp(n, 1); // 初始化所有位置为1
for (int i = 0; i < n; ++i) {
    for (int j = 0; j < i; ++j) {
        if (nums[j] < nums[i]) {           // 满足递增条件
            dp[i] = max(dp[i], dp[j] + 1); // 尝试接在dp[j]后面
        }
    }
}
return *max_element(dp.begin(), dp.end()); // 全局最大值即答案

优点:思路直观,易于理解。
缺点:时间复杂度 O(n²),在 n=2500 时约 6e6 次操作,勉强通过,但无法应对更大规模。


✅ 方法二:贪心 + 二分查找 —— O(n log n) ⭐⭐⭐(面试重点!)

📌 核心思想

我们不再记录每个位置的 LIS 长度,而是维护一个辅助数组 d

d[len] 表示:当前所有长度为 len 的递增子序列中,末尾元素的最小值。

为什么这样设计?

  • 贪心策略:为了让 LIS 尽可能长,我们希望末尾元素尽可能小,这样后续有更多机会接上更大的数。

  • 关键性质d 数组严格单调递增

    证明:假设 d[i] ≥ d[j]i < j,那么长度为 j 的序列末尾比长度为 i 的还小,我们可以截取前 i 项得到一个更优的 d[i],矛盾。

🔄 算法流程

  1. 初始化 len = 1d[1] = nums[0]

  2. 遍历 nums[1..n-1]

    • nums[i] > d[len] → 可延长 LIS,d[++len] = nums[i]
    • 否则 → 在 d[1..len]找到第一个 ≥ nums[i] 的位置,用 nums[i] 替换它(保持 d 的单调性和最优性)

🔍 注意:这里找的是 第一个 ≥ nums[i] 的位置,但官方题解采用“找最后一个 < nums[i] 的位置”,两者等价。我们采用后者,便于理解。

💻 C++ 代码(带详细行注释)

int len = 1;
vector<int> d(n + 1);      // d[1..len] 有效,d[0] 不使用
d[1] = nums[0];
for (int i = 1; i < n; ++i) {
    if (nums[i] > d[len]) {
        d[++len] = nums[i]; // 可以扩展最长序列
    } else {
        // 二分查找:在 d[1..len] 中找最大的 k,使得 d[k] < nums[i]
        int l = 1, r = len, pos = 0; // pos 初始化为0,表示若全≥,则替换d[1]
        while (l <= r) {
            int mid = (l + r) >> 1;
            if (d[mid] < nums[i]) {
                pos = mid;     // 记录满足条件的位置
                l = mid + 1;   // 尝试找更大的k
            } else {
                r = mid - 1;   // d[mid] >= nums[i],往左找
            }
        }
        d[pos + 1] = nums[i]; // 替换 d[pos+1],使其更小
    }
}
return len;

为什么正确?

  • d 数组不存储真实子序列,但长度 len 始终等于 LIS 长度
  • 每次替换不会改变当前 LIS 长度,但为未来更长的序列创造可能。

时间复杂度:外层 O(n),内层二分 O(log n) → 总 O(n log n)
空间复杂度:O(n)


🧩 解题思路(分步拆解)

步骤 1:理解子序列 vs 子数组

  • 子序列可跳跃,但顺序不变。
  • 例如 [10,9,2,5,3,7,101,18] 中,[2,3,7,101] 是合法 LIS。

步骤 2:尝试暴力 → 发现重叠子问题

  • 枚举所有子序列?2^n 种,不可行。
  • 但“以 i 结尾的 LIS” 可由前面的状态推导 → DP 适用

步骤 3:设计 DP 状态

  • dp[i] = 以 nums[i] 结尾的 LIS 长度
  • 转移:遍历 j < i,若 nums[j] < nums[i],则 dp[i] = max(dp[i], dp[j]+1)

步骤 4:思考优化 → 贪心 + 二分

  • 观察:我们只关心“长度为 L 的 LIS 的最小末尾值”
  • 维护 d 数组,利用其单调性,用二分加速查找

步骤 5:实现二分细节

  • 边界处理:pos 初始为 0,确保 d[1] 可被更新
  • 循环条件:l <= r,标准二分模板

📊 算法分析

方法时间复杂度空间复杂度是否输出实际序列面试推荐
动态规划O(n²)O(n)✅ 可回溯构造基础必会
贪心+二分O(n log n)O(n)❌ 仅长度进阶必考

🎯 面试建议

  1. 先写 O(n²) DP,展示基本功;
  2. 再提出优化思路,解释 d 数组含义;
  3. 手写二分,注意边界(如 pos=0 的处理);
  4. 强调:虽然 d 不是真实 LIS,但长度正确

💻 完整代码

C++ 版本

#include <bits/stdc++.h>
using namespace std;
using ll = long long;

class Solution {
public:
    int lengthOfLIS(vector<int>& nums) {
        int n = (int)nums.size();
        if (n == 0) return 0;
        
        // 方法二:贪心 + 二分查找(O(n log n))
        int len = 1;
        vector<int> d(n + 1, 0);
        d[len] = nums[0];
        for (int i = 1; i < n; ++i) {
            if (nums[i] > d[len]) {
                d[++len] = nums[i];
            } else {
                int l = 1, r = len, pos = 0;
                while (l <= r) {
                    int mid = (l + r) >> 1;
                    if (d[mid] < nums[i]) {
                        pos = mid;
                        l = mid + 1;
                    } else {
                        r = mid - 1;
                    }
                }
                d[pos + 1] = nums[i];
            }
        }
        return len;
    }
};

// 测试
signed main(){
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    cout.tie(nullptr);
    
    Solution sol;
    vector<int> nums1 = {10,9,2,5,3,7,101,18};
    cout << sol.lengthOfLIS(nums1) << "\n"; // 输出: 4
    
    vector<int> nums2 = {0,1,0,3,2,3};
    cout << sol.lengthOfLIS(nums2) << "\n"; // 输出: 4
    
    vector<int> nums3 = {7,7,7,7,7,7,7};
    cout << sol.lengthOfLIS(nums3) << "\n"; // 输出: 1
    
    return 0;
}

JavaScript 版本

/**
 * @param {number[]} nums
 * @return {number}
 */
var lengthOfLIS = function(nums) {
    const n = nums.length;
    if (n === 0) return 0;
    
    let len = 1;
    const d = new Array(n + 1).fill(0);
    d[len] = nums[0];
    
    for (let i = 1; i < n; i++) {
        if (nums[i] > d[len]) {
            d[++len] = nums[i];
        } else {
            let l = 1, r = len, pos = 0;
            while (l <= r) {
                const mid = Math.floor((l + r) / 2);
                if (d[mid] < nums[i]) {
                    pos = mid;
                    l = mid + 1;
                } else {
                    r = mid - 1;
                }
            }
            d[pos + 1] = nums[i];
        }
    }
    return len;
};

// 测试
console.log(lengthOfLIS([10,9,2,5,3,7,101,18])); // 4
console.log(lengthOfLIS([0,1,0,3,2,3]));         // 4
console.log(lengthOfLIS([7,7,7,7,7,7,7]));       // 1

🌟 本期完结,下期见!🔥

👉 点赞收藏加关注,新文更新不迷路。关注专栏【算法】LeetCode Hot100刷题日记,持续为你拆解每一道热题的底层逻辑与面试技巧!

💬 欢迎留言交流你的解法或疑问!一起进步,冲向 Offer!💪

📌 记住:当你在刷题时,不要只看答案,要像写这篇文章一样,深入思考每一步背后的原理、优化空间和面试价值。这才是真正提升算法能力的方式!