LeetCode 300. 最长递增子序列(动态规划)
一、完整代码(精简注释)
class Solution {
public:
int lengthOfLIS(vector<int>& nums) {
int n = nums.size();
if(n == 0) return 0;
// dp[i]表示以nums[i]结尾的最长递增子序列长度
vector<int> dp(n,1);
for(int i=0;i<n;i++){
for(int j=0;j<i;j++){
if(nums[j]<nums[i]){ // 满足严格递增
// 将nums[i]接到以nums[j]结尾的子序列后面
dp[i] = max(dp[i],dp[j]+1);
}
}
}
// 返回所有递增子序列中的最大长度
return *max_element(dp.begin(),dp.end());
}
};
二、动态规划五步分析
1. 确定 dp 数组含义
dp[i] 表示以 nums[i] 结尾的最长严格递增子序列长度。
注意:这里强调的是以 nums[i] 结尾,而不是前 i 个元素中的最长递增子序列长度。
例如:
nums = {1,3,2,4};
对应的 DP 数组:
| i | numsi | dpi | 对应的递增子序列 |
|---|---|---|---|
| 0 | 1 | 1 | 1 |
| 1 | 3 | 2 | 13 |
| 2 | 2 | 2 | 12 |
| 3 | 4 | 3 | 134 |
最终:
dp = {1,2,2,3};
最长递增子序列长度为 3。
2. 确定状态转移方程(核心)
if(nums[j]<nums[i]){
dp[i] = max(dp[i],dp[j]+1);
}
计算 dp[i] 时,需要遍历 i 前面的所有元素 nums[j],寻找能够连接到当前元素的递增子序列。
条件:
nums[j] < nums[i]
只有当前面的元素小于当前元素时,才能把 nums[i] 接在以 nums[j] 结尾的递增子序列后面。
状态转移:
\ dp\[i=\max(dpi,dpj+1) \]
其中:
-
dp[i]:当前已经找到的、以nums[i]结尾的最长递增子序列长度。 -
dp[j]:以nums[j]结尾的最长递增子序列长度。 -
+1:把当前元素nums[i]接到原来的递增子序列后面。 -
max():比较不同方案,保留最长长度。
例如:
nums = {1,3,2,4};
当 i=3,即 nums[i]=4 时:
j=0:nums[0]=1 < 4
dp[3] = max(1,dp[0]+1) = 2
j=1:nums[1]=3 < 4
dp[3] = max(2,dp[1]+1) = 3
j=2:nums[2]=2 < 4
dp[3] = max(3,dp[2]+1) = 3
最终:
dp[3] = 3;
说明以数字 4 结尾的最长递增子序列长度为 3。
3. 确定 dp 数组初始化
vector<int> dp(n,1);
将所有 dp[i] 初始化为 1。
原因是:每个元素本身都可以构成长度为 1 的递增子序列。
例如:
nums = {5,4,3,2};
由于数组严格递减,无法找到长度大于 1 的递增子序列。
所以:
dp = {1,1,1,1};
因此,dp[i] 的初始值应该为 1,而不是 0。
4. 确定遍历顺序
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);
}
}
}
外层循环 i:
从左到右遍历数组,依次计算以每个元素结尾的最长递增子序列长度。
内层循环 j:
遍历当前元素前面的所有元素,寻找能够连接到 nums[i] 的递增子序列。
为什么 j<i?
因为子序列必须保持原数组中的相对顺序,只能将当前元素接到它前面的元素之后。
而且 dp[i] 依赖之前已经计算完成的 dp[j],因此需要从左到右遍历。
5. 确定返回值
return *max_element(dp.begin(),dp.end());
max_element() 是 C++ STL 中已经封装好的函数,用于寻找指定范围内的最大元素。
-
dp.begin():指向数组第一个元素。 -
dp.end():指向数组最后一个元素的下一个位置。 -
max_element():返回最大元素的迭代器。 -
*:解引用,获得最大元素的值。
为什么不能直接返回 dp[n-1]?
因为最长递增子序列不一定以数组最后一个元素结尾。
例如:
nums = {1,2,3,0};
dp = {1,2,3,1};
最长递增子序列是 {1,2,3},长度为 3。
但是:
dp[n-1] = 1;
因此必须取整个 dp 数组中的最大值。
三、复杂度分析
时间复杂度:\(O(n^2)\)
外层循环遍历 n 个元素,内层循环遍历当前元素之前的所有元素,因此总体时间复杂度为 \(O(n^2)\)。
空间复杂度:\(O(n)\)
使用一个长度为 n 的 DP 数组保存每个位置的最长递增子序列长度。
四、核心思路总结
本题采用动态规划求解最长严格递增子序列的长度。
核心思想是:固定当前元素 nums[i] 作为递增子序列的结尾,向前寻找能够连接的元素 nums[j],并利用已经计算好的 dp[j] 更新 dp[i]。
具体过程:
-
定义
dp[i],表示以nums[i]结尾的最长递增子序列长度。 -
将所有
dp[i]初始化为1,因为每个元素自身都可以构成一个长度为 1 的子序列。 -
外层循环遍历当前元素
i,内层循环遍历它前面的元素j。 -
如果
nums[j]<nums[i],说明可以将当前元素接到以nums[j]结尾的递增子序列后面。 -
使用
dp[i]=max(dp[i],dp[j]+1),不断更新以当前元素结尾的最长长度。 -
最后使用
max_element()获取整个 DP 数组中的最大值。
核心状态转移方程:
\ \\boxed{dp\[i=\max(dpi,dpj+1),\quad numsj<numsi} \]
需要特别注意的三个地方:
-
dp[i]表示的是以当前元素结尾的最长递增子序列,而不是整个数组的最长递增子序列。 -
nums[j]<nums[i]必须是严格小于,不能写成<=,因为题目要求严格递增。 -
最终返回
max(dp),而不是dp[n-1],因为最长递增子序列不一定以最后一个元素结尾。
记忆模板:固定当前结尾 i,向前枚举 j,满足递增条件就通过 dp[j]+1 更新 dp[i],最后取所有 dp[i] 的最大值。