【动态规划算法】(子数组系列问题建模与解题思路精讲)


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

在动态规划问题中,子数组系列题目一直是高频且经典的一类.无论是最大子数组和、乘积最大子数组,还是带有约束条件的连续区间问题,它们表面形式各不相同,但本质上往往都围绕着"状态如何定义""转移从哪里来""当前选择如何影响后续结果"展开.很多同学在初学这类问题时,容易陷入一个误区:看到题目就套模板,却没有真正理解为什么这样建模、为什么状态可以这样转移.尤其是子数组问题强调"连续性",这使得它与子序列问题在思考方式上有明显区别.本文将围绕"子数组系列问题"的动态规划建模方法展开讲解,从常见题型入手,分析状态定义、转移方程、边界处理以及优化思路,帮助大家建立一套清晰、可复用的解题框架.希望读完本文后,你不仅能掌握具体题目的解法,更能理解这类问题背后的动态规划思维.废话不多说,下面跟着小编的节奏🎵一起去疯狂的学习吧!

目录

1.子数组系列问题背景介绍

子数组系列问题是数组类算法题中非常经典、也非常高频的一类问题.在动态规划、前缀和、滑动窗口、单调队列等专题中,都经常能够看到它的身影.所谓子数组,指的是原数组中一段连续的元素区间 .例如在数组 [1, 2, 3, 4, 5] 中,[2, 3, 4] 是一个子数组,而 [1, 3, 5] 虽然元素来自原数组,但由于不连续,所以它不是子数组.

子数组问题最重要的特点就是"连续性".这一点决定了它和子序列问题有本质区别.子序列可以在原数组中跳着选元素,更关注"选或不选"的决策;而子数组要求元素必须相邻,更关注某一段连续区间的起点、终点以及区间内部的整体性质.因此,在解决子数组问题时,我们通常不会孤立地看某一个元素,而是会思考:当前位置能否接在前一个连续区间后面?以当前位置结尾的最优结果是什么?当前区间是否满足题目限制?

在动态规划视角下,子数组问题非常适合用来训练"状态定义"的能力.很多经典子数组问题都可以从"以 nums[i] 结尾"这个角度切入.例如最大子数组和问题,我们可以定义 dp[i] 表示以第 i 个元素结尾的连续子数组的最大和.那么对于当前位置 i 来说,只有两种选择:要么把 nums[i] 接在前一个子数组后面,要么从 nums[i] 自己重新开始.于是状态转移就变成了:比较"延续前面的区间"和"重新开启一个新区间"哪个更优.这个思路非常重要,因为它把一个看似需要枚举所有区间的问题,转化成了线性遍历过程中的局部决策问题.

不过,并不是所有子数组问题都只适合用动态规划.根据题目条件的不同,常见解法也会有所变化.如果题目关注的是"区间和",并且需要快速判断某段区间的和,前缀和往往是非常有效的工具.例如"和为 K 的子数组个数"就可以通过前缀和加哈希表来统计.如果题目要求的是"满足某种条件的最长或最短连续区间",并且数组元素具有非负性,滑动窗口通常更合适.例如"长度最小的子数组和"这类问题,就可以通过左右指针动态维护窗口.如果题目中涉及固定长度窗口的最大值、最小值,或者需要维护某种单调性质,单调队列也可能成为关键方法.

因此,子数组系列问题并不是单一模板题,而是一组围绕"连续区间"展开的综合题型.它们考查的不只是代码实现能力,更考查我们是否能够根据题目特征选择合适的建模方式.看到"连续子数组"时,首先要明确问题目标:是求最大值、最小值,还是统计数量?其次要判断区间性质:是否与和有关?是否存在正负数?是否要求固定长度?是否允许删除、翻转、环形连接等特殊操作?这些条件都会影响最终的解法选择.

学习子数组问题的价值在于,它能够帮助我们建立一种非常重要的算法思维:把全局区间问题拆解成局部状态或可维护窗口.对于动态规划而言,我们可以通过"以当前位置结尾"来定义状态;对于前缀和而言,我们可以通过"两个前缀和之差"来表示区间;对于滑动窗口而言,我们可以通过移动左右边界来维护合法区间.这些方法虽然形式不同,但本质上都是在降低枚举所有子数组的复杂度,避免从暴力的 O(n²) 甚至更高复杂度,优化到 O(n)O(n log n).

总的来说,子数组系列问题是理解数组、区间与动态规划思想的重要入口.掌握这类问题,不仅可以帮助我们解决大量高频算法题,也能加深对"状态转移""连续性约束""区间维护"和"局部最优更新全局最优"等核心思想的理解.后续在学习更复杂的动态规划问题时,子数组问题中的建模方式也会反复出现,成为我们分析问题、拆解问题和优化解法的重要基础.


2.最大子数组和(OJ题)


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

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

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

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

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

2.状态转移方程:
dp[i] 的所有可能可以分为以下两种:

i. 子数组的长度为 1:此时 dp[i] = nums[i];

ii. 子数组的长度大于 1:此时 dp[i] 应该等于以 i - 1 做结尾的所有子数组中和的最大值再加上 nums[i],也就是 dp[i - 1] + nums[i].

由于我们要的是最大值,因此应该是两种情况下的最大值,因此可得转移方程:
dp[i] = max(nums[i], dp[i - 1] + nums[i]).

3.初始化:

可以在最前面加上一个辅助结点,帮助我们初始化.使用这种技巧要注意两个点:

i. 辅助结点里面的值要保证后续填表是正确的;

ii. 下标的映射关系.

在本题中,最前面加上一个格子,并且让 dp[0] = 0 即可.

4.填表顺序:

根据状态转移方程易得,填表顺序为从左往右.

5.返回值:

状态表示为以 i 为结尾的所有子数组的最大值,但是最大子数组和的结尾我们是不确定的.因此我们需要返回整个 dp 表中的最大值.

核心代码

cpp 复制代码
class Solution
{
public:
    //求解最大子数组和的核心函数,nums为输入的整数数组
    int maxSubArray(vector<int>& nums)
    {
        //动态规划解题四步走
        //1.创建 dp 表
        //2.初始化
        //3.填表
        //4.返回结果

        //获取输入数组的长度
        int n = nums.size();
        //定义dp数组:dp[i]表示以nums[i-1]为结尾的最大子数组和
        //大小为n+1,dp[0]是辅助节点,初始值默认为0,方便边界处理
        vector<int> dp(n + 1);

        //初始化结果变量为整型最小值,用于记录遍历过程中的最大子数组和
        int ret = INT_MIN;

        //从左往右遍历,填充dp表
        for(int i = 1; i <= n; i++)
        {
            //状态转移方程:
            //情况1:子数组只有当前元素 nums[i-1]
            //情况2:子数组包含前面的元素,即dp[i-1] + nums[i-1]
            //取两者最大值,就是以当前元素结尾的最大子数组和
            dp[i] = max(nums[i - 1], dp[i - 1] + nums[i - 1]);
            
            //更新全局最大值,因为最大子数组可能在任意位置结尾
            ret = max(ret, dp[i]);
        }

        //返回最终的最大子数组和
        return ret;
    }
};

完整测试代码

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

class Solution
{
public:
    // 求解最大子数组和的核心函数,nums为输入的整数数组
    int maxSubArray(vector<int>& nums)
    {
        // 动态规划解题四步走
        // 1. 创建 dp 表
        // 2. 初始化
        // 3. 填表
        // 4. 返回结果

        // 获取输入数组的长度
        int n = nums.size();
        // 定义dp数组:dp[i]表示以nums[i-1]为结尾的最大子数组和
        // 大小为n+1,dp[0]是辅助节点,初始值默认为0,方便边界处理
        vector<int> dp(n + 1);

        // 初始化结果变量为整型最小值,用于记录遍历过程中的最大子数组和
        int ret = INT_MIN;

        // 从左往右遍历,填充dp表
        for(int i = 1; i <= n; i++)
        {
            // 状态转移方程:
            // 情况1:子数组只有当前元素 nums[i-1]
            // 情况2:子数组包含前面的元素,即dp[i-1] + nums[i-1]
            // 取两者最大值,就是以当前元素结尾的最大子数组和
            dp[i] = max(nums[i - 1], dp[i - 1] + nums[i - 1]);

            // 更新全局最大值,因为最大子数组可能在任意位置结尾
            ret = max(ret, dp[i]);
        }

        // 返回最终的最大子数组和
        return ret;
    }
};

