【动态规划算法】(子序列问题解题框架与典型案例)


🔥承渊政道: 个人主页
❄️个人专栏: 《C语言基础语法知识》 《数据结构与算法》 《C++知识内容》 《Linux系统知识》 《算法刷题指南》 《测评文章活动推广》 《大模型语言路线学习》
✨逆境不吐心中苦,顺境不忘来时路!✨ 🎬 博主简介:

在算法学习和面试准备中,子序列问题是动态规划中最经典、最常见的一类题目之一.所谓子序列,是指在保持原数组元素顺序的前提下,通过删除若干元素(可以不删除)得到的一个元素序列.与子数组不同,子序列并不要求连续,这使得它在建模和状态转移上有更大的自由度,但同时也带来了更多的思考维度和复杂性.子序列问题的难点主要在于如何合理定义状态、设计转移方程,以及如何在满足题目约束的前提下高效求解.常见问题包括最长递增子序列(LIS)、最长公共子序列(LCS)、最大和递增子序列、带约束的子序列问题等.表面上题目形式多样,但本质上都围绕"局部决策如何影响全局最优解"展开,这正是动态规划的核心思想.本文将从动态规划的视角出发,总结子序列问题的通用解题框架.我们会讲解状态定义、状态转移以及边界条件的处理方法,并结合典型案例进行分析,帮助读者理解如何将抽象问题转化为可计算的状态.通过系统学习,你不仅可以掌握具体题目的解法,还能形成应对各类子序列问题的通用思维模式,为解决更复杂的动态规划问题打下坚实基础.废话不多说,下面跟着小编的节奏🎵一起去疯狂的学习吧!

目录

1.子序列问题背景介绍

子序列问题是动态规划中非常经典、也非常高频的一类问题.在数组、字符串以及序列相关题目中,只要题目要求我们在保持原有相对顺序的前提下进行选择,就很可能涉及子序列思想.所谓子序列,是指从原序列中删除若干个元素后,剩余元素按照原来的先后顺序组成的新序列.这里的"删除"可以是删除一个、多个,也可以一个都不删.

与子数组不同,子序列最大的特点是不要求元素连续 .例如在序列 [1, 3, 2, 4, 5] 中,[1, 2, 5] 是一个子序列,因为它们在原序列中的相对顺序没有改变;但它不是子数组,因为这些元素并不是连续出现的.而 [3, 2, 4] 既是子序列,也是子数组,因为它不仅保持了顺序,而且在原序列中连续出现.

正是因为子序列不要求连续,所以它的选择空间比子数组更大,问题也更灵活.对于序列中的每一个元素,我们都可能面临"选"或"不选"的决策.如果选择当前元素,就要判断它能否和前面的某个状态组合成更优结果;如果不选择当前元素,则可能需要继承之前已经得到的最优结果.这种不断在局部选择中寻找全局最优的过程,正是动态规划擅长解决的问题.

从题型上看,子序列问题覆盖范围非常广.常见的有最长递增子序列、最长公共子序列、最长回文子序列、最大和递增子序列、不相交线、编辑距离、不同子序列计数等.这些问题虽然题目背景不同,有的处理数组,有的处理字符串,有的要求最大长度,有的要求方案数量,有的要求最小操作次数,但本质上都围绕一个核心问题展开:如何在保持顺序的前提下,合理选择元素,使结果达到最优或满足特定条件.

在动态规划建模中,子序列问题通常有两种常见思路.第一种是"一维状态模型",常用于只涉及一个序列的问题.例如在最长递增子序列中,可以定义 dp[i] 表示以第 i 个元素结尾的最长递增子序列长度.此时我们需要枚举当前位置之前的元素,判断当前元素是否可以接在前面的某个子序列后面,从而更新当前状态.

第二种是"二维状态模型",常用于涉及两个序列之间关系的问题.例如最长公共子序列中,可以定义 dp[i][j] 表示第一个序列前 i 个元素与第二个序列前 j 个元素之间的最长公共子序列长度.如果两个当前位置的元素相等,就可以由前一个状态加一转移而来;如果不相等,则需要从舍弃其中一个元素的两种情况中取最优值.

子序列问题的难点,往往不在于代码本身,而在于如何正确理解题意并设计状态.因为子序列可以跳跃选择元素,所以当前状态可能不仅仅依赖前一个位置,而是依赖前面多个位置,甚至依赖另一个序列的状态.这就要求我们在解题时先明确几个问题:当前状态表示什么?当前元素是否参与结果?状态之间如何转移?最终答案应该从哪个状态中得到?

学习子序列问题的意义,不只是掌握某几道经典题的解法,更重要的是训练动态规划中的"选择思维"和"状态建模能力".通过这类问题,我们可以逐渐理解如何把一个复杂的全局最优问题,拆解成若干个可递推的局部问题;也能学会如何通过状态数组记录历史信息,避免重复计算.

总的来说,子序列问题是动态规划学习过程中非常重要的一环.它既能帮助我们理解"选或不选"的基本决策模型,也能引出一维动态规划、二维动态规划、字符串动态规划、计数型动态规划等多种解题方法.掌握子序列问题后,再去学习更复杂的动态规划专题,会更加容易建立清晰的分析框架和解题思路.


2.最长递增子序列(OJ题)


算法思路:解法(动态规划):
1.状态表示:

对于线性dp,我们可以用经验 + 题目要求来定义状态表示:

i. 以某个位置为结尾...

ii. 以某个位置为起点...

这里我们选择比较常用的方式,以某个位置为结尾,结合题目要求,定义一个状态表示:
dp[i] 表示:以 i 位置元素为结尾的所有子序列中,最长递增子序列的长度.

2.状态转移方程:

对于 dp[i],我们可以根据子序列的构成方式,进行分类讨论:

i. 子序列长度为 1:只能自己玩了,此时 dp[i] = 1;

ii. 子序列长度大于 1:nums[i] 可以跟在前面任何一个数后面形成子序列.

设前面的某一个数的下标为 j,其中 0 <= j <= i - 1.

只要 nums[j] < nums[i],i 位置元素跟在 j 元素后面就可以形成递增序列,长度为 dp[j] + 1.

因此,我们仅需找到满足要求的最大的 dp[j] + 1 即可.

综上,dp[i] = max(dp[j] + 1, dp[i]),其中 0 <= j <= i - 1 && nums[j] < nums[i].

3.初始化:

所有的元素单独都能构成一个递增子序列,因此可以将 dp 表内所有元素初始化为 1.

由于用到前面的状态,因此我们循环的时候从第二个位置开始即可.

4.填表顺序:

显而易见,填表顺序从左往右.

5.返回值:

由于不知道最长递增子序列以谁结尾,因此返回 dp 表里面的最大值.

核心代码

