动态规划:线性DP——多状态DP与子数组专题笔记

动态规划算法------简单多状态dp问题

统一解题模板:线性DP五步法

  1. 状态表示:以i位置结尾,拆分多种状态(选/不选、持有/不持有、不同颜色)

  2. 状态转移方程:根据上一步所有合法状态推导,取最值

  3. 初始化:处理边界,设置基础值;不存在的状态初始化为负无穷

  4. 填表顺序:从左往右,多个状态数组同步填写

  5. 返回值:最后位置所有合法状态的最大值/最小值

11. 按摩师(面试题 17.16 easy)

题目描述

算法思路

  1. 状态表示

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

i. 以某个位置为结尾 ii. 以某个位置为起点

这里选择以某个位置结尾,拆分两种抉择:

fi:选择到 i 位置时,numsi 必选,此时的最长预约时长;

gi:选择到 i 位置时,numsi 不选,此时的最长预约时长。

  1. 状态转移方程

• 对于fi:如果numsi必选,那么i-1位置一定不能选,取i-1不选的状态加上当前值。

fi = gi - 1 + numsi

• 对于gi:如果numsi不选,i-1可选可不选,取两者最大值。

gi = max(fi - 1, gi - 1)

  1. 初始化

无需辅助节点,初始化:f0 = nums0,g0 = 0。

  1. 填表顺序

从左往右,两个数组一起填。

  1. 返回值

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)

题目描述

算法思路

  1. 状态表示

dpi0:粉刷到i位置,最后房子刷红色,最小花费;

dpi1:粉刷到i位置,最后房子刷蓝色,最小花费;

dpi2:粉刷到i位置,最后房子刷绿色,最小花费。

  1. 状态转移方程

相邻房子颜色不能相同

dpi0 = min(dpi-11, dpi-12) + costsi-10

dpi1 = min(dpi-10, dpi-12) + costsi-11

dpi2 = min(dpi-10, dpi-11) + costsi-12

  1. 初始化

增加辅助结点,初始值全部为0。

  1. 填表顺序

从左往右,三个状态一起填。

  1. 返回值

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)

题目描述

算法思路

  1. 状态表示

dpi0:第i天结束,持有股票(买入状态),最大利润

dpi1:第i天结束,无股票、可交易,最大利润

dpi2:第i天结束,无股票、冷冻期,最大利润

  1. 状态转移方程

dpi0 = max(dpi-10, dpi-11 - pricesi)

①前一天就持有;②前一天可交易,今天买入

dpi1 = max(dpi-11, dpi-12)

①前一天可交易;②前一天冷冻期,今天解冻

dpi2 = dpi-10 + pricesi

只能是前一天持有,今天卖出

  1. 初始化

dp00 = -prices0;

dp01 = 0;

dp02 = 0;

  1. 填表顺序:从左向右,三个状态一起填

  2. 返回值: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)

题目描述

算法思路

  1. 状态表示

fi:第i天结束,持有股票(买入状态),最大利润

gi:第i天结束,不持有股票(卖出状态),最大利润

  1. 状态转移方程

手续费在卖出时扣除

fi = max(fi-1, gi-1 - pricesi)

①前一天持有;②前一天无股票,今天买入

gi = max(gi-1, fi-1 + pricesi - fee)

①前一天无股票;②前一天持有,今天卖出,减去手续费

  1. 初始化

f0 = -prices0,g0 = 0

  1. 填表顺序:从左向右,两个状态一起填

  2. 返回值: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 <= 105
  • 0 <= 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)

  1. 足够大,但加起来不会立刻溢出

INT_MAX + 正数 直接整型溢出 → 负数,bug。

0x3f3f3f3f + 0x3f3f3f3f = 2122219134,仍然小于 int 上限 2147483647

最常用在最短路、DP:distv = min(distv, distu+w),两个无穷相加不会爆int

  1. 方便批量初始化数组

memset(dp, 0x3f, sizeof(dp)); memset是按字节赋值,1字节=8bit。

int占4字节,每个字节填 0x3f,4个字节拼起来就是 0x3f3f3f3f。

一句话:一行memset就能把整个数组全部设成无穷大。

如果用INT_MAX,memset没法一次性设置。

  1. 不会和合法数值冲突

大部分题目数据范围远小于 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;
    }
};

核心知识点总结

  1. 基础模型:打家劫舍(选/不选二状态DP)

状态:fi必选i;gi不选i

转移:fi = gi-1+vali,gi=max(fi-1,gi-1)

变种:

环形(打家劫舍II):拆成两次线性DP,分别去掉首/尾

删除并获得点数:数值映射数组,转化打家劫舍

  1. 多状态DP(多个并行状态)

