【动态规划 | 子序列问题】子序列问题的最优解:动态规划方法详解
算法 | 相关知识点 | 可以通过点击 | 以下链接进行学习 | 一起加油! |
---|---|---|---|---|
斐波那契数列模型 | 路径问题 | 多状态问题 | 子数组 |
动态规划是解决子序列问题的利器。面对最长公共子序列、最长递增子序列等经典问题时,掌握状态定义、转移方程和边界处理三大核心要素,就能快速找到最优解。本文将用最简洁的方式,带你掌握动态规划解决子序列问题的精髓,提升算法解题能力。
🌈个人主页:是店小二呀
🌈C/C++专栏:C语言\ C++
🌈初/高阶数据结构专栏: 初阶数据结构\ 高阶数据结构
🌈Linux专栏: Linux
🌈算法专栏:算法
🌈Mysql专栏:Mysql
🌈你可知:无人扶我青云志 我自踏雪至山巅
文章目录
- 子序列概念
- 300. 最长递增子序列(重要)
- 376. 摆动序列
- 673. 最长递增子序列的个数
- 646. 最长数对链
- 1218. 最长定差子序列
- 873. 最长的斐波那契子序列的长度
- 1027. 最长等差数列
- 446. 等差数列划分 II - 子序列
子序列概念
子序列是一个从原始序列中删除某些元素(可以不删除元素,也可以删除一些元素)后,剩余元素保持原有顺序的序列。换句话说,子序列是从原始序列中选择若干个元素,保持它们的相对顺序。
300. 最长递增子序列(重要)
【题目】:300. 最长递增子序列
【算法思路】
由于这道题是“子序列”问题,若要找到以第 i
个位置为结尾的所有子序列,我们需要在区间 [0, i - 1]
内查找符合条件的子序列。因为要求是最长子序列,因此我们可以使用 max
函数不断比较,确保只选择最长的子序列。
需要注意的是,nums[j] < nums[i]
是前提条件,确保在 i
位置之前的子序列是递增的。以后类似的子序列问题一般采用这种思路,绘图在理解过程中非常重要。
【代码实现】
class Solution {
public:int lengthOfLIS(vector<int>& nums) {int n = nums.size();vector<int> dp(n, 1);for(int i = 1; i < n; i++)for(int j = 0; j <= i; j++)if(nums[j] < nums[i]) dp[i] = max(dp[j] + 1, dp[i]);int ret = 0;for(auto x : dp) ret = max(ret, x);return ret; }
};
376. 摆动序列
【题目】:376. 摆动序列
【算法思路】
这道题的算法思路与“最长湍流子数组”相似,但由于是处理“子序列”问题,我们需要在区间 [0, i - 1]
内查找所有符合条件的子序列,并通过 j
变量更新结果。而对于子数组问题,直接处理即可。
【代码实现】
class Solution {
public:int wiggleMaxLength(vector<int>& nums) {//1.创建dp表int n = nums.size();vector<int> f(n, 1);auto g = f;//2.填表int ret = 1;//更新最新的结果for(int i = 1; i < n; i++){for(int j = 0; j < i; j++){if(nums[j] < nums[i]) f[i] = max(g[j] + 1, f[i]);else if(nums[j] > nums[i]) g[i] = max(f[j] + 1, g[i]);}ret = max(ret, max(f[i], g[i]));}return ret;}
};
673. 最长递增子序列的个数
【题目】:673. 最长递增子序列的个数
【算法思路】
【小demo】:在数组中找出最大值出现的次数
通过这个小技巧,在以后需要找出最大值、最小值或最长/最短值的同时,还能统计其出现次数。这个小 demo 可以帮助实现这一需求
根据’经验 + 题目要求’,我们可以得到一个简单的状态表示。然而,这不足以直接满足’最长值与出现次数’的需求,因此需要使用两个状态表示。结合我们的小 demo,可以得出相应的状态转移方程。
同时,我们需要定义变量来记录长度和次数,供返回值使用。因为这些信息是必须的:当 relen == len[i]
时,recount++
;当小于时,则需要重新更新结果并进行记录
【代码实现】
class Solution {
public:int findNumberOfLIS(vector<int>& nums) {int n = nums.size();//1.创建dp表vector<int> len(n, 1);auto count = len;int relen = 1, recount = 1;//2.填表操作for(int i = 1; i < n; i++){for(int j = 0; j < i; j++){if(nums[j] < nums[i]){if(len[j] + 1 > len[i]){len[i] = len[j] + 1;count[i] = count[j];}else if(len[j] + 1 == len[i]){count[i] += count[j];}}}if(relen == len[i]){recount += count[i];}else if(relen < len[i]){relen = len[i];recount = count[i];}}//3.返回值操作return recount;}
};
646. 最长数对链
【题目】:646. 最长数对链
【算法思路】
首先,对题目进行分析,并通过绘图将题目提供的信息转化为数字和图形,以便更清晰地理解和处理问题。
【细节问题】
"无论是子数组问题还是之前的子序列问题,通常以 i
位置为结尾的所有子数组或字符串都会保证倒数第二个元素出现在 i
位置之前。而这道题却要求倒数第二个元素出现在 i
位置右侧。为了解决这个问题,我们需要根据第一个位置进行排序。
排序的依据是根据题目要求和数学分析得出的,同时我们将题目转化为一个类似于子序列的问题,按照已有的思路进行解决。关键是要理解子序列的含义,并找出如何将这道题目转化为一个子序列相关问题
【代码实现】
class Solution {
public:int findLongestChain(vector<vector<int>>& pairs) {//1.预处理:按照第一个元素排序sort(pairs.begin(), pairs.end());//2.创建dp表int n = pairs.size();vector<int> dp(n, 1);//3.填表int ret = 1;for(int i = 1; i < n; i++){for(int j = 0; j < i; j++){if(pairs[j][1] < pairs[i][0]){dp[i] = max(dp[j] + 1, dp[i]);ret = max(ret, dp[i]);}}}//4.返回值return ret;}
};
1218. 最长定差子序列
【题目】:1218. 最长定差子序列
【算法思路】
根据 i
位置的情况,我们结合数学分析得出状态转移方程。需要注意的是,题目并未限定子序列必须是严格递增的,因此可能会出现重复元素。
在这种情况下,我们可以通过不同的前一个元素来更新 dp[nums[i]]
。然而,最终我们关心的是最长子序列的长度,因此重复元素不会导致错误,最终的结果会自动选取最大的长度。
为了优化空间复杂度,我们可以选择使用哈希表来存储动态规划状态。通过将“元素”和对应的最长子序列长度(dp[j]
)绑定并存入哈希表中,我们甚至可以省去 dp
数组的空间,直接在哈希表中进行动态规划。
【代码实现】
class Solution {
public:int longestSubsequence(vector<int>& arr, int difference) {unordered_map<int, int> hash;int n = arr.size();int ret = 1; hash[arr[0]] = 1;for(int i = 1; i < n; i++){hash[arr[i]] = hash[arr[i] - difference] + 1;ret = max(ret, hash[arr[i]]);}return ret;}
};
873. 最长的斐波那契子序列的长度
【题目】:873. 最长的斐波那契子序列的长度
【算法思路】
根据斐波那契数的性质和题目要求,不能仅凭一个位置的元素来推出斐波那契数列的其他元素,需要借助两个元素来推算出下一个元素,从而得到最长的斐波那契子序列。
通过分析斐波那契数列的性质以及最后一个位置的元素,我们得出了状态转移方程。为了简化计算,当无法构成斐波那契数列时,我们默认长度为2,最终在返回值时进行判断。
此外,我们通过固定序列的最后一个元素,再依次固定倒数第二个元素,来简化遍历过程,便于高效计算。
【代码实现】
class Solution {
public:int lenLongestFibSubseq(vector<int>& arr) {int n = arr.size();vector<vector<int>> dp(n, vector<int>(n, 2));unordered_map<int, int> hash;for(int i = 0; i < n; i++) hash[arr[i]] = i;int ret = 2;for(int j = 2; j < n; j++) //固定最后一个数{for(int i = 1; i < j; i++){int x = arr[j] - arr[i];if(hash.count(x) && x < arr[i]) dp[i][j] = dp[hash[x]][i] + 1;ret = max(ret, dp[i][j]);}}return ret < 3 ? 0 : ret;}
};
1027. 最长等差数列
【题目】:1027. 最长等差数列
【算法思路】
这道题同"873. 最长的斐波那契子序列的长度"很相似,无非是从"斐波那契"换成了"等差数列",同时题目没有说明"严格递增",意味着可能存在重复元素。
这里通过"上道题经验 + 题目分析",需要两个元素去确定我们的状态。根据最后一个位置进行分析,得到我们的状态转移方程。
【优化方案】
针对重复元素的下标处理,我们采用了一种策略:一遍遍历动态规划,一遍记录每个元素离它最近的下标。对于填表顺序,有两种选择。第一种是先固定最后一个元素,再枚举倒数第二个元素,这种方法可能会导致重复记录元素下标。相比之下,第二种方法,即先固定倒数第二个元素,再枚举最后一个元素,更符合我们保存最近元素下标的需求。
由于采用‘先固定倒数第二个元素,枚举最后一个元素’的策略,hash.count(x)
已经保证了 x
在 nums[i]
之前。因此,不需要额外的顺序判断。如果选用其他方法,就需要加上顺序判断,以防止计算出的 x
导致逆序。
【代码实现】
class Solution {
public:int longestArithSeqLength(vector<int>& nums) {int n = nums.size();vector<vector<int>> dp(n, vector<int>(n, 2));unordered_map<int, int> hash;hash[nums[0]] = 0;int ret = 2;for(int i = 1; i < n; i++){for(int j = i + 1; j < n; j++){int x = 2 * nums[i] - nums[j];if(hash.count(x))dp[i][j] = dp[hash[x]][i] + 1;ret = max(ret, dp[i][j]);}hash[nums[i]] = i;}return ret;}
};
446. 等差数列划分 II - 子序列
【题目】:446. 等差数列划分 II - 子序列
【算法思路】
根据前两道题的经验和题目分析,许多状态表示的产生是由于需要两个位置的元素来确定另一个元素,从而推导出状态转移方程。
题目中未明确提到“严格递增”,因此需要考虑元素重复的情况。通过绘图和数学分析,得到关键的状态信息,并分析最后一个位置的状态。最终得出结论:只有当 a
存在且 Kx < i
时,才会发生状态转移。
【优化方案】
这样就能将相同元素考虑进去了。当遇到 index >= i
的情况时,说明 x
对应的元素已经不符合构成等差数列的条件(因为 nums[i]
应该是递增的)。因此,我们可以通过 else break;
提前退出内层循环,避免进行不必要的计算。
【代码实现】
class Solution {
public:int numberOfArithmeticSlices(vector<int>& nums) {int n = nums.size();vector<vector<int>> dp(n, vector<int>(n));unordered_map<long long, vector<int>> hash;for(int i = 0; i < n; i++) hash[nums[i]].push_back(i);int sum = 0;for(int j = 2; j < n; j++){for(int i = 1; i < j; i++){long long x = (long long)2 * nums[i] - nums[j];if(hash.count(x)){for(auto index: hash[x]){if(index < i)dp[i][j] += dp[index][i] + 1;else break;}}sum += dp[i][j];}}return sum;}
};
快和小二一起踏上精彩的算法之旅!关注我,我们将一起破解算法奥秘,探索更多实用且有趣的知识,开启属于你的编程冒险!