【算法】【学习笔记】动态规划
笔者又偷懒了。动态规划果然好难,难就难在不是看不懂,而是我怎么没想到!
真是讨厌啊。
动态规划(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}\):
- \(P - N = \text{target}\)
- \(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:
- \(k = 1\):
直接对arr跑一遍 Kadane 算法(DP),求单数组的最大连续子数组和max_sub_1。 - \(k \ge 2\) 且
total_sum <= 0:
中间再怎么叠加完整数组也只会拉低总和,所以最大子数组最多只会跨越 2 个 串联的arr。对arr + arr跑一遍 Kadane 算法得到max_sub_2即为答案。 - \(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. 交错字符串
解题思路
- 状态定义
dp[i][j]表示:s1的前 \(i\) 个字符 和s2的前 \(j\) 个字符,能否交错组成s3的前 \(i+j\) 个字符(true/false)。 - 转移方程
要让dp[i][j]为 true,最后一步(第 \(i+j\) 个字符)只有两种可能:- 匹配 s1 的第 \(i\) 个字符:必须满足
dp[i-1][j] == true且s1[i-1] == s3[i+j-1]。 - 匹配 s2 的第 \(j\) 个字符:必须满足
dp[i][j-1] == true且s2[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]) \] - 匹配 s1 的第 \(i\) 个字符:必须满足
- 边界条件长度校验:
- 如果
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\) 使上式为true,dp[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 辅助整理。

浙公网安备 33010602011771号