int main()
{
    Solution sol;

    // 测试用例1:经典正负混合数组(力扣原题用例)
    vector<int> nums1 = {-2, 1, -3, 4, -1, 2, 1, -5, 4};
    cout << "测试用例1:[-2,1,-3,4,-1,2,1,-5,4]" << endl;
    cout << "最大子数组和:" << sol.maxSubArray(nums1) << endl << endl;

    // 测试用例2:全负数数组
    vector<int> nums2 = {-1, -2, -3, -4};
    cout << "测试用例2:[-1,-2,-3,-4]" << endl;
    cout << "最大子数组和:" << sol.maxSubArray(nums2) << endl << endl;

    // 测试用例3:全正数数组
    vector<int> nums3 = {1, 2, 3, 4};
    cout << "测试用例3:[1,2,3,4]" << endl;
    cout << "最大子数组和:" << sol.maxSubArray(nums3) << endl << endl;

    // 测试用例4:单个元素数组
    vector<int> nums4 = {5};
    cout << "测试用例4:[5]" << endl;
    cout << "最大子数组和:" << sol.maxSubArray(nums4) << endl;

    return 0;
}

3.环形子数组的最大和(OJ题)


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

本题与最大子数组和的区别在于,考虑问题的时候不仅要分析数组内的连续区域,还要考虑数组首尾相连的一部分.结果的可能情况分为以下两种:

i.结果在数组的内部,包括整个数组;

ii.结果在数组首尾相连的一部分上.

其中,对于第一种情况,我们仅需按照最大子数组和的求法就可以得到结果,记为 fmax.

对于第二种情况,我们可以分析一下:

i. 如果数组首尾相连的一部分是最大的数组和,那么数组中间就会空出来一部分;

ii. 因为数组的总和 sum 是不变的,那么中间连续的一部分的和一定是最小的;

因此,我们就可以得出一个结论,对于第二种情况的最大和,应该等于 sum - gmin,其中 gmin 表示数组内的最小子数组和.

两种情况下的最大值,就是我们要的结果.

但是,由于数组内有可能全部都是负数,第一种情况下的结果是数组内的最大值(是个负数),第二种情况下的 gmin == sum,求的得结果就会是 0.若直接求两者的最大值,就会是 0.但是实际的结果应该是数组内的最大值.对于这种情况,我们需要特殊判断一下.

由于最大子数组和的方法已经讲过,这里只提一下最小子数组和的求解过程,其实与最大子数组和的求法是一致的.用 f 表示最大和,g 表示最小和.

1.状态表示:
g[i] 表示:以 i 做结尾的所有子数组中和的最小值.

2.状态转移方程:
g[i] 的所有可能可以分为以下两种:

i. 子数组的长度为 1:此时 g[i] = nums[i];

ii. 子数组的长度大于 1:此时 g[i] 应该等于以 i - 1 做结尾的所有子数组中和的最小值再加上 nums[i],也就是 g[i - 1] + nums[i].

由于我们要的是最小子数组和,因此应该是两种情况下的最小值,因此可得转移方程:
g[i] = min(nums[i], g[i - 1] + nums[i]).

3.初始化:

可以在最前面加上一个辅助结点,帮助我们初始化.使用这种技巧要注意两个点:

i. 辅助结点里面的值要保证后续填表是正确的;

ii. 下标的映射关系.

在本题中,最前面加上一个格子,并且让 g[0] = 0 即可.

4.填表顺序:

根据状态转移方程易得,填表顺序为从左往右.

5.返回值:

a. 先找到 f 表里面的最大值 -> fmax;

b. 找到 g 表里面的最小值 -> gmin;

c. 统计所有元素的和 -> sum;

b. 返回 sum == gmin ? fmax : max(fmax, sum - gmin).

核心代码

cpp 复制代码
// 解题思路:环形数组的最大子数组和分两种情况
// 1. 最大子数组是普通的中间子数组(非环形)→ 用常规最大子数组和求解
// 2. 最大子数组是首尾相连的环形子数组 → 总和 - 最小子数组和(最小子数组在中间)
// 最终结果取两种情况的最大值
class Solution
{
public:
    int maxSubarraySumCircular(vector<int>& nums)
    {
        //动态规划解题四步走
        //1.创建 dp 表
        //2.初始化
        //3.填表
        //4.返回结果

        //获取数组长度
        int n = nums.size();
        //定义两个dp数组:
        //f[i]:以 nums[i-1] 为结尾的【最大】子数组和(常规最大子数组dp)
        //g[i]:以 nums[i-1] 为结尾的【最小】子数组和(求最小子数组用)
        //大小n+1,f[0]、g[0]为辅助节点,默认初始值0
        vector<int> f(n + 1), g(n + 1);

        //定义三个关键变量:
        int fmax = INT_MIN;  //记录全局最大子数组和(情况1的结果)
        int gmin = INT_MAX;  //记录全局最小子数组和(用于计算情况2)
        int sum = 0;         //记录数组所有元素的总和

        //从左往右遍历,填充dp表
        for(int i = 1; i <= n; i++)
        {
            //当前遍历的元素(dp数组下标映射原数组)
            int x = nums[i - 1];

            //状态转移:最大子数组和dp
            //要么单独取当前元素x,要么x拼接前一个的最大子数组
            f[i] = max(x, x + f[i - 1]);
            //更新全局最大子数组和
            fmax = max(fmax, f[i]);

            //状态转移:最小子数组和dp
            //要么单独取当前元素x,要么x拼接前一个的最小子数组
            g[i] = min(x, x + g[i - 1]);
            //更新全局最小子数组和
            gmin = min(gmin, g[i]);

            //累加数组总和
            sum += x;
        }

        //最终结果判断:
        //如果sum == gmin:说明数组全是负数,此时环形无意义,直接返回最大子数组和fmax
        //否则:返回 普通最大子数组和 和 总和-最小子数组和 中的较大值
        return sum == gmin ? fmax : max(fmax, sum - gmin);
    }
};

完整测试代码

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

class Solution
{
public:
    int maxSubarraySumCircular(vector<int>& nums)
    {
        // 动态规划解题四步走
        // 1. 创建 dp 表
        // 2. 初始化
        // 3. 填表
        // 4. 返回结果

        // 获取数组长度
        int n = nums.size();
        // 定义两个dp数组:
        // f[i]:以 nums[i-1] 为结尾的【最大】子数组和
        // g[i]:以 nums[i-1] 为结尾的【最小】子数组和
        vector<int> f(n + 1), g(n + 1);

        // 全局最大子数组和、全局最小子数组和、数组总和
        int fmax = INT_MIN, gmin = INT_MAX, sum = 0;

        // 从左往右遍历填充dp表
        for(int i = 1; i <= n; i++)
        {
            int x = nums[i - 1];
            // 状态转移:最大子数组和
            f[i] = max(x, x + f[i - 1]);
            fmax = max(fmax, f[i]);
            // 状态转移:最小子数组和
            g[i] = min(x, x + g[i - 1]);
            gmin = min(gmin, g[i]);
            // 累加总和
            sum += x;
        }

        // 特殊处理:全负数时直接返回fmax,否则返回两种情况的最大值
        return sum == gmin ? fmax : max(fmax, sum - gmin);
    }
};

