动态规划算法------简单多状态dp问题
统一解题模板:线性DP五步法
-
状态表示:以i位置结尾,拆分多种状态(选/不选、持有/不持有、不同颜色)
-
状态转移方程:根据上一步所有合法状态推导,取最值
-
初始化:处理边界,设置基础值;不存在的状态初始化为负无穷
-
填表顺序:从左往右,多个状态数组同步填写
-
返回值:最后位置所有合法状态的最大值/最小值
11. 按摩师(面试题 17.16 easy)
题目描述

算法思路
- 状态表示
对于简单的线性 dp,我们可以用「经验 + 题目要求」来定义状态表示:
i. 以某个位置为结尾 ii. 以某个位置为起点
这里选择以某个位置结尾,拆分两种抉择:
fi:选择到 i 位置时,numsi 必选,此时的最长预约时长;
gi:选择到 i 位置时,numsi 不选,此时的最长预约时长。
- 状态转移方程
• 对于fi:如果numsi必选,那么i-1位置一定不能选,取i-1不选的状态加上当前值。
fi = gi - 1 + numsi
• 对于gi:如果numsi不选,i-1可选可不选,取两者最大值。
gi = max(fi - 1, gi - 1)
- 初始化
无需辅助节点,初始化:f0 = nums0,g0 = 0。
- 填表顺序
从左往右,两个数组一起填。
- 返回值
max(fn - 1, gn - 1)

C++代码
cpp
class Solution {
public:
int massage(vector<int>& nums) {
// 1. 创建一个 dp 表
// 2. 初始化
// 3. 填表
// 4. 返回值
int n = nums.size();
if(n == 0) return 0; // 处理边界条件
vector<int> f(n);
auto g = f;
f[0] = nums[0];
g[0] = 0;
for(int i = 1; i < n; i++)
{
f[i] = g[i - 1] + nums[i];
g[i] = max(f[i - 1], g[i - 1]);
}
return max(f[n - 1], g[n - 1]);
}
};
12. 打家劫舍 II(LeetCode 213 medium)
题目描述

算法思路
这是「打家劫舍I」的变形。普通打家劫舍是单排,本题是环形(首尾相连)。
环形问题转化成两次单排DP:
a. 偷第一个房屋,就不能偷最后一个:区间0, n-2
b. 不偷第一个房屋,可以偷最后一个:区间1, n-1
最终答案 = 两种情况的最大值

C++代码
cpp
class Solution
{
public:
int rob(vector<int>& nums){
int n = nums.size();
// 两种情况下的最大值
return max(rob1(nums, 0, n - 2), rob1(nums, 1, n - 1));
}
int rob1(vector<int>& nums, int left, int right){
if(left > right) return 0;
// 1. 创建 dp 表
// 2. 初始化
// 3. 填表
// 4. 返回结果
int n = nums.size();
vector<int> f(n);
auto g = f;
f[left] = nums[left]; // 初始化
g[left] = 0;
for(int i = left + 1; i <= right; i++)
{
f[i] = g[i - 1] + nums[i];
g[i] = max(f[i - 1], g[i - 1]);
}
return max(f[right], g[right]);
}
};
13. 删除并获得点数(LeetCode 740 medium)
题目描述

算法思路
本质是打家劫舍的变形:选x,则不能选x-1和x+1。
预处理:构建hash数组,arrx存放所有x数字的总和,在arr数组上跑打家劫舍DP。

C++代码
cpp
class Solution {
public:
int deleteAndEarn(vector<int>& nums) {
const int N = 10001;
// 1. 预处理
int arr[N] = { 0 };
for(auto x : nums) arr[x] += x;
// 2. 在 arr 数组上,做一次"打家劫舍"问题
// 创建 dp 表
vector<int> f(N);
auto g = f;
// 填表
for(int i = 1; i < N; i++)
{
f[i] = g[i - 1] + arr[i];
g[i] = max(f[i - 1], g[i - 1]);
}
// 返回结果
return max(f[N - 1], g[N - 1]);
}
};
14. 粉刷房子(LCR 091 medium)
题目描述

算法思路
- 状态表示
dpi0:粉刷到i位置,最后房子刷红色,最小花费;
dpi1:粉刷到i位置,最后房子刷蓝色,最小花费;
dpi2:粉刷到i位置,最后房子刷绿色,最小花费。
- 状态转移方程
相邻房子颜色不能相同
dpi0 = min(dpi-11, dpi-12) + costsi-10
dpi1 = min(dpi-10, dpi-12) + costsi-11
dpi2 = min(dpi-10, dpi-11) + costsi-12
- 初始化
增加辅助结点,初始值全部为0。
- 填表顺序
从左往右,三个状态一起填。
- 返回值
min(dpn0, dpn1, dpn2)

