AIGC标识 【算法】【学习笔记】动态规划

笔者又偷懒了。动态规划果然好难,难就难在不是看不懂,而是我怎么没想到!
真是讨厌啊。

动态规划(Dynamic Programming,简称 DP)是一种通过将复杂问题拆解为重叠子问题,并通过表格记忆化避免重复计算,最终递推出全局解的算法思想。它的核心本质是“带记忆的递推”或“用空间换时间的暴力搜索优化”。


三大核心特点

  • 重叠子问题:在求解过程中,相同的子问题会被反复计算(不同于分治算法中彼此独立的子问题)。DP 专门开辟空间将子问题答案存起来,后续直接查表使用。
  • 最优子结构:大问题的最优解可以由子问题的最优解组合推导出。如果子问题的解不是最优的,就无法凑出整体的最优解。
  • 无后效性:某个状态的值一旦确定,就不再受后续决策的影响;未来的决策只依赖于当前的状态,而不关心这个状态过去是怎么推导出来的。

解题通用技巧与步骤

  • 明确状态定义(DP 数组含义):想清楚 dp[i]dp[i][j] 具体物理/数学含义(如 dp[i] 表示“达到第 i 个台阶的最少步数”)。
  • 推导状态转移方程:寻找当前状态与历史状态的数学关系,这是 DP 的灵魂(如 dp[i] = max(dp[i-1], dp[i-2] + weight[i]))。
  • 初始化与确定遍历顺序:确定边界条件(如 dp[0] = 0),并根据状态依赖关系决定是正向遍历(自底向上)、反向遍历(如 0-1 背包优化空间时),还是二维矩阵的对角线遍历。
  • 空间压缩(滚动数组技巧):若 dp[i] 只与上一次的结果 dp[i-1] 有关,可将 \(O(N^2)\)\(O(N)\) 的空间复杂度压缩至 \(O(N)\)\(O(1)\)
  • 记忆化搜索(自顶向下):如果直觉上写不出自底向上的循环递推,可以先写递归,再加上一个缓存数组/哈希表(Memoization),代码逻辑等价于 DP。

主要解决的问题类型

题型分类 目标与特征 经典例题
最值问题 (Optimization) 求解最大收益、最小成本、最长长度等 0-1 背包、最长递增子序列 (LIS)、最长公共子序列 (LCS)、编辑距离
计数问题 (Counting) 计算达到目标的方案数、路径数 不同路径 、零钱兑换 II (求组合数)
存在性/可行性 (Feasibility) 判断能否达到特定状态 分割等额子集、单词拆分
区间/博弈问题 状态由区间 \([i, j]\) 决定,按区间长度扩展 石子合并、最长回文子串、预测赢家

❔题目

1. 爬楼梯

解题思路

  • 状态定义:dp[i] 表示到达第 \(i\) 阶台阶的方法数。
  • 状态转移方程\(dp[i] = dp[i-1] + dp[i-2]\)
    要迈到第 \(i\) 阶,最后一步只有两种可能:从第 \(i-1\) 阶跨 1 步,或者从第 \(i-2\) 阶跨 2 步。这两部分的走法互不重叠,直接相加即可(本质就是斐波那契数列)。
  • 初始状态:dp[1] = 1, dp[2] = 2。
  • 空间优化(滚动变量):因为计算 dp[i] 只依赖前面最近的两个状态,完全不需要开辟长度为 \(N\) 的数组,用两个变量动态滚动保存 \(O(1)\) 空间即可。

实现

function climbStairs(n: number): number {
    if (n <= 2) return n;

    let prev2 = 1; // 代表 dp[i-2]
    let prev1 = 2; // 代表 dp[i-1]

    for (let i = 3; i <= n; i++) {
        const curr = prev1 + prev2;
        prev2 = prev1;
        prev1 = curr;
    }

    return prev1;
}
public class Solution {
    public int ClimbStairs(int n) {
        if (n <= 2) return n;

        int prev2 = 1; // 代表 dp[i-2]
        int prev1 = 2; // 代表 dp[i-1]

        for (int i = 3; i <= n; i++) {
            int curr = prev1 + prev2;
            prev2 = prev1;
            prev1 = curr;
        }

        return prev1;
    }
}
class Solution {
public:
    int climbStairs(int n) {
        if (n <= 2) return n;
        
        int prev2 = 1; // 代表 dp[i-2]
        int prev1 = 2; // 代表 dp[i-1]
        
        for (int i = 3; i <= n; ++i) {
            int curr = prev1 + prev2; // dp[i] = dp[i-1] + dp[i-2]
            prev2 = prev1;
            prev1 = curr;
        }
        
        return prev1;
    }
};

