目录
简介
上一节当中,我们学的是子数组,这个是连续的,比如123这个数组,求得都是1,12,123,2,23,3这一类连续的子数组
因为子数组是连续的,所以 “以 i 结尾的子数组” 只有两种可能:
- 接在 “以 i-1 结尾的子数组” 后面(延续连续区间);
- 仅包含 i 自己(重新开始一个连续区间)。这就决定了子数组 DP 的状态
dp[i]只能依赖dp[i-1],转移逻辑极其固定。而本节学的子序列,不一定是连续的
子序列的 “不连续性” 意味着:
- 以 i 结尾的子序列,可能接在任意一个 j < i 且满足条件的位置后面(而非仅 i-1);
- 比如 [1,3,2,4] 中,以 4 结尾的子序列,可接在 1、3、2 后面,需遍历所有 j 找最大值。
而子数组的 “连续性” 直接锁死了依赖范围 —— 只能接在 i-1 后面,无需遍历更前面的元素。
子序列的选法包含了子数组,子序列的选法有2^N,也就是一个数字有选和不选两种情况
但子数组不是,子数组有n+(n-1)+(n-2)种,即N^2种选法
所以子序列的选法多余子数组,复杂度高
最长递增子序列
经验+题目要求,以i位置为结尾的最长递增子序列
然后推导状态转移方程,发现一个状态刚好够
此时需要向前遍历,nums[i]和nums[j]进行对比
因为你当前子序列的构成,除了自己本身以外,还可能跟在前面任何一个数字的后面,我们需要对所有的子序列求一个最长的填到dp表当中
class Solution {
public:
int lengthOfLIS(vector<int>& nums) {
int m=nums.size();
vector<int>dp(m,1);
for(int i=0;i<m;i++){
for(int j=i;j>=0;j--){
if(nums[i]>nums[j])
dp[i]=max(dp[i],dp[j]+1);
}
}
int ret=dp[0];
for(int i=0;i<m;i++){
ret=max(ret,dp[i]);
}
return ret;
}
};
摆动序列

一个状态是无法表示的,因为你最后结束可能是下降也可能是上升,为什么子数组那里可以一个状态,因为子数组是连续的,你仅仅需要比较i i-1 i-2 即可,但是这里不行,因为不是连续的,所以导致比较不行
所以需要定义两个状态,明确表示最后是上升还是下降

很明显时间复杂度为O(n^2),因为你每次填一个位置,都要往前面找一个最大的状态

class Solution {
public:
int wiggleMaxLength(vector<int>& nums) {
int m=nums.size();
vector<int> f(m,1),g(m,1);
for(int i=1;i<m;i++){
for(int j=i-1;j>=0;j--){
if(nums[i]>nums[j]){
//找下降
f[i]=max(g[j]+1,f[i]);
}
if(nums[i]<nums[j]){
//找上升
g[i]=max(f[j]+1,g[i]);
}
}
}
return max(f[m-1],g[m-1]);
}
};
小总结
所以,子序列无非就是往前一个个找适合的,子数组是往i-1位置找,一个是不连续一个是连续
不连续注定了有时候时间复杂度是O(n^2)
最长递增子序列的个数
673. 最长递增子序列的个数 - 力扣(LeetCode)

前置知识,如何一遍扫描数组,找到最大的数已经其对应的个数
只需要一个小贪心算法即可
本题定义dp[i]一个状态的时候发现无法完成,因为推状态转移方程的时候推不出来,即当前位置的更新依赖最长的长度,已经最长长度的个数
所以我们需要两个状态

考虑两种情况
1:当前位置单独是一个子序列,那此时len[i]=1
2:当前位置和前进的j位置能够形成递增子序列,那此时我们需要找到最长的,并且找到最长当中的最多的个数
对于第一种情况,我们只需要初始化dp表的时候将两个表都初始化为1即可
对于第二种我们要分情况讨论
a.当j位置+1长度==len[i]时,说明此时是最长的,需要统计个数相加
b.当长度小于时,说明不是最长的,忽略
c.当长度大于时,说明找到新的最长的,需要重新统计,统计的个数应当变为count[j]
为什么要比较+1,是因为这些情况都是以i位置结尾了

