📌 题目链接: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],矛盾。
🔄 算法流程
-
初始化
len = 1,d[1] = nums[0] -
遍历
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) | ❌ 仅长度 | 进阶必考 |
🎯 面试建议:
- 先写 O(n²) DP,展示基本功;
- 再提出优化思路,解释
d数组含义;- 手写二分,注意边界(如
pos=0的处理);- 强调:虽然
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!💪
📌 记住:当你在刷题时,不要只看答案,要像写这篇文章一样,深入思考每一步背后的原理、优化空间和面试价值。这才是真正提升算法能力的方式!