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

在动态规划问题中,子数组系列题目一直是高频且经典的一类.无论是最大子数组和、乘积最大子数组,还是带有约束条件的连续区间问题,它们表面形式各不相同,但本质上往往都围绕着"状态如何定义""转移从哪里来""当前选择如何影响后续结果"展开.很多同学在初学这类问题时,容易陷入一个误区:看到题目就套模板,却没有真正理解为什么这样建模、为什么状态可以这样转移.尤其是子数组问题强调"连续性",这使得它与子序列问题在思考方式上有明显区别.本文将围绕"子数组系列问题"的动态规划建模方法展开讲解,从常见题型入手,分析状态定义、转移方程、边界处理以及优化思路,帮助大家建立一套清晰、可复用的解题框架.希望读完本文后,你不仅能掌握具体题目的解法,更能理解这类问题背后的动态规划思维.废话不多说,下面跟着小编的节奏🎵一起去疯狂的学习吧!
目录
- 1.子数组系列问题背景介绍
- 2.最大子数组和(OJ题)
- 3.环形子数组的最大和(OJ题)
- 4.乘积最大子数组(OJ题)
- 5.乘积为正数的最长子数组长度(OJ题)
- 6.等差数列划分(OJ题)
- 7.最⻓湍流⼦数组(OJ题)
- 8.单词拆分(OJ题)
- 9.环绕字符串中唯⼀的⼦字符串(OJ题)
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] 的值,又要分两种情况:
g[i - 1] = 0,说明以i - 1为结尾的乘积为负数的最长子数组是不存在的,又因为nums[i] < 0,所以以i结尾的乘积为正数的最长子数组也是不存在的,此时f[i] = 0;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] 的值,又要分两种情况:
g[i - 1] = 0,说明以i - 1为结尾的乘积为负数的最长子数组是不存在的,又因为nums[i] > 0,所以以i结尾的乘积为负数的最长子数组也是不存在的,此时g[i] = 0;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;
}


🚀真正的勇者不是流泪的人,而是含泪奔跑的人!
敬请期待下一篇文章内容:【动态规划算法】(子序列问题解题框架与典型案例)
每日心灵鸡汤:事过翻篇是格局
昨天的太阳,晒不干今天的衣裳,人生在世,或多或少会有放不下的人和事,但若总是耿耿于怀,最后伤害的往往是自己."当断不断,反受其乱",为无法挽回的事烦闷忧愁,既无法让时光倒流,也无力改变现状,无疑是自寻烦恼,与其让自己深陷忧虑不能自拔,不如昂首阔步走出困境,与其让琐事消耗自己,不如把时间用来做更有意义的事.该翻篇的就翻篇,不纠结过往,只为腾出双手,拥抱现在和未来,拥有事过翻篇的能力,才能看到更美的风景.