int main()
{
    Solution sol;

    // 测试用例1:力扣官方示例1 → 预期结果3
    vector<int> nums1 = {1, -2, 3, -2};
    cout << "测试用例1:[1,-2,3,-2]" << endl;
    cout << "环形最大子数组和:" << sol.maxSubarraySumCircular(nums1) << endl << endl;

    // 测试用例2:首尾相连最大 → 预期结果10
    vector<int> nums2 = {5, -3, 5};
    cout << "测试用例2:[5,-3,5]" << endl;
    cout << "环形最大子数组和:" << sol.maxSubarraySumCircular(nums2) << endl << endl;

    // 测试用例3:全负数数组 → 预期结果-2
    vector<int> nums3 = {-3, -2, -3};
    cout << "测试用例3:[-3,-2,-3]" << endl;
    cout << "环形最大子数组和:" << sol.maxSubarraySumCircular(nums3) << endl << endl;

    // 测试用例4:常规混合数组 → 预期结果4
    vector<int> nums4 = {3, -1, 2, -1};
    cout << "测试用例4:[3,-1,2,-1]" << endl;
    cout << "环形最大子数组和:" << sol.maxSubarraySumCircular(nums4) << endl << endl;

    // 测试用例5:单个元素数组 → 预期结果5
    vector<int> nums5 = {5};
    cout << "测试用例5:[5]" << endl;
    cout << "环形最大子数组和:" << sol.maxSubarraySumCircular(nums5) << endl;

    return 0;
}

4.乘积最大子数组(OJ题)


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

这道题与最大子数组和非常相似,我们可以效仿着定义一下状态表示以及状态转移:

i. dp[i] 表示以 i 为结尾的所有子数组的最大乘积;

ii. dp[i] = max(nums[i], dp[i - 1] * nums[i]);

由于正负号的存在,我们很容易就可以得到,这样求 dp[i] 的值是不正确的.因为 dp[i - 1] 的信息并不能让我们得到 dp[i] 的正确值.比如数组 [-2, 5, -2],用上述状态转移得到的 dp 数组为 [-2, 5, -2],最大乘积为 5.但是实际上的最大乘积应该是所有数相乘,结果为 20.

究其原因,就是因为我们在求 dp[2] 的时候,因为 nums[2] 是一个负数,因此我们需要的是i - 1 位置结尾的最小的乘积(-10),这样一个负数乘以最小值,才会得到真实的最大值.

因此,我们不仅需要一个乘积最大值的 dp 表,还需要一个乘积最小值的 dp 表.

1.状态表示:
f[i] 表示:以 i 结尾的所有子数组的最大乘积;
g[i] 表示:以 i 结尾的所有子数组的最小乘积.

2.状态转移方程:

遍历每一个位置的时候,我们要同步更新两个 dp 数组的值.

对于 f[i],也就是以 i 为结尾的所有子数组的最大乘积,对于所有子数组,可以分为下面三种形式:

i. 子数组的长度为 1,也就是 nums[i];

ii. 子数组的长度大于 1,但 nums[i] > 0,此时需要的是 i - 1 为结尾的所有子数组的最大乘积 f[i - 1],再乘上 nums[i],也就是 nums[i] * f[i - 1];

iii. 子数组的长度大于 1,但 nums[i] < 0,此时需要的是 i - 1 为结尾的所有子数组的最小乘积 g[i - 1],再乘上 nums[i],也就是 nums[i] * g[i - 1];

(如果 nums[i] = 0,所有子数组的乘积均为 0,三种情况其实都包含了)

综上所述,f[i] = max(nums[i], max(nums[i] * f[i - 1], nums[i] * g[i - 1])).

对于 g[i],也就是「以 i 为结尾的所有子数组的最小乘积」,对于所有子数组,可以分为下面三种形式:

i. 子数组的长度为 1,也就是 nums[i];

ii. 子数组的长度大于 1,但 nums[i] > 0,此时需要的是 i - 1 为结尾的所有子数组的最小乘积 g[i - 1],再乘上 nums[i],也就是 nums[i] * g[i - 1];

iii. 子数组的长度大于 1,但 nums[i] < 0,此时需要的是 i - 1 为结尾的所有子数组的最大乘积 f[i - 1],再乘上 nums[i],也就是 nums[i] * f[i - 1];

综上所述,g[i] = min(nums[i], min(nums[i] * f[i - 1], nums[i] * g[i - 1])).

(如果 nums[i] = 0,所有子数组的乘积均为 0,三种情况其实都包含了)

3.初始化:

可以在最前面加上一个辅助结点,帮助我们初始化.使用这种技巧要注意两个点:

i. 辅助结点里面的值要保证后续填表是正确的;

ii. 下标的映射关系.

在本题中,最前面加上一个格子,并且让 f[0] = g[0] = 1 即可.

4.填表顺序:

根据状态转移方程易得,填表顺序为从左往右,两个表一起填.

5.返回值:

返回 f 表中的最大值.

核心代码

cpp 复制代码
// 解题核心:乘法中 负负得正 → 必须同时维护【最大乘积】和【最小乘积】
// 因为当前负数 * 前一个最小乘积(负数)= 很大的正数
class Solution
{
public:
    int maxProduct(vector<int>& nums)
    {
        //动态规划解题四步走
        //1.创建 dp 表
        //2.初始化
        //3.填表
        //4.返回结果

        //获取数组长度
        int n = nums.size();
        //定义两个dp数组:
        //f[i]:以 nums[i-1] 元素为结尾的【最大乘积子数组】的值
        //g[i]:以 nums[i-1] 元素为结尾的【最小乘积子数组】的值
        //必须同时维护最大/最小,应对负数反转的情况
        vector<int> f(n + 1), g(n + 1);

        //初始化:乘法的单位元是1,f[0]、g[0]设为1,不影响乘积计算(辅助节点)
        f[0] = g[0] = 1;

        //记录最终结果,初始化为整型最小值
        int ret = INT_MIN;

        //从左往右遍历,填充dp表
        for(int i = 1; i <= n; i++)
        {
            //定义三个候选值:
            int x = nums[i - 1];                //1.子数组仅包含当前元素
            int y = f[i - 1] * nums[i - 1];     //2.前一个最大乘积 * 当前元素
            int z = g[i - 1] * nums[i - 1];     //3.前一个最小乘积 * 当前元素(负负得正)

            //状态转移:取三个值的最大值 → 当前位置的最大乘积
            f[i] = max(x, max(y, z));
            //状态转移:取三个值的最小值 → 当前位置的最小乘积
            g[i] = min(x, min(y, z));

            //更新全局最大乘积
            ret = max(ret, f[i]);
        }

        //返回最终的最大乘积
        return ret;
    }
};

完整测试代码

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

// 解题核心:乘法中 负负得正 → 必须同时维护【最大乘积】和【最小乘积】
class Solution
{
public:
    int maxProduct(vector<int>& nums)
    {
        // 动态规划解题四步走
        // 1. 创建 dp 表
        // 2. 初始化
        // 3. 填表
        // 4. 返回结果

        // 获取数组长度
        int n = nums.size();
        // 定义两个dp数组:
        // f[i]:以 nums[i-1] 为结尾的【最大乘积子数组】
        // g[i]:以 nums[i-1] 为结尾的【最小乘积子数组】
        vector<int> f(n + 1), g(n + 1);

        // 初始化:乘法单位元为1,辅助节点不影响乘积计算
        f[0] = g[0] = 1;

        // 记录最终结果
        int ret = INT_MIN;

        // 从左往右遍历,同步填充最大/最小dp表
        for(int i = 1; i <= n; i++)
        {
            // 三个候选值:单独当前元素、前最大*当前、前最小*当前
            int x = nums[i - 1];
            int y = f[i - 1] * nums[i - 1];
            int z = g[i - 1] * nums[i - 1];

            // 状态转移:取三者最大值为当前最大乘积
            f[i] = max(x, max(y, z));
            // 状态转移:取三者最小值为当前最小乘积
            g[i] = min(x, min(y, z));

            // 更新全局最大乘积
            ret = max(ret, f[i]);
        }

        return ret;
    }
};