复杂度分析

  • 时间复杂度\(O(N)\)
  • 空间复杂度\(O(1)\)

2. 目标和

解题思路

用动态规划的话,首先要找:状态的定义、转移方程、初始条件和边界条件。

先用数学知识转化问题

假设所有添加 + 号的数字之和为 \(P\),添加 - 号的数字之和为 \(N\),数组元素总和为 \(\text{sum}\)

  1. \(P - N = \text{target}\)
  2. \(P + N = \text{sum}\)

两式相加得到:\(2P = \text{target} + \text{sum} \implies P = \frac{\text{target} + \text{sum}}{2}\)

此时,原问题直接等价于:从 nums 中挑选若干个元素,使得它们的和恰好等于容量 \(P\) 的组合方案数。

然后,就可以分析动态规划该怎么做了

  • 剪枝与边界:如果 \(\text{target} + \text{sum} < 0\)\((\text{target} + \text{sum})\) 是奇数,不可能整除,直接返回 0。如果 \(\text{abs}(\text{target}) > \text{sum}\),也无法达成,返回 0。
  • 状态定义:dp[j] 表示填满容量为 \(j\) 的背包共有多少种方法。
  • 转移方程:dp[j] = dp[j] + dp[j - num](不选当前数的方法数 + 选当前数的方法数)。
  • 初始条件:dp[0] = 1(容量为 0 时只有“什么都不选”这 1 种方案),其余全为 0。

如何获得转移方程
第一步:明确 dp[j] 的物理含义
定义 dp[j] 表示:从可选的数字中挑选若干个,凑成总和恰好等于 \(j\) 的方案总数。
第二步:对当前数字 num 做二选一决策
假设我们手里多了一个数字 num,要凑出目标和 \(j\),只有两种完全互斥的情况:

  • 情况 1:不选 num不用这个 num,全靠之前的数字来凑成总和 \(j\)
    - 方案数等于:没有考虑 num 之前的 dp[j](即旧的 dp[j])。
  • 情况 2:选择 num既然选了 num,它就贡献了 num 的数值。剩下的数值 \(j - \text{num}\) 必须由之前的数字来凑齐。
    • 方案数等于:之前凑出 \(j - \text{num}\) 的方案数,即 dp[j - num]

第三步:应用加法原理合并方程
由于“选”和“不选”是两种互斥的合法途径,把这两部分的方案数加起来,就是考虑完 num 之后凑出 \(j\) 的总方案数:

\[\text{新的 } dp[j] = \underbrace{\text{旧的 } dp[j]}_{\text{不选 } num \text{ 的方案数}} + \underbrace{dp[j - \text{num}]}_{\text{选 } num \text{ 的方案数}} \]

写成一维数组原地更新的代码形式就是:dp[j] += dp[j - num]

实现

function findTargetSumWays(nums: number[], target: number): number {
    const sum = nums.reduce((acc, curr) => acc + curr, 0);

    if (Math.abs(target) > sum || (target + sum) % 2 !== 0) return 0;

    const capacity = (target + sum) / 2;
    const dp = new Array(capacity + 1).fill(0);
    dp[0] = 1;

    for (const num of nums) {
        for (let j = capacity; j >= num; j--) {
            dp[j] += dp[j - num];
        }
    }

    return dp[capacity];
}
public class Solution {
    public int FindTargetSumWays(int[] nums, int target) {
        int sum = 0;
        foreach (int num in nums) sum += num;

        if (Math.Abs(target) > sum || (target + sum) % 2 != 0) return 0;

        int capacity = (target + sum) / 2;
        int[] dp = new int[capacity + 1];
        dp[0] = 1;

        foreach (int num in nums) {
            for (int j = capacity; j >= num; j--) {
                dp[j] += dp[j - num];
            }
        }

        return dp[capacity];
    }
}
class Solution {
public:
    int findTargetSumWays(vector<int>& nums, int target) {
        int sum = 0;
        for (int num : nums) sum += num;

        // 无法凑齐的无解情况
        if (abs(target) > sum || (target + sum) % 2 != 0) return 0;

        int capacity = (target + sum) / 2;
        vector<int> dp(capacity + 1, 0);
        dp[0] = 1; // 初始条件

        for (int num : nums) {
            // 0-1 背包倒序遍历,防止重复使用同一个数字
            for (int j = capacity; j >= num; --j) {
                dp[j] += dp[j - num];
            }
        }

        return dp[capacity];
    }
};