对于返回值,完全可以在填dp表的时候,填完某个位置的时候就更新,否则你完整的填完dp表之后,还需要遍历两次才能找到返回值
class Solution {
public:
int findNumberOfLIS(vector<int>& nums) {
int m = nums.size();
vector<int> len(m, 1), count(m, 1);
int retlen = -1, retcount = -1; // 存结果
for (int i = 0; i < m; i++) {
for (int j = i-1; j >= 0; j--) {
if (nums[i] > nums[j]) {
// 能跟在j后面
if (len[j] + 1 == len[i]) {
// 说明此时又找到一个相同最长的
// 因为第一次找到一个最长的时候已经更新了len,所以这里不需要更新也行
count[i] += count[j];
}
if (len[j] + 1 > len[i]) {
// 说明此时找到一个更长的
len[i] = len[j] + 1;
count[i] = count[j];
}
// 对于<直接忽略
}
}
// 循环结束之后,更新一下len和count
if (retlen < len[i]) {
// 小于则更新
retlen = len[i];
retcount = count[i];
}
else if (retlen == len[i]) {
retcount += count[i];
}
}
return retcount;
}
};
最长数对链
本题其实很简单,只是之前学的是单个数,这里是两个数字,但是比较也是一个数,因为每个数对严格递增
本题作者没有读清楚题目,想当然的认为是一个升序,但是这里特殊在于示例二,有可能先链接后面再链接前面,也就是每一组的选择是随机的
以下就是错误的写法

本质原因是因为你在以i位置结尾的时候,跟之前的子数组和子序列一样,当前状态只依赖之前的状态,也就是你以i位置为结尾的所有子序列只跟i-1,i-2有关
如果你这里没有预处理,就会导致你填写i位置的时候,即跟i-1,i-2有关,又跟i+1,i+2有关
那么我们这里想一想是否可以排个序,做个预处理
当然可以,假设你排完序之后是[a,b][c,d],ab填写的时候肯定用不到cd,即使a和c相等,那你a压根链接不了在d后面
这样排完序之后,所有当前状态的填写仅仅依赖于前面的状态
class Solution {
public:
int findLongestChain(vector<vector<int>>& pairs) {
//预处理,排序一下
sort(pairs.begin(), pairs.end(), [](const vector<int>& a, const vector<int>& b) {
if (a[0] == b[0]) return a[1] < b[1];
return a[0] < b[0];
});
int m=pairs.size();
vector<int> dp(m,1);
for(int i=0;i<m;i++){
for(int j=i-1;j>=0;j--){
if(pairs[i][0]>pairs[j][1]){
//大于则跟在后面
dp[i]=max(dp[j]+1,dp[i]);
}
}
}
//返回dp中最大的
int ret=0;
for(int i=0;i<m;i++){
ret=max(ret,dp[i]);
}
return ret;
}
};
最长定差子序列
这一看就是简单题,跟之前一样,定义dp[i]
向前遍历找到最长的符合定差的子序列
初始化:全部为1,因为至少都有一个
填表顺序:从左往右
返回值:返回dp表当中最大的,因为其他的不会被改变,只能为1,定差的要么为1要么很大