cpp 复制代码
//解法:动态规划,时间复杂度 O(n²),空间复杂度 O(n)
class Solution
{
public:
    //函数功能:返回数组nums的最长严格递增子序列的长度
    //参数:nums 输入的整数数组
    int lengthOfLIS(vector<int>& nums)
    {
        //获取数组的长度
        int n = nums.size();
        //边界处理:如果数组为空,直接返回0
        if(n == 0) return 0;

        //动态规划数组定义
        //dp[i] 表示:以 nums[i] 这个元素结尾的最长递增子序列的长度
        //初始化:每个元素自身就是一个长度为1的子序列,所以dp数组全部初始化为1
        vector<int> dp(n, 1);

        //记录最终的答案:最长递增子序列的长度,初始值为1(最小长度)
        int ret = 1;

        //填充dp数组(核心逻辑)
        //外层循环:遍历数组中的每一个元素,从第二个元素开始(i=1)
        for(int i = 1; i < n; i++)
        {
            //内层循环:遍历i之前的所有元素 j(0 ~ i-1)
            for(int j = 0; j < i; j++)
            {
                //状态转移条件:nums[j] < nums[i],说明可以接在nums[j]后面形成更长的递增子序列
                if(nums[j] < nums[i])
                {
                    //状态转移方程:
                    //dp[i] = max(当前dp[i]的值, 以j结尾的最长子序列长度 + 1)
                    dp[i] = max(dp[j] + 1, dp[i]);
                }
            }
            //每计算完一个dp[i],就更新全局的最大长度
            ret = max(ret, dp[i]);
        }

        //返回最终结果
        //ret 存储了整个数组的最长递增子序列长度
        return ret;
    }
};

完整测试代码

cpp 复制代码
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;

//解法:动态规划,时间复杂度 O(n²),空间复杂度 O(n)
class Solution
{
public:
    //函数功能:返回数组nums的最长严格递增子序列的长度
    //参数:nums 输入的整数数组
    int lengthOfLIS(vector<int>& nums)
    {
        //获取数组的长度
        int n = nums.size();
        //边界处理:如果数组为空,直接返回0
        if(n == 0) return 0;

        //dp[i] 表示:以 nums[i] 这个元素结尾的最长递增子序列的长度
        //初始化:每个元素自身就是一个长度为1的子序列,所以dp数组全部初始化为1
        vector<int> dp(n, 1);

        //记录最终的答案:最长递增子序列的长度,初始值为1(最小长度)
        int ret = 1;

        //外层循环:遍历数组中的每一个元素,从第二个元素开始(i=1)
        for(int i = 1; i < n; i++)
        {
            //内层循环:遍历i之前的所有元素 j(0 ~ i-1)
            for(int j = 0; j < i; j++)
            {
                //状态转移条件:nums[j] < nums[i],可以接在nums[j]后面形成更长的递增子序列
                if(nums[j] < nums[i])
                {
                    //状态转移方程:更新以i结尾的最长子序列长度
                    dp[i] = max(dp[j] + 1, dp[i]);
                }
            }
            //每计算完一个dp[i],更新全局最大长度
            ret = max(ret, dp[i]);
        }

        //返回整个数组的最长递增子序列长度
        return ret;
    }
};

void testLIS(vector<int> nums, int expected) {
    Solution sol;
    int res = sol.lengthOfLIS(nums);
    //打印输入数组
    cout << "输入数组:[";
    for (int i = 0; i < nums.size(); ++i) {
        if (i > 0) cout << ", ";
        cout << nums[i];
    }
    cout << "]" << endl;
    //打印结果
    cout << "最长递增子序列长度:" << res << ",预期结果:" << expected << endl;
    cout << (res == expected ? "T 测试通过" : "F 测试失败") << endl << endl;
}

int main() {
    cout << "===== 最长递增子序列 动态规划解法 测试 =====" << endl << endl;

    // 测试用例1:LeetCode官方示例,预期结果4
    testLIS({10,9,2,5,3,7,101,18}, 4);
    // 测试用例2:混合重复数字,预期结果4
    testLIS({0,1,0,3,2,3}, 4);
    // 测试用例3:所有数字相同,预期结果1
    testLIS({7,7,7,7,7}, 1);
    // 测试用例4:空数组,预期结果0
    testLIS({}, 0);
    // 测试用例5:单个元素,预期结果1
    testLIS({1}, 1);
    // 测试用例6:复杂递增场景,预期结果6
    testLIS({1,3,6,7,9,4,10,5,6}, 6);
    // 测试用例7:严格递减数组,预期结果1
    testLIS({5,4,3,2,1}, 1);

    return 0;
}

3.摆动序列(OJ题)


算法思路:解法(动态规划):
1.状态表示:

对于线性dp,我们可以用经验 + 题目要求来定义状态表示:

i. 以某个位置为结尾...

ii. 以某个位置为起点...

这里我们选择比较常用的方式,以某个位置为结尾,结合题目要求,定义一个状态表示:
dp[i] 表示以 i 位置为结尾的最长摆动序列的长度.

但是,问题来了,如果状态表示这样定义的话,以 i 位置为结尾的最长摆动序列的长度我们没法从之前的状态推导出来.因为我们不知道前一个最长摆动序列的结尾处是递增的,还是递减的.因此,我们需要状态表示能表示多一点的信息:要能让我们知道这一个最长摆动序列的结尾是递增的还是递减的.

解决的方式很简单:搞两个 dp 表就好了.

  • f[i] 表示:以 i 位置元素为结尾的所有的子序列中,最后一个位置呈现上升趋势的最长摆动序列的长度;
  • g[i] 表示:以 i 位置元素为结尾的所有的子序列中,最后一个位置呈现下降趋势的最长摆动序列的长度.

2.状态转移方程:

由于子序列的构成比较特殊,i 位置为结尾的子序列,前一个位置可以是 [0, i - 1] 的任意位置,因此设 j[0, i - 1] 区间内的某一个位置.

对于 f[i],我们可以根据子序列的构成方式,进行分类讨论:

i. 子序列长度为 1:只能自己玩了,此时 f[i] = 1;

ii. 子序列长度大于 1:因为结尾要呈现上升趋势,因此需要 nums[j] < nums[i].在满足这个条件下,j 结尾需要呈现下降状态,最长的摆动序列就是 g[j] + 1.

因此我们要找出所有满足条件下的最大的 g[j] + 1.

综上,f[i] = max(g[j] + 1, f[i]),注意使用 g[j] 时需要判断.

对于 g[i],我们可以根据子序列的构成方式,进行分类讨论:

i. 子序列长度为 1:只能自己玩了,此时 g[i] = 1;

ii. 子序列长度大于 1:因为结尾要呈现下降趋势,因此需要 nums[j] > nums[i].在满足这个条件下,j 结尾需要呈现上升状态,因此最长的摆动序列就是 f[j] + 1.

因此我们要找出所有满足条件下的最大的 f[j] + 1.

综上,g[i] = max(f[j] + 1, g[i]),注意使用 f[j] 时需要判断.

3.初始化:

所有的元素单独都能构成一个摆动序列,因此可以将 dp 表内所有元素初始化为 1.

4.填表顺序:

毫无疑问是从左往右.

5.返回值:

应该返回两个 dp 表里面的最大值,我们可以在填表的时候,顺便更新一个最大值.

核心代码