C++代码
cpp
class Solution {
public:
int minCost(vector<vector<int>>& costs) {
// dp[i][j] 第i个房子刷成第j种颜色最小花费
int n = costs.size();
vector<vector<int>> dp(n + 1, vector<int>(3));
for (int i = 1; i <= n; i++) {
dp[i][0] = min(dp[i - 1][1], dp[i - 1][2]) + costs[i - 1][0];
dp[i][1] = min(dp[i - 1][0], dp[i - 1][2]) + costs[i - 1][1];
dp[i][2] = min(dp[i - 1][1], dp[i - 1][0]) + costs[i - 1][2];
}
return min(dp[n][0], min(dp[n][1], dp[n][2]));
}
};
15. 买卖股票的最佳时机含冷冻期(LeetCode 309 medium)
题目描述

算法思路
- 状态表示
dpi0:第i天结束,持有股票(买入状态),最大利润
dpi1:第i天结束,无股票、可交易,最大利润
dpi2:第i天结束,无股票、冷冻期,最大利润
- 状态转移方程
dpi0 = max(dpi-10, dpi-11 - pricesi)
①前一天就持有;②前一天可交易,今天买入
dpi1 = max(dpi-11, dpi-12)
①前一天可交易;②前一天冷冻期,今天解冻
dpi2 = dpi-10 + pricesi
只能是前一天持有,今天卖出
- 初始化
dp00 = -prices0;
dp01 = 0;
dp02 = 0;
-
填表顺序:从左向右,三个状态一起填
-
返回值:max(dpn-11, dpn-12)

C++代码
cpp
class Solution
{
public:
int maxProfit(vector<int>& prices)
{
// 1. 创建 dp 表
// 2. 初始化
// 3. 填表
// 4. 返回结果
int n = prices.size();
vector<vector<int>> dp(n, vector<int>(3));
dp[0][0] = -prices[0];
dp[0][1] = 0;
dp[0][2] = 0;
for(int i = 1; i < n; i++)
{
dp[i][0] = max(dp[i - 1][0], dp[i - 1][1] - prices[i]);
dp[i][1] = max(dp[i - 1][1], dp[i - 1][2]);
dp[i][2] = dp[i - 1][0] + prices[i];
}
return max(dp[n - 1][1], dp[n - 1][2]);
}
};
16. 买卖股票的最佳时机含手续费(LeetCode 714 medium)
题目描述

算法思路
- 状态表示
fi:第i天结束,持有股票(买入状态),最大利润
gi:第i天结束,不持有股票(卖出状态),最大利润
- 状态转移方程
手续费在卖出时扣除
fi = max(fi-1, gi-1 - pricesi)
①前一天持有;②前一天无股票,今天买入
gi = max(gi-1, fi-1 + pricesi - fee)
①前一天无股票;②前一天持有,今天卖出,减去手续费
- 初始化
f0 = -prices0,g0 = 0
-
填表顺序:从左向右,两个状态一起填
-
返回值:gn-1

C++代码
cpp
class Solution
{
public:
int maxProfit(vector<int>& prices, int fee)
{
// 1. 创建 dp 表
// 2. 初始化
// 3. 填表
// 4. 返回结果
int n = prices.size();
vector<int> f(n);
auto g = f;
f[0] = -prices[0];
g[0] = 0;
for(int i = 1; i < n; i++)
{
f[i] = max(f[i - 1], g[i - 1] - prices[i]);
g[i] = max(g[i - 1], f[i - 1] + prices[i] - fee);
}
return g[n - 1];
}
};
17. 买卖股票的最佳时机 III(LeetCode 123 hard)
题目描述