粉刷房子:3种颜色,相邻不能相同,求最小花费;状态是3个

股票系列:

基础2状态:持有 / 不持有

冷冻期:增加冷冻期状态,一共3种

带交易次数限制:增加一维j代表交易次数,三维DP(天数,次数,持有状态)

  1. 通用解题套路(五步法万能模板)
  1. 状态定义:以i结尾,拆分所有互斥的状态(选/不选、持有/无、颜色)

  2. 转移方程:分析当前状态可以从前面哪些合法状态转移而来,取max/min

  3. 初始化:

合法基础状态赋值;不可能的状态赋值负无穷(求最大值时)

辅助结点技巧:在最前面虚拟下标简化边界判断

  1. 填表顺序:从左往右,多个状态数组同步循环更新

  2. 返回结果:最后位置所有合法状态的最值

  1. 易错点
  1. 环形DP不能直接一次性计算,首尾互斥,必须拆分两次线性DP取max

  2. 股票DP卖出才增加交易次数;买入不增加次数

  3. 负无穷0x3f3f3f3f,防止INT_MIN加法溢出

  4. 下标映射:题目数组下标和dp表下标要区分,不要错位



动态规划算法------子数组系列

核心模板:线性DP五步法

  1. 状态表示:一般定义 dpi 表示以i位置结尾的目标量(最常用)

  2. 状态转移方程:根据最近一步划分情况推导

  3. 初始化:增设辅助结点,处理下标越界,保证填表逻辑正确

  4. 填表顺序:从左往右(依赖前面状态)

  5. 返回值:看题目,可能返回dp数组最大值 / dpn / dp数组总和

19. 最大子数组和(LeetCode 53 medium)

题目描述

算法思路

  1. 状态表示

dpi:以 i 位置元素结尾的所有子数组中和的最大值。

  1. 状态转移方程

子数组分两种情况:

长度为1:dpi = numsi

长度>1:dpi = dpi-1 + numsi

dpi = max(numsi, dpi-1+numsi)

  1. 初始化

前面增加辅助结点,dp0=0。

  1. 填表顺序:从左往右

  2. 返回值: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

  1. 状态表示

gi:以 i 做结尾的所有子数组中和的最小值。

  1. 状态转移方程

gi = min(numsi, gi-1+numsi)

  1. 初始化:辅助结点 g0=0

  2. 填表顺序:从左往右

  3. 返回值: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结尾最大乘积、最小乘积」

  1. 状态表示

fi:以i结尾的所有子数组的最大乘积

gi:以i结尾的所有子数组的最小乘积

  1. 状态转移方程

fi = max(numsi, max(numsi*fi-1, numsi*gi-1))

gi = min(numsi, min(numsi*fi-1, numsi*gi-1))

  1. 初始化:辅助结点 f0=g0=1

  2. 填表顺序:从左往右,两个数组一起填

  3. 返回值: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)

题目描述

算法思路

  1. 状态表示

fi:以i结尾,乘积为正数的最长子数组长度

gi:以i结尾,乘积为负数的最长子数组长度

  1. 状态转移

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

  1. 初始化:f0=g0=0

  2. 填表顺序:从左往右,两表一起填

  3. 返回值: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)

题目描述

算法思路

  1. 状态表示

dpi:必须以i位置元素结尾的等差数列有多少种。

  1. 状态转移方程

判断 numsi-2, numsi-1, numsi 是否等差:

不是等差数列:dpi=0

是等差数列:dpi = dpi-1 + 1

  1. 初始化:dp0=0,dp1=0(前两个元素无法构成等差数列)

  2. 填表顺序:从左往右

  3. 返回值: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 * 104
  • 0 <= arr[i] <= 109

算法思路

湍流需要记录结尾状态是上升/下降,双dp数组

  1. 状态表示

fi:以i结尾,最后一步是上升的最长湍流子数组长度

gi:以i结尾,最后一步是下降的最长湍流子数组长度

  1. 状态转移

arri > arri-1:fi = gi-1+1

arri < arri-1:gi = fi-1+1

arri==arri-1:不构成湍流,f,g都不变

  1. 初始化:f、g数组全部初始化为1(单个元素自身长度为1)

  2. 填表顺序:从左往右,两表一起填

  3. 返回值:两个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)

题目描述

算法思路

  1. 状态表示

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

i. 以某个位置为结尾,巴拉巴拉;

ii. 以某个位置为起点,巴拉巴拉。

这里我们选择比较常用的方式,以某个位置为结尾,结合题目要求,定义一个状态表示:

dpi 表示:0,i 区间内的字符串,能否被字典中的单词拼接而成。

  1. 状态转移方程

对于 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。

  1. 初始化

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

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

