👋 欢迎阅读

🎯 欢迎来到「最长递增子序列」题解之旅! 本文将带你从“寻找最长的严格递增数字序列”这一经典问题出发,深入理解动态规划的核心思想,并掌握如何用贪心 + 二分查找将时间复杂度优化至 O(nlogn)O(nlogn)。
在开始之前,建议你先:
-
了解题目背景:这是 LeetCode 300 题,给定整数数组
nums,求最长严格递增子序列(LIS) 的长度。子序列不要求连续,但必须保持相对顺序。这是动态规划入门必学的经典题目,也是许多复杂 DP 问题的基础。 -
准备好环境:建议在本地 IDE 或 LeetCode 在线编辑器中打开代码,边看边运行,亲手验证示例(如
nums = [10,9,2,5,3,7,101,18]输出4)。
本文将从问题转化、状态定义、转移方程、初始化、填表顺序到代码实现,层层递进;同时引入进阶优化(贪心 + 二分),让你感受从 O(n2)O(n2) 到 O(nlogn)O(nlogn) 的思维跃迁。即使你对动态规划还不熟悉,我们也会从“以每个位置结尾能接多长”这一直觉出发,让你轻松抓住核心思想——记住每个长度下最小的末尾值,就能贪心地扩展更长的递增序列。现在,让我们一起在数组中寻找最长的上升轨迹吧! 📈🔍
一、题目

二、做题思路
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]。 -
二分查找时,
left和right分别指向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取反或修改比较符号,你能快速写出相应的二分代码吗? -
尝试用 动态规划 实现本题,并对比两种方法在代码复杂度和运行速度上的差异。
如果你觉得本文对你有所帮助,欢迎:
👍 点赞 / 收藏
👤 关注作者,获取更多题解
💬 留言交流你的疑问或优化思路
祝你在 算法之路 上越走越稳,早日攻克每一道难题!下次见 🚀✨

212

被折叠的 条评论
为什么被折叠?



