300. 最长递增子序列
给你一个整数数组
nums,找到其中最长严格递增子序列的长度。子序列 是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。例如,
[3,6,2,7]是数组[0,3,1,6,2,2,7]的子序列。
cpp
class Solution {
public:
int lengthOfLIS(vector<int>& nums) {
int n = nums.size();
// dp[i] 的定义:以 nums[i] 结尾的,最长递增子序列的长度
// 初始化:每个元素自身至少构成长度为 1 的子序列
vector<int> dp(n, 1); // 原代码用了 n+1,其实 n 就足够了,因为下标 0 到 n-1 刚好对应 n 个元素
int res = 1;
for(int i = 1; i < n; i++){
for(int j = 0; j < i; j++){
// 状态转移:只要前面的数比我小,我就可以接在它后面
if(nums[j] < nums[i]) {
dp[i] = max(dp[i], dp[j] + 1);
}
}
// 【微小优化】:把更新最大值的步骤挪出内层循环,减少不必要的判断执行次数
res = max(res, dp[i]);
}
return res;
}
};
总结
1. dp[i] 定义的"陷阱"
最容易踩的坑:误把 dp[i] 理解为"前 i 个元素中的最长递增子序列长度"。
- 如果是这样理解,
dp[i]和dp[i-1]之间根本无法建立直接联系,代码写不出来。 - 正确定义:
dp[i]必须是以nums[i]结尾的长度。这意味着nums[i]必须被选中。有了这个强制前提,才能去前面找比它小的数来"接龙"。
2. 为什么时间复杂度是 O(n^2)?
外层 i 遍历每个数,内层 j 让当前数 nums[i] 去它前面所有的数里"找备胎"。找到一个比它小的,就把它的长度拿过来 +1。这是最朴素的暴力枚举。
674. 最长连续递增序列
给定一个未经排序的整数数组,找到最长且 连续递增的子序列,并返回该序列的长度。
连续递增的子序列 可以由两个下标
l和r(l < r)确定,如果对于每个l <= i < r,都有nums[i] < nums[i + 1],那么子序列[nums[l], nums[l + 1], ..., nums[r - 1], nums[r]]就是连续递增子序列。
cpp
class Solution {
public:
int findLengthOfLCIS(vector<int>& nums) {
int n = nums.size();
// dp[i] 定义:以 nums[i] 结尾的,最长连续递增子序列的长度
// 初始化:每个元素自身长度为 1
vector<int> dp(n, 1);
int res = 1;
for(int i = 1; i < n; i++){
// 【核心变化】:因为要求"连续",nums[i] 只能接在它正前方的 nums[i-1] 后面
// 不需要像 300 题那样写内层循环去遍历前面的所有元素
if(nums[i] > nums[i-1]) {
dp[i] = dp[i-1] + 1;
}
// 【微小优化】:同理,将判断移出,直接使用 max 函数更简洁
res = max(res, dp[i]);
}
return res;
}
};
总结
1. 为什么去掉了内层 for 循环?
- 300题(不连续/子序列):
[1, 3, 5, 2, 8]。算8的时候,它可以接在1、3、5、2任何一个后面,所以必须用j从0遍历到i-1去找所有比它小的数。复杂度 O(n^2)。 - 本题(连续/子数组):
[1, 3, 5, 2, 8]。如果要求连续,8只能接在紧挨着它的2后面。如果8比2大,长度就是2的长度+1;如果比2小,连续断裂,长度重置为1。它没有其他选择,所以不需要内层循环。
2. 进阶思考:连 dp 数组都不需要
正是因为 dp[i] 仅仅依赖于 dp[i-1],根本不需要记住之前所有的状态。这道题完全可以把空间复杂度降维到 O(1):
cpp
int count = 1, res = 1;
for(int i = 1; i < n; i++) {
if(nums[i] > nums[i-1]) count++;
else count = 1; // 断裂直接重置
res = max(res, count);
}
718. 最长重复子数组
给两个整数数组
nums1和nums2,返回 两个数组中 公共的 、长度最长的子数组的长度。
cpp
class Solution {
public:
int findLength(vector<int>& nums1, vector<int>& nums2) {
int n = nums1.size(), m = nums2.size();
// dp[i][j] 定义:以 nums1[i-1] 和 nums2[j-1] 结尾的,最长公共子数组的长度
// 初始化为 0(代表没有公共部分)
vector<vector<int>> dp(n + 1, vector<int>(m + 1, 0));
int res = 0;
for(int i = 1; i <= n; i++){
for(int j = 1; j <= m; j++){
// 只有当两个数相等时,才能接上之前的公共长度
if(nums1[i-1] == nums2[j-1]){
// 状态转移:等于左上角的状态 + 1
dp[i][j] = dp[i-1][j-1] + 1;
// 实时更新全局最大值
res = max(res, dp[i][j]);
}
// 如果不相等,dp[i][j] 默认就是 0,不需要额外写 else
}
}
return res;
}
};
总结
1. 为什么 dp 数组要开 (n+1, m+1) 的大小?
防越界,减心智负担。
如果不开大一格,直接用 dp[n][m],当比较 nums1[0] 和 nums2[0] 时,公式需要找 dp[-1][-1],这在代码里会直接报错。
通过整体向后偏移一位,dp[i][j] 实际上对应的是 nums1[i-1] 和 nums2[j-1]。这样 dp[0][j]和 dp[i][0] 永远是 0,作为天然的安全垫,内层循环直接从 1 开始跑,极其清爽。
2. 为什么公式是找"左上角" dp[i-1][j-1]?
这是"连续"带来的铁律。
- 这里是子数组(必须连续),既然
nums1[i-1]和nums2[j-1]强行绑定了,那么它们前面的那一坨必须是nums1[i-2]和nums2[j-2]绑定的结果。所以只能走对角线,看左上角。
3. 为什么没有 else 分支?
在本题(连续)中,如果不相等,意味着以这两个元素结尾的公共子数组长度直接断崖式归零。因为初始化就是 0,所以不相等时什么都不用做,天然就是 0。