复杂度分析

  • 时间复杂度\(O(N \times \text{capacity})\),其中 \(N\) 为数组长度,\(\text{capacity} \le \text{sum}\)
  • 空间复杂度\(O(\text{capacity})\)
    通过倒序遍历只使用了一维 DP 数组。

3. 打家劫舍

解题思路

dp[i] 存储的就是前 \(i\) 间房屋能偷到的最高总金额。

  • 状态定义:dp[i] 表示考虑前 \(i\) 间房屋时,能够偷到的最大金额。
  • 状态转移方程:对于第 \(i\) 间房屋(金额为 nums[i]),面临选择:
    • 偷第 \(i\) 间:不能偷第 \(i-1\) 间,收益等于“前 \(i-2\) 间最大金额” + “当前房屋金额”,即 dp[i-2] + nums[i]。
    • 不偷第 \(i\) 间:收益直接延续前 \(i-1\) 间的最大金额,即 dp[i-1]。
    • 两者取最大值:dp[i] = max(dp[i-1], dp[i-2] + nums[i])
  • 初始条件:
    • dp[0] = nums[0](只有 1 间房,必偷)
    • dp[1] = max(nums[0], nums[1])(有 2 间房,选金额大的偷)
  • 空间优化:dp[i] 只依赖 dp[i-1] 和 dp[i-2] 两个状态,可以用两个滚动变量 prev1 和 prev2 将空间优化至 \(O(1)\)

实现

function rob(nums: number[]): number {
    const n = nums.length;
    if (n === 0) return 0;
    if (n === 1) return nums[0];

    let prev2 = nums[0];
    let prev1 = Math.max(nums[0], nums[1]);

    for (let i = 2; i < n; i++) {
        const curr = Math.max(prev1, prev2 + nums[i]);
        prev2 = prev1;
        prev1 = curr;
    }

    return prev1;
}
public class Solution {
    public int Rob(int[] nums) {
        int n = nums.Length;
        if (n == 0) return 0;
        if (n == 1) return nums[0];

        int prev2 = nums[0];
        int prev1 = Math.Max(nums[0], nums[1]);

        for (int i = 2; i < n; i++) {
            int curr = Math.Max(prev1, prev2 + nums[i]);
            prev2 = prev1;
            prev1 = curr;
        }

        return prev1;
    }
}
class Solution {
public:
    int rob(vector<int>& nums) {
        int n = nums.size();
        if (n == 0) return 0;
        if (n == 1) return nums[0];

        // 滚动变量分别代表 dp[i-2] 和 dp[i-1]
        int prev2 = nums[0];
        int prev1 = max(nums[0], nums[1]);

        for (int i = 2; i < n; ++i) {
            // 要么偷了上一个房间,要么偷这个和上上个房间
            int curr = max(prev1, prev2 + nums[i]); // dp[i] = max(dp[i-1], dp[i-2] + nums[i])
            prev2 = prev1;
            prev1 = curr;
        }

        return prev1;
    }
};

复杂度分析

  • 时间复杂度\(O(N)\)
  • 空间复杂度\(O(1)\)

4. K 次串联后最大子数组之和

解题思路

原始思路:
计算好前缀和,然后*上除数+余数对应的前缀和。

不过这个误会了题目的意思,题目是说拼接后,找一个子数组,和最大。

然后,因为数组可能包含负数,最大连续子数组可能出现在任何位置:

  • 可能只存在于某单个数组内部(如 [1, -10, 3, 2] 中的 [3, 2]);
  • 可能跨越两个相邻数组(前一个数组的“最大后缀” + 后一个数组的“最大前缀”);
  • ⭐只有当整个数组的总和大于 0 时,中间重复的那些完整数组才会带来正收益。

使用Kadane 算法(最大子数组和的 DP)+ 简单的数学分类讨论方法。

