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

发布于:2025-08-04 ⋅ 阅读:(14) ⋅ 点赞:(0)

在这里插入图片描述

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

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

请添加图片描述

Alt

🌈个人主页:是店小二呀
🌈C/C++专栏:C语言\ C++
🌈初/高阶数据结构专栏: 初阶数据结构\ 高阶数据结构
🌈Linux专栏: Linux
🌈算法专栏:算法
🌈Mysql专栏:Mysql

🌈你可知:无人扶我青云志 我自踏雪至山巅 请添加图片描述

子序列概念

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

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. 最长定差子序列

在这里插入图片描述

算法思路

image-20250319203904659

根据 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) 已经保证了 xnums[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;
    }
};

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


网站公告

今日签到

点亮在社区的每一天
去签到