cpp 复制代码
//解法:动态规划,时间复杂度 O(n²),空间复杂度 O(n)
class Solution
{
public:
    //函数功能:返回数组的最长摆动子序列的长度
    //摆动序列:连续数字之间的差严格正负交替,单个元素/两个元素也属于摆动序列
    int wiggleMaxLength(vector<int>& nums)
    {
        //获取数组长度
        int n = nums.size();
        //边界处理:数组长度为0/1时,直接返回自身长度
        if(n <= 1) return n;

        //状态定义
        //f[i]:以 i 位置元素为结尾,最后一步呈现【上升趋势】的最长摆动序列长度
        //g[i]:以 i 位置元素为结尾,最后一步呈现【下降趋势】的最长摆动序列长度
        //初始化:每个元素自身就是长度为1的摆动序列,因此初始值全为1
        vector<int> f(n, 1), g(n, 1);

        //记录最终结果:最长摆动序列的长度,初始值为1
        int ret = 1;

        //核心逻辑:填充DP数组
        //外层循环:从第二个元素开始遍历(i=1),依次计算每个位置的f[i]和g[i]
        for(int i = 1; i < n; i++)
        {
            //内层循环:遍历 i 之前的所有元素 j (0 ~ i-1)
            for(int j = 0; j < i; j++)
            {
                //情况1:nums[j] < nums[i] → 形成上升趋势
                //此时前一步必须是下降趋势,才能构成摆动,因此用 g[j] + 1 更新 f[i]
                if(nums[j] < nums[i])
                    f[i] = max(g[j] + 1, f[i]);
                
                //情况2:nums[j] > nums[i] → 形成下降趋势
                //此时前一步必须是上升趋势,才能构成摆动,因此用 f[j] + 1 更新 g[i]
                else if(nums[j] > nums[i])
                    g[i] = max(f[j] + 1, g[i]);
                
                //相等时无趋势,不做处理
            }
            //每计算完一个位置,更新全局最长摆动序列长度
            ret = max(ret, max(f[i], g[i]));
        }

        //返回结果
        //ret 存储了整个数组的最长摆动序列长度
        return ret;
    }
};

完整测试代码

cpp 复制代码
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;

class Solution
{
public:
    int wiggleMaxLength(vector<int>& nums)
    {
        int n = nums.size();

        if(n <= 1) return n;

        vector<int> f(n, 1), g(n, 1);

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

void printVector(const vector<int>& nums)
{
    cout << "[";
    for(int i = 0; i < nums.size(); i++)
    {
        cout << nums[i];
        if(i != nums.size() - 1) cout << ", ";
    }
    cout << "]";
}

void test(vector<int> nums, int expected)
{
    Solution sol;

    int result = sol.wiggleMaxLength(nums);

    cout << "测试数组:";
    printVector(nums);
    cout << endl;

    cout << "实际结果:" << result << endl;
    cout << "预期结果:" << expected << endl;

    if(result == expected)
        cout << "测试通过" << endl;
    else
        cout << "测试失败" << endl;

    cout << "------------------------" << endl;
}

int main()
{
    // 示例1:标准摆动序列
    test({1, 7, 4, 9, 2, 5}, 6);

    // 示例2:部分摆动
    test({1, 17, 5, 10, 13, 15, 10, 5, 16, 8}, 7);

    // 示例3:单调递增,只能选两个元素
    test({1, 2, 3, 4, 5, 6, 7, 8, 9}, 2);

    // 示例4:单个元素
    test({1}, 1);

    // 示例5:空数组
    test({}, 0);

    // 示例6:全部相等,没有上升下降趋势
    test({3, 3, 3, 3, 3}, 1);

    // 示例7:包含相等元素
    test({1, 7, 7, 4, 9, 2, 5}, 6);

    // 示例8:严格下降,只能选两个元素
    test({9, 8, 7, 6, 5}, 2);

    return 0;
}

4.最长递增子序列的个数(OJ题)


算法思路:解法(动态规划):
1.状态表示:

先尝试定义一个状态:以 i 为结尾的最长递增子序列的个数.那么问题就来了,我都不知道以 i 为结尾的最长递增子序列的长度是多少,我怎么知道最长递增子序列的个数呢?

因此,我们解决这个问题需要两个状态,一个是长度,一个是个数:

  • len[i] 表示:以 i 为结尾的最长递增子序列的长度;
  • count[i] 表示:以 i 为结尾的最长递增子序列的个数.

2.状态转移方程:

求个数之前,我们得先知道长度,因此先看 len[i]:

i. 在求 i 结尾的最长递增序列的长度时,我们已经知道 [0, i - 1] 区间上的 len[j] 信息,用 j 表示 [0, i - 1] 区间上的下标;

ii. 我们需要的是递增序列,因此 [0, i - 1] 区间上的 nums[j] 只要能和 nums[i] 构成上升序列,那么就可以更新 dp[i] 的值,此时最长长度为 dp[j] + 1;

iii. 我们要的是 [0, i - 1] 区间上所有情况下的最大值.

综上所述,对于 len[i],我们可以得到状态转移方程为:
len[i] = max(len[j] + 1, len[i]),其中 0 <= j < i,并且 nums[j] < nums[i].

在知道每一个位置结尾的最长递增子序列的长度时,我们来看看能否得到 count[i]

i. 我们此时已经知道 len[i] 的信息,还知道 [0, i - 1] 区间上的 count[j] 信息,用 j 表示 [0, i - 1] 区间上的下标;

ii. 我们可以再遍历一遍 [0, i - 1] 区间上的所有元素,只要能够构成上升序列,并且上升序列的长度等于 dp[i],那么我们就把 count[i] 加上 count[j] 的值.这样循环一遍之后,count[i] 存的就是我们想要的值.

综上所述,对于 count[i],我们可以得到状态转移方程为:
count[i] += count[j],其中 0 <= j < i,并且 nums[j] < nums[i] && dp[j] + 1 == dp[i].

3.初始化:

  • 对于 len[i],所有元素自己就能构成一个上升序列,直接全部初始化为 1;
  • 对于 count[i],如果全部初始化为 1,在累加的时候可能会把不是最大长度的情况累加进去,因此,我们可以先初始化为 0,然后在累加的时候判断一下即可.具体操作情况看代码~

4.填表顺序:

毫无疑问是从左往右.

5.返回值:

maxLen 表示最终的最长递增子序列的长度.

根据题目要求,我们应该返回所有长度等于 maxLen 的子序列的个数.

核心代码

cpp 复制代码
//解法:动态规划,时间复杂度 O(n²),空间复杂度 O(n)
class Solution
{
public:
    //函数功能:返回数组中最长递增子序列的个数
    int findNumberOfLIS(vector<int>& nums)
    {
        //获取数组长度
        int n = nums.size();
        //边界处理:空数组直接返回0(题目保证nums长度≥1,此处为鲁棒性补充)
        if(n == 0) return 0;

        //状态定义
        //len[i]:以 nums[i] 为结尾的「最长递增子序列」的长度
        //count[i]:以 nums[i] 为结尾的「最长递增子序列」的个数
        //初始化:每个元素自身就是长度为1的子序列,个数为1
        vector<int> len(n, 1), count(n, 1);

        //全局结果:retlen 记录整个数组的最长递增子序列长度
        //retcount 记录对应最长长度的子序列个数
        int retlen = 1, retcount = 1;

        //填表循环:计算每个位置的len和count
        for(int i = 1; i < n; i++)
        {
            //遍历i之前的所有元素j,寻找能和nums[i]组成递增子序列的j
            for(int j = 0; j < i; j++)
            {
                //条件:nums[j] < nums[i],可以接在nums[j]后面形成更长的递增子序列
                if(nums[j] < nums[i])
                {
                    //情况1:nums[j]能形成的子序列长度+1 == 当前len[i]
                    //说明找到了新的路径,能达到和当前len[i]相同的最长长度
                    //此时需要把这些路径的个数加到count[i]上
                    if(len[j] + 1 == len[i])
                        count[i] += count[j];
                    
                    //情况2:nums[j]能形成的子序列长度+1 > 当前len[i]
                    //说明找到了更长的递增子序列,需要更新len[i]为新的长度
                    //同时重置count[i]为count[j],因为现在只有这一种路径能达到新的最长长度
                    else if(len[j] + 1 > len[i])
                    {
                        len[i] = len[j] + 1;
                        count[i] = count[j];
                    }
                }
            }

            //更新全局结果
            //情况1:当前位置的最长长度等于全局最长长度retlen
            //说明新增了和全局最长长度相同的子序列,需要把个数累加到retcount
            if(retlen == len[i])
                retcount += count[i];
            
            //情况2:当前位置的最长长度大于全局最长长度retlen
            //说明找到了新的全局最长长度,需要更新retlen为当前长度,重置retcount为count[i]
            else if(retlen < len[i])
            {
                retlen = len[i];
                retcount = count[i];
            }
        }

        //返回结果
        return retcount;
    }
};

完整测试代码

cpp 复制代码
#include <iostream>
#include <vector>
using namespace std;

// 解法:动态规划,计算最长递增子序列的个数
class Solution
{
public:
    int findNumberOfLIS(vector<int>& nums)
    {
        int n = nums.size();
        if(n == 0) return 0;

        vector<int> len(n, 1), count(n, 1);
        int retlen = 1, retcount = 1;

        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])
                        count[i] += count[j];
                    else if(len[j] + 1 > len[i])
                    {
                        len[i] = len[j] + 1;
                        count[i] = count[j];
                    }
                }
            }

            if(retlen == len[i])
                retcount += count[i];
            else if(retlen < len[i])
            {
                retlen = len[i];
                retcount = count[i];
            }
        }

        return retcount;
    }
};