假设原数组 arr 的总和为 total_sum:

  1. \(k = 1\)
    直接对 arr 跑一遍 Kadane 算法(DP),求单数组的最大连续子数组和 max_sub_1
  2. \(k \ge 2\)total_sum <= 0
    中间再怎么叠加完整数组也只会拉低总和,所以最大子数组最多只会跨越 2 个 串联的 arr。对 arr + arr 跑一遍 Kadane 算法得到 max_sub_2 即为答案。
  3. \(k \ge 2\) 且 total_sum > 0:
    中间的 \(k - 2\) 个完整数组全是“净收益”,必须全吃掉。
    • 最大和 = max_sub_2 + (k - 2) * total_sum

dp的核心要点:
如果前面的累加和(curr_sum)是正数:说明前面的子数组能带来“正收益”,继续把当前数字 x 加进去(curr_sum = curr_sum + x)。
如果前面的累加和已经变成了负数:说明前面的子数组完全是“拖后腿”的负资产,果断抛弃前面的所有元素,把当前数字 x 作为新子数组的起点(curr_sum = x)。

实现

function kConcatenationMaxSum(arr: number[], k: number): number {
    const MOD = 1000000007n;
    let totalSum = 0n;
    for (const x of arr) totalSum += BigInt(x);

    // Kadane 算法 (DP):求 count 次串联的最大连续子数组和
    const getMaxSubarray = (count: number): bigint => {
        let maxSum = 0n;
        let currSum = 0n;
        for (let i = 0; i < count; i++) {
            for (const x of arr) {
                const val = BigInt(x);
                currSum = currSum + val > val ? currSum + val : val;
                if (currSum > maxSum) maxSum = currSum;
            }
        }
        return maxSum;
    };

    if (k === 1) {
        return Number(getMaxSubarray(1) % MOD);
    }

    const maxSub2 = getMaxSubarray(2);
    if (totalSum > 0n) {
        return Number((maxSub2 + BigInt(k - 2) * totalSum) % MOD);
    } else {
        return Number(maxSub2 % MOD);
    }
}
public class Solution {
    public int KConcatenationMaxSum(int[] arr, int k) {
        long MOD = 1000000007;
        long totalSum = 0;
        foreach (int x in arr) totalSum += x;

        // Kadane 算法 (DP)
        long GetMaxSubarray(int count) {
            long maxSum = 0, currSum = 0;
            for (int i = 0; i < count; i++) {
                foreach (int x in arr) {
                    currSum = Math.Max((long)x, currSum + x);
                    maxSum = Math.Max(maxSum, currSum);
                }
            }
            return maxSum;
        }

        if (k == 1) {
            return (int)(GetMaxSubarray(1) % MOD);
        }

        long maxSub2 = GetMaxSubarray(2);
        if (totalSum > 0) {
            return (int)((maxSub2 + (k - 2) * totalSum) % MOD);
        } else {
            return (int)(maxSub2 % MOD);
        }
    }
}
class Solution {
public:
    int kConcatenationMaxSum(vector<int>& arr, int k) {
        int MOD = 1e9 + 7;
        long long total_sum = 0;
        for (int x : arr) total_sum += x;

        // Kadane 算法 (DP):求 1 次或 2 次串联的最大连续子数组和
        auto getMaxSubarray = [&](int count) {
            long long max_sum = 0, curr_sum = 0;
            for (int i = 0; i < count; ++i) {
                for (int x : arr) {
                    curr_sum = max((long long)x, curr_sum + x);
                    max_sum = max(max_sum, curr_sum);
                }
            }
            return max_sum;
        };

        if (k == 1) {
            return getMaxSubarray(1) % MOD;
        }

        long long max_sub_2 = getMaxSubarray(2);
        if (total_sum > 0) {
            return (max_sub_2 + (k - 2) * total_sum) % MOD;
        } else {
            return max_sub_2 % MOD;
        }
    }
};

复杂度分析

  • 时间复杂度\(O(N)\)
    无论 \(k\) 多大,最多只需扫描原数组 2 遍(\(2N\) 的长度)。
  • 空间复杂度\(O(1)\)
    仅用几个状态变量保存当前和与最大和。

5. 交错字符串

解题思路

  1. 状态定义
    dp[i][j] 表示:s1 的前 \(i\) 个字符 和 s2 的前 \(j\) 个字符,能否交错组成 s3 的前 \(i+j\) 个字符(true / false)。
  2. 转移方程
    要让 dp[i][j] 为 true,最后一步(第 \(i+j\) 个字符)只有两种可能:
    • 匹配 s1 的第 \(i\) 个字符:必须满足 dp[i-1][j] == trues1[i-1] == s3[i+j-1]
    • 匹配 s2 的第 \(j\) 个字符:必须满足 dp[i][j-1] == trues2[j-1] == s3[i+j-1]

    \[\text{dp}[i][j] = (\text{dp}[i-1][j] \text{ and } s1[i-1] == s3[i+j-1]) \;\mathbf{OR}\; (\text{dp}[i][j-1] \text{ and } s2[j-1] == s3[i+j-1]) \]

  3. 边界条件长度校验:
    • 如果 s1.length + s2.length != s3.length,直接返回 false
    • dp[0][0] = true(空串可以组成空串)。