ii.「下标的映射关系」。

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

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

  1. 填表顺序

填表顺序:从左往右。

  1. 返回值

由「状态表示」可得:返回 dpn 位置的布尔值。

  1. 哈希表优化的小细节

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

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

✅ 知识点整理

  1. DP状态定义

dpi:字符串前i个字符是否可以被字典单词拼接成功。dp0=true代表空串合法,是经典辅助初始位技巧。

  1. 状态转移

遍历分割点j:如果dpj-1为true,并且子串sj\~i存在于字典 → dpi=true

含义:前面j-1长度可以拼出来,j~i这一段是字典单词,那么前i个字符就可以拼出。

  1. 哈希优化

unordered_set存储字典单词,子串查找从O(k)变为O(1),加快查询速度。

  1. 下标处理技巧

s = " " + s 把原字符串下标整体后移一位,dp数组下标和字符串长度对齐,规避边界判断的麻烦。

  1. 填表顺序:从左向右,因为求dpi依赖前面dp0~dpi-1的结果。

  2. 时间复杂度:O(n^2),n是字符串长度;两层循环+substr截取子串。空间复杂度:O(n),dp数组+哈希集合。

  3. 优化点:找到一种合法分割就直接break,不用继续枚举j。

26. 环绕字符串中唯一的子字符串(LeetCode 467 medium)

题目描述

算法思路

  1. 状态表示

dpi:以i位置字符结尾,在base中出现的连续子串长度。

  1. 状态转移

长度=1,一定合法

长度>1:判断是否连续 si-1+1 == si 或者 si-1='z' && si='a'

满足:dpi = dpi-1+1;否则dpi=1

重点:去重! 相同字符结尾,只保留最大长度,放到hash26数组,最后累加总和

  1. 初始化:dp全部初始化为1

  2. 填表顺序:从左往右

  3. 返回值: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(以结尾型)

适用于:子数组、子串、连续区间类题目

  1. 状态定义套路:dpi 表示以第i个元素结尾的满足条件的量。

✅ 好处:子数组具有连续性,新增元素只需要和前一个状态推导。

  1. 分类讨论:

子数组只有当前一个元素

子数组长度>1,接上前面以i-1结尾的子数组

  1. 初始化技巧:增加辅助结点 dp0,偏移下标,避免i=1的时候越界;辅助结点的值要保证转移方程计算正确。

  2. 填表顺序固定:从左往右(dpi依赖dpi-1)

  3. 返回值分三类

求全局最优:返回dp数组最大值(最大子数组和、乘积最大、最长湍流)

求全部方案数量:返回dp数组累加和(等差数列划分)

求n位置状态:返回dpn(单词拆分)

二、什么时候需要双dp数组(f,g)

当单一状态无法记录足够信息时,需要两个dp数组保存两种状态:

  1. 乘积最大子数组:要同时保存最大乘积、最小乘积(负数相乘反转大小)

  2. 乘积为正数最长子数组:保存正乘积长度、负乘积长度

  3. 最长湍流子数组:保存上升结尾长度、下降结尾长度

核心思想:一个状态的结果会因为正负/方向翻转,所以同时维护两种极值/状态。

易错点汇总

  1. 子数组必须连续!和子序列区分开

  2. 乘积类题目,负数会翻转最值,只用一个dp数组一定错

  3. 环形子数组存在边界特例:数组全负数,不能用sum-gmin

  4. 计数类题目(等差数列划分):dp存以i结尾的数量,答案是总和,不是最大值

  5. 环绕字符串题目,存在大量重复子串,必须去重,不能直接累加dp数组

相关推荐
跨知易知识产权1 小时前
申请过程中如果需要调整策略(如加快 / 延迟审查),流程管理可以同步调整排期吗?
笔记
霖霖7141 小时前
【ESP32笔记】蓝牙BLE连接与广播
笔记·单片机·mcu·物联网
Data analyse4561 小时前
rrweb回放事件资源加载顺序重放校验
前端·人工智能·算法·机器学习·数据分析
昨夜见军贴06161 小时前
原始采样记录数字化落地,IACheck AI 报告文档审核 Agent 实现监测文档校验自动化
人工智能·算法·自动化
额额额对了1 小时前
Linux 驱动学习笔记(部分)
linux·笔记·学习
微学AI1 小时前
从历史曲线到提前预警:TimechoAI 的时序预测与业务落地实践
人工智能·算法
不会就选b2 小时前
算法日常・每日刷题--<动态规划>26
算法·动态规划
霖霖7142 小时前
【ESP32笔记】SmartConfig一键配网
笔记·单片机·mcu·物联网
霖霖7142 小时前
【ESP32笔记】WIFI连接—STA模式
笔记·单片机·mcu·物联网