void printVector(const vector<int>& nums)
{
    cout << "[";
    for(int i = 0; i < nums.size(); i++)
    {
        cout << nums[i];
        if(i != nums.size() - 1) cout << ", ";
    }
    cout << "]";
}

void test(vector<int> nums, int expected)
{
    Solution sol;
    int result = sol.findNumberOfLIS(nums);

    cout << "输入数组:";
    printVector(nums);
    cout << endl;

    cout << "最长递增子序列个数:实际结果=" << result
         << ",预期结果=" << expected << endl;

    if(result == expected)
        cout << "测试通过" << endl;
    else
        cout << "测试失败" << endl;

    cout << "------------------------" << endl;
}

int main()
{
    cout << "===== 最长递增子序列个数测试 =====" << endl << endl;

    // 示例1:LeetCode示例,预期结果2
    test({1,3,5,4,7}, 2);

    // 示例2:多个最长递增子序列,预期结果5
    test({2,2,2,2,2}, 5);

    // 示例3:单调递增,只有1个LIS
    test({1,2,3,4,5}, 1);

    // 示例4:单调递减,所有元素单独为LIS
    test({5,4,3,2,1}, 5);

    // 示例5:空数组,预期结果0
    test({}, 0);

    // 示例6:混合数组
    test({1,2,4,3,5,4,7,2}, 3);

    return 0;
}

5.最长数对链(OJ题)


算法思路:解法(动态规划):

这道题目让我们在数对数组中挑选出来一些数对,组成一个呈现上升形态的最长的数对链.像不像我们整数数组中挑选一些数,让这些数组成一个最长的上升序列?因此,我们可以把问题转化成我们学过的一个模型:最长递增子序列.因此我们解决问题的方向,应该在最长递增子序列这个模型上.

不过,与整形数组有所区别.在用动态规划划结局问题之前,应该先把数组排个序.因为我们在计算 dp[i] 的时候,要知道所有左区间比 pairs[i] 的左区间小的链对.排完序之后,只用往前遍历一遍即可.

1.状态表示:
dp[i] 表示以 i 位置的数对为结尾时,最长数对链的长度.

2.状态转移方程:

对于 dp[i],遍历所有 [0, i - 1] 区间内数对(用 j 表示下标),找出所有满足 pairs[j][1] < pairs[i][0]j.找出里面最大的 dp[j],然后加上 1,就是以 i 位置为结尾的最长数对链.

3.初始化:

刚开始的时候,全部初始化为 1.

4.填表顺序:

根据状态转移方程,填表顺序应该是从左往右.

5.返回值:

根据状态表示,返回整个 dp 表中的最大值.

核心代码

cpp 复制代码
//解法:动态规划 + 排序 (转化为最长递增子序列模型)
class Solution
{
public:
    //函数功能:求能够组成的最长数对链的长度
    //数对链规则:前一个数对的第二个数 < 后一个数对的第一个数
    int findLongestChain(vector<vector<int>>& pairs)
    {
        //排序:将数对按照左端点升序排列
        //排序后转化为最长递增子序列问题,只需向前遍历即可
        sort(pairs.begin(), pairs.end());
        
        //获取数对的数量
        int n = pairs.size();
        
        //dp[i] 表示:以 i 位置的数对为结尾时,最长数对链的长度
        //初始化:每个数对单独成链,长度为 1
        vector<int> dp(n, 1);

        //记录全局最长数对链的长度,初始值为 1
        int ret = 1;
        
        //填表顺序:从左往右遍历
        for(int i = 1; i < n; i++)
        {
            //遍历 i 之前的所有数对 j
            for(int j = 0; j < i; j++)
            {
                //状态转移条件:前一个数对的右端 < 当前数对的左端,可以拼接
                if(pairs[j][1] < pairs[i][0]) 
                {
                    //状态转移方程:取最大长度
                    dp[i] = max(dp[i], dp[j] + 1);
                }
            }
            
            //每次计算完 dp[i],更新全局最大值
            ret = max(ret, dp[i]);
        }
        
        //返回值:dp数组中的最大值(最长数对链不一定以最后一个元素结尾)
        //【重要修正】原代码返回 dp[n-1] 错误,必须返回 ret
        return ret;
    }
};

完整测试代码

cpp 复制代码
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;

// 解法:动态规划 + 排序
class Solution
{
public:
    // 函数功能:求能够组成的最长数对链的长度
    int findLongestChain(vector<vector<int>>& pairs)
    {
        sort(pairs.begin(), pairs.end()); // 按左端点排序

        int n = pairs.size();
        if(n == 0) return 0;

        vector<int> dp(n, 1); // 初始化每个数对单独成链
        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[i], dp[j] + 1);
                }
            }
            ret = max(ret, dp[i]);
        }

        return ret;
    }
};

void printPairs(const vector<vector<int>>& pairs)
{
    cout << "[";
    for(size_t i = 0; i < pairs.size(); i++)
    {
        cout << "[" << pairs[i][0] << ", " << pairs[i][1] << "]";
        if(i != pairs.size() - 1) cout << ", ";
    }
    cout << "]";
}

void test(vector<vector<int>> pairs, int expected)
{
    Solution sol;
    int result = sol.findLongestChain(pairs);

    cout << "输入数对数组:";
    printPairs(pairs);
    cout << endl;

    cout << "最长数对链长度:实际结果 = " << result
         << ",预期结果 = " << expected << endl;

    if(result == expected)
        cout << "测试通过" << endl;
    else
        cout << "测试失败" << endl;

    cout << "------------------------" << endl;
}

