文章目录
- 动态规划理论基础
-
- [1. 动态规划的基本概念](#1. 动态规划的基本概念)
-
- [1.1 基本术语](#1.1 基本术语)
- [1.2 动态规划的特点](#1.2 动态规划的特点)
- [1.3 动态规划的本质](#1.3 动态规划的本质)
- [2. 动态规划的适用条件](#2. 动态规划的适用条件)
-
- [2.1 最优子结构](#2.1 最优子结构)
- [2.2 重叠子问题](#2.2 重叠子问题)
- [2.3 无后效性](#2.3 无后效性)
- [3. 动态规划与贪心算法的区别](#3. 动态规划与贪心算法的区别)
-
- [3.1 核心区别](#3.1 核心区别)
- [3.2 如何选择](#3.2 如何选择)
- [4. 动态规划五部曲](#4. 动态规划五部曲)
-
- [4.1 第一步:确定dp数组(dp table)以及下标的含义](#4.1 第一步:确定dp数组(dp table)以及下标的含义)
- [4.2 第二步:确定递推公式](#4.2 第二步:确定递推公式)
- [4.3 第三步:dp数组如何初始化](#4.3 第三步:dp数组如何初始化)
- [4.4 第四步:确定遍历顺序](#4.4 第四步:确定遍历顺序)
- [4.5 第五步:举例推导dp数组](#4.5 第五步:举例推导dp数组)
- [5. 动态规划常见题型](#5. 动态规划常见题型)
-
- [5.1 基础DP问题](#5.1 基础DP问题)
- [5.2 二维DP问题](#5.2 二维DP问题)
- [5.3 背包问题](#5.3 背包问题)
- [5.4 股票问题](#5.4 股票问题)
- [5.5 子序列问题](#5.5 子序列问题)
- [5.6 打家劫舍问题](#5.6 打家劫舍问题)
- [6. 动态规划模板](#6. 动态规划模板)
-
- [6.1 一维DP模板](#6.1 一维DP模板)
- [6.2 二维DP模板](#6.2 二维DP模板)
- [6.3 01背包模板](#6.3 01背包模板)
- [6.4 完全背包模板](#6.4 完全背包模板)
- [7. 动态规划经典题目详解](#7. 动态规划经典题目详解)
-
- [7.1 斐波那契数(509)](#7.1 斐波那契数(509))
- [7.2 爬楼梯(70)](#7.2 爬楼梯(70))
- [7.3 使用最小花费爬楼梯(746)](#7.3 使用最小花费爬楼梯(746))
- [7.4 不同路径(62)](#7.4 不同路径(62))
- [7.5 分割等和子集(416)](#7.5 分割等和子集(416))
- [7.6 零钱兑换II(518)](#7.6 零钱兑换II(518))
- [7.7 打家劫舍(198)](#7.7 打家劫舍(198))
- [7.8 完全平方数(279)](#7.8 完全平方数(279))
- [7.9 零钱兑换(322)](#7.9 零钱兑换(322))
- [7.10 单词拆分(139)](#7.10 单词拆分(139))
- [7.11 最长递增子序列(300)](#7.11 最长递增子序列(300))
- [7.12 乘积最大子数组(152)](#7.12 乘积最大子数组(152))
- [7.13 最小路径和(64)](#7.13 最小路径和(64))
- [7.14 最长回文子串(5)](#7.14 最长回文子串(5))
- [7.15 最长公共子序列(1143)](#7.15 最长公共子序列(1143))
- [7.16 编辑距离(72)](#7.16 编辑距离(72))
- [8. 动态规划的时间复杂度](#8. 动态规划的时间复杂度)
-
- [8.1 时间复杂度分析](#8.1 时间复杂度分析)
- [8.2 空间复杂度分析](#8.2 空间复杂度分析)
- [9. 何时使用动态规划](#9. 何时使用动态规划)
-
- [9.1 使用动态规划的场景](#9.1 使用动态规划的场景)
- [9.2 判断标准](#9.2 判断标准)
- [9.3 动态规划的优缺点](#9.3 动态规划的优缺点)
- [10. 动态规划的优化技巧](#10. 动态规划的优化技巧)
-
- [10.1 空间优化](#10.1 空间优化)
- [10.2 状态压缩](#10.2 状态压缩)
- [10.3 记忆化搜索](#10.3 记忆化搜索)
- [11. 常见题型总结](#11. 常见题型总结)
-
- [11.1 基础DP类](#11.1 基础DP类)
- [11.2 二维DP类](#11.2 二维DP类)
- [11.3 背包问题类](#11.3 背包问题类)
- [11.4 股票问题类](#11.4 股票问题类)
- [11.5 子序列问题类](#11.5 子序列问题类)
- [11.6 打家劫舍问题类](#11.6 打家劫舍问题类)
- [12. 总结](#12. 总结)
动态规划理论基础
1. 动态规划的基本概念
**动态规划(Dynamic Programming,简称DP)**是一种通过把原问题分解为相对简单的子问题的方式来解决复杂问题的方法。动态规划常常适用于有重叠子问题和最优子结构性质的问题。
1.1 基本术语
- 状态(State):动态规划中每个子问题的解,通常用dp数组表示
- 状态转移方程(State Transition Equation):描述状态之间如何转移的公式
- 最优子结构(Optimal Substructure):问题的最优解包含子问题的最优解
- 重叠子问题(Overlapping Subproblems):递归过程中会重复计算相同的子问题
- 记忆化(Memoization):将已计算的子问题结果存储起来,避免重复计算
- 自底向上(Bottom-up):从最小的子问题开始,逐步构建更大的子问题的解
- 自顶向下(Top-down):从原问题开始,递归地解决子问题
1.2 动态规划的特点
- 最优子结构:问题的最优解包含子问题的最优解
- 重叠子问题:递归过程中会重复计算相同的子问题
- 无后效性:当前状态只依赖于之前的状态,不依赖于未来的状态
- 状态转移:通过状态转移方程,从已知状态推导出未知状态
核心思想:
动态规划的核心思想是:将大问题分解为小问题,通过解决小问题来解决大问题。
关键点:
1. 找到状态定义
2. 找到状态转移方程
3. 确定初始状态
4. 确定遍历顺序
示例:
动态规划就像爬楼梯:
- 要到达第n层,可以从第n-1层或第n-2层到达
- 要到达第n-1层,可以从第n-2层或第n-3层到达
- 子问题重叠:计算第n层时,需要用到第n-1层和第n-2层
- 最优子结构:到达第n层的最优方案包含到达第n-1层或第n-2层的最优方案
1.3 动态规划的本质
动态规划的本质是记忆化递归的优化:
- 通过存储子问题的解,避免重复计算
- 通过状态转移方程,从已知状态推导出未知状态
- 通过自底向上的方式,逐步构建问题的解
关键点:
- 状态定义:明确dp[i]或dp[i][j]表示什么
- 状态转移:找到状态之间的递推关系
- 初始状态:确定边界条件
- 遍历顺序:确定如何填充dp数组
2. 动态规划的适用条件
2.1 最优子结构
定义:问题的最优解包含子问题的最优解。
判断方法:
- 问题可以分解为子问题
- 子问题的最优解可以组合成原问题的最优解
- 子问题之间相互独立
示例:
- 爬楼梯:到达第n层的最优方案包含到达第n-1层或第n-2层的最优方案
- 不同路径:到达(i,j)的最优方案包含到达(i-1,j)或(i,j-1)的最优方案
2.2 重叠子问题
定义:递归过程中会重复计算相同的子问题。
判断方法:
- 递归过程中会多次遇到相同的子问题
- 可以通过记忆化避免重复计算
- 动态规划通过存储子问题的解来优化
示例:
- 斐波那契数列:计算fib(n)需要计算fib(n-1)和fib(n-2),而fib(n-1)又需要计算fib(n-2)和fib(n-3),存在重叠
- 爬楼梯:计算到达第n层需要计算到达第n-1层和第n-2层,存在重叠
2.3 无后效性
定义:当前状态只依赖于之前的状态,不依赖于未来的状态。
判断方法:
- 当前状态的值只由之前的状态决定
- 不会因为未来的状态而改变当前状态
- 状态转移是单向的
示例:
- 爬楼梯:到达第n层只依赖于第n-1层和第n-2层,不依赖于第n+1层
- 不同路径:到达(i,j)只依赖于(i-1,j)和(i,j-1),不依赖于(i+1,j)或(i,j+1)
3. 动态规划与贪心算法的区别
3.1 核心区别
| 特性 | 动态规划 | 贪心算法 |
|---|---|---|
| 选择方式 | 考虑所有可能的选择 | 每一步都选择当前最优 |
| 状态转移 | 考虑所有历史状态 | 只考虑当前状态 |
| 回溯 | 可能回溯,改变选择 | 不回溯,不改变选择 |
| 时间复杂度 | 通常较高 O(n²) 或更高 | 通常较低 O(n) 或 O(n log n) |
| 空间复杂度 | 通常较高 O(n) 或 O(n²) | 通常较低 O(1) 或 O(n) |
| 适用问题 | 具有最优子结构的问题 | 具有贪心选择性质的问题 |
3.2 如何选择
使用动态规划:
- 问题具有最优子结构
- 存在重叠子问题
- 需要保存历史状态
- 贪心策略无法得到最优解
使用贪心算法:
- 问题具有贪心选择性质
- 可以通过局部最优推导全局最优
- 时间复杂度要求较低
示例对比:
- 跳跃游戏:可以用贪心(每次跳最远),也可以用DP(记录每个位置是否可达)
- 背包问题:0-1背包用DP,分数背包用贪心
4. 动态规划五部曲
动态规划的解题过程遵循DP五部曲:
4.1 第一步:确定dp数组(dp table)以及下标的含义
关键点:
- 明确dp[i]或dp[i][j]表示什么
- 确定下标的含义
- 理解状态的定义
示例:
- 爬楼梯:dp[i]表示到达第i层的方法数
- 不同路径:dp[i][j]表示到达(i,j)的路径数
- 01背包:dp[i][j]表示前i个物品在容量j下的最大价值
4.2 第二步:确定递推公式
关键点:
- 找到状态之间的递推关系
- 写出状态转移方程
- 理解如何从已知状态推导出未知状态
示例:
- 爬楼梯:dp[i] = dp[i-1] + dp[i-2]
- 不同路径:dp[i][j] = dp[i-1][j] + dp[i][j-1]
- 01背包:dp[i][j] = max(dp[i-1][j], dp[i-1][j-weight[i]] + value[i])
4.3 第三步:dp数组如何初始化
关键点:
- 确定边界条件
- 初始化dp数组
- 注意初始化的值要符合状态定义
示例:
- 爬楼梯:dp[1] = 1, dp[2] = 2
- 不同路径:dp[0][j] = 1, dp[i][0] = 1(第一行和第一列都是1)
- 01背包:dp[0][j] = value[0](当j >= weight[0]时)
4.4 第四步:确定遍历顺序
关键点:
- 确定如何填充dp数组
- 注意遍历顺序要保证状态转移的正确性
- 一维DP和二维DP的遍历顺序可能不同
示例:
- 爬楼梯:从前往后遍历,i从3到n
- 不同路径:从左上到右下遍历,i和j都从1开始
- 01背包(一维):物品从前往后,容量从后往前(避免重复使用)
4.5 第五步:举例推导dp数组
关键点:
- 手动推导几个例子
- 验证状态转移方程的正确性
- 检查边界条件
示例:
- 爬楼梯(n=5) :
- dp[1] = 1
- dp[2] = 2
- dp[3] = dp[2] + dp[1] = 2 + 1 = 3
- dp[4] = dp[3] + dp[2] = 3 + 2 = 5
- dp[5] = dp[4] + dp[3] = 5 + 3 = 8
5. 动态规划常见题型
5.1 基础DP问题
特点:一维DP,状态定义简单,递推公式直观。
常见题目:
- 509.斐波那契数:基础DP
- 70.爬楼梯:经典DP问题
- 746.使用最小花费爬楼梯:带权重的爬楼梯
- 343.整数拆分:整数拆分问题
- 96.不同的二叉搜索树:树形DP
核心思路:
- 状态定义:dp[i]表示第i个状态的值
- 递推公式:dp[i] = f(dp[i-1], dp[i-2], ...)
- 初始化:确定前几个状态的值
5.2 二维DP问题
特点:二维DP,状态定义涉及两个维度,递推公式更复杂。
常见题目:
- 62.不同路径:二维网格路径问题
- 63.不同路径II:带障碍物的路径问题
- 64.最小路径和:带权重的路径问题
核心思路:
- 状态定义:dp[i][j]表示到达(i,j)的状态值
- 递推公式:dp[i][j] = f(dp[i-1][j], dp[i][j-1], ...)
- 初始化:第一行和第一列的值
5.3 背包问题
特点:经典的DP问题,涉及物品和容量的选择。
常见题目:
- 01背包:每个物品只能选一次
- 完全背包:每个物品可以选无限次
- 416.分割等和子集:01背包应用
- 1049.最后一块石头的重量II:01背包应用
- 494.目标和:01背包应用
- 474.一和零:多维背包
- 518.零钱兑换II:完全背包应用
- 377.组合总和IV:完全背包应用
核心思路:
- 状态定义:dp[i][j]表示前i个物品在容量j下的最优值
- 递推公式:根据背包类型不同而不同
- 遍历顺序:01背包容量从后往前,完全背包容量从前往后
5.4 股票问题
特点:涉及买卖股票,状态定义需要考虑持有/不持有股票。
常见题目:
- 121.买卖股票的最佳时机:只能买卖一次
- 122.买卖股票的最佳时机II:可以多次买卖
- 123.买卖股票的最佳时机III:最多买卖两次
- 188.买卖股票的最佳时机IV:最多买卖k次
- 309.最佳买卖股票时机含冷冻期:含冷冻期
- 714.买卖股票的最佳时机含手续费:含手续费
核心思路:
- 状态定义:dp[i][0]表示第i天不持有股票的最大利润,dp[i][1]表示第i天持有股票的最大利润
- 递推公式:根据题目条件不同而不同
- 初始化:第一天持有或不持有股票的利润
5.5 子序列问题
特点:涉及字符串或数组的子序列,状态定义需要考虑位置和长度。
常见题目:
- 300.最长递增子序列:LIS问题
- 674.最长连续递增序列:连续LIS
- 718.最长重复子数组:最长公共子数组
- 1143.最长公共子序列:LCS问题
- 1035.不相交的线:LCS应用
- 53.最大子序和:最大子数组和
- 392.判断子序列:判断是否为子序列
- 115.不同的子序列:子序列计数
- 583.两个字符串的删除操作:删除操作
- 72.编辑距离:编辑距离问题
核心思路:
- 状态定义:dp[i][j]表示以i和j结尾的子序列的状态
- 递推公式:根据字符是否相等而不同
- 初始化:空字符串的情况
5.6 打家劫舍问题
特点:涉及相邻关系的约束,不能选择相邻的元素。
常见题目:
- 198.打家劫舍:线性数组
- 213.打家劫舍II:环形数组
- 337.打家劫舍III:树形结构
核心思路:
- 状态定义:dp[i]表示前i个房屋的最大金额
- 递推公式:dp[i] = max(dp[i-1], dp[i-2] + nums[i])
- 初始化:前两个房屋的金额
6. 动态规划模板
6.1 一维DP模板
适用场景:基础DP问题,如斐波那契、爬楼梯等。
核心思路:
- 状态定义:dp[i]表示第i个状态的值
- 递推公式:dp[i] = f(dp[i-1], dp[i-2], ...)
- 初始化:确定前几个状态的值
- 遍历顺序:从前往后
模板代码:
cpp
// 一维DP模板
class Solution {
public:
int dp(int n) {
// 1. 确定dp数组及下标含义
vector<int> dp(n + 1);
// 2. 确定递推公式
// dp[i] = f(dp[i-1], dp[i-2], ...)
// 3. dp数组初始化
dp[0] = ...;
dp[1] = ...;
// 4. 确定遍历顺序
for (int i = 2; i <= n; i++) {
dp[i] = ...; // 根据递推公式计算
}
// 5. 举例推导dp数组(用于调试)
return dp[n];
}
};
示例:爬楼梯:
cpp
class Solution {
public:
int climbStairs(int n) {
if (n <= 1) return n; // 因为下面直接对dp[2]操作了,防止空指针
vector<int> dp(n + 1);
dp[1] = 1;
dp[2] = 2;
for (int i = 3; i <= n; i++) { // 注意i是从3开始的
dp[i] = dp[i - 1] + dp[i - 2];
}
return dp[n];
}
};
6.2 二维DP模板
适用场景:二维网格问题,如不同路径、最小路径和等。
核心思路:
- 状态定义:dp[i][j]表示到达(i,j)的状态值
- 递推公式:dp[i][j] = f(dp[i-1][j], dp[i][j-1], ...)
- 初始化:第一行和第一列的值
- 遍历顺序:从左上到右下
模板代码:
cpp
// 二维DP模板
class Solution {
public:
int dp(int m, int n) {
// 1. 确定dp数组及下标含义
vector<vector<int>> dp(m, vector<int>(n, 0));
// 2. 确定递推公式
// dp[i][j] = f(dp[i-1][j], dp[i][j-1], ...)
// 3. dp数组初始化
for (int j = 0; j < n; j++) dp[0][j] = ...;
for (int i = 0; i < m; i++) dp[i][0] = ...;
// 4. 确定遍历顺序
for (int i = 1; i < m; i++) {
for (int j = 1; j < n; j++) {
dp[i][j] = ...; // 根据递推公式计算
}
}
// 5. 举例推导dp数组(用于调试)
return dp[m - 1][n - 1];
}
};
示例:不同路径:
cpp
class Solution {
public:
int uniquePaths(int m, int n) {
vector<vector<int>> dp(m, vector<int>(n, 0));
// 1. 赋值,第一行和第一列都是1
for (int j = 0; j < n; j++) dp[0][j] = 1;
for (int i = 0; i < m; i++) dp[i][0] = 1;
// 2. 根据递推往下求,只能从左边或者上边到
for (int i = 1; i < m; i++) {
for (int j = 1; j < n; j++) {
dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
}
}
return dp[m - 1][n - 1];
}
};
6.3 01背包模板
适用场景:每个物品只能选一次的问题。
核心思路:
- 状态定义:dp[i][j]表示前i个物品在容量j下的最大价值
- 递推公式:dp[i][j] = max(dp[i-1][j], dp[i-1][j-weight[i]] + value[i])
- 初始化:第一行的值
- 遍历顺序:物品从前往后,容量从后往前(一维优化)
模板代码(二维):
cpp
// 01背包模板(二维)
class Solution {
public:
int knapsack(vector<int>& weight, vector<int>& value, int bagweight) {
// 1. 确定dp数组及下标含义
// dp[i][j]表示前i个物品在容量j下的最大价值
vector<vector<int>> dp(weight.size(), vector<int>(bagweight + 1, 0));
// 2. 确定递推公式
// dp[i][j] = max(dp[i-1][j], dp[i-1][j-weight[i]] + value[i])
// 3. dp数组初始化
for (int j = weight[0]; j <= bagweight; j++) {
dp[0][j] = value[0];
}
// 4. 确定遍历顺序
for (int i = 1; i < weight.size(); i++) {
for (int j = 0; j <= bagweight; j++) {
if (j < weight[i]) {
dp[i][j] = dp[i - 1][j]; // 装不下
} else {
dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
}
}
}
return dp[weight.size() - 1][bagweight];
}
};
模板代码(一维优化):
cpp
// 01背包模板(一维优化)
class Solution {
public:
int knapsack(vector<int>& weight, vector<int>& value, int bagweight) {
// 1. 确定dp数组及下标含义
// dp[j]表示容量j下的最大价值
vector<int> dp(bagweight + 1, 0);
// 2. 确定递推公式
// dp[j] = max(dp[j], dp[j-weight[i]] + value[i])
// 3. dp数组初始化(全0即可)
// 4. 确定遍历顺序
// 物品从前往后,容量从后往前(避免重复使用)
for (int i = 0; i < weight.size(); i++) {
for (int j = bagweight; j >= weight[i]; j--) {
dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
}
}
return dp[bagweight];
}
};
关键点:
- 二维DP:dp[i][j]表示前i个物品在容量j下的最大价值
- 一维优化:dp[j]表示容量j下的最大价值
- 遍历顺序:01背包容量从后往前,避免重复使用物品
6.4 完全背包模板
适用场景:每个物品可以选无限次的问题。
核心思路:
- 状态定义:dp[i][j]表示前i个物品在容量j下的最大价值
- 递推公式:dp[i][j] = max(dp[i-1][j], dp[i][j-weight[i]] + value[i])
- 初始化:第一行的值
- 遍历顺序:物品从前往后,容量从前往后(可以重复使用)
模板代码(二维):
cpp
// 完全背包模板(二维)
class Solution {
public:
int knapsack(vector<int>& weight, vector<int>& value, int bagweight) {
// 1. 确定dp数组及下标含义
vector<vector<int>> dp(weight.size(), vector<int>(bagweight + 1, 0));
// 2. 确定递推公式
// dp[i][j] = max(dp[i-1][j], dp[i][j-weight[i]] + value[i])
// 注意:完全背包是dp[i][j-weight[i]],不是dp[i-1][j-weight[i]]
// 3. dp数组初始化
for (int j = weight[0]; j <= bagweight; j++) {
dp[0][j] = value[0] * (j / weight[0]);
}
// 4. 确定遍历顺序
for (int i = 1; i < weight.size(); i++) {
for (int j = 0; j <= bagweight; j++) {
if (j < weight[i]) {
dp[i][j] = dp[i - 1][j];
} else {
dp[i][j] = max(dp[i - 1][j], dp[i][j - weight[i]] + value[i]);
}
}
}
return dp[weight.size() - 1][bagweight];
}
};
模板代码(一维优化):
cpp
// 完全背包模板(一维优化)
class Solution {
public:
int knapsack(vector<int>& weight, vector<int>& value, int bagweight) {
// 1. 确定dp数组及下标含义
vector<int> dp(bagweight + 1, 0);
// 2. 确定递推公式
// dp[j] = max(dp[j], dp[j-weight[i]] + value[i])
// 3. dp数组初始化(全0即可)
// 4. 确定遍历顺序
// 物品从前往后,容量从前往后(可以重复使用)
for (int i = 0; i < weight.size(); i++) {
for (int j = weight[i]; j <= bagweight; j++) {
dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
}
}
return dp[bagweight];
}
};
关键点:
- 递推公式:完全背包是dp[i][j-weight[i]],01背包是dp[i-1][j-weight[i]]
- 遍历顺序:完全背包容量从前往后,可以重复使用物品
- 初始化:完全背包第一行可以放多个物品
7. 动态规划经典题目详解
7.1 斐波那契数(509)
题目:斐波那契数,通常用 F(n) 表示,形成的序列称为 斐波那契数列 。该数列由 0 和 1 开始,后面的每一项数字都是前面两项数字的和。
DP五部曲:
- 确定dp数组及下标含义:dp[i]表示第i个斐波那契数
- 确定递推公式:dp[i] = dp[i-1] + dp[i-2]
- dp数组初始化:dp[0] = 0, dp[1] = 1
- 确定遍历顺序:从前往后
- 举例推导dp数组:dp[2] = 1, dp[3] = 2, dp[4] = 3, ...
代码:
cpp
class Solution {
public:
int fib(int n) {
if (n <= 1) return n;
vector<int> dp(n + 1);
dp[0] = 0;
dp[1] = 1;
for (int i = 2; i <= n; i++) {
dp[i] = dp[i - 1] + dp[i - 2];
}
return dp[n];
}
};
关键点:
- 基础DP问题
- 状态定义简单
- 递推公式直观
7.2 爬楼梯(70)
题目:假设你正在爬楼梯。需要 n 阶你才能到达楼顶。每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?
DP五部曲:
- 确定dp数组及下标含义:dp[i]表示到达第i层的方法数
- 确定递推公式:dp[i] = dp[i-1] + dp[i-2]
- dp数组初始化:dp[1] = 1, dp[2] = 2
- 确定遍历顺序:从前往后
- 举例推导dp数组:dp[3] = 3, dp[4] = 5, dp[5] = 8, ...
代码:
cpp
class Solution {
public:
int climbStairs(int n) {
if (n <= 1) return n; // 因为下面直接对dp[2]操作了,防止空指针
vector<int> dp(n + 1);
dp[1] = 1;
dp[2] = 2;
for (int i = 3; i <= n; i++) { // 注意i是从3开始的
dp[i] = dp[i - 1] + dp[i - 2];
}
return dp[n];
}
};
关键点:
- 就是斐波那契数列,但是是由递推公式推出来的
- 搞清楚dp的真正含义,搞清楚递推公式,最后赋初值赋值正确
7.3 使用最小花费爬楼梯(746)
题目:给你一个整数数组 cost ,其中 cost[i] 是从楼梯第 i 个台阶向上爬需要支付的费用。一旦你支付此费用,即可选择向上爬一个或者两个台阶。你可以选择从下标为 0 或下标为 1 的台阶开始爬楼梯。请你计算并返回达到楼梯顶部的最低花费。
DP五部曲:
- 确定dp数组及下标含义:dp[i]表示到达第i层的最小花费
- 确定递推公式:dp[i] = min(dp[i-1] + cost[i-1], dp[i-2] + cost[i-2])
- dp数组初始化:dp[0] = 0, dp[1] = 0
- 确定遍历顺序:从前往后
- 举例推导dp数组:...
代码:
cpp
class Solution {
public:
int minCostClimbingStairs(vector<int>& cost) {
vector<int> dp(cost.size() + 1);
dp[0] = 0;
dp[1] = 0;
for (int i = 2; i <= cost.size(); i++) {
dp[i] = min(dp[i - 1] + cost[i - 1], dp[i - 2] + cost[i - 2]);
}
return dp[cost.size()];
}
};
关键点:
- 带权重的爬楼梯问题
- 注意dp数组的大小是cost.size() + 1
7.4 不同路径(62)
题目:一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为 "Start" )。机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为 "Finish" )。问总共有多少条不同的路径?
DP五部曲:
- 确定dp数组及下标含义:dp[i][j]表示到达(i,j)的路径数
- 确定递推公式:dp[i][j] = dp[i-1][j] + dp[i][j-1]
- dp数组初始化:dp[0][j] = 1, dp[i][0] = 1
- 确定遍历顺序:从左上到右下
- 举例推导dp数组:...
代码:
cpp
class Solution {
public:
int uniquePaths(int m, int n) {
vector<vector<int>> dp(m, vector<int>(n, 0));
// 初始化第一行和第一列
// 只能从左边或者上边过来
for (int i = 0; i < m; i++) dp[i][0] = 1;
for (int j = 0; j < n; j++) dp[0][j] = 1;
// 状态转移
for (int i = 1; i < m; i++) {
for (int j = 1; j < n; j++) {
dp[i][j] = dp[i - 1][j] + dp[i][j - 1]; // 只能从左上过来
}
}
return dp[m - 1][n - 1];
}
};
关键点:
- 搞清楚dp的真正含义,搞清楚递推公式,最后赋初值赋值正确
- 第一行和第一列都是1,因为只有一条路径
- 只能从左边或者上边过来
7.5 分割等和子集(416)
题目:给你一个 只包含正整数 的 非空 数组 nums 。请你判断是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。
DP五部曲:
- 确定dp数组及下标含义:dp[i]表示是否存在子集和为i
- 确定递推公式:dp[i] = dp[i] || dp[i - nums[j]]
- dp数组初始化:dp[0] = true(空集和为0)
- 确定遍历顺序:物品从前往后,容量从后往前(01背包)
- 举例推导dp数组:...
代码:
cpp
class Solution {
public:
bool canPartition(vector<int>& nums) {
int n = nums.size();
int sum = 0;
// 计算总和
for (int num : nums) sum += num;
// 总和为奇数,不可能分成两个相等子集
if (sum % 2 != 0) return false;
int target = sum / 2;
// dp[i] 表示是否存在子集和为 i
vector<bool> dp(target + 1, false);
dp[0] = true; // 空集和为 0
// 枚举每个数字
for (int num : nums) {
// 逆序遍历,保证每个数字只能用一次,01背包
for (int i = target; i >= num; i--) {
dp[i] = dp[i] || dp[i - num];
}
}
return dp[target];
}
};
关键点:
- 相当于01背包,就看是否可以组成sum/2
- 使用bool数组更直观,表示是否存在
- 逆序遍历,保证每个数字只能用一次
7.6 零钱兑换II(518)
题目:给你一个整数数组 coins 表示不同面额的硬币,另给一个整数 amount 表示总金额。请你计算并返回可以凑成总金额的硬币组合数。如果任何硬币组合都无法凑出总金额,返回 0 。假设每一种面额的硬币有无限个。
DP五部曲:
- 确定dp数组及下标含义:dp[i][j]表示前i种硬币凑成金额j的组合数
- 确定递推公式:dp[i][j] = dp[i-1][j] + dp[i][j-coins[i]]
- dp数组初始化:dp[0][j] = 1(当j % coins[0] == 0时),dp[i][0] = 1
- 确定遍历顺序:物品从前往后,容量从前往后(完全背包)
- 举例推导dp数组:...
代码:
cpp
class Solution {
public:
int change(int amount, vector<int>& coins) {
int bagSize = amount;
// coins数组就是物品(不限量)
vector<vector<uint64_t>> dp(coins.size(), vector<uint64_t>(bagSize + 1, 0));
// 初始化最上行,能整除说明若干个coins[0]可以凑出来j
for (int j = 0; j <= bagSize; j++) {
if (j % coins[0] == 0) dp[0][j] = 1;
}
// 初始化最左列,0块钱,不论面值是多少,只有一种可能就是不选
for (int i = 0; i < coins.size(); i++) {
dp[i][0] = 1;
}
// 以下遍历顺序行列可以颠倒
for (int i = 1; i < coins.size(); i++) { // 行,遍历物品
for (int j = 0; j <= bagSize; j++) { // 列,遍历背包
if (coins[i] > j) dp[i][j] = dp[i - 1][j]; // 面值太大就是上一行复制
// 小于等于都可以
// 因为dp[i-1][j]:表示不选当前硬币coins[i],直接用前i-1种硬币凑出金额j的组合数
// dp[i][j - coins[i]]:表示选当前硬币coins[i],然后凑剩余金额j-coins[i]的组合数(因为硬币无限,可以重复选)
else dp[i][j] = dp[i - 1][j] + dp[i][j - coins[i]];
}
}
return dp[coins.size() - 1][bagSize]; // 表格最右下就是结果
}
};
关键点:
- 完全背包问题
- dp[i-1][j]:不选当前硬币
- dp[i][j-coins[i]]:选当前硬币(因为硬币无限,可以重复选)
- 遍历顺序行列可以颠倒
7.7 打家劫舍(198)
题目:你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。给定一个代表每个房屋存放金额的非负整数数组,计算你 不触动警报装置的情况下 ,一夜之内能够偷窃到的最高金额。
DP五部曲:
- 确定dp数组及下标含义:dp[i]表示前i个房屋能偷到的最大金额
- 确定递推公式:dp[i] = max(dp[i-1], dp[i-2] + nums[i])
- dp数组初始化:dp[0] = nums[0], dp[1] = max(nums[0], nums[1])
- 确定遍历顺序:从前往后
- 举例推导dp数组:...
代码:
cpp
class Solution {
public:
int rob(vector<int>& nums) {
int n = nums.size();
if (n == 0) return 0;
if (n == 1) return nums[0];
vector<int> dp(n, 0);
dp[0] = nums[0];
dp[1] = max(nums[0], nums[1]); // 修正点
for (int i = 2; i < n; i++) {
dp[i] = max(dp[i - 1], dp[i - 2] + nums[i]);
}
return dp[n - 1]; // 不需要 result
}
};
关键点:
- dp[1] = max(nums[0], nums[1]):前两个房屋取最大值
- 不能偷相邻房屋,所以要么偷当前房屋,要么不偷
- 时间复杂度:O(n),空间复杂度:O(n)
7.8 完全平方数(279)
题目:给你一个整数 n ,返回 和为 n 的完全平方数的最少数量 。完全平方数 是一个整数,其值等于另一个整数的平方;换句话说,其值等于一个整数自乘的积。例如,1、4、9 和 16 都是完全平方数,而 3 和 11 不是。
DP五部曲:
- 确定dp数组及下标含义:dp[i]表示凑成数字i所需要的「最少完全平方数个数」
- 确定递推公式:dp[i] = min(dp[i], dp[i - j*j] + 1)
- dp数组初始化:dp[0] = 0(凑成0不需要任何平方数)
- 确定遍历顺序:外层枚举目标值i,内层枚举完全平方数j*j
- 举例推导dp数组:dp[12] = 3(4+4+4),dp[13] = 2(9+4)
代码:
cpp
// 完全背包问题,每个数使用次数无限制
class Solution {
public:
int numSquares(int n) {
/* ===============================
* 1️ dp 数组含义
* dp[i] 表示:凑成数字 i 所需要的「最少完全平方数个数」
* 例如:
* dp[12] = 3 (4 + 4 + 4)
* dp[13] = 2 (9 + 4)
* =============================== */
vector<int> dp(n + 1, INT_MAX);
/* ===============================
* 2️ 初始化
* dp[0] = 0:
* 凑成 0 不需要任何平方数,这是所有递推的起点
* =============================== */
dp[0] = 0;
/* ===============================
* 3️ 迭代(遍历)顺序
* 外层:枚举当前要凑的目标值 i(1 → n)
* 内层:枚举可以选择的完全平方数 j*j
* =============================== */
for (int i = 1; i <= n; i++) {
/* ===============================
* 4️ 递推(状态转移)公式
*
* 如果最后一个使用的是平方数 j*j,
* 那么之前一定已经凑出了 i - j*j
*
* dp[i] = min(
* dp[i],
* dp[i - j*j] + 1
* )
*
* +1 表示「再使用一个平方数 j*j」
* =============================== */
for (int j = 1; j * j <= i; j++) {
dp[i] = min(dp[i], dp[i - j * j] + 1);
}
}
/* ===============================
* 5️ 返回结果
* dp[n] 就是凑成 n 的最少平方数个数
* =============================== */
return dp[n];
}
};
关键点:
- 完全背包问题,每个数使用次数无限制
- dp[0] = 0是递推的起点
- 内层循环枚举完全平方数j*j
7.9 零钱兑换(322)
题目:给你一个整数数组 coins ,表示不同面额的硬币;以及一个整数 amount ,表示总金额。计算并返回可以凑成总金额所需的 最少的硬币个数 。如果没有任何一种硬币组合能组成总金额,返回 -1 。你可以认为每种硬币的数量是无限的。
DP五部曲:
- 确定dp数组及下标含义:dp[i]表示凑出金额i所需要的「最少硬币数量」
- 确定递推公式:dp[i] = min(dp[i], dp[i - coins[j]] + 1)
- dp数组初始化:dp[0] = 0(凑出金额0不需要任何硬币)
- 确定遍历顺序:外层枚举目标金额i,内层枚举硬币coins[j](完全背包)
- 举例推导dp数组:dp[11] = 3(5+5+1)
代码:
cpp
class Solution {
public:
int coinChange(vector<int>& coins, int amount) {
/* =====================================
* 1️ dp 数组含义
*
* dp[i] 表示:
* 凑出金额 i 所需要的「最少硬币数量」
*
* 例如:
* dp[11] = 3 (5 + 5 + 1)
* ===================================== */
vector<int> dp(amount + 1, INT_MAX);
/* =====================================
* 2️ 初始化
*
* dp[0] = 0:
* 凑出金额 0 不需要任何硬币
* 这是所有状态转移的起点
* ===================================== */
dp[0] = 0;
/* =====================================
* 3️ 迭代顺序
*
* 外层:当前目标金额 i(1 → amount)
* 内层:枚举每一种硬币 coins[j]
*
* 因为「硬币可以无限使用」
* 所以这是一个【完全背包】问题
* ===================================== */
for (int i = 1; i <= amount; i++) {
/* =====================================
* 4 状态转移(递推公式)
*
* 如果最后使用了一枚 coins[j]:
* 那么之前一定已经凑出了 i - coins[j]
*
* dp[i] = min(
* dp[i],
* dp[i - coins[j]] + 1
* )
*
* +1 表示「再使用一枚硬币」
*
* 注意:
* dp[i - coins[j]] 可能是 INT_MAX,
* 说明该状态不可达,要跳过
* ===================================== */
for (int coin : coins) {
if (i - coin >= 0 && dp[i - coin] != INT_MAX) {
dp[i] = min(dp[i], dp[i - coin] + 1);
}
}
}
/* =====================================
* 5️ 返回结果
*
* 如果 dp[amount] 仍然是 INT_MAX,
* 说明无法凑出 amount,返回 -1
* 否则返回 dp[amount]
* ===================================== */
return dp[amount] == INT_MAX ? -1 : dp[amount];
}
};
关键点:
- 完全背包问题,硬币可以无限使用
- 注意检查dp[i - coin] != INT_MAX,避免不可达状态
- 如果无法凑出,返回-1
7.10 单词拆分(139)
题目:给你一个字符串 s 和一个字符串列表 wordDict 作为字典。请你判断是否可以利用字典中出现的单词拼接出 s 。注意:不要求字典中出现的单词全部都使用,并且字典中的单词可以重复使用。
DP五部曲:
- 确定dp数组及下标含义:dp[i]表示s的前i个字符(s[0...i-1])是否可以被wordDict中的单词拆分
- 确定递推公式:如果s.substr(i, len) == word,则dp[i + len] = true
- dp数组初始化:dp[0] = true(空字符串是"可拆分"的)
- 确定遍历顺序:外层枚举当前位置i,内层枚举每个单词word(完全背包)
- 举例推导dp数组:...
代码:
cpp
class Solution {
public:
bool wordBreak(string s, vector<string>& wordDict) {
int n = s.size();
/* =====================================
* 1️ dp 数组含义
* dp[i] 表示:
* s 的前 i 个字符(s[0...i-1])
* 是否可以被 wordDict 中的单词拆分
* ===================================== */
vector<bool> dp(n + 1, false);
/* =====================================
* 2️ 初始化
* dp[0] = true:
* 空字符串是"可拆分"的
* ===================================== */
dp[0] = true;
/* =====================================
* 3️ 迭代顺序(完全背包)
*
* 外层:当前位置 i(0 → n-1)
* 内层:枚举每个单词 word
*
* 每个单词可以重复使用
* ===================================== */
for (int i = 0; i < n; i++) {
// 如果当前位置不可达,直接跳过
if (!dp[i]) continue;
/* =====================================
* 4️ 状态转移
* 尝试用 wordDict 中的单词去匹配 s[i ... ]
* ===================================== */
for (const string& word : wordDict) {
int len = word.size();
// 边界检查 + 子串匹配
if (i + len <= n && s.substr(i, len) == word) {
dp[i + len] = true;
}
}
}
/* =====================================
* 5️ 返回结果
* ===================================== */
return dp[n];
}
};
关键点:
- 完全背包问题,每个单词可以重复使用
- 如果当前位置不可达,直接跳过
- 边界检查 + 子串匹配
7.11 最长递增子序列(300)
题目:给你一个整数数组 nums ,找到其中最长严格递增子序列的长度。子序列 是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。例如,[3,6,2,7] 是数组 [0,3,1,6,2,2,7] 的子序列。
DP五部曲:
- 确定dp数组及下标含义:dp[i]表示以nums[i]结尾的「最长严格递增子序列长度」
- 确定递推公式:如果nums[i] > nums[j],则dp[i] = max(dp[i], dp[j] + 1)
- dp数组初始化:dp[i] = 1(每个数自己都能构成长度为1的LIS)
- 确定遍历顺序:外层枚举结尾位置i,内层枚举i之前的所有位置j
- 举例推导dp数组:nums = [3, 10, 2, 1, 20],过一遍就理解了
代码:
cpp
class Solution {
public:
int lengthOfLIS(vector<int>& nums) {
int n = nums.size();
if (n == 0) return 0;
/*
* dp[i] 表示:
* 以 nums[i] 结尾的「最长严格递增子序列长度」
*/
vector<int> dp(n, 1); // 初始化为 1:每个数自己都能构成长度为 1 的 LIS
int result = 1;
// 外层:枚举结尾位置 i
for (int i = 0; i < n; i++) {
// 内层:枚举 i 之前的所有位置 j
for (int j = 0; j < i; j++) {
// 如果 nums[i] 可以接在 nums[j] 后面
if (nums[i] > nums[j]) {
dp[i] = max(dp[i], dp[j] + 1);
}
}
// 举个具体例子nums = [3, 10, 2, 1, 20],过一遍就理解了
// 维护全局最大值
result = max(result, dp[i]);
}
return result;
}
};
关键点:
- dp[i]表示以nums[i]结尾的最长递增子序列长度
- 初始化为1,每个数自己都能构成长度为1的LIS
- 需要维护全局最大值
7.12 乘积最大子数组(152)
题目:给你一个整数数组 nums ,请你找出数组中乘积最大的非空连续子数组(该子数组中至少包含一个数字),并返回该子数组所对应的乘积。
DP五部曲:
- 确定dp数组及下标含义:maxDp[i]表示以nums[i]结尾的最大乘积,minDp[i]表示以nums[i]结尾的最小乘积
- 确定递推公式 :
- maxDp[i] = max(nums[i], max(prevMax * nums[i], prevMin * nums[i]))
- minDp[i] = min(nums[i], min(prevMax * nums[i], prevMin * nums[i]))
- dp数组初始化:maxDp[0] = nums[0], minDp[0] = nums[0]
- 确定遍历顺序:从前往后
- 举例推导dp数组:...
代码:
cpp
// 负负得正,所以最大值可能来自于之前的最大值或者最小值乘以当前负数
// 或者当前值是0,比如【-2,0】,最大值是当前值
class Solution {
public:
int maxProduct(vector<int>& nums) {
int n = nums.size();
// maxDp[i]:以 nums[i] 结尾的最大乘积
// minDp[i]:以 nums[i] 结尾的最小乘积
vector<int> maxDp(n), minDp(n);
// 初始化
maxDp[0] = nums[0];
minDp[0] = nums[0];
int ans = nums[0];
// 从第二个元素开始遍历
for (int i = 1; i < n; i++) {
// 先保存前一状态,避免被覆盖
int prevMax = maxDp[i - 1];
int prevMin = minDp[i - 1];
// 三种情况取最大/最小
maxDp[i] = max(nums[i], max(prevMax * nums[i], prevMin * nums[i]));
minDp[i] = min(nums[i], min(prevMax * nums[i], prevMin * nums[i]));
// 更新答案
ans = max(ans, maxDp[i]);
}
return ans;
}
};
关键点:
- 负负得正,所以最大值可能来自于之前的最大值或者最小值乘以当前负数
- 或者当前值是0,比如【-2,0】,最大值是当前值
- 需要同时维护最大值和最小值
7.13 最小路径和(64)
题目:给定一个包含非负整数的 m x n 网格 grid ,请找出一条从左上角到右下角的路径,使得路径上的数字总和为最小。说明:每次只能向下或者向右移动一步。
DP五部曲:
- 确定dp数组及下标含义:dp[i][j]表示到达(i,j)的最小路径和
- 确定递推公式:dp[i][j] = min(dp[i-1][j], dp[i][j-1]) + grid[i][j]
- dp数组初始化:dp[0][0] = grid[0][0],第一行和第一列需要累加
- 确定遍历顺序:从左上到右下
- 举例推导dp数组:...
代码:
cpp
class Solution {
public:
int minPathSum(vector<vector<int>>& grid) {
int m = grid.size();
int n = grid[0].size();
vector<vector<int>> dp(m, vector<int>(n, 0));
dp[0][0] = grid[0][0];
for (int i = 1; i < m; i++) dp[i][0] = grid[i][0] + dp[i - 1][0];
for (int j = 1; j < n; j++) dp[0][j] = grid[0][j] + dp[0][j - 1];
for (int i = 1; i < m; i++) {
for (int j = 1; j < n; j++) {
dp[i][j] = min(dp[i - 1][j], dp[i][j - 1]) + grid[i][j];
}
}
return dp[m - 1][n - 1];
}
};
关键点:
- 第一行和第一列需要累加,因为只有一条路径
- 状态转移:从上方或左方选择最小值
7.14 最长回文子串(5)
题目:给你一个字符串 s,找到 s 中最长的回文子串。如果字符串的反序与原始字符串相同,则该字符串称为回文字符串。
DP五部曲:
- 确定dp数组及下标含义:dp[i][j]表示子串s[i...j]是否是回文串
- 确定递推公式:dp[i][j] = (s[i] == s[j]) && (j - i < 2 || dp[i + 1][j - 1])
- dp数组初始化:dp[i][i] = true(单个字符一定是回文)
- 确定遍历顺序:i从大到小,j从小到大(dp[i][j]依赖dp[i+1][j-1])
- 举例推导dp数组:...
代码:
cpp
// dp[i][j] 表示:子串 s[i..j] 是否是回文串
// dp[i][i] = true; // 单个字符一定是回文
// dp[i][j] = (s[i] == s[j]) &&
// (j - i < 2 || dp[i + 1][j - 1]);//首尾字符相等,中间也是回文
// 注意遍历顺序:dp[i][j] 依赖 dp[i+1][j-1],所以i从大到小,j从小到大
class Solution {
public:
string longestPalindrome(string s) {
int n = s.size();
if (n <= 1) return s;
vector<vector<bool>> dp(n, vector<bool>(n, false));
int start = 0; // 最长回文起点
int maxLen = 1; // 最长回文长度
// i 从后往前,保证 dp[i+1][j-1] 已计算
for (int i = n - 1; i >= 0; i--) {
for (int j = i; j < n; j++) {
if (s[i] == s[j] && (j - i < 2 || dp[i + 1][j - 1])) {
dp[i][j] = true;
if (j - i + 1 > maxLen) {
maxLen = j - i + 1;
start = i;
}
}
}
}
return s.substr(start, maxLen);
}
};
关键点:
- 注意遍历顺序:i从大到小,j从小到大,保证dp[i+1][j-1]已计算
- 首尾字符相等,中间也是回文
- 单个字符一定是回文
7.15 最长公共子序列(1143)
题目:给定两个字符串 text1 和 text2,返回这两个字符串的最长 公共子序列 的长度。如果不存在 公共子序列 ,返回 0 。一个字符串的 子序列 是指这样一个新的字符串:它是由原字符串在不改变字符的相对顺序的情况下删除某些字符(也可以不删除任何字符)后组成的新字符串。
DP五部曲:
- 确定dp数组及下标含义:dp[i][j]表示text1的前i个字符和text2的前j个字符的最长公共子序列长度
- 确定递推公式 :
- 如果text1[i-1] == text2[j-1]:dp[i][j] = dp[i-1][j-1] + 1
- 否则:dp[i][j] = max(dp[i-1][j], dp[i][j-1])
- dp数组初始化:dp[0][j] = 0,dp[i][0] = 0(只要有一个字符串长度为0,公共子序列长度一定是0)
- 确定遍历顺序:从左上到右下
- 举例推导dp数组:...
代码:
cpp
// dp[i][j] 表示:text1 的前 i 个字符和text2 的前 j 个字符的最长公共子序列长度
// dp[0][j] = 0,dp[i][0] = 0,只要有一个字符串长度为 0,公共子序列长度一定是 0
class Solution {
public:
int longestCommonSubsequence(string text1, string text2) {
int m = text1.size();
int n = text2.size();
// dp[i][j]:text1 前 i 个字符 和 text2 前 j 个字符 的 LCS 长度
vector<vector<int>> dp(m + 1, vector<int>(n + 1, 0));
// 遍历两个字符串
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
// 当前字符相等
if (text1[i - 1] == text2[j - 1]) {
dp[i][j] = dp[i - 1][j - 1] + 1;
}
// 当前字符不相等
else {
dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]);
}
}
}
return dp[m][n];
}
};
关键点:
- dp[0][j] = 0,dp[i][0] = 0:只要有一个字符串长度为0,公共子序列长度一定是0
- 字符相等时,长度+1;不相等时,取最大值
7.16 编辑距离(72)
题目:给你两个单词 word1 和 word2, 请返回将 word1 转换成 word2 所使用的最少操作数 。你可以对一个单词进行如下三种操作:插入一个字符、删除一个字符、替换一个字符。
DP五部曲:
- 确定dp数组及下标含义:dp[i][j]表示word1的前i个字符变成word2的前j个字符所需的最少操作数
- 确定递推公式 :
- 如果word1[i-1] == word2[j-1]:dp[i][j] = dp[i-1][j-1]
- 否则:dp[i][j] = min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]) + 1
- dp数组初始化 :
- dp[0][j] = j(word1为空只能插入)
- dp[i][0] = i(word2为空只能删除)
- 确定遍历顺序:从左上到右下
- 举例推导dp数组:...
代码:
cpp
// dp[i][j] = word1 的前 i 个字符变成word2 的前 j 个字符所需的最少操作数
// 初始化:
// dp[0][j] = j//word1位为空只能插入。
// dp[i][0] = i//word2为空只能删除
class Solution {
public:
int minDistance(string word1, string word2) {
int m = word1.size();
int n = word2.size();
// dp[i][j]:word1前i个字符变成word2前j个字符的最少操作数
vector<vector<int>> dp(m + 1, vector<int>(n + 1, 0));
// 初始化:空串情况
for (int i = 0; i <= m; i++) {
dp[i][0] = i; // 删除
}
for (int j = 0; j <= n; j++) {
dp[0][j] = j; // 插入
}
// 填表
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
if (word1[i - 1] == word2[j - 1]) {
// 字符相等,不需要操作
dp[i][j] = dp[i - 1][j - 1];
} else {
// 三种操作取最小
dp[i][j] = min({
dp[i - 1][j], // 删除
dp[i][j - 1], // 插入
dp[i - 1][j - 1] // 替换
}) + 1;
}
}
}
return dp[m][n];
}
};
关键点:
- dp[0][j] = j:word1为空只能插入
- dp[i][0] = i:word2为空只能删除
- 三种操作:删除、插入、替换,取最小值
8. 动态规划的时间复杂度
8.1 时间复杂度分析
| 类型 | 时间复杂度 | 说明 |
|---|---|---|
| 一维DP | O(n) | 遍历一次数组 |
| 二维DP | O(m × n) | 遍历二维数组 |
| 背包问题(二维) | O(n × m) | n个物品,m容量 |
| 背包问题(一维) | O(n × m) | 虽然是一维,但需要两层循环 |
| 子序列问题 | O(n²) | 通常需要两层循环 |
注意:
- 大多数DP问题的时间复杂度是O(n)或O(n²)
- 空间优化通常不影响时间复杂度
- 状态转移的时间复杂度通常是O(1)
8.2 空间复杂度分析
| 类型 | 空间复杂度 | 说明 |
|---|---|---|
| 一维DP | O(n) | 需要一维数组 |
| 二维DP | O(m × n) | 需要二维数组 |
| 背包问题(二维) | O(n × m) | 需要二维数组 |
| 背包问题(一维优化) | O(m) | 只需要一维数组 |
| 滚动数组优化 | O(min(m, n)) | 只保留必要的状态 |
注意:
- 大多数DP问题可以通过空间优化降低空间复杂度
- 一维DP可以优化到O(1)(如果只依赖前几个状态)
- 二维DP可以优化到O(n)(滚动数组)
9. 何时使用动态规划
9.1 使用动态规划的场景
-
最优化问题
- 求最大值、最小值
- 求最优方案数
- 求是否存在
-
计数问题
- 求方案数
- 求路径数
- 求组合数
-
判断问题
- 判断是否存在
- 判断是否可行
- 判断是否满足条件
-
子序列/子数组问题
- 最长递增子序列
- 最长公共子序列
- 最大子数组和
-
背包问题
- 01背包
- 完全背包
- 多重背包
9.2 判断标准
当遇到以下情况时,考虑使用动态规划:
- 问题具有最优子结构
- 存在重叠子问题
- 需要保存历史状态
- 可以通过状态转移方程求解
当遇到以下情况时,考虑使用其他算法:
- 问题不具有最优子结构:考虑贪心或回溯
- 不存在重叠子问题:考虑直接递归或迭代
- 状态空间太大:考虑优化或剪枝
9.3 动态规划的优缺点
优点:
- 可以解决复杂的最优化问题
- 通过记忆化避免重复计算
- 可以找到最优解
- 思路清晰,易于理解
缺点:
- 时间复杂度可能较高
- 空间复杂度可能较高
- 需要找到正确的状态定义和转移方程
- 对于某些问题,状态空间可能很大
10. 动态规划的优化技巧
10.1 空间优化
一维DP优化:
- 如果只依赖前几个状态,可以用几个变量代替数组
- 例如:斐波那契数列可以用两个变量代替数组
二维DP优化:
- 滚动数组:只保留必要的行
- 一维优化:如果当前状态只依赖上一行的状态,可以用一维数组
示例:
cpp
// 二维DP
vector<vector<int>> dp(m, vector<int>(n, 0));
// 滚动数组优化
vector<int> dp(n, 0);
vector<int> prev(n, 0);
10.2 状态压缩
位运算优化:
- 如果状态可以用位表示,可以用位运算优化
- 例如:旅行商问题可以用位运算表示访问过的城市
示例:
cpp
// 状态压缩:用整数表示集合
int state = 0; // 初始状态
state |= (1 << i); // 添加元素i
state & (1 << i); // 检查元素i是否存在
10.3 记忆化搜索
递归 + 记忆化:
- 自顶向下的方式
- 用哈希表存储已计算的结果
- 避免重复计算
示例:
cpp
unordered_map<int, int> memo;
int fib(int n) {
if (n <= 1) return n;
if (memo.count(n)) return memo[n];
memo[n] = fib(n - 1) + fib(n - 2);
return memo[n];
}
11. 常见题型总结
11.1 基础DP类
-
斐波那契数列
- 509.斐波那契数:基础DP
-
爬楼梯问题
- 70.爬楼梯:经典DP问题
- 746.使用最小花费爬楼梯:带权重的爬楼梯
-
整数问题
- 343.整数拆分:整数拆分问题
- 96.不同的二叉搜索树:树形DP
-
其他基础问题
- 118.杨辉三角:二维DP基础
- 198.打家劫舍:线性打家劫舍
11.2 二维DP类
-
路径问题
- 62.不同路径:二维网格路径问题
- 63.不同路径II:带障碍物的路径问题
- 64.最小路径和:带权重的路径问题
-
字符串问题
- 5.最长回文子串:二维DP,注意遍历顺序
- 1143.最长公共子序列:LCS问题
- 72.编辑距离:编辑距离问题
11.3 背包问题类
-
01背包
- 416.分割等和子集:01背包应用
- 1049.最后一块石头的重量II:01背包应用
- 494.目标和:01背包应用
- 474.一和零:多维背包
-
完全背包
- 518.零钱兑换II:完全背包应用
- 377.组合总和IV:完全背包应用
- 279.完全平方数:完全背包应用
- 322.零钱兑换:完全背包应用(最少硬币数)
- 139.单词拆分:完全背包应用
11.4 股票问题类
-
基础股票问题
- 121.买卖股票的最佳时机:只能买卖一次
- 122.买卖股票的最佳时机II:可以多次买卖
-
进阶股票问题
- 123.买卖股票的最佳时机III:最多买卖两次
- 188.买卖股票的最佳时机IV:最多买卖k次
- 309.最佳买卖股票时机含冷冻期:含冷冻期
- 714.买卖股票的最佳时机含手续费:含手续费
11.5 子序列问题类
-
最长递增子序列
- 300.最长递增子序列:LIS问题
- 674.最长连续递增序列:连续LIS
-
最长公共子序列
- 718.最长重复子数组:最长公共子数组
- 1143.最长公共子序列:LCS问题
- 1035.不相交的线:LCS应用
-
最大子数组和
- 53.最大子序和:最大子数组和
- 152.乘积最大子数组:需要同时维护最大值和最小值(负负得正)
-
编辑距离
- 392.判断子序列:判断是否为子序列
- 115.不同的子序列:子序列计数
- 583.两个字符串的删除操作:删除操作
- 72.编辑距离:编辑距离问题
11.6 打家劫舍问题类
-
线性打家劫舍
- 198.打家劫舍:线性数组
-
环形打家劫舍
- 213.打家劫舍II:环形数组
-
树形打家劫舍
- 337.打家劫舍III:树形结构
12. 总结
动态规划是一种重要的算法策略,通过将大问题分解为小问题,通过解决小问题来解决大问题。
核心要点:
- DP五部曲:确定dp数组、递推公式、初始化、遍历顺序、举例推导
- 最优子结构:问题的最优解包含子问题的最优解
- 重叠子问题:递归过程中会重复计算相同的子问题
- 状态转移:通过状态转移方程,从已知状态推导出未知状态
使用建议:
- 根据问题特性判断是否适合动态规划
- 按照DP五部曲逐步分析问题
- 搞清楚dp的真正含义,搞清楚递推公式,最后赋初值赋值正确
- 注意边界条件处理
- 掌握常见题型的模板
常见题型总结:
- 基础DP:斐波那契、爬楼梯、整数拆分、杨辉三角、打家劫舍
- 二维DP:不同路径、最小路径和、最长回文子串、最长公共子序列、编辑距离
- 背包问题:01背包、完全背包及其应用(分割等和子集、零钱兑换、完全平方数、单词拆分)
- 股票问题:买卖股票的各种变种
- 子序列问题:最长递增子序列、最长公共子序列、编辑距离、乘积最大子数组
- 打家劫舍问题:线性、环形、树形打家劫舍
学习路径:
- 理解DP的基本思想
- 掌握DP五部曲
- 从简单的斐波那契、爬楼梯开始
- 学习背包问题(01背包、完全背包)
- 学习二维DP、多维DP
- 练习各种DP变种