int main()
{
    Solution sol;

    // 测试用例1:经典负负得正 → 预期20
    vector<int> nums1 = {-2, 5, -2};
    cout << "测试用例1:[-2,5,-2]" << endl;
    cout << "最大乘积:" << sol.maxProduct(nums1) << endl << endl;

    // 测试用例2:含0的常规数组 → 预期6
    vector<int> nums2 = {2, 3, -2, 4};
    cout << "测试用例2:[2,3,-2,4]" << endl;
    cout << "最大乘积:" << sol.maxProduct(nums2) << endl << endl;

    // 测试用例3:全负数数组 → 预期24
    vector<int> nums3 = {-2, -3, -4};
    cout << "测试用例3:[-2,-3,-4]" << endl;
    cout << "最大乘积:" << sol.maxProduct(nums3) << endl << endl;

    // 测试用例4:包含0和负数 → 预期0
    vector<int> nums4 = {-2, 0, -1};
    cout << "测试用例4:[-2,0,-1]" << endl;
    cout << "最大乘积:" << sol.maxProduct(nums4) << endl << endl;

    // 测试用例5:单个正数 → 预期5
    vector<int> nums5 = {5};
    cout << "测试用例5:[5]" << endl;
    cout << "最大乘积:" << sol.maxProduct(nums5) << endl << endl;

    // 测试用例6:单个负数 → 预期-1
    vector<int> nums6 = {-1};
    cout << "测试用例6:[-1]" << endl;
    cout << "最大乘积:" << sol.maxProduct(nums6) << endl;

    return 0;
}

5.乘积为正数的最长子数组长度(OJ题)


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

继续效仿最大子数组和中的状态表示,尝试解决这个问题.

状态表示:dp[i] 表示所有以 i 结尾的子数组,乘积为正数的最长子数组的长度.

思考状态转移:对于 i 位置上的 nums[i],我们可以分三种情况讨论:

i. 如果 nums[i] = 0,那么所有以 i 为结尾的子数组的乘积都不可能是正数,此时 dp[i] = 0;

ii. 如果 nums[i] > 0,那么直接找到 dp[i - 1] 的值(这里请再读一遍 dp[i - 1] 代表的意义,并且考虑如果 dp[i - 1] 的结值是 0 的话,影不影响结果),然后加一即可,此时 dp[i] = dp[i - 1] + 1;

iii. 如果 nums[i] < 0,这时候你该蛋疼了,因为在现有的条件下,你根本没办法得到此时的最长长度.因为乘法是存在负负得正的,单单靠一个 dp[i - 1],我们无法推导出 dp[i] 的值.

但是,如果我们知道以 i - 1 为结尾的所有子数组,乘积为负数的最长子数组的长度neg[i - 1],那么此时的 dp[i] 是不是就等于 neg[i - 1] + 1 呢?

通过上面的分析,我们可以得出,需要两个 dp 表,才能推导出最终的结果.不仅需要一个乘积为正数的最长子数组,还需要一个乘积为负数的最长子数组.

1.状态表示:
f[i] 表示:以 i 结尾的所有子数组中,乘积为正数的最长子数组的长度;
g[i] 表示:以 i 结尾的所有子数组中,乘积为负数的最长子数组的长度.

2.状态转移方程:

遍历每一个位置的时候,我们要同步更新两个 dp 数组的值.

对于 f[i],也就是以 i 为结尾的乘积为正数的最长子数组,根据 nums[i] 的值,可以分为三种情况:

i. nums[i] = 0 时,所有以 i 为结尾的子数组的乘积都不可能是正数,此时 f[i] = 0;

ii. nums[i] > 0 时,那么直接找到 f[i - 1] 的值(这里请再读一遍 f[i - 1] 代表的意义,并且考虑如果 f[i - 1] 的结值是 0 的话,影不影响结果),然后加一即可,此时 f[i] = f[i - 1] + 1;

iii. nums[i] < 0 时,此时我们要看 g[i - 1] 的值(这里请再读一遍 g[i - 1] 代表的意义.因为负负得正,如果我们知道以 i - 1 为结尾的乘积为负数的最长子数组的长度,加上 1 即可),根据 g[i - 1] 的值,又要分两种情况:

  1. g[i - 1] = 0,说明以 i - 1 为结尾的乘积为负数的最长子数组是不存在的,又因为 nums[i] < 0,所以以 i 结尾的乘积为正数的最长子数组也是不存在的,此时 f[i] = 0;
  2. g[i - 1] != 0,说明以 i - 1 为结尾的乘积为负数的最长子数组是存在的,又因为 nums[i] < 0,所以以 i 结尾的乘积为正数的最长子数组就等于 g[i - 1] + 1;

综上所述,nums[i] < 0 时,f[i] = g[i - 1] == 0 ? 0 : g[i - 1] + 1;

对于 g[i],也就是以 i 为结尾的乘积为负数的最长子数组,根据 nums[i] 的值,可以分为三种情况:

i. nums[i] = 0 时,所有以 i 为结尾的子数组的乘积都不可能是负数,此时 g[i] = 0;

ii. nums[i] < 0 时,那么直接找到 f[i - 1] 的值(这里请再读一遍 f[i - 1] 代表的意义,并且考虑如果 f[i - 1] 的结值是 0 的话,影不影响结果),然后加一即可(因为正数 * 负数 = 负数),此时 g[i] = f[i - 1] + 1;

iii. nums[i] > 0 时,此时我们要看 g[i - 1] 的值(这里请再读一遍 g[i - 1] 代表的意义.因为正数 * 负数 = 负数),根据 g[i - 1] 的值,又要分两种情况:

  1. g[i - 1] = 0,说明以 i - 1 为结尾的乘积为负数的最长子数组是不存在的,又因为 nums[i] > 0,所以以 i 结尾的乘积为负数的最长子数组也是不存在的,此时 g[i] = 0
  2. g[i - 1] != 0,说明以 i - 1 为结尾的乘积为负数的最长子数组是存在的,又因为 nums[i] > 0,所以以 i 结尾的乘积为负数的最长子数组就等于 g[i - 1] + 1;

综上所述,nums[i] > 0 时,g[i] = g[i - 1] == 0 ? 0 : g[i - 1] + 1;

这里的推导比较绕,因为不断的出现正数和负数的分情况讨论,我们只需根据下面的规则,严格找到此状态下需要的 dp 数组即可:

i. 正数 * 正数 = 正数

ii. 负数 * 负数 = 正数

iii. 负数 * 正数 = 正数 * 负数 = 负数

3.初始化:

可以在最前面加上一个辅助结点,帮助我们初始化.使用这种技巧要注意两个点:

i. 辅助结点里面的值要保证后续填表是正确的;

ii. 下标的映射关系.

在本题中,最前面加上一个格子,并且让 f[0] = g[0] = 0 即可.

4.填表顺序:

根据状态转移方程易得,填表顺序为从左往右,两个表一起填.

5.返回值:

根据状态表示,我们要返回 f 表中的最大值.

核心代码