int main()
{
    cout << "===== 最长数对链测试 =====" << endl << endl;

    // 示例1:LeetCode示例,预期结果3
    test({{1,2},{2,3},{3,4}}, 2);

    // 示例2:数对有重叠,预期结果2
    test({{1,2},{7,8},{4,5}}, 3);

    // 示例3:数对完全不重叠,预期结果3
    test({{1,2},{3,4},{5,6}}, 3);

    // 示例4:数对乱序,预期结果3
    test({{5,24},{15,25},{27,40},{50,60}}, 4);

    // 示例5:空数组,预期结果0
    test({}, 0);

    return 0;
}

6.最长定差子序列(OJ题)


算法思路:解法(动态规划):

这道题和最长递增子序列 有一些相似,但仔细读题就会发现,本题的 arr.lenght 高达 10 5 10^5 105,使用 O ( N 2 ) O(N^2) O(N2) 的 LIS 模型一定会超时.

那么,它有什么信息是最长递增子序列 所没有的呢?是定差 .之前,我们只知道要递增,不知道前一个数应当是多少;现在我们可以计算出前一个数是多少了,就可以用数值来定义 dp 数组的值,并形成状态转移.这样,就把已有信息有效地利用了起来.

1.状态表示:
dp[i] 表示:以 i 位置的元素为结尾所有的子序列中,最长的等差子序列的长度.

2.状态转移方程:

对于 dp[i],上一个定差子序列的取值定为 arr[i] - difference.只要找到以上一个数字为结尾的定差子序列长度的 dp[arr[i] - difference],然后加上 1,就是以 i 为结尾的定差子序列的长度.

因此,这里可以选择使用哈希表做优化.我们可以把元素,dp[j]绑定,放进哈希表中.甚至不用创建 dp 数组,直接在哈希表中做动态规划.

3.初始化:

刚开始的时候,需要把第一个元素放进哈希表中,hash[arr[0]] = 1.

4.填表顺序:

根据状态转移方程,填表顺序应该是从左往右.

5.返回值:

根据状态表示,返回整个 dp 表中的最大值.

核心代码

cpp 复制代码
//解法:哈希表优化动态规划,时间复杂度 O(n),空间复杂度 O(n)
class Solution
{
public:
    //函数功能:返回最长定差子序列的长度
    //参数:arr 输入数组;difference 给定的公差
    int longestSubsequence(vector<int>& arr, int difference)
    {
        //哈希表优化 DP:
        //key:数组中的元素值 x
        //value:以元素 x 结尾的最长定差子序列的长度
        unordered_map<int, int> hash; 
        
        //初始化:数组第一个元素自身构成长度为1的子序列
        hash[arr[0]] = 1; 

        //记录全局最长定差子序列的长度,初始值为1
        int ret = 1;

        //从数组第二个元素开始遍历
        for(int i = 1; i < arr.size(); i++)
        {
            //核心状态转移:
            //设当前元素为 x = arr[i],要形成公差为difference的子序列
            //前一个元素必须是 x - difference
            //因此以x结尾的子序列长度 = 以x-difference结尾的长度 + 1
            //若哈希表中无x-difference,hash[x-difference]默认为0,结果为1(元素自身)
            hash[arr[i]] = hash[arr[i] - difference] + 1;
            
            //更新全局最大值
            ret = max(ret, hash[arr[i]]);
        }

        //返回最终结果
        return ret;
    }
};

完整测试代码

cpp 复制代码
#include <iostream>
#include <vector>
#include <unordered_map>
#include <algorithm>
using namespace std;

// 解法:哈希表优化动态规划
class Solution
{
public:
    // 函数功能:返回最长定差子序列的长度
    int longestSubsequence(vector<int>& arr, int difference)
    {
        unordered_map<int, int> hash;

        // 初始化:数组第一个元素自身构成长度为1的子序列
        hash[arr[0]] = 1;

        // 记录全局最长定差子序列的长度,初始值为1
        int ret = 1;

        // 从数组第二个元素开始遍历
        for(int i = 1; i < arr.size(); i++)
        {
            // 核心状态转移
            hash[arr[i]] = hash[arr[i] - difference] + 1;

            // 更新全局最大值
            ret = max(ret, hash[arr[i]]);
        }

        // 返回最终结果
        return ret;
    }
};

void printVector(const vector<int>& nums)
{
    cout << "[";
    for(int i = 0; i < nums.size(); i++)
    {
        cout << nums[i];
        if(i != nums.size() - 1) cout << ", ";
    }
    cout << "]";
}

void test(vector<int> arr, int difference, int expected)
{
    Solution sol;
    int result = sol.longestSubsequence(arr, difference);

    cout << "输入数组:";
    printVector(arr);
    cout << ",公差 = " << difference << endl;

    cout << "最长定差子序列的长度:实际结果 = " << result
         << ",预期结果 = " << expected << endl;

    if(result == expected)
        cout << "测试通过" << endl;
    else
        cout << "测试失败" << endl;

    cout << "------------------------" << endl;
}

int main()
{
    cout << "===== 最长定差子序列测试 =====" << endl << endl;

    // 示例1:标准测试,预期结果4
    test({3, 6, 9, 12}, 3, 4);

    // 示例2:不同的公差,预期结果3
    test({1, 5, 7, 8, 5, 3, 4, 2, 1}, 2, 4);

    // 示例3:只有一个元素,预期结果1
    test({7}, 3, 1);

    // 示例4:没有满足条件的子序列,预期结果1
    test({1, 4, 7, 10}, 2, 1);

    // 示例5:空数组,预期结果0
    test({}, 3, 0);

    // 示例6:重复数字,预期结果5
    test({1, 1, 1, 1, 1}, 0, 5);

    return 0;
}

7.最⻓的斐波那契⼦序列的⻓度(OJ题)


算法思路:解法(动态规划):
1.状态表示:

对于线性dp,我们可以用经验 + 题目要求来定义状态表示:

i. 以某个位置为结尾...

ii. 以某个位置为起点...

这里我们选择比较常用的方式,以某个位置为结尾,结合题目要求,定义一个状态表示:
dp[i] 表示:以 i 位置元素为结尾的所有子序列中,最长的斐波那契子数列的长度.

但是这里有一个非常致命的问题,那就是我们无法确定 i 结尾的斐波那契序列的样子.这样就会导致我们无法推导状态转移方程,因此我们定义的状态表示需要能够确定一个斐波那契序列.

根据斐波那契数列的特性,我们仅需知道序列里面的最后两个元素,就可以确定这个序列的样子.

因此,我们修改我们的状态表示为:
dp[i][j] 表示:以 i 位置以及 j 位置的元素为结尾的所有的子序列中,最长的斐波那契子序列的长度.规定一下 i < j.

2.状态转移方程:

nums[i] = bnums[j] = c,那么这个序列的前一个元素就是 a = c - b.我们根据 a 的情况讨论:

i. a 存在,下标为 k,并且 a < b:此时我们需要以 k 位置以及 i 位置元素为结尾的最长斐波那契子序列的长度,然后再加上 j 位置的元素即可.于是 dp[i][j] = dp[k][i] + 1;

ii. a 存在,但是 b < a < c:此时只能两个元素自己玩了,dp[i][j] = 2;

iii. a 不存在:此时依旧只能两个元素自己玩了,dp[i][j] = 2.

综上,状态转移方程分情况讨论即可.

优化点:我们发现,在状态转移方程中,我们需要确定 a 元素的下标.因此我们可以在 dp 之前,将所有的元素 + 下标绑定在一起,放到哈希表中.