实现

function isInterleave(s1: string, s2: string, s3: string): boolean {
    const m = s1.length, n = s2.length;
    if (m + n !== s3.length) return false;

    const dp: boolean[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(false));
    dp[0][0] = true;

    // 初始化最开始由s1组成的部位
    for (let i = 1; i <= m; i++) {
        dp[i][0] = dp[i - 1][0] && s1[i - 1] === s3[i - 1];
    }
    // 初始化最开始由s2组成的部分
    for (let j = 1; j <= n; j++) {
        dp[0][j] = dp[0][j - 1] && s2[j - 1] === s3[j - 1];
    }

    // 判断s3当前是否由s1和s2组成
    for (let i = 1; i <= m; i++) {
        for (let j = 1; j <= n; j++) {
            const fromS1 = dp[i - 1][j] && s1[i - 1] === s3[i + j - 1];
            const fromS2 = dp[i][j - 1] && s2[j - 1] === s3[i + j - 1];
            dp[i][j] = fromS1 || fromS2;
        }
    }

    return dp[m][n];
}
public class Solution {
    public bool IsInterleave(string s1, string s2, string s3) {
        int m = s1.Length, n = s2.Length;
        if (m + n != s3.Length) return false;

        bool[,] dp = new bool[m + 1, n + 1];
        dp[0, 0] = true;

        for (int i = 1; i <= m; i++) {
            dp[i, 0] = dp[i - 1, 0] && (s1[i - 1] == s3[i - 1]);
        }
        for (int j = 1; j <= n; j++) {
            dp[0, j] = dp[0, j - 1] && (s2[j - 1] == s3[j - 1]);
        }

        for (int i = 1; i <= m; i++) {
            for (int j = 1; j <= n; j++) {
                bool fromS1 = dp[i - 1, j] && (s1[i - 1] == s3[i + j - 1]);
                bool fromS2 = dp[i, j - 1] && (s2[j - 1] == s3[i + j - 1]);
                dp[i, j] = fromS1 || fromS2;
            }
        }

        return dp[m, n];
    }
}
class Solution {
public:
    bool isInterleave(string s1, string s2, string s3) {
        int m = s1.length(), n = s2.length();
        if (m + n != s3.length()) return false;

        vector<vector<bool>> dp(m + 1, vector<bool>(n + 1, false));
        dp[0][0] = true;

        // 初始化第一列(只使用 s1)
        for (int i = 1; i <= m; ++i) {
            dp[i][0] = dp[i - 1][0] && (s1[i - 1] == s3[i - 1]);
        }
        // 初始化第一行(只使用 s2)
        for (int j = 1; j <= n; ++j) {
            dp[0][j] = dp[0][j - 1] && (s2[j - 1] == s3[j - 1]);
        }

        for (int i = 1; i <= m; ++i) {
            for (int j = 1; j <= n; ++j) {
                bool from_s1 = dp[i - 1][j] && (s1[i - 1] == s3[i + j - 1]);
                bool from_s2 = dp[i][j - 1] && (s2[j - 1] == s3[i + j - 1]);
                dp[i][j] = from_s1 || from_s2;
            }
        }

        return dp[m][n];
    }
};

复杂度分析

  • 时间复杂度\(O(MN)\)
  • 空间复杂度\(O(MN)\)

6. 单词拆分

解题思路

原始思路:
i是第一个单词,然后j是选择了哪个单词?!
哇塞,真的不会啊!!!

我们对问题可以想到切甘蔗思维把字符串切成两半
假设我们要看 s 的前 \(i\) 个字符能不能被拼出来,我们在中间位置 \(j\)\(0 \le j < i\))切一刀,分成两段:

  • 左半段 s[0...j-1]:长度为 \(j\) 的前缀。如果之前的计算已经证明它是合法的(即 dp[j] == true);
  • 右半段 s[j...i-1]:切下来的这段子串。只要它恰好是字典里的某个单词(查哈希表 wordSet);

