子数组/子串问题
文章目录
- 53. 最大子数组和
- 918. 环形子数组的最大和
- 152. 乘积最大子数组
- 1567. 乘积为正数的最长子数组长度
- 413. 等差数列划分
- 978. 最长湍流子数组
- 139. 单词拆分
- 467. 环绕字符串中唯一的子字符串
- 总结
53. 最大子数组和
题目链接
子数组/子串 都是连续的
子序列:不连续
核心思路
找到和最大的连续子数组,用动态规划聚焦 “以当前元素结尾” 的子数组。
状态与转移
- dp[i]:以 nums[i] 结尾的连续子数组的最大和。
然后针对选不选i,进行分类讨论。这里要找最大。
不选i的话说明,dp[i-1]的值比dp[i-1]+nums[i]要大。 也就是i对应的值为负。
选的话,就是i对应的值为正
最后的状态转移方程可以根据对i进行分类讨论,用if else来表示,也可以直接总结成max去获取
- 转移方程:dp[i] = max(nums[i], dp[i-1] + nums[i])(判断 “单独当前元素” 或 “延续前序子数组” 哪个和更大)。
实现要点
- 填表顺序:从左到右(依赖前一个状态 dp[i-1])。
- 结果:遍历 dp 取最大值(最大子数组可能以任意位置结尾)。
class Solution {
public:int maxSubArray(vector<int>& nums) {int n=nums.size();vector<int>dp(n,0);dp[0]=nums[0];int ret=dp[0];for(int i=1;i<n;i++){if (dp[i-1] > 0) {// 前一个子数组和为正 → 选前序,延续子数组dp[i] = dp[i-1] + nums[i];} else {// 前一个子数组和为负/零 → 不选前序,重新以当前元素开头dp[i] = nums[i];}// dp[i]=max(dp[i-1]+nums[i],nums[i]);ret=max(ret,dp[i]);}
return ret;}
};
918. 环形子数组的最大和
题目链接
核心思路
环形子数组的最大和分两种情况:
- 普通连续子数组(非环形,同 “最大子数组和” 问题)。
- 跨首尾的环形子数组(总和 = 数组总和 - 中间最小连续子数组和)。
第一种方法只需要把题目给出的数组,连续两个拼接起来即可,就能包含环形的连续情况。然后带入第一题即可。
下面说说第二种
状态定义
- f[i]:以 nums[i] 结尾的最大连续子数组和。
- g[i]:以 nums[i] 结尾的最小连续子数组和。
状态转移
- 最大和转移:f[i] = max(nums[i], f[i-1] + nums[i])(延续前序或重新开始)。
- 最小和转移:g[i] = min(nums[i], g[i-1] + nums[i])(延续前序或重新开始)。
结果计算
1.数组总和 sum = accumulate(nums.begin(), nums.end(), 0)。
2.遍历得到 f_max(f 数组的最大值)、g_min(g 数组的最小值)。
3.最终结果:
- 若 sum == g_min(数组全为负):返回 f_max(避免 sum - g_min = 0 错误)。
- 否则:返回 max(f_max, sum - g_min)(普通最大 和 环形最大 取较大者)。
class Solution {
public:int maxSubarraySumCircular(vector<int>& nums) {int n=nums.size();vector<int>fmax(n,0);auto gmin=fmax;fmax[0]=nums[0];int sum=nums[0];gmin[0]=nums[0];int ret1=nums[0],ret2=nums[0];for(int i=1;i<n;i++){sum+=nums[i];fmax[i]=max(fmax[i-1]+nums[i],nums[i]);ret1=max(fmax[i],ret1);gmin[i]=min(gmin[i-1]+nums[i],nums[i]);ret2=min(gmin[i],ret2);}return sum==ret2?ret1:max(ret1,sum-ret2);}
};
152. 乘积最大子数组
题目链接
先直接常规思路去dp。
dp[i]:以i为结尾乘积最大的子数组。然后针对选i /不选i 分类讨论。
讨论过程中,很容易遇到一个问题,如果i为负数的时候我们应该怎么处理,如果前面的dp都是负的,当前的i为正,直接取i就好。
如果前面的dp是正的,当前i为负,或者连续两个i都是负,乘积是正的,但我们一个个遍历讨论的时候无法得知后面的i的正负。
所以可以引入一个g去存乘积最小的结果,让最大和最小的在转移时相互依赖
class Solution {
public:int maxProduct(vector<int>& nums) {int n=nums.size();vector<int>f(n,0);auto g=f;g[0]=f[0]=nums[0];int ret=nums[0];for(int i=1;i<n;i++){
f[i]=max(nums[i],max(f[i-1]*nums[i],g[i-1]*nums[i]));
g[i]=min(nums[i],min(f[i-1]*nums[i],g[i-1]*nums[i]));
ret=max(ret,f[i]);}return ret;}
};
1567. 乘积为正数的最长子数组长度
题目链接
这题同样
要找最大长度,我无法判断后续的正负值,如果只用一个dp去存乘积为正最大值,当连续出现两个负数时,乘积为正,但处理第一个负数时,为确保以i为结尾乘积为正 ,把dp设为0了,会导致后续的推导错误,所以需要两个dp分别去存正负的情况,相互依赖推导。
因为负数的 “负负得正” 特性,单维护 “最大乘积” 会丢失 “前一个最小负乘积 × 当前负数 → 大的正乘积” 的可能,因此需要两个 DP 数组相互依赖
然后在根据i对应值的正负去分类讨论就行
class Solution {
public:int getMaxLen(vector<int>& nums) {int n=nums.size();vector<int>f(n,0);auto g=f;f[0]=nums[0]>0;g[0]=nums[0]<0;int ret=f[0];for(int i=1;i<n;i++){if(nums[i]>0){
f[i]=f[i-1]+1;
g[i]=g[i-1]!=0?g[i-1]+1:0;}else if(nums[i]<0){f[i]=g[i-1]!=0?g[i-1]+1:0;g[i]=f[i-1]+1;}ret=max(ret,f[i]);}return ret;}
};
413. 等差数列划分
题目链接
直接定义状态表示即可:以xxx为结尾,xxx
这里就是
dp[i] 的含义是:以 nums[i] 为最后一个元素的等差数列子数组的个数。
状态方程根据等差数列性质推导即可
然后让统计所有的等差数列个数,累加就好
class Solution {
public:int numberOfArithmeticSlices(vector<int>& nums) {int n=nums.size();if(n<3)return 0;vector<int>dp(n,0);int ret=0;for(int i=2;i<n;i++){if(nums[i]-nums[i-1]==nums[i-1]-nums[i-2])dp[i]=dp[i-1]+1;ret+=dp[i];}return ret;}
};
978. 最长湍流子数组
题目链接
问题核心
寻找数组中最长的 “交替增减” 子数组(湍流子数组,如 a < b > c < d 或 a > b < c > d)。
动态规划设计
- 状态定义
- up[i]:以 nums[i] 结尾、最后呈上升趋势(nums[i] > nums[i-1])的最长湍流子数组长度。
- down[i]:以 nums[i] 结尾、最后呈下降趋势(nums[i] < nums[i-1])的最长湍流子数组长度。
- 状态转移(分情况)
- 上升(nums[i] > nums[i-1]):up[i] = down[i-1] + 1(延续前一下降趋势,长度 + 1),down[i] = 1(下降趋势重置)。
- 下降(nums[i] < nums[i-1]):down[i] = up[i-1] + 1(延续前一上升趋势,长度 + 1),up[i] = 1(上升趋势重置)。
- 相等(nums[i] == nums[i-1]):up[i] = down[i] = 1(无法形成湍流,长度重置为 1)。
class Solution {
public:int maxTurbulenceSize(vector<int>& arr) {int n=arr.size();vector<int>f(n,1);auto g=f;int ret=1;for(int i=1;i<n;i++){if(arr[i]>arr[i-1])f[i]=g[i-1]+1;else if(arr[i]<arr[i-1])g[i]=f[i-1]+1;ret=max(ret,max(f[i],g[i]));}return ret;}
};
139. 单词拆分
题目链接
dp[i]:字符串 前 i 个字符(区间 [0, i-1]) 能否被字典单词拼接而成(true/false)。
枚举最后一个单词的结束位置 j(0 ≤ j < i):若 dp[j](前 j 个字符可拼接)且子串 s[j…i-1] 在字典中 → dp[i] = true。
和132. 分割回文串 II 的思路差不多,一个是判断子串是否回文,一个是判断子串是否在字典中。这种重复的子问题刚好都是被dp[i]表示。所以代码类似
class Solution {
public:
bool exist(vector<string>& wordDict,string t)
{for(int i=0;i<wordDict.size();i++){if(t==wordDict[i])return true;}return false;
}bool wordBreak(string s, vector<string>& wordDict) {int n=s.size();vector<bool>dp(n+1,false);dp[0]=true;s=' '+s;for(int i=1;i<=n;i++){for(int j=0;j<i;j++){if(dp[j]&&exist(wordDict,s.substr(j+1,i-j))){dp[i]=true;break;}}}return dp[n];}
};
这道单词拆分的题,如果看完背包问题后,可以发现,符合“完全背包”的要求:
判断字符串 s 是否能被字典中可重复选取的单词拼接而成(属于「完全背包」模型:物品可重复选,凑出目标 “容量”—— 字符串长度)。
- 物品:字典中的单词(可重复选)。
- 背包容量:字符串 s 的长度 n。
- 状态定义:dp[i] 表示「前 i 个字符(子串 s[0…i-1])能否被字典单词拼接」。
然后通过完全背包的思路解决:
class Solution {
public:bool wordBreak(string s, vector<string>& wordDict) {int n = s.size();unordered_set<string> dict(wordDict.begin(), wordDict.end());vector<vector<bool>> dp(n, vector<bool>(n, false));// 初始化:长度为1的子串for (int i = 0; i < n; ++i) {if (dict.count(s.substr(i, 1))) {dp[i][i] = true;}}// 遍历长度 >=2 的子串for (int len = 2; len <= n; ++len) { // len:子串长度for (int i = 0; i + len - 1 < n; ++i) { // i:子串起点int j = i + len - 1; // 子串终点// 情况1:子串本身在字典中if (dict.count(s.substr(i, len))) {dp[i][j] = true;continue;}// 情况2:找分割点kfor (int k = i; k < j; ++k) {if (dp[i][k] && dp[k+1][j]) {dp[i][j] = true;break;}}}}return dp[0][n-1]; // 整个字符串是否可拆分
}
};
滚动数组优化:
class Solution {
public:bool wordBreak(string s, vector<string>& wordDict) {unordered_set<string> dict(wordDict.begin(), wordDict.end());int n = s.size();vector<bool> dp(n + 1, false);dp[0] = true; // 空串可拼接for (int i = 1; i <= n; ++i) { // 遍历背包容量(子串长度)for (string word : wordDict) { // 遍历物品(字典单词)int len = word.size();if (len <= i && s.substr(i - len, len) == word && dp[i - len]) {dp[i] = true;break; // 找到一个符合的单词即可标记}}}return dp[n];
}
};
467. 环绕字符串中唯一的子字符串
题目链接
1.状态定义:dp[i] 表示以 s[i] 结尾的最长连续环绕子串的长度。(例如:s = “abc”,则 dp[2] = 3,对应子串 “abc”、“bc”、“c”)
2.状态转移:
- 若 s[i] 与 s[i-1] 是连续递增(s[i]-s[i-1] == 1),或环绕递增(s[i] == ‘a’ 且 s[i-1] == ‘z’):dp[i] = dp[i-1] + 1(延续前一个子串的长度)。
- 否则:dp[i] = 1(只能单独以 s[i] 作为子串)。
3.去重与统计:用长度为 26 的数组 hash 记录每个字符(a-z)作为结尾的最长子串长度。(因为 “以同一字符结尾的更长子串会包含更短的子串”,所以取最长长度即可去重,且能代表该字符贡献的所有唯一子串数)
4.结果计算:将 hash 数组所有元素相加,得到所有唯一环绕子串的总数。
class Solution {
public:int findSubstringInWraproundString(string s) {int n=s.size();vector<int>dp(n,0);dp[0]=1;for(int i=1;i<n;i++){
if(s[i]-s[i-1]==1||s[i]=='a'&&s[i-1]=='z')dp[i]=dp[i-1]+1;
else dp[i]=1;}int hash[26]={0};
for(int i=0;i<n;i++)hash[s[i]-'a']=max(hash[s[i]-'a'],dp[i]);
int sum=0;
for(auto&e:hash)sum+=e;
return sum;}
};
总结
- 状态设计核心逻辑
由于子数组 / 子串是连续的,动态规划状态通常定义为:
dp[i] 表示 “以第 i 个元素结尾的满足条件的子数组” 的某种属性(如最大和、最长长度、数量、可行性等)。
→ 利用 “连续” 的传递性,dp[i] 可通过 dp[i-1](或关联状态)推导,保证递推的连贯性。
- 状态转移的常见模式
根据问题的 “约束条件”(和、乘积、回文、字典匹配、等差 / 湍流等),分析当前元素与前一元素的关系,推导 dp[i]:
问题类型 | 核心转移逻辑 | 典型题目 |
---|---|---|
极值类(和 / 长度) | 若当前元素能 “延续” 前一子数组的有效状态,则 dp[i] = dp[i-1] + 增量;否则重置。 | 53. 最大子数组和、978. 最长湍流子数组 |
正负 / 二元状态类 | 因 “负负得正”“升降交替” 等二元特性,需多个 dp 数组(如 f[i] 记录正 / 升,g[i] 记录负 / 降),相互依赖转移。 | 152. 乘积最大子数组、1567. 乘积为正的最长子数组长度 |
计数类 | 若当前满足条件(如等差),则 dp[i] = dp[i-1] + 1;累加所有 dp[i] 得到总数。 | 413. 等差数列划分 |
可行性类 | 枚举 “最后一个子串” 的分割点,若前部分可行且当前子串满足条件(如在字典中),则当前可行。 | 139. 单词拆分 |
去重统计类 | 先记录 “以每个元素结尾的最长有效长度”,再通过哈希 / 数组去重(同一结尾的长串包含短串),最后求和。 | 467. 环绕字符串中唯一的子字符串 |
- 通用解题步骤
1.状态定义:明确 dp[i] 代表 “以 i 结尾的子数组的什么属性”。
2.状态转移:分析当前元素与前序状态的关系,推导转移方程(必要时引入多个辅助 dp 数组)。
3.初始化:处理单个元素或空区间的边界情况(如 dp[0])。
4.结果提取:根据问题要求,提取 dp 数组的最大值、总和、最后一个元素,或结合额外逻辑(如环形问题的 “总和 - 最小子数组和”)。
5.优化:
- 空间优化:用变量代替数组(滚动数组,如湍流子数组问题)。
- 去重处理:利用 “长串包含短串” 的特性,记录每个结尾的最长长度以避免重复统计。