3.初始化:

可以将表里面的值都初始化为 2.

4.填表顺序:

a. 先固定最后一个数;

b. 然后枚举倒数第二个数.

5.返回值:

因为不知道最终结果以谁为结尾,因此返回 dp 表中的最大值 ret.

但是 ret 可能小于 3,小于 3 的话说明不存在.

因此需要判断一下.

核心代码

cpp 复制代码
//解法:二维动态规划 + 哈希表优化,时间复杂度 O(n²),空间复杂度 O(n²)
class Solution
{
public:
    //函数功能:返回数组中最长斐波那契子序列的长度,无则返回0
    //参数:arr 严格递增的整数数组
    int lenLongestFibSubseq(vector<int>& arr)
    {
        int n = arr.size(); //获取数组长度

        //哈希表优化:存储「数值 -> 对应下标」
        //作用:快速查找斐波那契序列中前一个数是否存在,以及其下标
        unordered_map<int, int> hash;
        for(int i = 0; i < n; i++) 
            hash[arr[i]] = i;

        //记录最终结果:最长斐波那契子序列长度
        //初始值为2:任意两个数都能构成长度为2的序列
        int ret = 2;

        //二维DP数组定义
        //dp[i][j]:表示以 arr[i]、arr[j] 作为**最后两个元素**的最长斐波那契子序列的长度
        //初始化:任意两个元素构成长度为2的序列,因此全部初始化为2
        vector<vector<int>> dp(n, vector<int>(n, 2));

        //动态规划填表
        //外层循环:固定最后一个元素的下标 j(从2开始,因为至少需要3个元素)
        for(int j = 2; j < n; j++) 
        {
            //内层循环:固定倒数第二个元素的下标 i(i 必须小于 j)
            for(int i = 1; i < j; i++) 
            {
                //根据斐波那契规则:前一个数 a = 最后一个数 - 倒数第二个数
                int a = arr[j] - arr[i];

                //两个核心条件:
                //1.a < arr[i]:数组严格递增,保证a在i的左侧,顺序合法
                //2.hash.count(a):哈希表中存在a,说明数组中有这个数
                if(a < arr[i] && hash.count(a))
                {
                    //状态转移方程:
                    //以 a、arr[i] 结尾的序列长度 + 1,就是以 arr[i]、arr[j] 结尾的长度
                    dp[i][j] = dp[hash[a]][i] + 1;
                }

                //每次计算完,更新全局最长长度
                ret = max(ret, dp[i][j]);
            }
        }

        //返回结果
        //斐波那契子序列长度至少为3,若ret<3说明没有有效序列,返回0
        return ret < 3 ? 0 : ret;
    }
};

完整测试代码

cpp 复制代码
#include <iostream>
#include <vector>
#include <unordered_map>
#include <algorithm>
using namespace std;

// 解法:二维DP + 哈希表优化
class Solution
{
public:
    int lenLongestFibSubseq(vector<int>& arr)
    {
        int n = arr.size();
        unordered_map<int, int> hash;
        for(int i = 0; i < n; i++)
            hash[arr[i]] = i;

        int ret = 2;
        vector<vector<int>> dp(n, vector<int>(n, 2));

        for(int j = 2; j < n; j++)
        {
            for(int i = 1; i < j; i++)
            {
                int a = arr[j] - arr[i];
                if(a < arr[i] && hash.count(a))
                {
                    dp[i][j] = dp[hash[a]][i] + 1;
                }
                ret = max(ret, dp[i][j]);
            }
        }

        return ret < 3 ? 0 : ret;
    }
};

void printVector(const vector<int>& arr)
{
    cout << "[";
    for(size_t i = 0; i < arr.size(); i++)
    {
        cout << arr[i];
        if(i != arr.size() - 1) cout << ", ";
    }
    cout << "]";
}

void test(vector<int> arr, int expected)
{
    Solution sol;
    int result = sol.lenLongestFibSubseq(arr);

    cout << "输入数组:";
    printVector(arr);
    cout << endl;

    cout << "最长斐波那契子序列长度:实际结果 = " << result
         << ",预期结果 = " << expected << endl;

    if(result == expected)
        cout << "测试通过" << endl;
    else
        cout << "测试失败" << endl;

    cout << "------------------------" << endl;
}

int main()
{
    cout << "===== 最长斐波那契子序列测试 =====" << endl << endl;

    // 示例1:LeetCode示例,预期结果5
    test({1,2,3,4,5,6,7,8}, 5); // 1,2,3,5,8

    // 示例2:没有斐波那契序列,预期结果0
    test({1,3,7,11,12}, 0);

    // 示例3:最短有效斐波那契序列,长度3
    test({1,2,3}, 3);

    // 示例4:数组长度小于3,预期结果0
    test({1,2}, 0);

    // 示例5:混合数组,最长序列长度4
    test({1,3,7,11,12,14,18}, 3); // 1,11,12?

    // 示例6:严格递增,最长序列长度5
    test({1,3,4,7,11,18,29,47}, 7); // 1,3,4,7,11,18,29

    return 0;
}

8.最⻓等差数列(OJ题)


算法思路:解法(动态规划):
1.状态表示:

对于线性dp,我们可以用经验 + 题目要求来定义状态表示:

i. 以某个位置为结尾...

ii. 以某个位置为起点...

这里我们选择比较常用的方式,以某个位置为结尾,结合题目要求,定义一个状态表示:
dp[i] 表示:以 i 位置元素为结尾的所有子序列中,最长的等差序列的长度.

但是这里有一个非常致命的问题,那就是我们无法确定 i 结尾的等差序列的样子.这样就会导致我们无法推导状态转移方程,因此我们定义的状态表示需要能够确定一个等差序列.

根据等差序列的特性,我们仅需知道序列里面的最后两个元素,就可以确定这个序列的样子.

因此,我们修改我们的状态表示为:
dp[i][j] 表示:以 i 位置以及 j 位置的元素为结尾的所有的子序列中,最长的等差序列的长度.规定一下 i < j.

2.状态转移方程:

nums[i] = b,nums[j] = c,那么这个序列的前一个元素就是 a = 2 * b - c.我们根据 a 的情况讨论:

a. a 存在,下标为 k,并且 a < b:此时我们需要以 k 位置以及 i 位置元素为结尾的最长等差序列的长度,然后再加上 j 位置的元素即可.于是 dp[i][j] = dp[k][i] + 1.这里因为会有许多个 k,我们仅需离 i 最近的 k 即可.因此任何最长的都可以以 k 为结尾;

b. a 存在,但是 b < a < c:此时只能两个元素自己玩了,dp[i][j] = 2;

c. a 不存在:此时依旧只能两个元素自己玩了,dp[i][j] = 2.

综上,状态转移方程分情况讨论即可.

优化点:我们发现,在状态转移方程中,我们需要确定 a 元素的下标.因此我们可以将所有的元素 + 下标绑定在一起,放到哈希表中,这里有两种策略:

a. 在 dp 之前,放入哈希表中.这是可以的,但是需要将下标形成一个数组放进哈希表中.这样时间复杂度较高,我帮大家试过了,超时.

b. 一边 dp,一边保存.这种方式,我们仅需保存最近的元素的下标,不用保存下标数组.但是用这种方法的话,我们在遍历顺序那里,先固定倒数第二个数,再遍历倒数第一个数.这样就可以在 i 使用完时候,将 nums[i] 扔到哈希表中.