提示:
1 <= prices.length <= 1050 <= prices[i] <= 105
算法思路
增加一维:交易次数j
fij:第i天结束,完成j次交易,持有股票,最大利润
gij:第i天结束,完成j次交易,不持有股票,最大利润
转移方程:
fij = max(fi-1j, gi-1j - pricesi)
①前一天j次交易,持有;②前一天j次交易无股票,今天买入
gij = gi-1j
前一天j次交易无股票,不操作;
如果j>=1:gij = max(gij, fi-1j-1 + pricesi)
前一天j-1次交易持有,今天卖出,交易次数+1
初始化:不存在的状态赋值为负无穷0x3f3f3f3f
f00 = -prices0; g00 = 0;
返回:g数组最后一行的最大值
0x3f3f3f3f 详解(算法题常用无穷大)
0x3f3f3f3f 是十六进制,换算十进制 = 1061109567
✅ 为什么选这个数,而不是 INT_MAX(2147483647)
- 足够大,但加起来不会立刻溢出
INT_MAX + 正数 直接整型溢出 → 负数,bug。
0x3f3f3f3f + 0x3f3f3f3f = 2122219134,仍然小于 int 上限 2147483647
最常用在最短路、DP:distv = min(distv, distu+w),两个无穷相加不会爆int
- 方便批量初始化数组
memset(dp, 0x3f, sizeof(dp)); memset是按字节赋值,1字节=8bit。
int占4字节,每个字节填 0x3f,4个字节拼起来就是 0x3f3f3f3f。
一句话:一行memset就能把整个数组全部设成无穷大。
如果用INT_MAX,memset没法一次性设置。
- 不会和合法数值冲突
大部分题目数据范围远小于 1e9,0x3f3f3f3f代表"不可到达、无穷远",不会和正常答案混淆。

C++代码
cpp
class Solution
{
public:
const int INF = 0x3f3f3f;
int maxProfit(vector<int>& prices)
{
// 1. 创建 dp 表
// 2. 初始化
// 3. 填表
// 4. 返回结果
int n = prices.size();
vector<vector<int>> f(n, vector<int>(3, -INF));
auto g = f;
f[0][0] = -prices[0], g[0][0] = 0;
for(int i = 1; i < n; i++)
{
for(int j = 0; j < 3; j++)
{
f[i][j] = max(f[i - 1][j], g[i - 1][j] - prices[i]);
g[i][j] = g[i - 1][j];
if(j >= 1) // 如果该状态存在
g[i][j] = max(g[i][j], f[i - 1][j - 1] + prices[i]);
}
}
// 找到最后一行的最大值
int ret = 0;
for(int j = 0; j < 3; j++)
ret = max(ret, g[n - 1][j]);
return ret;
}
};
18. 买卖股票的最佳时机 IV(LeetCode 188 hard)
题目描述

算法思路
状态表示
• fij:第i天结束,完成j笔交易,手里有股票的最大收益;
• gij:第i天结束,完成j笔交易,手里没有股票的最大收益。
状态转移方程
fij = max(fi-1j, gi-1j - pricesi)
gij = max(gi-1j, fi-1j-1 + pricesi)
优化:最多交易次数不会超过n/2,k = min(k, n/2),减少计算规模。

C++代码
cpp
class Solution {
public:
const int INF = 0x3f3f3f3f;
int maxProfit(int k, vector<int>& prices) {
// 处理细节:交易上限最多 n/2
int n = prices.size();
k = min(k, n / 2);
vector<vector<int>> f(n, vector<int>(k + 1, -INF));
auto g = f;
f[0][0] = -prices[0];
g[0][0] = 0;
for(int i = 1; i < n; i++){
for(int j = 0; j <= k; j++){
f[i][j] = max(f[i-1][j], g[i-1][j] - prices[i]);
g[i][j] = g[i-1][j];
if(j >= 1){
g[i][j] = max(g[i][j], f[i-1][j-1] + prices[i]);
}
}
}
int ans = 0;
for(int j = 0; j <= k; j++) ans = max(ans, g[n-1][j]);
return ans;
}
};
核心知识点总结
- 基础模型:打家劫舍(选/不选二状态DP)
状态:fi必选i;gi不选i
转移:fi = gi-1+vali,gi=max(fi-1,gi-1)
变种:
环形(打家劫舍II):拆成两次线性DP,分别去掉首/尾
删除并获得点数:数值映射数组,转化打家劫舍
- 多状态DP(多个并行状态)
粉刷房子:3种颜色,相邻不能相同,求最小花费;状态是3个
股票系列:
基础2状态:持有 / 不持有
冷冻期:增加冷冻期状态,一共3种
带交易次数限制:增加一维j代表交易次数,三维DP(天数,次数,持有状态)
- 通用解题套路(五步法万能模板)
-
状态定义:以i结尾,拆分所有互斥的状态(选/不选、持有/无、颜色)
-
转移方程:分析当前状态可以从前面哪些合法状态转移而来,取max/min
-
初始化:
合法基础状态赋值;不可能的状态赋值负无穷(求最大值时)
辅助结点技巧:在最前面虚拟下标简化边界判断
-
填表顺序:从左往右,多个状态数组同步循环更新
-
返回结果:最后位置所有合法状态的最值
- 易错点
-
环形DP不能直接一次性计算,首尾互斥,必须拆分两次线性DP取max
-
股票DP卖出才增加交易次数;买入不增加次数
-
负无穷0x3f3f3f3f,防止INT_MIN加法溢出
-
下标映射:题目数组下标和dp表下标要区分,不要错位
动态规划算法------子数组系列
核心模板:线性DP五步法
-
状态表示:一般定义 dpi 表示以i位置结尾的目标量(最常用)
-
状态转移方程:根据最近一步划分情况推导
-
初始化:增设辅助结点,处理下标越界,保证填表逻辑正确
-
填表顺序:从左往右(依赖前面状态)
-
返回值:看题目,可能返回dp数组最大值 / dpn / dp数组总和
19. 最大子数组和(LeetCode 53 medium)
题目描述