但是超时,此时要想办法去优化
优化点1:直接找到第一个符合的dp[j]就退出循环,但是这个也超时了
优化点2:将元素和dp[j]绑定存入hash表中,并且直接在hash表当中做动态规划
此时初始化为hash[arr[0]]=1,意思就是hash[元素]=1
class Solution { public: int longestSubsequence(vector<int>& arr, int difference) { int m = arr.size(); unordered_map<int, int> hash; hash[arr[0]]=1; int ret = 1; for (int i = 1; i < m; i++) { int val = hash[arr[i] - difference];//找到对应的元素的dp[j] hash[arr[i]] = val + 1; ret = max(ret, hash[arr[i]]); } return ret; } };注意如果你使用int hash[],需要开20000的空间,因为可能arr[i]为负数,如果映射进去会越界
这种写法会保证hash[arr[i]]至少是1,而且你从左往右填表
也能保证是最后一个元素,因为你是从左往右填表,会就导致右边的覆盖左边的hash,所以你找到的元素是最后一个元素
然后初始化要初始化第一个元素,不然第一个找不到前驱会出错
时间复杂度O(N),空间复杂度O(1)
最长的斐波那契子序列的长度
873. 最长的斐波那契子序列的长度 - 力扣(LeetCode)
如果按照之前的以某个位置为结尾去推导,是推导不出状态转移方程的
对于dp[i]和dp[j],你可以用nums[i]-nums[j]找到第一个元素,这样就可以组成斐波那契子序列,但是你不能用dp[j]的值去更新dp[i],因为你压根不知道dp[j]结尾的最长斐波那契子序列长啥样,不一定是nums[i]-nums[j],nums[j],nums[i]这个序列
也可能是a,nums[j] b,nums[j]
根本原因是你无法通过dp[j]这个状态知道以j位置为结尾的最长斐波那契子序列长啥样,你不知道i位置能否和j位置匹配上
解决方案:将两个元素固定为结尾,找前一个元素,因为我们可以通过a,b 推导出前一个元素为b-a , a, b
接着推导出 a-(b-a) , b-a , a , b
并且很明显感受到你以两个位置为结尾,只需要像之前做的题目一样,找到前一个符合的
这里为什么不能像之前,是因为你以一个结尾时,要找到前两个符合的
所以我们把两个结尾放后面
dp[i][j]:以i位置j位置结尾的最长的斐波那契子序列的长度(规定i<j)

这里a要分三种情况
1:如果a存在,并且在ij前面
2:如果a存在,a在i和j之间
3:如果a不存在
并且这里注意更新为2是他们本身的长度至少两个元素,不然你后面更新+1的时候会出错
优化点由于题目严格递增,所以hash表当中,元素和下标是唯一的

初始化的时候是更新表当中的值为2,因为你是规定i<j的,也就是你只会用到表上方的,表下方和对角线用不到
class Solution { public: int lenLongestFibSubseq(vector<int>& arr) { int m = arr.size(); // 预处理,把元素和下标绑定 unordered_map<int, int> hash; for (int i = 0; i < m; i++) { hash[arr[i]] = i; } int ret = 2; vector<vector<int>> dp(m, vector<int>(m, 2)); for (int j = 2; j < m; j++) { for (int i = 1; i < j; i++) { if (hash.count(arr[j] - arr[i]) && hash[arr[j] - arr[i]] < i) { // 存在且小于i dp[i][j] = dp[hash[arr[j] - arr[i]]][i] + 1; } // 其余情况不管,本身初始化已为2了 ret = max(ret, dp[i][j]); } } return ret < 3 ? 0 : ret; } };
最长等差数列
一开始尝试dp[i]能否推出状态转移方程,这和之前一样无法推出,因为你以i位置结尾的时候,是无法知道你前面位置的状态,你无法知道能否组成最长等差数列,有时候可以知道等差,但你无法知道最长,你不知道前面那个状态的最长的等差数列的公差是多少
你必须知道一个等差数列的最后两项才能推出这个等差数列
也就是你得知道i位置和i位置前面某个位置的最长的等差数列
换言之,你得以两个位置为结尾才能表示一个等差数列的信息
本题不能像之前一样,采用hash来表示元素-下标对应关系,因为这里题目没有说明是严格递增或者说明每个元素只出现一次,否则hash当中会出现后元素覆盖前一个元素的情况
状态转移方程其实跟之前的一样的
注意当a存在,k<i的时候,假设有很多个符合的a,那只需要取最接近i的那个即可,因为这个的情况可能是最长的
如果不使用hash,那找下标k的时候时间复杂度会多一层O(N),导致最后是O(n^3)
所以要想办法优化
优化点来说:如果还想要沿用hash表,那就得创建unordered_map<int,vector<int>>,因为你同一个元素的下标不能覆盖,所以要创建一个下标数组,但是当特殊情况的时候,你的下标数组非常大,就会导致还需要去下标数组当中遍历一遍,时间复杂度仍然O(n^3)
优化二:一边dp一边保存最近的元素的下标,这和填表顺序有关,看下面
初始化:全部为2,因为严格定义i<j,所以对角线和下方dp表不需要
填表中dp[i][j]=dp[k][i]+1,我们仅仅需要保证填表时从上往下填表即可
这样就衍生出两种填表方式
为了匹配优化点2,我们需要采用第二种填表
第二种填表的意思时,你固定i位置,去跑j位置,那此时i之前的所有就可以用hash去存,不用担心覆盖,因为覆盖时,他会采用离i位置最近的去覆盖
如果你采用第一种,那你每次跑i位置的时候,里你最近的元素会动态变换
所以本质就是因为你需要离你最近的元素的下标,当你固定i时,最近的元素的下标也固定了
当你i跑动的时候,最近元素的下标也会随i跑动变来变去
class Solution { public: int longestArithSeqLength(vector<int>& nums) { unordered_map<int, int> hash; // 元素值和下标的映射关系 hash[nums[0]] = 0; int m = nums.size(); if (m <= 2) { return m; } vector<vector<int>> dp(m, vector<int>(m, 2)); int ret = 2; for (int i = 1; i < m; i++) { // 固定倒数第二个数 for (int j = i + 1; j < m; j++) { // 固定倒数第一个数 int a = 2 * nums[i] - nums[j]; // 要找的k if (hash.count(a)) { // k存在并且k小于i dp[i][j] = dp[hash[a]][i] + 1; } // 不存在或k>i都不用管 // 每次处理完更新ret ret = max(ret, dp[i][j]); } // 更新hash hash[nums[i]] = i; } return ret; } };这里不需要判断k下标是不是在i的左边,是因为你hash当中定义存储的是离i位置最近的元素,并且都是在左边
等差数列划分Ⅱ
446. 等差数列划分 II - 子序列 - 力扣(LeetCode)
根据前面做的这么多道题
直接以i位置为结尾肯定不行,因为你不能采用单单一个i位置的元素去确定前面的等差数列
所以这里以i j为结尾
dp[i][j]:表示以i j位置为结尾的所有等差数列的个数
状态转移方程:a==2b-c
跟之前一样分a存不存在,下标k在哪里
当a存在且k<i时,dp[i][j]= dp[k][i]==0?1:dp[k][i]+1
0的意思是一开始如果是两个元素我们需要初始化为0,当来第三个元素时我们需要判断一下之前是不是两个元素也就是0,如果是,那此时dp[i][j]=1
也有可能之前已经有多个等差数列了,那此时在加一个元素进来,应该要在之前的基础上+1
当a不存在或者存在k不符合时,dp[i][j]=0即可
注意:需要三次循环,因为我们要找到每个以k i为结尾的等差数列,然后相加
初始化:全部为0即可
填表顺序:从上往下
返回值:dp表当中的总和,因为一开始初始化为0,所以不影响
class Solution {
public:
int numberOfArithmeticSlices(vector<int>& nums) {
int m = nums.size();
vector<vector<int>> dp(m, vector<int>(m, 0));
int ret = 0;
for (int j = 2; j < m; j++) {
for (int i = j - 1; i >= 0; i--) {
for (int k = i - 1; k >= 0; k--) {
long long tmp = 2 * (long long)nums[i] - (long long)nums[j];
if (tmp == (long long)nums[k]) {
if (dp[k][i] == 0) {
dp[i][j] += 1;
} else {
dp[i][j] += dp[k][i];
dp[i][j] += 1;
}
}
}
ret += dp[i][j];
}
}
return ret;
}
};
很明显时间复杂度O(n^3)
想办法优化一下:是否可以将所有以计算出来的a==2b-c
这个a存入到hash中呢?这样就不需要每次固定i和j,往前遍历k了
很明显可以,hash当中存储<元素值,下标数组>
class Solution { public: int numberOfArithmeticSlices(vector<int>& nums) { int m = nums.size(); unordered_map<long long, vector<int>> hash; for (int i = 0; i < m; i++) { hash[nums[i]].push_back(i); } vector<vector<long long>> dp(m, vector<long long>(m, 0)); int ret = 0; for (int j = 2; j < m; j++) { for (int i = j - 1; i >= 0; i--) { long long tmp = 2 * (long long)nums[i] - (long long)nums[j]; if (hash.count(tmp)) { for (auto k : hash[tmp]) { if (k < i) { dp[i][j] += dp[k][i] + 1; } } } ret+=dp[i][j]; } } return ret; } };
总结
做了这么多道题
很明显感受到子序列不连续而导致结果非常多
而且有些不能以某个位置为结尾,因为你只能判断个数或者长度,无法知道前面的子序列长什么样
所以我们有些题需要以i j位置为结尾
有时候你不优化,时间复杂度为O(n^3),因为你需要固定i 和 j,还要往前遍历k寻找结果
核心特征是子序列不要求连续,但保持元素原有顺序,与 “子数组 / 子串(连续)” 形成关键区别。以下从核心分类、解题框架、经典题型、避坑要点四个维度,结合你刷过的题目做系统总结:
一、子序列题型核心分类(按考点)
1. 最长类子序列(求最大长度)
核心特征
目标是找到满足特定条件的最长子序列,是子序列题型的基础。
经典题型 & 解题框架
表格
题型
核心解法
关键思路
最长递增子序列(LIS)
动态规划 O (n²)/ 二分 O (n)
dp [i] = 以 nums [i] 结尾的最长递增子序列长度;二分优化需维护有序数组
最长定差子序列
哈希优化 DP O (n)
hash [num] = 以 num 结尾的最长定差子序列长度,转移:hash [num] = hash [num-d]+1
最长等差数列
哈希优化二维 DP O (n²)
dp [j][d] = 以 j 结尾、公差 d 的最长等差数列长度,转移:dp [j][d] = dp [i][d]+1
核心规律
单条件约束(如递增、定差):优先一维 DP + 哈希优化(避免 O (n²));
多条件约束(如等差数列,需公差):二维 DP(维度为 “结尾位置 + 约束条件”)。
2. 计数类子序列(求符合条件的个数)
核心特征
统计满足条件的子序列总数(常要求长度≥k),易出现 “重复计数”“漏计数” 问题。
经典题型 & 解题框架
表格
题型
核心解法
关键思路
等差数列划分 II - 子序列
哈希优化二维 DP O (n²)
dp [j][d] = 以 j 结尾、公差 d 的子序列个数(长度≥2),结果累加 dp [i][d](长度≥3)
不同的子序列(字符串)
动态规划 O (n*m)
dp [i][j] = 前 i 个字符包含前 j 个模式串的子序列数,注意去重(重复字符)
核心规律
避免重复计数:按 “结尾位置 + 约束条件(如公差)” 分组统计,而非暴力遍历所有子序列;
长度≥k 的统计:先统计长度≥2 的基础数,再剔除不符合长度的部分(如等差数列只累加 dp [i][d]);
溢出问题:涉及大数时用 long long,避免 int 溢出(如公差计算、计数累加)。
3. 存在性类子序列(判断是否存在)
核心特征
判断是否存在满足条件的子序列(如是否存在和为 k 的子序列、是否为斐波那契子序列)。
解题框架
暴力 DFS(小数据量):枚举所有可能的子序列,判断条件;
动态规划(大数据量):dp [i] 记录前 i 个元素是否能满足条件(如和为 k);
哈希辅助:快速查找是否存在满足条件的前驱元素(如斐波那契子序列需找 nums [i]-nums [j] 是否存在)。
4. 构造类子序列(构造符合条件的子序列)
核心特征
构造满足条件的最优子序列(如最大字典序子序列、最短子序列)。
解题框架
贪心 + 栈(字符串类):如 “移掉 k 位数字”“最大子序列”,按贪心规则维护栈;
双指针:如 “删除最短子数组使剩余有序”,找前后有序区间的衔接点。
二、子序列题型通用解题技巧
1. 动态规划是核心(90% 的子序列题)
状态定义技巧:优先定义 “以 nums [i] 结尾” 的子序列状态(如 dp [i]),而非 “前 i 个元素”,减少状态冗余;
转移方程技巧:
单约束:dp [i] = 基于前驱元素的 dp [j] + 1(如最长递增子序列);
多约束:dp [i][c] = 基于前驱的 dp [j][c'] + 1(c 为约束条件,如公差);
优化技巧:哈希表替代前驱遍历(如定差 / 等差数列题),将 O (n²)→O (n)。
2. 避免暴力枚举(子序列总数是 2ⁿ,必超时)
暴力枚举仅适用于 n≤20 的场景(可回溯),n≥100 时必须用 DP / 哈希优化;
计数类题目:绝不能遍历所有子序列再判断,需按 “分组统计” 思路(如按公差分组)。
3. 关键细节避坑
下标越界:子序列要求元素顺序,前驱下标必须小于当前下标(如 k<i<j),需严格判断;
重复元素处理:
最长类:哈希表存储 “值→所有下标”,而非最后一个下标(避免覆盖);
计数类:按约束条件分组(如公差),避免同一子序列被多次统计;
数据类型:涉及大数计算(如公差、计数)时,用 long long 替代 int(如 2*nums [i] 易溢出);
边界条件:
数组长度 < 2/3 时,直接返回长度 / 0(如等差数列题,长度 < 3 无符合条件的子序列);
空序列 / 单元素序列:单独处理,避免 DP 初始化错误。
三、刷过的题目核心回顾
表格
题目
题型分类
核心错误 & 修复要点
longestSubsequence(定差)
最长类
哈希覆盖重复值下标→存储所有下标;负数下标→map / 偏移量
longestArithSeqLength(等差)
最长类
哈希覆盖→存储下标列表;k≥i 导致越界→严格判断 k<i + 二分查找
numberOfArithmeticSlices(计数)
计数类
三维循环超时→哈希优化 O (n²);重复计数→按公差分组;漏加 1→dp [j][d] += dp [i][d]+1
四、总结关键点
核心思想:子序列的核心是 “顺序不变 + 非连续”,解题优先抓 “结尾位置 + 约束条件”,用 DP + 哈希优化避免暴力;
最长类:状态定义为 “以 i 结尾的最长长度”,转移依赖前驱的符合条件状态;
计数类:状态定义为 “以 i 结尾的个数”,结果累加需剔除长度不足的部分,避免重复;
避坑重点:哈希表存储所有下标(防覆盖)、严格判断下标顺序(防越界)、用 long long 防溢出。
掌握以上框架,能解决 80% 以上的子序列题型,剩余 20%(如构造类)需结合贪心 / 栈等特殊技巧,核心仍是 “按约束条件分组,避免暴力枚举”。







871



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