3.初始化:

根据实际情况,可以将所有位置初始化为 2.

4.填表顺序:

a. 先固定倒数第二个数;

b. 然后枚举倒数第一个数.

5.返回值:

由于不知道最长的结尾在哪里,因此返回 dp 表中的最大值.

核心代码

cpp 复制代码
//解法:二维动态规划 + 哈希表优化,时间复杂度 O(n²),空间复杂度 O(n²)
class Solution
{
public:
    int longestArithSeqLength(vector<int>& nums)
    {
        //哈希表优化:存储 「数值 -> 对应的数组下标」
        //作用:快速查找等差数列的前驱元素是否存在,及其下标
        unordered_map<int, int> hash;
        //初始化:将第一个元素存入哈希表
        hash[nums[0]] = 0;

        int n = nums.size();
        //二维DP数组定义 
        //dp[i][j]:表示以 nums[i]、nums[j] 作为**最后两个元素**的最长等差数列的长度
        //初始化:任意两个元素都能构成长度为 2 的等差数列,因此全部初始化为 2
        vector<vector<int>> dp(n, vector<int>(n, 2));

        //记录最终结果:最长等差数列长度,初始值为 2
        int ret = 2;

        //外层循环:固定倒数第二个元素的下标 i
        for(int i = 1; i < n; i++)
        {
            //内层循环:枚举最后一个元素的下标 j(j 必须大于 i)
            for(int j = i + 1; j < n; j++)
            {
                //核心公式:计算等差数列的**前驱元素 a**
                //推导:设公差为 d = nums[j] - nums[i],则前驱元素 a = nums[i] - d = 2*nums[i] - nums[j]
                int a = 2 * nums[i] - nums[j];
                
                //如果哈希表中存在前驱元素 a,说明可以拼接成更长的等差数列
                if(hash.count(a))
                    //状态转移:以 a、nums[i] 结尾的长度 + 1
                    dp[i][j] = dp[hash[a]][i] + 1;
                
                //更新全局最长长度
                ret = max(ret, dp[i][j]);
            }
            //遍历完 i 对应的所有 j 后,将当前元素 nums[i] 加入哈希表
            hash[nums[i]] = i;
        }

        //返回最长等差数列的长度
        return ret;
    }
};

完整测试代码

cpp 复制代码
#include <iostream>
#include <vector>
#include <unordered_map>
#include <algorithm>
using namespace std;

// 解法:二维动态规划 + 哈希表优化
class Solution
{
public:
    int longestArithSeqLength(vector<int>& nums)
    {
        unordered_map<int, int> hash;
        hash[nums[0]] = 0; // 初始化哈希表

        int n = nums.size();
        vector<vector<int>> dp(n, vector<int>(n, 2)); // dp[i][j]表示以 nums[i]、nums[j] 作为最后两个元素的最长等差数列的长度
        int ret = 2; // 最短的等差数列长度为 2

        for(int i = 1; i < n; i++)
        {
            for(int j = i + 1; j < n; j++)
            {
                int a = 2 * nums[i] - nums[j]; // 计算前驱元素 a

                if(hash.count(a)) // 如果前驱元素 a 存在
                {
                    dp[i][j] = dp[hash[a]][i] + 1; // 状态转移:更新 dp[i][j]
                }
                ret = max(ret, dp[i][j]); // 更新全局最大长度
            }
            hash[nums[i]] = i; // 将当前元素 nums[i] 加入哈希表
        }

        return ret; // 返回最大等差数列长度
    }
};

void printVector(const vector<int>& nums)
{
    cout << "[";
    for(int i = 0; i < nums.size(); i++)
    {
        cout << nums[i];
        if(i != nums.size() - 1) cout << ", ";
    }
    cout << "]";
}

void test(vector<int> nums, int expected)
{
    Solution sol;
    int result = sol.longestArithSeqLength(nums);

    cout << "输入数组:";
    printVector(nums);
    cout << endl;

    cout << "最长等差数列的长度:实际结果 = " << result
         << ",预期结果 = " << expected << endl;

    if(result == expected)
        cout << "测试通过" << endl;
    else
        cout << "测试失败" << endl;

    cout << "------------------------" << endl;
}

int main()
{
    cout << "===== 最长等差数列测试 =====" << endl << endl;

    // 示例1:标准测试,预期结果4
    test({3, 6, 9, 12}, 4); // 等差数列 3, 6, 9, 12

    // 示例2:没有有效的等差数列,预期结果2
    test({1, 3, 7, 11, 12}, 2); // 没有满足条件的等差数列

    // 示例3:数组中只有两个元素,预期结果2
    test({5, 10}, 2); // 等差数列只有两个元素 5, 10

    // 示例4:数组中有重复元素,预期结果4
    test({1, 5, 7, 11, 12, 14, 18}, 4); // 1, 5, 7, 11, 12, 14, 18

    // 示例5:数组只有一个元素,预期结果2
    test({1}, 2); // 只有一个元素

    // 示例6:空数组,预期结果0
    test({}, 0); // 空数组

    // 示例7:重复的数值,预期结果6
    test({1, 1, 1, 1, 1, 1}, 2); // 所有元素相同,最长序列长度为 2

    return 0;
}

9.等差数列划分II-⼦序列(OJ题)


算法思路:解法(动态规划):
1.状态表示:

对于线性dp,我们可以用经验 + 题目要求来定义状态表示:

i. 以某个位置为结尾...

ii. 以某个位置为起点...

这里我们选择比较常用的方式,以某个位置为结尾,结合题目要求,定义一个状态表示:
dp[i] 表示:以 i 位置元素为结尾的所有子序列中,等差子序列的个数.

但是这里有一个非常致命的问题,那就是我们无法确定 i 结尾的等差序列的样子.这样就会导致我们无法推导状态转移方程,因此我们定义的状态表示需要能够确定一个等差序列.

根据等差序列的特性,我们仅需知道序列里面的最后两个元素,就可以确定这个序列的样子.因此,我们修改我们的状态表示为:
dp[i][j] 表示:以 i 位置以及 j 位置的元素为结尾的所有的子序列中,等差子序列的个数.规定一下 i < j.

2.状态转移方程:

nums[i] = b,nums[j] = c,那么这个序列的前一个元素就是 a = 2 * b - c.我们根据 a 的情况讨论:

a. a 存在,下标为 k,并且 a < b:此时我们知道以 k 元素以及 i 元素结尾的等差序列的个数 dp[k][i],在这些子序列的后面加上 j 位置的元素依旧是等差序列.但是这里会多出来一个以 k,i,j 位置的元素组成的新的等差序列,因此 dp[i][j] = dp[k][i] + 1;

b. 因为 a 可能有很多个,我们需要全部累加起来.

综上,dp[i][j] += dp[k][i] + 1.

优化点:我们发现,在状态转移方程中,我们需要确定 a 元素的下标.因此我们可以在 dp 之前,将所有元素 + 下标数组绑定在一起,放到哈希表中.这里为何要保存下标数组,是因为我们要统计个数,所有的下标都需要统计.

3.初始化:

刚开始是没有等差数列的,因此初始化 dp 表为 0.

4.填表顺序:

a. 先固定倒数第一个数;

b. 然后枚举倒数第二个数.

5.返回值:

我们要统计所有的等差子序列,因此返回 dp 表中所有元素的和.

核心代码

cpp 复制代码
//解法:二维动态规划 + 哈希表优化,统计所有长度≥3的等差子序列个数
class Solution
{
public:
    //函数功能:返回数组中所有等差子序列的数量(子序列长度至少为3)
    int numberOfArithmeticSlices(vector<int>& nums)
    {
        int n = nums.size();
        //哈希表优化:key = 数组元素值(long long防止溢出),value = 该元素对应的所有下标
        //作用:快速找到等差数列前驱元素 a 的所有下标位置
        unordered_map<long long, vector<int>> hash;
        for(int i = 0; i < n; i++) 
            hash[nums[i]].push_back(i);

        //二维DP数组定义
        //dp[i][j]:表示以 nums[i]、nums[j] 作为**最后两个元素**的等差子序列的**个数**
        //初始值默认为0:没有找到前驱元素时,无法构成长度≥3的等差子序列
        vector<vector<int>> dp(n, vector<int>(n));

        //统计所有符合条件的等差子序列总数
        int sum = 0;

        //外层循环:固定最后一个元素的下标 j(j从2开始,因为子序列至少需要3个元素)
        for(int j = 2; j < n; j++) 
        {
            //内层循环:枚举倒数第二个元素的下标 i(i < j)
            for(int i = 1; i < j; i++) 
            {
                //核心公式:计算等差数列的前驱元素 a
                //推导:a = 2*nums[i] - nums[j]
                //强转long long:防止计算时整型数据溢出
                long long a = (long long)nums[i] * 2 - nums[j];
                
                //如果哈希表中存在前驱元素 a
                if(hash.count(a))
                {
                    //遍历前驱元素 a 对应的所有下标 k
                    for(auto k : hash[a])
                    {
                        //保证下标顺序:k < i < j,符合子序列的先后顺序
                        if(k < i) 
                            //状态转移:
                            //以k、i结尾的等差子序列 + 以k、i、j结尾的新子序列
                            dp[i][j] += dp[k][i] + 1;
                        //哈希表中存储的下标是递增的,k≥i时直接跳出循环
                        else 
                            break;
                    }
                }
                //将以i、j结尾的所有等差子序列数量,累加到总结果中
                sum += dp[i][j];
            }
        }
        //返回所有长度≥3的等差子序列的总数
        return sum;
    }
};

完整测试代码

cpp 复制代码
#include <iostream>
#include <vector>
#include <unordered_map>
using namespace std;

// 解法:二维动态规划 + 哈希表优化
class Solution
{
public:
    int numberOfArithmeticSlices(vector<int>& nums)
    {
        int n = nums.size();
        unordered_map<long long, vector<int>> hash;
        for(int i = 0; i < n; i++)
            hash[nums[i]].push_back(i);

        vector<vector<int>> dp(n, vector<int>(n, 0));
        int sum = 0;

        for(int j = 2; j < n; j++)
        {
            for(int i = 1; i < j; i++)
            {
                long long a = (long long)nums[i] * 2 - nums[j];
                if(hash.count(a))
                {
                    for(auto k : hash[a])
                    {
                        if(k < i)
                            dp[i][j] += dp[k][i] + 1;
                        else
                            break;
                    }
                }
                sum += dp[i][j];
            }
        }
        return sum;
    }
};

void printVector(const vector<int>& nums)
{
    cout << "[";
    for(size_t i = 0; i < nums.size(); i++)
    {
        cout << nums[i];
        if(i != nums.size() - 1) cout << ", ";
    }
    cout << "]";
}

void test(vector<int> nums, int expected)
{
    Solution sol;
    int result = sol.numberOfArithmeticSlices(nums);

    cout << "输入数组:";
    printVector(nums);
    cout << endl;

    cout << "长度≥3的等差子序列数量:实际结果 = " << result
         << ",预期结果 = " << expected << endl;

    if(result == expected)
        cout << "测试通过" << endl;
    else
        cout << "测试失败" << endl;

    cout << "------------------------" << endl;
}

int main()
{
    cout << "===== 等差子序列数量测试 =====" << endl << endl;

    // 示例1:标准测试,预期结果3
    test({2, 4, 6, 8, 10}, 7);
    // 可能的等差子序列:
    // [2,4,6], [4,6,8], [6,8,10], [2,4,6,8], [4,6,8,10], [2,4,6,8,10], [2,6,10]

    // 示例2:数组中没有长度≥3的等差子序列,预期结果0
    test({1, 3, 5}, 1); // [1,3,5]

    // 示例3:空数组,预期结果0
    test({}, 0);

    // 示例4:数组中只有两个元素,预期结果0
    test({1, 2}, 0);

    // 示例5:重复元素,预期结果2
    test({1, 2, 3, 3, 3}, 4); // [1,2,3], [1,2,3], [1,3,3], [2,3,3]

    // 示例6:长度为3的数组
    test({1, 2, 3}, 1);

    // 示例7:混合数组
    test({1, 3, 5, 7, 9}, 6); // 组合较多

    return 0;
}


🚀真正的勇者不是流泪的人,而是含泪奔跑的人!


敬请期待下一篇文章内容:【动态规划算法】(回文串问题解题框架与经典案例)


每日心灵鸡汤:"尽我所能,敬我不能"
我们都是跋涉在人间的旅人,怀揣勇气,也带着伤痕,一半热烈滚烫,一半沉静从容,在能与不能之间,活出完整的灵魂.有些高山,即便拼尽全力也难以翻越;有些河流,即便渴望横渡也力有不逮,承认自己的局限,懂得适时放下,是比勇往直前更难的人生课题.生命的价值,往往就体现在"尽我所能"的执着中.即便前路荆棘丛生,即便成功的希望渺茫,也要怀揣着"虽千万人,吾往矣"的勇气,因为真正的遗憾,从来不是结果的不尽人意,而是面对机会时的犹豫不决,望而却步.承认"不能"不是认输,而是给自己腾出空间,去做真正热爱和擅长的事.有些梦,像握不住的流沙,越用力抓紧,越从指缝漏下.尽我所能时,做燃烧的太阳,敬我不能时,做温柔的月光.

相关推荐
阿Y加油吧8 小时前
二刷 LeetCode:215. 数组中的第 K 个最大元素 & 347. 前 K 个高频元素 复盘笔记
笔记·leetcode·排序算法
pop_xiaoli8 小时前
【iOS】KVC与KVO
笔记·macos·ios·objective-c·cocoa
_F_y8 小时前
仿RabbitMQ实现消息队列-服务端核心模块实现(3)
c++·算法·rabbitmq
m0_629494738 小时前
LeetCode 热题 100-----15.轮转数组
数据结构·算法·leetcode
AI科技星8 小时前
从180°旋转定值π、e论证时空宿命与未来可预测性—全域数学视角
人工智能·算法·机器学习·数学建模·数据挖掘
WL_Aurora8 小时前
Python 算法基础篇之栈和队列
python·算法
YJlio8 小时前
Windows Internals 10.5.3:ETW 架构详解,从事件产生到性能分析的完整链路
windows·笔记·python·stm32·嵌入式硬件·学习·架构
橙子也要努力变强8 小时前
进程与信号
linux·服务器·c++
艺术电影节8 小时前
惊喜映后 | 伍迪·艾伦经典修复澳门首映
算法·推荐算法·电视