1. 动态规划解子序列问题核心思路
动态规划是解决子序列问题的利器,其核心在于状态定义和转移方程的设计。对于最长子序列这类问题,我们需要明确几个关键点:
-
子序列与子数组的区别:子序列不要求连续,而子数组必须连续。这个区别直接影响状态转移方程的设计。
-
状态定义:通常dp[i]表示以第i个元素结尾的某种子序列的长度。对于二维情况(如718题),dp[i][j]表示以A[i]和B[j]结尾的子问题的解。
-
状态转移:根据问题特性,找到当前状态与前驱状态的关系。对于递增序列,当前元素大于前驱时才更新;对于公共子数组,需要当前元素相等才更新。
2. 300. 最长递增子序列深度解析
2.1 问题重述
给定一个整数数组nums,找到其中最长严格递增子序列的长度。子序列不要求连续。
示例:
输入:nums = [10,9,2,5,3,7,101,18]
输出:4
解释:最长递增子序列是 [2,3,7,101],因此长度为4。
2.2 DP解法详解
cpp复制class Solution {
public:
int lengthOfLIS(vector<int>& nums) {
if(nums.size()<=1) return nums.size();
vector<int> dp(nums.size(),1); // 初始化为1,每个元素本身构成长度为1的子序列
int result=0;
for(int i=1;i<nums.size();i++) {
for(int j=0;j<i;j++) {
if(nums[i]>nums[j]) dp[i]=max(dp[i],dp[j]+1);
}
result=max(result,dp[i]);
}
return result;
}
};
关键点解析:
- dp[i]定义:以nums[i]结尾的最长递增子序列长度
- 双层循环:外层遍历每个元素作为结尾,内层检查所有可能的前驱
- 状态转移:当nums[i]>nums[j]时,dp[i]可以更新为dp[j]+1
- 时间复杂度:O(n²),空间复杂度:O(n)
2.3 优化思路:二分查找法
对于大规模数据,O(n²)可能不够高效。可以采用贪心+二分的方法优化到O(nlogn):
cpp复制int lengthOfLIS(vector<int>& nums) {
vector<int> tails;
for(int num : nums) {
auto it = lower_bound(tails.begin(), tails.end(), num);
if(it == tails.end()) tails.push_back(num);
else *it = num;
}
return tails.size();
}
这种方法维护一个tails数组,其中tails[i]表示长度为i+1的所有递增子序列的最小末尾。
3. 674. 最长连续递增序列解析
3.1 问题特点
与300题不同,本题要求子序列必须是连续的。这使得问题简化,因为只需要比较当前元素与前一个元素的关系。
示例:
输入:nums = [1,3,5,4,7]
输出:3
解释:最长连续递增子序列是 [1,3,5],长度为3。
3.2 DP解法实现
cpp复制class Solution {
public:
int findLengthOfLCIS(vector<int>& nums) {
if(nums.size()<=1) return nums.size();
vector<int> dp(nums.size(),1);
int result=0;
for(int i=1;i<nums.size();i++) {
if(nums[i]>nums[i-1]) dp[i]=dp[i-1]+1;
result=max(result,dp[i]);
}
return result;
}
};
关键改进:
- 单层循环:只需比较当前与前一个元素
- 状态转移简化:dp[i] = dp[i-1] + 1(当nums[i]>nums[i-1]时)
- 时间复杂度降为O(n)
3.3 空间优化版本
由于当前状态只依赖前一个状态,可以进一步优化空间:
cpp复制int findLengthOfLCIS(vector<int>& nums) {
int curr = 1, max_len = 1;
for(int i=1;i<nums.size();i++) {
if(nums[i]>nums[i-1]) {
curr++;
max_len = max(max_len, curr);
} else {
curr = 1;
}
}
return max_len;
}
4. 718. 最长重复子数组深度剖析
4.1 问题复杂性
这是三题中最具挑战性的,因为涉及两个数组的比较,需要二维DP来解决。
示例:
输入:
A: [1,2,3,2,1]
B: [3,2,1,4,7]
输出:3
解释:最长重复子数组是[3,2,1],长度为3。
4.2 二维DP解法
cpp复制class Solution {
public:
int findLength(vector<int>& nums1, vector<int>& nums2) {
vector<vector<int>> dp(nums1.size()+1,vector<int>(nums2.size()+1,0));
int result=0;
for(int i=1;i<=nums1.size();i++) {
for(int j=1;j<=nums2.size();j++) {
if(nums1[i-1]==nums2[j-1]) {
dp[i][j]=dp[i-1][j-1]+1;
}
result=max(result,dp[i][j]);
}
}
return result;
}
};
关键设计:
- dp[i][j]定义:以nums1[i-1]和nums2[j-1]结尾的最长公共子数组长度
- 初始化:dp[0][j]和dp[i][0]初始化为0,方便边界处理
- 状态转移:仅当元素相等时,dp[i][j] = dp[i-1][j-1] + 1
- 时间复杂度:O(mn),空间复杂度:O(mn)
4.3 空间优化技巧
通过滚动数组可以将空间复杂度优化到O(min(m,n)):
cpp复制int findLength(vector<int>& nums1, vector<int>& nums2) {
if(nums1.size()<nums2.size()) swap(nums1,nums2);
vector<int> dp(nums2.size()+1,0);
int res=0;
for(int i=1;i<=nums1.size();i++) {
for(int j=nums2.size();j>=1;j--) {
if(nums1[i-1]==nums2[j-1]) {
dp[j]=dp[j-1]+1;
res=max(res,dp[j]);
} else {
dp[j]=0;
}
}
}
return res;
}
5. 动态规划解题方法论
5.1 解题四步法
- 确定dp数组含义
- 找出状态转移方程
- 初始化dp数组
- 确定遍历顺序和结果获取方式
5.2 常见陷阱
- 初始化错误:特别是边界条件的处理
- 遍历顺序不当:如二维DP中内外循环顺序
- 状态转移条件遗漏:如718题中元素不等时需要重置为0
5.3 调试技巧
- 打印dp表格:对于二维DP特别有用
- 小规模测试:先用手算验证简单case
- 边界测试:空数组、单元素数组等特殊情况
6. 扩展思考
6.1 输出具体子序列
如何修改代码不仅返回长度,还返回具体的子序列?这需要额外维护路径信息。
6.2 其他变种问题
- 最长公共子序列(不要求连续)
- 最短公共超序列
- 编辑距离问题
6.3 实际应用场景
- DNA序列比对
- 代码相似性检测
- 版本控制系统中的差异分析
7. 性能对比与选择
| 问题 | 标准DP复杂度 | 优化后复杂度 | 适用场景 |
|---|---|---|---|
| 300 | O(n²) | O(nlogn) | 大规模数据 |
| 674 | O(n) | O(n) | 简单场景 |
| 718 | O(mn) | O(min(m,n)) | 双序列匹配 |
在实际面试中,建议先给出标准DP解法,再讨论优化可能。对于竞赛场景,直接采用最优解法更合适。
8. 编码实践建议
- 统一索引处理:718题中使用i-1,j-1是为了避免单独处理边界,这种技巧值得掌握
- 变量命名:使用有意义的变量名如max_len比result更直观
- 代码复用:对于类似问题,可以抽象出公共函数
- 测试用例设计:应包括升序、降序、随机序列等不同情况
提示:动态规划问题的调试往往比较困难,建议在IDE中设置断点逐步查看dp数组的变化,这比单纯打印更有效率。
9. 常见错误分析
- 300题错误:忘记初始化dp数组为1,导致结果偏小
- 674题错误:混淆连续与非连续条件,错误套用300题解法
- 718题错误:没有正确处理不等情况下的状态转移
10. 学习路径建议
- 先掌握一维DP问题(如斐波那契、爬楼梯)
- 然后练习经典一维序列问题(如最大子数组和)
- 接着过渡到二维序列问题(如编辑距离)
- 最后挑战更复杂的多序列问题
动态规划的学习曲线较陡,建议从简单问题入手,逐步建立解题直觉。对于每道题,不仅要写出代码,更要理解为什么这样设计状态和转移方程。