那么,前 \(i\) 个字符组成的子串就是合法的(dp[i] = true)!

然后进行分析动态规划:

  • 状态定义
    dp[i] 表示字符串 s 的前 \(i\) 个字符(即 s[0...i-1])能否被字典里的单词拼出来(true / false)。
  • 转移方程
    对于每个 \(i\),枚举所有可能的切点 \(j\) (\(0 \le j < i\)):

    \[\text{dp}[i] = \text{dp}[j] \mathbf{\text{ AND }} (s[j...i-1] \in \text{wordSet}) \]

    只要找到一个合法切点 \(j\) 使上式为 truedp[i] 即可标记为 true 并提前跳出循环。
  • 初始条件
    dp[0] = true(空字符串默认可以被“拼接”)。
  • 最终答案dp[s.length]

需要默认的 true 才可以保证能正常找到一个个单词;
倒着来时,可以提前剪枝,从短到长开始搜索,当 i-j 的长度超过最长单词,说明不可能拼成,后续直接不查。
i代表的是当前被检查的字符串长度,然后j就代表着切割,在当前0~i的字符串长度中,是否可以切出一个单词。

实现

function wordBreak(s: string, wordDict: string[]): boolean {
    const wordSet = new Set<string>();
    let maxLen = 0;

    // 1. 预处理:存入 Set 并顺便算出 maxLen
    for (const w of wordDict) {
        wordSet.add(w);
        maxLen = Math.max(maxLen, w.length);
    }

    const n = s.length;
    const dp: boolean[] = new Array(n + 1).fill(false);
    dp[0] = true;

    for (let i = 1; i <= n; i++) {
        // 2. 倒序遍历 + maxLen 剪枝
        for (let j = i - 1; j >= 0; j--) {
            if (i - j > maxLen) break;

            if (dp[j] && wordSet.has(s.substring(j, i))) {
                dp[i] = true;
                break;
            }
        }
    }

    return dp[n];
}
public class Solution {
    public bool WordBreak(string s, IList<string> wordDict) {
        var wordSet = new HashSet<string>();
        int maxLen = 0;

        // 1. 预处理:存入 Set 并顺便算出 maxLen
        foreach (var w in wordDict) {
            wordSet.Add(w);
            maxLen = Math.Max(maxLen, w.Length);
        }

        int n = s.Length;
        bool[] dp = new bool[n + 1];
        dp[0] = true;

        for (int i = 1; i <= n; i++) {
            // 2. 倒序遍历 + maxLen 剪枝
            for (int j = i - 1; j >= 0; j--) {
                if (i - j > maxLen) break;

                if (dp[j] && wordSet.Contains(s.Substring(j, i - j))) {
                    dp[i] = true;
                    break;
                }
            }
        }

        return dp[n];
    }
}
class Solution {
public:
    bool wordBreak(string s, vector<string>& wordDict) {
        unordered_set<string> wordSet;
        int maxLen = 0;
        
        // 1. 预处理:存入 Set 并顺便算出 maxLen
        for (const string& w : wordDict) {
            wordSet.insert(w);
            maxLen = max(maxLen, (int)w.length());
        }

        int n = s.length();
        vector<bool> dp(n + 1, false);
        dp[0] = true;

        for (int i = 1; i <= n; ++i) {
            // 2. 倒序遍历 + maxLen 剪枝
            for (int j = i - 1; j >= 0; --j) {
                if (i - j > maxLen) break; // 超过最长单词长度,直接跳出
                
                if (dp[j] && wordSet.count(s.substr(j, i - j))) {
                    dp[i] = true;
                    break;
                }
            }
        }

        return dp[n];
    }
};

复杂度分析

  • 时间复杂度\(O(N \cdot L_{\max}^2)\)。,其中 \(N\) 是字符串 s 的长度,\(L\) 是子串截取与哈希匹配的时间。
    先获取字典中最长单词的长度 \(L_{\max}\),内层循环可将 \(j\) 的范围限制在 \([i - L_{\max}, i-1]\)
  • 空间复杂度\(O(N + M)\)\(N\) 为 DP 数组空间,\(M\) 为字典哈希表占用空间。

引用

[1] 力扣探索模式


注:本文为个人学习与刷题笔记,部分文本结构与排版格式由 AI 辅助整理。

posted @ 2026-09-02 22:02  SEHOD  阅读(12)  评论(0)    收藏  举报