【动态规划 | 子序列问题】子序列问题的最优解:动态规划方法详解

算法 相关知识点 可以通过点击 以下链接进行学习 一起加油!
斐波那契数列模型 路径问题 多状态问题 子数组

动态规划是解决子序列问题的利器。面对最长公共子序列、最长递增子序列等经典问题时,掌握状态定义、转移方程和边界处理三大核心要素,就能快速找到最优解。本文将用最简洁的方式,带你掌握动态规划解决子序列问题的精髓,提升算法解题能力。

🌈个人主页:是店小二呀

🌈C/C++专栏:C语言\ C++

🌈初/高阶数据结构专栏: 初阶数据结构\ 高阶数据结构

🌈Linux专栏: Linux

🌈算法专栏:算法

🌈Mysql专栏:Mysql

🌈你可知:无人扶我青云志 我自踏雪至山巅

文章目录

    • 子序列概念
    • [300. 最长递增子序列(重要)](#300. 最长递增子序列(重要))
    • [376. 摆动序列](#376. 摆动序列)
    • [673. 最长递增子序列的个数](#673. 最长递增子序列的个数)
    • [646. 最长数对链](#646. 最长数对链)
    • [1218. 最长定差子序列](#1218. 最长定差子序列)
    • [873. 最长的斐波那契子序列的长度](#873. 最长的斐波那契子序列的长度)
    • [1027. 最长等差数列](#1027. 最长等差数列)
    • [446. 等差数列划分 II - 子序列](#446. 等差数列划分 II - 子序列)

子序列概念

子序列是一个从原始序列中删除某些元素(可以不删除元素,也可以删除一些元素)后,剩余元素保持原有顺序的序列。换句话说,子序列是从原始序列中选择若干个元素,保持它们的相对顺序。

300. 最长递增子序列(重要)

题目 】:300. 最长递增子序列

算法思路

由于这道题是"子序列"问题,若要找到以第 i 个位置为结尾的所有子序列,我们需要在区间 [0, i - 1] 内查找符合条件的子序列。因为要求是最长子序列,因此我们可以使用 max 函数不断比较,确保只选择最长的子序列。

需要注意的是,nums[j] < nums[i] 是前提条件,确保在 i 位置之前的子序列是递增的。以后类似的子序列问题一般采用这种思路,绘图在理解过程中非常重要。

代码实现

cpp 复制代码
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 变量更新结果。而对于子数组问题,直接处理即可。

代码实现

cpp 复制代码
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++;当小于时,则需要重新更新结果并进行记录

代码实现

cpp 复制代码
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 位置右侧。为了解决这个问题,我们需要根据第一个位置进行排序。

排序的依据是根据题目要求和数学分析得出的,同时我们将题目转化为一个类似于子序列的问题,按照已有的思路进行解决。关键是要理解子序列的含义,并找出如何将这道题目转化为一个子序列相关问题

代码实现

cpp 复制代码
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 数组的空间,直接在哈希表中进行动态规划。

代码实现

cpp 复制代码
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,最终在返回值时进行判断。

此外,我们通过固定序列的最后一个元素,再依次固定倒数第二个元素,来简化遍历过程,便于高效计算。

代码实现

cpp 复制代码
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) 已经保证了 xnums[i] 之前。因此,不需要额外的顺序判断。如果选用其他方法,就需要加上顺序判断,以防止计算出的 x 导致逆序。

代码实现

cpp 复制代码
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; 提前退出内层循环,避免进行不必要的计算。

代码实现

cpp 复制代码
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;
    }
};


快和小二一起踏上精彩的算法之旅!关注我,我们将一起破解算法奥秘,探索更多实用且有趣的知识,开启属于你的编程冒险!

相关推荐
躲着人群25 分钟前
次短路&&P2865 [USACO06NOV] Roadblocks G题解
c语言·数据结构·c++·算法·dijkstra·次短路
心动啊1211 小时前
支持向量机
算法·机器学习·支持向量机
小欣加油2 小时前
leetcode 1493 删掉一个元素以后全为1的最长子数组
c++·算法·leetcode
蓝风破云3 小时前
C++实现常见的排序算法
数据结构·c++·算法·排序算法·visual studio
艾醒3 小时前
大模型面试题剖析:Pre-Norm与Post-Norm的对比及当代大模型选择Pre-Norm的原因
算法
怀旧,3 小时前
【C++】 9. vector
java·c++·算法
浩浩测试一下4 小时前
06高级语言逻辑结构到汇编语言之逻辑结构转换 for (...; ...; ...)
汇编·数据结构·算法·安全·web安全·网络安全·安全架构
辞--忧5 小时前
K-Means 聚类算法详解与实战指南
算法·kmeans·聚类
尤超宇5 小时前
K 均值聚类(K-Means)演示,通过生成笑脸和爱心两种形状的模拟数据,展示了无监督学习中聚类算法的效果。以下是详细讲解:
算法·均值算法·聚类
qq_479875436 小时前
设置接收超时(SO_RCVTIMEO)
c语言·算法