算法思路
- 状态表示
dpi:以 i 位置元素结尾的所有子数组中和的最大值。
- 状态转移方程
子数组分两种情况:
长度为1:dpi = numsi
长度>1:dpi = dpi-1 + numsi
dpi = max(numsi, dpi-1+numsi)
- 初始化
前面增加辅助结点,dp0=0。
-
填表顺序:从左往右
-
返回值:dp表里面的最大值

C++代码
cpp
class Solution
{
public:
int maxSubArray(vector<int>& nums)
{
int n = nums.size();
vector<int> dp(n + 1);
int ret = INT_MIN;
for(int i = 1; i <= n; i++)
{
dp[i] = max(nums[i - 1], dp[i - 1] + nums[i - 1]);
ret = max(ret, dp[i]);
}
return ret;
}
};
20. 环形子数组的最大和(LeetCode 918 medium)
题目描述

算法思路
两种情况:
1)最大子数组在数组内部:fmax(直接用最大子数组和)
2)最大子数组跨首尾:总和 sum 减去最小子数组和 gmin
边界特例:数组全部是负数时,sum == gmin,此时直接返回数组最大值fmax
- 状态表示
gi:以 i 做结尾的所有子数组中和的最小值。
- 状态转移方程
gi = min(numsi, gi-1+numsi)
-
初始化:辅助结点 g0=0
-
填表顺序:从左往右
-
返回值:sum == gmin ? fmax : max(fmax, sum - gmin)

C++代码
cpp
class Solution
{
public:
int maxSubarraySumCircular(vector<int>& nums)
{
int n = nums.size();
vector<int> f(n + 1), g(n + 1);
int fmax = INT_MIN, gmin = INT_MAX, sum = 0;
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;
}
return sum == gmin ? fmax : max(fmax, sum - gmin);
}
};
21. 乘积最大子数组(LeetCode 152 medium)
题目描述

算法思路
⚠️ 乘积和求和不一样!负数 × 负数 变正数。只存最大值不够,需要同时保存「以i结尾最大乘积、最小乘积」
- 状态表示
fi:以i结尾的所有子数组的最大乘积
gi:以i结尾的所有子数组的最小乘积
- 状态转移方程
fi = max(numsi, max(numsi*fi-1, numsi*gi-1))
gi = min(numsi, min(numsi*fi-1, numsi*gi-1))
-
初始化:辅助结点 f0=g0=1
-
填表顺序:从左往右,两个数组一起填
-
返回值:f表中的最大值