cpp 复制代码
//解题思路:需要维护两个DP数组
//f[i]:以 nums[i-1] 结尾,乘积为【正数】的最长子数组长度
//g[i]:以 nums[i-1] 结尾,乘积为【负数】的最长子数组长度
class Solution
{
public:
    int getMaxLen(vector<int>& nums)
    {
        //动态规划解题四步走
        //1.创建 dp 表
        //2.初始化
        //3.填表
        //4.返回结果

        //获取数组长度
        int n = nums.size();
        //定义dp数组:
        //f[i]:以nums[i-1]结尾,乘积为正的最长子数组长度
        //g[i]:以nums[i-1]结尾,乘积为负的最长子数组长度
        vector<int> f(n + 1), g(n + 1);

        //记录最终结果:最长正数乘积子数组长度
        int ret = INT_MIN;

        //从左往右遍历,同步填充f和g数组
        for(int i = 1; i <= n; i++)
        {
            //情况1:当前元素为正数
            if(nums[i - 1] > 0)
            {
                //正数+正数=正数:正长度直接+1
                f[i] = f[i - 1] + 1;
                //正数+负数=负数:负长度存在则+1,不存在则保持0
                g[i] = g[i - 1] == 0 ? 0 : g[i - 1] + 1;
            }
            //情况2:当前元素为负数
            else if(nums[i - 1] < 0)
            {
                //负数*负数=正数:正长度 = 前负长度+1,无则为0
                f[i] = g[i - 1] == 0 ? 0 : g[i - 1] + 1;
                //负数*正数=负数:负长度 = 前正长度+1
                g[i] = f[i - 1] + 1;
            }
            //情况3:当前元素为0
            //乘积为0,不满足正数要求,f[i]和g[i]保持默认值0

            //更新全局最长正数子数组长度
            ret = max(ret, f[i]);
        }

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

完整测试代码

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

// 解题思路:维护两个DP数组,分别记录正负乘积的最长子数组长度
// f[i]:以nums[i-1]结尾,乘积为正数的最长子数组长度
// g[i]:以nums[i-1]结尾,乘积为负数的最长子数组长度
class Solution
{
public:
    // 核心函数:求乘积为正数的最长连续子数组长度
    int getMaxLen(vector<int>& nums)
    {
        // 动态规划解题四步走
        // 1. 创建 dp 表
        // 2. 初始化
        // 3. 填表
        // 4. 返回结果

        // 获取数组长度
        int n = nums.size();
        // 定义dp数组
        vector<int> f(n + 1), g(n + 1);

        // 记录最终结果
        int ret = INT_MIN;

        // 从左往右遍历,同步填充正负长度数组
        for(int i = 1; i <= n; i++)
        {
            // 当前元素为正数:正长度+1,负长度存在则+1
            if(nums[i - 1] > 0)
            {
                f[i] = f[i - 1] + 1;
                g[i] = g[i - 1] == 0 ? 0 : g[i - 1] + 1;
            }
                // 当前元素为负数:正长度=前负长度+1,负长度=前正长度+1
            else if(nums[i - 1] < 0)
            {
                f[i] = g[i - 1] == 0 ? 0 : g[i - 1] + 1;
                g[i] = f[i - 1] + 1;
            }
            // 当前元素为0:f[i]和g[i]保持默认值0,子数组中断

            // 更新全局最长正数子数组长度
            ret = max(ret, f[i]);
        }

        return ret;
    }
};

int main()
{
    Solution sol;

    // 测试用例1:常规正负混合 → 预期4
    vector<int> nums1 = {1,-2,-3,4};
    cout << "测试用例1:[1,-2,-3,4]" << endl;
    cout << "乘积为正数的最长子数组长度:" << sol.getMaxLen(nums1) << endl << endl;

    // 测试用例2:包含0 → 预期3
    vector<int> nums2 = {0,1,-2,-3,-4};
    cout << "测试用例2:[0,1,-2,-3,-4]" << endl;
    cout << "乘积为正数的最长子数组长度:" << sol.getMaxLen(nums2) << endl << endl;

    // 测试用例3:全正数 → 预期3
    vector<int> nums3 = {1,2,3};
    cout << "测试用例3:[1,2,3]" << endl;
    cout << "乘积为正数的最长子数组长度:" << sol.getMaxLen(nums3) << endl << endl;

    // 测试用例4:偶数个负数 → 预期4
    vector<int> nums4 = {-1,-2,-3,-4};
    cout << "测试用例4:[-1,-2,-3,-4]" << endl;
    cout << "乘积为正数的最长子数组长度:" << sol.getMaxLen(nums4) << endl << endl;

    // 测试用例5:奇数个负数 → 预期2
    vector<int> nums5 = {-1,-2,-3};
    cout << "测试用例5:[-1,-2,-3]" << endl;
    cout << "乘积为正数的最长子数组长度:" << sol.getMaxLen(nums5) << endl << endl;

    // 测试用例6:单个正数 → 预期1
    vector<int> nums6 = {5};
    cout << "测试用例6:[5]" << endl;
    cout << "乘积为正数的最长子数组长度:" << sol.getMaxLen(nums6) << endl << endl;

    // 测试用例7:单个负数 → 预期0
    vector<int> nums7 = {-1};
    cout << "测试用例7:[-1]" << endl;
    cout << "乘积为正数的最长子数组长度:" << sol.getMaxLen(nums7) << endl << endl;

    // 测试用例8:全0 → 预期0
    vector<int> nums8 = {0,0,0};
    cout << "测试用例8:[0,0,0]" << endl;
    cout << "乘积为正数的最长子数组长度:" << sol.getMaxLen(nums8) << endl;

    return 0;
}

6.等差数列划分(OJ题)


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

由于我们的研究对象是一段连续的区间,如果我们状态表示定义成 [0, i] 区间内一共有多少等差数列,那么我们在分析 dp[i] 的状态转移时,会无从下手,因为我们不清楚前面那么多的等差数列都在什么位置.所以说,我们定义的状态表示必须让等差数列有迹可循,让状态转移的时候能找到大部队因此,我们可以固定死等差数列的结尾,定义下面的状态表示:
dp[i] 表示必须以 i 位置的元素为结尾的等差数列有多少种.

2.状态转移方程

我们需要了解一下等差数列的性质:如果 a b c 三个数成等差数列,这时候来了一个 d,其中 b c d 也能构成一个等差数列,那么 a b c d 四个数能够成等差序列吗?答案是:显然的.因为他们之间相邻两个元素之间的差值都是一样的.有了这个理解,我们就可以转而分析我们的状态转移方程了.

对于 dp[i] 位置的元素 nums[i],会与前面的两个元素有下面两种情况:

i. nums[i - 2], nums[i - 1], nums[i] 三个元素不能构成等差数列:那么以 nums[i] 为结尾的等差数列就不存在,此时 dp[i] = 0;

ii. nums[i - 2], nums[i - 1], nums[i] 三个元素可以构成等差数列:那么以 nums[i - 1] 为结尾的所有等差数列后面填上一个 nums[i] 也是一个等差数列,此时 dp[i] = dp[i - 1].但是,因为 nums[i - 2], nums[i - 1], nums[i] 三者又能构成一个新的等差数列,因此要在之前的基础上再添上一个等差数列,于是 dp[i] = dp[i - 1] + 1.

综上所述:状态转移方程为:

  • 当:nums[i - 2] + nums[i] != 2 * nums[i - 1] 时,dp[i] = 0
  • 当:nums[i - 2] + nums[i] == 2 * nums[i - 1] 时,dp[i] = 1 + dp[i - 1]

3.初始化:

由于需要用到前两个位置的元素,但是前两个位置的元素又无法构成等差数列,因此 dp[0] = dp[1] = 0.

核心代码

cpp 复制代码
// 核心定义:至少由 3 个元素组成,且相邻元素差值相等的连续子数组,称为等差数列子数组
// 解题思路:动态规划,统计以每个位置结尾的等差数列子数组数量,最后求和
class Solution
{
public:
    int numberOfArithmeticSlices(vector<int>& nums)
    {
        //动态规划解题四步走
        //1.创建 dp 表
        //2.初始化
        //3.填表
        //4.返回结果

        int n = nums.size();
        //边界情况:数组长度小于3,无法构成等差数列,直接返回0
        if(n < 3) return 0;

        //dp[i] 定义:以 nums[i] 为结尾的【等差数列子数组】的个数
        vector<int> dp(n);

        //记录所有等差数列子数组的总数量
        int sum = 0;

        //等差数列至少需要3个元素,因此从下标 i=2 开始遍历
        for(int i = 2; i < n; i++)
        {
            //判断:当前元素与前一个元素的差值 == 前一个与前前个的差值
            //相等:说明可以延续前一个的等差数列,数量 +1
            //不相等:无法构成等差数列,dp[i] = 0
            dp[i] = nums[i] - nums[i - 1] == nums[i - 1] - nums[i - 2] ? dp[i - 1] + 1 : 0;
            
            //累加每个位置结尾的等差数列数量,得到总个数
            sum += dp[i];
        }

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

完整测试代码

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

class Solution
{
public:
    // 计算数组中等差数列子数组的总数
    int numberOfArithmeticSlices(vector<int>& nums)
    {
        // 动态规划解题四步走
        // 1. 创建 dp 表
        // 2. 初始化
        // 3. 填表
        // 4. 返回结果

        int n = nums.size();
        // 边界条件:数组长度小于3,无法构成等差数列
        if(n < 3) return 0;

        // dp[i]:以nums[i]为结尾的等差数列子数组的数量
        vector<int> dp(n);

        // 统计所有等差数列的总数
        int sum = 0;

        // 从第三个元素开始遍历(下标2),等差数列至少3个元素
        for(int i = 2; i < n; i++)
        {
            // 判断连续三个元素是否为等差数列
            if(nums[i] - nums[i-1] == nums[i-1] - nums[i-2])
            {
                // 可以构成:延续前一个的等差数列,数量+1
                dp[i] = dp[i-1] + 1;
            }
            else
            {
                // 无法构成:数量为0
                dp[i] = 0;
            }
            // 累加所有以当前位置结尾的等差数列数量
            sum += dp[i];
        }
        return sum;
    }
};

int main()
{
    Solution sol;

    // 测试用例1:标准等差数列 [1,2,3,4] → 预期结果3
    vector<int> nums1 = {1,2,3,4};
    cout << "测试用例1:[1,2,3,4]" << endl;
    cout << "等差数列子数组个数:" << sol.numberOfArithmeticSlices(nums1) << endl << endl;

    // 测试用例2:无等差数列 → 预期0
    vector<int> nums2 = {1,2,4,5};
    cout << "测试用例2:[1,2,4,5]" << endl;
    cout << "等差数列子数组个数:" << sol.numberOfArithmeticSlices(nums2) << endl << endl;

    // 测试用例3:长度不足3 → 预期0
    vector<int> nums3 = {1,2};
    cout << "测试用例3:[1,2]" << endl;
    cout << "等差数列子数组个数:" << sol.numberOfArithmeticSlices(nums3) << endl << endl;

    // 测试用例4:全相同元素(特殊等差)→ 预期6
    vector<int> nums4 = {2,2,2,2};
    cout << "测试用例4:[2,2,2,2]" << endl;
    cout << "等差数列子数组个数:" << sol.numberOfArithmeticSlices(nums4) << endl << endl;

    // 测试用例5:混合数组 → 预期2
    vector<int> nums5 = {1,3,5,7,9,10};
    cout << "测试用例5:[1,3,5,7,9,10]" << endl;
    cout << "等差数列子数组个数:" << sol.numberOfArithmeticSlices(nums5) << endl;

    return 0;
}

7.最⻓湍流⼦数组(OJ题)


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

我们先尝试定义状态表示为:
dp[i] 表示以 i 位置为结尾的最长湍流数组的长度.

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

因此需要两个 dp 表:
f[i] 表示:以 i 位置元素为结尾的所有子数组中,最后呈现上升状态下的最长湍流数组的长度;
g[i] 表示:以 i 位置元素为结尾的所有子数组中,最后呈现下降状态下的最长湍流数组的长度.

2.状态转移方程:

对于i位置的元素 arr[i],有下面两种情况:

i. arr[i] > arr[i - 1]:如果 i 位置的元素比 i - 1 位置的元素大,说明接下来应该去找 i - 1 位置结尾,并且 i - 1 位置元素比前一个元素小的序列,那就是 g[i - 1].更新 f[i] 位置的值:f[i] = g[i - 1] + 1;

ii. arr[i] < arr[i - 1]:如果 i 位置的元素比 i - 1 位置的元素小,说明接下来应该去找 i - 1 位置结尾,并且 i - 1 位置元素比前一个元素大的序列,那就是 f[i - 1]。更新 g[i] 位置的值:g[i] = f[i - 1] + 1;

iii. arr[i] == arr[i - 1]:不构成湍流数组.

3.初始化:

所有的元素单独都能构成一个湍流数组,因此可以将 dp 表内所有元素初始化为 1.

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

4.填表顺序:

毫无疑问是从左往右,两个表一起填.

5.返回值

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

核心代码

cpp 复制代码
//湍流子数组定义:相邻元素交替 上升/下降,如 [1,3,2,4,3]
//f[i]:以 i 结尾,最后一步是【上升】(arr[i-1] < arr[i]) 的最长湍流子数组长度
//g[i]:以 i 结尾,最后一步是【下降】(arr[i-1] > arr[i]) 的最长湍流子数组长度
class Solution
{
public:
    //求解最长湍流子数组的长度
    int maxTurbulenceSize(vector<int>& arr)
    {
        //动态规划解题四步走
        //1.创建 dp 表
        //2.初始化
        //3.填表
        //4.返回结果

        //获取数组长度
        int n = arr.size();
        //初始化dp数组:每个元素自身构成长度为1的子数组
        //f[i] 结尾上升,g[i] 结尾下降
        vector<int> f(n, 1), g(n, 1);

        //记录最终结果,初始值为1(单个元素的情况)
        int ret = 1;

        //从第二个元素开始遍历(i从1开始)
        for(int i = 1; i < n; i++)
        {
            //情况1:当前是上升趋势 (前 < 后)
            //上升的前一步必须是下降,因此长度 = 前一个下降长度 + 1
            if(arr[i - 1] < arr[i]) 
                f[i] = g[i - 1] + 1;
            
            //情况2:当前是下降趋势 (前 > 后)
            //下降的前一步必须是上升,因此长度 = 前一个上升长度 + 1
            else if(arr[i - 1] > arr[i]) 
                g[i] = f[i - 1] + 1;
            
            //情况3:arr[i-1] == arr[i],无法形成湍流,f[i]和g[i]保持初始值1

            //更新全局最长湍流子数组长度
            ret = max(ret, max(f[i], g[i]));
        }

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

完整测试代码

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

// 湍流子数组:相邻元素交替上升/下降,相等则中断
// f[i]:以i结尾,最后一步【上升】的最长湍流长度
// g[i]:以i结尾,最后一步【下降】的最长湍流长度
class Solution
{
public:
    int maxTurbulenceSize(vector<int>& arr)
    {
        // 动态规划解题四步走
        // 1. 创建 dp 表
        // 2. 初始化
        // 3. 填表
        // 4. 返回结果

        int n = arr.size();
        // 初始化:每个元素自身长度为1
        vector<int> f(n, 1), g(n, 1);

        // 记录最大长度,初始值为1
        int ret = 1;

        // 从第二个元素开始遍历
        for(int i = 1; i < n; i++)
        {
            // 上升趋势:前 < 后,承接前一个下降的长度+1
            if(arr[i - 1] < arr[i])
                f[i] = g[i - 1] + 1;
                // 下降趋势:前 > 后,承接前一个上升的长度+1
            else if(arr[i - 1] > arr[i])
                g[i] = f[i - 1] + 1;
            // 相等:保持初始值1,不更新

            // 更新全局最大值
            ret = max(ret, max(f[i], g[i]));
        }
        return ret;
    }
};

int main()
{
    Solution sol;

    // 测试用例1:力扣官方示例1 → 预期结果5
    vector<int> arr1 = {9,4,2,10,7,8,8,1,9};
    cout << "测试用例1:[9,4,2,10,7,8,8,1,9]" << endl;
    cout << "最长湍流子数组长度:" << sol.maxTurbulenceSize(arr1) << endl << endl;

    // 测试用例2:力扣官方示例2(单调递增)→ 预期结果2
    vector<int> arr2 = {4,8,12,16};
    cout << "测试用例2:[4,8,12,16]" << endl;
    cout << "最长湍流子数组长度:" << sol.maxTurbulenceSize(arr2) << endl << endl;

    // 测试用例3:全相等元素 → 预期结果1
    vector<int> arr3 = {5,5,5};
    cout << "测试用例3:[5,5,5]" << endl;
    cout << "最长湍流子数组长度:" << sol.maxTurbulenceSize(arr3) << endl << endl;

    // 测试用例4:完美交替湍流 → 预期结果5
    vector<int> arr4 = {1,3,2,4,3};
    cout << "测试用例4:[1,3,2,4,3]" << endl;
    cout << "最长湍流子数组长度:" << sol.maxTurbulenceSize(arr4) << endl << endl;

    // 测试用例5:两个元素 → 预期结果2
    vector<int> arr5 = {1,2};
    cout << "测试用例5:[1,2]" << endl;
    cout << "最长湍流子数组长度:" << sol.maxTurbulenceSize(arr5) << endl << endl;

    // 测试用例6:单个元素 → 预期结果1
    vector<int> arr6 = {7};
    cout << "测试用例6:[7]" << endl;
    cout << "最长湍流子数组长度:" << sol.maxTurbulenceSize(arr6) << endl;

    return 0;
}

8.单词拆分(OJ题)


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

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

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

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

这里我们选择比较常用的方式,以某个位置为结尾,结合题目要求,定义一个状态表示:
dp[i] 表示:[0, i] 区间内的字符串,能否被字典中的单词拼接而成.

2.状态转移方程:

对于 dp[i],为了确定当前的字符串能否由字典里面的单词构成,根据最后一个单词的起始位置 j,我们可以将其分解为前后两部分:

i. 前面一部分 [0, j - 1] 区间的字符串;

ii. 后面一部分 [j, i] 区间的字符串.

其中前面部分我们可以在 dp[j - 1] 中找到答案,后面部分的子串可以在字典里面找到.

因此,我们得出一个结论:当我们在从 0 ~ i 枚举 j 的时候,只要 dp[j - 1] = true 并且后面部分的子串 s.substr(j, i - j + 1) 能够在字典中找到,那么 dp[i] = true.

3.初始化:

可以在最前面加上一个辅助结点,帮助我们初始化.使用这种技巧要注意两个点:

i. 辅助结点里面的值要保证后续填表是正确的;

ii. 下标的映射关系.

在本题中,最前面加上一个格子,并且让 dp[0] = true,可以理解为空串能够拼接而成.

其中为了方便处理下标的映射关系,我们可以将字符串前面加上一个占位符 s = ' ' + s,这样就没有下标的映射关系的问题了,同时还能处理空串的情况.

4.填表顺序:

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

5.返回值:

由状态表示可得:返回 dp[n] 位置的布尔值.

哈希表优化的小细节:

在状态转移中,我们需要判断后面部分的子串是否在字典之中,因此会频繁的用到查询操作.为了节省效率,我们可以提前把字典中的单词存入到哈希表中.

核心代码

cpp 复制代码
//题目描述:给定字符串s和字典wordDict,判断s能否被拆分成字典中单词的组合
//解题思路:动态规划 + 哈希表优化查找效率
class Solution
{
public:
    //核心函数:判断字符串是否可以被拆分
    bool wordBreak(string s, vector<string>& wordDict)
    {
        //优化一:将字典单词存入哈希表,实现 O(1) 时间复杂度的查找
        unordered_set<string> hash;
        //遍历字典,将所有单词插入哈希表
        for(auto& str : wordDict) 
            hash.insert(str);

        //字符串长度
        int n = s.size();
        //1.创建 dp 表
        //dp[i] 表示:字符串的【前 i 个字符】能否被成功拆分成字典中的单词
        vector<bool> dp(n + 1);
        
        //2.初始化
        //dp[0] = true:空字符串可以被拆分,是动态规划的起始条件
        dp[0] = true; 
        
        //统一下标:在字符串前加一个空格,让原字符串下标从 1 开始,方便dp数组匹配
        s = ' ' + s;  
        
        //3.填表:从左到右填充dp数组
        //外层循环:处理前 i 个字符的拆分情况
        for(int i = 1; i <= n; i++) 
        {
            //内层循环:寻找最后一个单词的【起始下标 j】
            //从后往前遍历,找到符合条件的立即退出,提升效率
            for(int j = i; j >= 1; j--) 
            {
                //状态转移方程:
                //1.dp[j-1] = true:前 j-1 个字符可以被拆分
                //2.子串 s[j, i] 存在于哈希表中:最后一个单词有效
                if(dp[j - 1] && hash.count(s.substr(j, i - j + 1)))
                {
                    //满足条件,前 i 个字符可以拆分
                    dp[i] = true;
                    //优化二:找到一个可行方案就退出内层循环,无需继续查找
                    break; 
                }
            }
        }

        //4.返回结果
        // dp[n] 表示整个字符串(前n个字符)能否被拆分
        return dp[n];
    }
};

完整测试代码

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

class Solution
{
public:
    bool wordBreak(string s, vector<string>& wordDict)
    {
        // 优化一:哈希表存储字典,O(1)查找
        unordered_set<string> hash;
        for(auto& str : wordDict)
            hash.insert(str);

        int n = s.size();
        // dp[i]:前i个字符能否被拆分
        vector<bool> dp(n + 1);
        // 初始化:空字符串可以被拆分
        dp[0] = true;
        // 下标统一:字符串前加空格,从1开始索引
        s = ' ' + s;

        // 填表:从左到右
        for(int i = 1; i <= n; i++)
        {
            // 寻找最后一个单词的起始位置
            for(int j = i; j >= 1; j--)
            {
                // 状态转移:前j-1个字符可拆分 + 子串在字典中
                if(dp[j - 1] && hash.count(s.substr(j, i - j + 1)))
                {
                    dp[i] = true;
                    break; // 找到即退出,优化效率
                }
            }
        }
        return dp[n];
    }
};

int main()
{
    Solution sol;

    // 测试用例1:经典可拆分 → 预期 true
    string s1 = "leetcode";
    vector<string> dict1 = {"leet", "code"};
    cout << "测试用例1:s=\"leetcode\"" << endl;
    cout << "是否可拆分:" << boolalpha << sol.wordBreak(s1, dict1) << endl << endl;

    // 测试用例2:重复单词可拆分 → 预期 true
    string s2 = "applepenapple";
    vector<string> dict2 = {"apple", "pen"};
    cout << "测试用例2:s=\"applepenapple\"" << endl;
    cout << "是否可拆分:" << boolalpha << sol.wordBreak(s2, dict2) << endl << endl;

    // 测试用例3:无法拆分 → 预期 false
    string s3 = "catsandog";
    vector<string> dict3 = {"cats", "dog", "sand", "and", "cat"};
    cout << "测试用例3:s=\"catsandog\"" << endl;
    cout << "是否可拆分:" << boolalpha << sol.wordBreak(s3, dict3) << endl << endl;

    // 测试用例4:单个单词匹配 → 预期 true
    string s4 = "hello";
    vector<string> dict4 = {"hello"};
    cout << "测试用例4:s=\"hello\"" << endl;
    cout << "是否可拆分:" << boolalpha << sol.wordBreak(s4, dict4) << endl << endl;

    // 测试用例5:空字符串 → 预期 true
    string s5 = "";
    vector<string> dict5 = {"a"};
    cout << "测试用例5:s=\"\"(空串)" << endl;
    cout << "是否可拆分:" << boolalpha << sol.wordBreak(s5, dict5) << endl;

    return 0;
}

9.环绕字符串中唯⼀的⼦字符串(OJ题)


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

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

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

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

这里我们选择比较常用的方式,以某个位置为结尾,结合题目要求,定义一个状态表示:
dp[i] 表示:以 i 位置的元素为结尾的所有子串里面,有多少个在 base 中出现过.

2.状态转移方程:

对于 dp[i],我们可以根据子串的长度划分为两类:

i. 子串的长度等于 1:此时这一个字符会出现在 base 中;

ii. 子串的长度大于 1:如果 i 位置的字符和 i - 1 位置上的字符组合后,出现在 base 中的话,那么 dp[i - 1] 里面的所有子串后面填上一个 s[i] 依旧在 base 中出现.因此 dp[i] = dp[i - 1].

综上,dp[i] = 1 + dp[i - 1],其中 dp[i - 1] 是否加上需要先做一下判断.

3.初始化:

可以根据实际情况,将表里面的值都初始化为 1.

4.填表顺序:

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

5.返回值:

这里不能直接返回 dp 表里面的和,因为会有重复的结果.在返回之前,我们需要先去重:

i. 相同字符结尾的 dp 值,我们仅需保留最大的即可,其余 dp 值对应的子串都可以在最大的里面找到;

ii. 可以创建一个大小为 26 的数组,统计所有字符结尾的最大 dp 值.

最后返回数组中所有元素的和即可.

核心代码

cpp 复制代码
//环绕字符串:"abcdefghijklmnopqrstuvwxyz" 无限循环拼接
//题目要求:统计字符串 s 中,是环绕字符串 子串 的【唯一】子串数量
class Solution
{
public:
    int findSubstringInWraproundString(string s)
    {
        int n = s.size(); //字符串长度
        //dp[i]:以 s[i] 字符结尾的、符合环绕规则的【最长连续子串长度】
        //初始化:每个字符自身都是一个子串,初始长度为1
        vector<int> dp(n, 1);

        //第一步:填充dp数组,计算每个位置结尾的最长连续子串
        for(int i = 1; i < n; i++)
        {
            //判断条件:满足环绕字符串的连续规则
            //1.普通连续:当前字符 = 前一个字符 +1 (如 b = a+1)
            //2.环绕连续:前一个是z,当前是a (z -> a 是环绕连续)
            if(s[i] - 1 == s[i - 1] || (s[i - 1] == 'z' && s[i] == 'a'))
            {
                //满足连续,长度 = 前一个位置的最长长度 +1
                dp[i] = dp[i - 1] + 1;
            }
            //不满足连续:dp[i] 保持初始值 1
        }

        //第二步:去重统计(核心优化)
        //hash[26]:记录 26个小写字母 各自结尾的【最长连续子串长度】
        //原理:以同一个字符结尾的子串,最长的那个会包含所有短的子串,直接取最大值即可去重
        int hash[26] = { 0 };
        for(int i = 0; i < n; i++)
        {
            int idx = s[i] - 'a'; //字符转下标(a=0, b=1...z=25)
            //保留每个字符结尾的【最长长度】,自动去重
            hash[idx] = max(hash[idx], dp[i]);
        }

        //第三步:累加所有字符的最长长度,就是最终唯一子串的总数
        int sum = 0;
        for(auto x : hash) 
            sum += x;

        return sum;
    }
};

完整测试代码

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

// 环绕字符串:无限循环的字母序列 ...zabcdefghijklmnopqrstuvwxyza...
// 统计s中属于环绕字符串的【唯一】子串数量
class Solution
{
public:
    int findSubstringInWraproundString(string s)
    {
        int n = s.size();
        // dp[i]:以s[i]结尾的、符合环绕规则的最长连续子串长度
        vector<int> dp(n, 1);

        // 第一步:计算每个位置结尾的最长连续子串
        for (int i = 1; i < n; i++)
        {
            // 判断是否符合环绕字符串的连续规则:普通连续 或 z→a环绕连续
            if (s[i] - 1 == s[i - 1] || (s[i - 1] == 'z' && s[i] == 'a'))
            {
                dp[i] = dp[i - 1] + 1;
            }
        }

        // 第二步:去重统计,记录每个字母结尾的最大长度(长串包含所有短串)
        int hash[26] = { 0 };
        for (int i = 0; i < n; i++)
        {
            int idx = s[i] - 'a';
            hash[idx] = max(hash[idx], dp[i]);
        }

        // 第三步:累加所有字母的最大长度,得到唯一子串总数
        int sum = 0;
        for (int x : hash)
            sum += x;

        return sum;
    }
};

int main()
{
    Solution sol;

    // 测试用例1:单个字符 → 预期结果 1
    string s1 = "z";
    cout << "测试用例1:s = \"z\"" << endl;
    cout << "唯一子串数量:" << sol.findSubstringInWraproundString(s1) << endl << endl;

    // 测试用例2:连续两个字符 → 预期结果 3 (a, b, ab)
    string s2 = "ab";
    cout << "测试用例2:s = \"ab\"" << endl;
    cout << "唯一子串数量:" << sol.findSubstringInWraproundString(s2) << endl << endl;

    // 测试用例3:连续三个字符 → 预期结果 6 (a,b,c,ab,bc,abc)
    string s3 = "abc";
    cout << "测试用例3:s = \"abc\"" << endl;
    cout << "唯一子串数量:" << sol.findSubstringInWraproundString(s3) << endl << endl;

    // 测试用例4:环绕连续(z→a→b) → 预期结果 6 (z,a,b,za,ab,zab)
    string s4 = "zab";
    cout << "测试用例4:s = \"zab\"" << endl;
    cout << "唯一子串数量:" << sol.findSubstringInWraproundString(s4) << endl << endl;

    // 测试用例5:重复字符(去重) → 预期结果 3 (a,b,ab)
    string s5 = "aba";
    cout << "测试用例5:s = \"aba\"" << endl;
    cout << "唯一子串数量:" << sol.findSubstringInWraproundString(s5) << endl << endl;

    // 测试用例6:官方示例 → 预期结果 6
    string s6 = "cac";
    cout << "测试用例6:s = \"cac\"" << endl;
    cout << "唯一子串数量:" << sol.findSubstringInWraproundString(s6) << endl;

    return 0;
}


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


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


每日心灵鸡汤:事过翻篇是格局
昨天的太阳,晒不干今天的衣裳,人生在世,或多或少会有放不下的人和事,但若总是耿耿于怀,最后伤害的往往是自己."当断不断,反受其乱",为无法挽回的事烦闷忧愁,既无法让时光倒流,也无力改变现状,无疑是自寻烦恼,与其让自己深陷忧虑不能自拔,不如昂首阔步走出困境,与其让琐事消耗自己,不如把时间用来做更有意义的事.该翻篇的就翻篇,不纠结过往,只为腾出双手,拥抱现在和未来,拥有事过翻篇的能力,才能看到更美的风景.

相关推荐
冷小鱼7 小时前
数据结构:从“生活常识“到“工程实战“
数据结构
AI进化营-智能译站7 小时前
ROS2 C++开发系列04:如何有效输出机器人状态
开发语言·c++·ai·机器人
AI进化营-智能译站7 小时前
ROS2 C++开发系列05:机器人启动如何传递命令行参数实战
开发语言·c++·ai·机器人
春蕾夏荷_7282977257 小时前
1、c++ acl udp服务器客户端简单实例-客户器端(2)
服务器·c++·udp
袋子(PJ)7 小时前
2026年pytorch基础学习(基于jupyter notebook开发)——从原理到落地:PyTorch神经网络架构与工程优化解析
人工智能·pytorch·深度学习·学习·jupyter
落羽的落羽7 小时前
【网络】计算机网络世界的基础概念
linux·服务器·网络·c++·人工智能·计算机网络·机器学习
梦想画家7 小时前
RAG应用基石:从六种文档切分算法看语义完整性
人工智能·算法·rag
Volunteer Technology7 小时前
ES相关度评分算法
大数据·算法·elasticsearch
炽烈小老头7 小时前
【每天学习一点算法 2026/04/30】寻找重复数
学习·算法