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

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

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

🌈个人主页:是店小二呀

🌈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;
    }
};


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

相关推荐
夏乌_Wx2 分钟前
练题100天——DAY31:相对名次+数组拆分+重塑矩阵
数据结构·算法
LYFlied2 分钟前
【算法解题模板】-解二叉树相关算法题的技巧
前端·数据结构·算法·leetcode
Ven%27 分钟前
【AI大模型算法工程师面试题解析与技术思考】
人工智能·python·算法
天勤量化大唯粉28 分钟前
枢轴点反转策略在铜期货中的量化应用指南(附天勤量化代码)
ide·python·算法·机器学习·github·开源软件·程序员创富
爱学习的小仙女!43 分钟前
算法效率的度量 时间复杂度 空间复杂度
数据结构·算法
AndrewHZ1 小时前
【复杂网络分析】什么是图神经网络?
人工智能·深度学习·神经网络·算法·图神经网络·复杂网络
Swizard1 小时前
拒绝“狗熊掰棒子”!用 EWC (Elastic Weight Consolidation) 彻底终结 AI 的灾难性遗忘
python·算法·ai·训练
fab 在逃TDPIE2 小时前
Sentaurus TCAD 仿真教程(十)
算法
天赐学c语言2 小时前
12.19 - 买卖股票的最佳时机 && const的作用
c++·算法·leecode
菜鸟233号2 小时前
力扣78 子集 java实现
java·数据结构·算法·leetcode