C++代码
cpp
class Solution
{
public:
int maxProduct(vector<int>& nums)
{
int n = nums.size();
vector<int> f(n + 1), g(n + 1);
f[0] = g[0] = 1;
int ret = INT_MIN;
for(int i = 1; i <= n; i++)
{
int x = nums[i - 1], y = f[i - 1] * nums[i - 1], 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;
}
};
22. 乘积为正数的最长子数组(LeetCode 1567 medium)
题目描述

算法思路
- 状态表示
fi:以i结尾,乘积为正数的最长子数组长度
gi:以i结尾,乘积为负数的最长子数组长度
- 状态转移
numsi==0:fi=0,gi=0
numsi>0:fi=fi-1+1;gi = gi-1==0 ? 0 : gi-1+1
numsi<0:fi = gi-1==0 ? 0 : gi-1+1;gi = fi-1+1
-
初始化:f0=g0=0
-
填表顺序:从左往右,两表一起填
-
返回值:f数组的最大值

C++代码
cpp
class Solution
{
public:
int getMaxLen(vector<int>& nums)
{
int n = nums.size();
vector<int> f(n + 1), g(n + 1);
int ret = INT_MIN;
for(int i = 1; i <= n; i++)
{
if(nums[i - 1] > 0)
{
f[i] = f[i - 1] + 1;
g[i] = g[i - 1] == 0 ? 0 : g[i - 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;
}
else
{
f[i] = 0;
g[i] = 0;
}
ret = max(ret, f[i]);
}
return ret;
}
};
23. 等差数列划分(LeetCode 413 medium)
题目描述

算法思路
- 状态表示
dpi:必须以i位置元素结尾的等差数列有多少种。
- 状态转移方程
判断 numsi-2, numsi-1, numsi 是否等差:
不是等差数列:dpi=0
是等差数列:dpi = dpi-1 + 1
-
初始化:dp0=0,dp1=0(前两个元素无法构成等差数列)
-
填表顺序:从左往右
-
返回值:dp数组全部元素之和

C++代码
cpp
class Solution
{
public:
int numberOfArithmeticSlices(vector<int>& nums)
{
int n = nums.size();
vector<int> dp(n);
int sum = 0;
for(int i = 2; i < n; i++)
{
dp[i] = nums[i] - nums[i - 1] == nums[i - 1] - nums[i - 2] ? dp[i - 1] + 1 : 0;
sum += dp[i];
}
return sum;
}
};
24. 最长湍流子数组(LeetCode 978 medium)
题目描述

提示:
1 <= arr.length <= 4 * 1040 <= arr[i] <= 109
算法思路
湍流需要记录结尾状态是上升/下降,双dp数组
- 状态表示
fi:以i结尾,最后一步是上升的最长湍流子数组长度
gi:以i结尾,最后一步是下降的最长湍流子数组长度
- 状态转移
arri > arri-1:fi = gi-1+1
arri < arri-1:gi = fi-1+1
arri==arri-1:不构成湍流,f,g都不变
-
初始化:f、g数组全部初始化为1(单个元素自身长度为1)
-
填表顺序:从左往右,两表一起填
-
返回值:两个dp表里面的最大值

C++代码
cpp
class Solution
{
public:
int maxTurbulenceSize(vector<int>& arr)
{
int n = arr.size();
vector<int> f(n, 1), g(n, 1);
int ret = 1;
for(int i = 1; i < n; i++)
{
if(arr[i - 1] < arr[i])
f[i] = g[i - 1] + 1;
else if(arr[i - 1] > arr[i])
g[i] = f[i - 1] + 1;
ret = max(ret, max(f[i], g[i]));
}
return ret;
}
};
25. 单词拆分(LeetCode 139 medium)
题目描述

算法思路
- 状态表示
对于线性 dp,我们可以用「经验 + 题目要求」来定义状态表示:
i. 以某个位置为结尾,巴拉巴拉;
ii. 以某个位置为起点,巴拉巴拉。
这里我们选择比较常用的方式,以某个位置为结尾,结合题目要求,定义一个状态表示:
dpi 表示:0,i 区间内的字符串,能否被字典中的单词拼接而成。
- 状态转移方程
对于 dpi,为了确定当前的字符串能否由字典里面的单词构成,根据最后一个单词的起始位置 j,我们可以将其分解为前后两部分:
i. 前面一部分 0,j - 1 区间的字符串;
ii. 后面一部分 j,i 区间的字符串。
其中前面部分我们可以在 dpj - 1 中找到答案,后面部分的子串可以在字典里面找到。因此,我们得出一个结论:当我们在从 0 ~ i 枚举 j 的时候,只要 dpj - 1 = true 并且后面部分的子串 s.substr(j, i - j + 1) 能够在字典中找到,那么 dpi = true。
- 初始化
可以在最前面加上一个「辅助结点」,帮助我们初始化。使用这种技巧要注意两个点:
i. 辅助结点里面的值要「保证后续填表是正确的」;
ii.「下标的映射关系」。
在本题中,最前面加上一个格子,并且让 dp0 = true,可以理解为空串能够拼接而成。
其中为了方便处理下标的映射关系,我们可以将字符串前面加上一个占位符 s = ' ' + s,这样就没有下标的映射关系的问题了,同时还能处理「空串」的情况。
- 填表顺序
填表顺序:从左往右。
- 返回值
由「状态表示」可得:返回 dpn 位置的布尔值。
- 哈希表优化的小细节
在状态转移中,我们需要判断后面部分的子串「是否在字典」之中,因此会「频繁的用到查询操作」。为了节省效率,我们可以提前把「字典中的单词」存入到「哈希表」中。

C++ 算法代码
cpp
class Solution
{
public:
bool wordBreak(string s, vector<string>& wordDict)
{
// 优化一:将字典里面的单词存在哈希表里面
unordered_set<string> hash;
for(auto& s : wordDict) hash.insert(s);
int n = s.size();
vector<bool> dp(n + 1);
dp[0] = true; // 保证后续填表是正确的
s = ' ' + s; // 使原始字符串的下标统一+1
for(int i = 1; i <= n; i++) // 填 dp[i]
{
for(int j = i; j >= 1; j--) // 最后一个单词的起始位置
{
if(dp[j - 1] && hash.count(s.substr(j, i - j + 1)))
{
dp[i] = true;
break; // 优化二
}
}
}
return dp[n];
}
};
✅ 知识点整理
- DP状态定义
dpi:字符串前i个字符是否可以被字典单词拼接成功。dp0=true代表空串合法,是经典辅助初始位技巧。
- 状态转移
遍历分割点j:如果dpj-1为true,并且子串sj\~i存在于字典 → dpi=true
含义:前面j-1长度可以拼出来,j~i这一段是字典单词,那么前i个字符就可以拼出。
- 哈希优化
unordered_set存储字典单词,子串查找从O(k)变为O(1),加快查询速度。
- 下标处理技巧
s = " " + s 把原字符串下标整体后移一位,dp数组下标和字符串长度对齐,规避边界判断的麻烦。
-
填表顺序:从左向右,因为求dpi依赖前面dp0~dpi-1的结果。
-
时间复杂度:O(n^2),n是字符串长度;两层循环+substr截取子串。空间复杂度:O(n),dp数组+哈希集合。
-
优化点:找到一种合法分割就直接break,不用继续枚举j。
26. 环绕字符串中唯一的子字符串(LeetCode 467 medium)
题目描述

算法思路
- 状态表示
dpi:以i位置字符结尾,在base中出现的连续子串长度。
- 状态转移
长度=1,一定合法
长度>1:判断是否连续 si-1+1 == si 或者 si-1='z' && si='a'
满足:dpi = dpi-1+1;否则dpi=1
重点:去重! 相同字符结尾,只保留最大长度,放到hash26数组,最后累加总和
-
初始化:dp全部初始化为1
-
填表顺序:从左往右
-
返回值:hash数组所有元素累加和

C++代码
cpp
class Solution
{
public:
int findSubstringInWraproundString(string s)
{
int n = s.size();
vector<int> dp(n, 1);
for(int i = 1; i < n; i++)
{
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++)
{
hash[s[i] - 'a'] = max(hash[s[i] - 'a'], dp[i]);
}
int sum = 0;
for(auto x : hash) sum += x;
return sum;
}
};
📚 统一知识点总结
一、通用解题模板:线性DP(以结尾型)
适用于:子数组、子串、连续区间类题目
- 状态定义套路:dpi 表示以第i个元素结尾的满足条件的量。
✅ 好处:子数组具有连续性,新增元素只需要和前一个状态推导。
- 分类讨论:
子数组只有当前一个元素
子数组长度>1,接上前面以i-1结尾的子数组
-
初始化技巧:增加辅助结点 dp0,偏移下标,避免i=1的时候越界;辅助结点的值要保证转移方程计算正确。
-
填表顺序固定:从左往右(dpi依赖dpi-1)
-
返回值分三类
求全局最优:返回dp数组最大值(最大子数组和、乘积最大、最长湍流)
求全部方案数量:返回dp数组累加和(等差数列划分)
求n位置状态:返回dpn(单词拆分)
二、什么时候需要双dp数组(f,g)
当单一状态无法记录足够信息时,需要两个dp数组保存两种状态:
-
乘积最大子数组:要同时保存最大乘积、最小乘积(负数相乘反转大小)
-
乘积为正数最长子数组:保存正乘积长度、负乘积长度
-
最长湍流子数组:保存上升结尾长度、下降结尾长度
核心思想:一个状态的结果会因为正负/方向翻转,所以同时维护两种极值/状态。
易错点汇总
-
子数组必须连续!和子序列区分开
-
乘积类题目,负数会翻转最值,只用一个dp数组一定错
-
环形子数组存在边界特例:数组全负数,不能用sum-gmin
-
计数类题目(等差数列划分):dp存以i结尾的数量,答案是总和,不是最大值
-
环绕字符串题目,存在大量重复子串,必须去重,不能直接累加dp数组