2026-06-10 11:17:10 +08:00
|
|
|
|
---
|
|
|
|
|
|
tags:
|
|
|
|
|
|
- 算法设计与分析
|
|
|
|
|
|
- 复习
|
|
|
|
|
|
- 动态规划
|
|
|
|
|
|
create time: 2026-06-10 11:11
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
# 动态规划
|
|
|
|
|
|
|
|
|
|
|
|
## 概述
|
|
|
|
|
|
动态规划(Dynamic Programming, DP)是解决多阶段决策最优化问题的重要方法。与贪心算法的"不回头"不同,动态规划通过系统地考察所有可能的子问题解,利用存储机制避免重复计算,从而高效地求得全局最优解。本文将从基本思想出发,讲解动态规划的五个步骤和多个经典应用。
|
|
|
|
|
|
|
|
|
|
|
|
## 正文
|
|
|
|
|
|
|
|
|
|
|
|
### 一、动态规划基本思想
|
|
|
|
|
|
|
|
|
|
|
|
动态规划的核心思想是:**通过存储子问题的解,避免重复计算,将优化问题转化为决策过程。**
|
|
|
|
|
|
|
|
|
|
|
|
```mermaid
|
|
|
|
|
|
flowchart TD
|
|
|
|
|
|
Analyze["Step 1: Analyze optimal substructure"]
|
|
|
|
|
|
State["Step 2: Define state (subproblems)"]
|
|
|
|
|
|
Equation["Step 3: Write state transition equation"]
|
|
|
|
|
|
Compute["Step 4: Compute bottom-up (fill table)"]
|
|
|
|
|
|
Construct["Step 5: Construct optimal solution"]
|
|
|
|
|
|
|
|
|
|
|
|
Analyze --> State
|
|
|
|
|
|
State --> Equation
|
|
|
|
|
|
Equation --> Compute
|
|
|
|
|
|
Compute --> Construct
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### 二、两个关键性质
|
|
|
|
|
|
|
|
|
|
|
|
1. **最优子结构(Optimal Substructure)**:问题的最优解包含子问题的最优解。可以通过组合子问题的最优解得到原问题的最优解。
|
|
|
|
|
|
|
|
|
|
|
|
2. **重叠子问题(Overlapping Subproblems)**:递归求解时,相同的子问题被反复计算。动态规划通过存储已解决的子问题解来避免重复计算。
|
|
|
|
|
|
|
|
|
|
|
|
> [!question] 动态规划的两个关键性质是什么?
|
|
|
|
|
|
> 最优子结构和重叠子问题。最优子结构说明子问题的最优解可以组合成全局最优解;重叠子问题说明存在重复计算,需要存储子问题的解。
|
|
|
|
|
|
|
|
|
|
|
|
### 三、动态规划五个基本步骤
|
|
|
|
|
|
|
|
|
|
|
|
这是解决动态规划问题的标准流程:
|
|
|
|
|
|
|
|
|
|
|
|
1. **分析最优子结构**:证明原问题的最优解包含子问题的最优解
|
|
|
|
|
|
2. **定义状态(子问题)**:明确 dp[i] 或 dp[i][j] 代表什么含义
|
|
|
|
|
|
3. **写出状态转移方程**:dp[i] 如何由之前的状态推导而来
|
|
|
|
|
|
4. **自底向上计算(填表)**:从小规模子问题开始,逐步填表
|
|
|
|
|
|
5. **构造最优解**:根据dp表回溯得到具体的最优解方案(不总是需要)
|
|
|
|
|
|
|
|
|
|
|
|
> [!warning] 状态定义是最关键的一步
|
|
|
|
|
|
> 动态规划的难点往往在于正确地定义状态。状态必须能完整描述一个子问题,且能通过之前的状态推导出来。
|
|
|
|
|
|
|
|
|
|
|
|
### 四、与分治法的区别
|
|
|
|
|
|
|
|
|
|
|
|
| 特性 | 分治法 | 动态规划 |
|
|
|
|
|
|
|------|--------|---------|
|
|
|
|
|
|
| 子问题关系 | 相互独立、无重叠 | 有重叠子问题 |
|
|
|
|
|
|
| 是否需要存储 | 不需要(子问题只算一次) | 需要(避免重复计算) |
|
|
|
|
|
|
| 计算方向 | 自顶向下递归 | 自底向上填表(也可记忆化递归) |
|
|
|
|
|
|
| 典型应用 | 归并排序、快速排序 | 0-1背包、LCS、矩阵链乘法 |
|
|
|
|
|
|
|
|
|
|
|
|
### 五、经典应用
|
|
|
|
|
|
|
|
|
|
|
|
#### 5.1 最大子数组和(Kadane算法)
|
|
|
|
|
|
|
|
|
|
|
|
**问题**:给定一个整数数组,找到一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。
|
|
|
|
|
|
|
|
|
|
|
|
**状态定义**:dp[i] = 以第 i 个元素结尾的最大子数组和
|
|
|
|
|
|
|
|
|
|
|
|
**状态转移方程**:
|
|
|
|
|
|
|
|
|
|
|
|
$$dp[i] = \max(arr[i], \ dp[i-1] + arr[i])$$
|
|
|
|
|
|
|
|
|
|
|
|
**含义**:以第 i 个元素结尾的最大子数组和,要么是从头开始(只取 arr[i]),要么是接在之前的最大子数组后面。
|
|
|
|
|
|
|
2026-06-10 11:19:43 +08:00
|
|
|
|
```cpp
|
|
|
|
|
|
#include <vector>
|
|
|
|
|
|
#include <algorithm>
|
|
|
|
|
|
using namespace std;
|
|
|
|
|
|
|
|
|
|
|
|
int maxSubarraySum(vector<int>& arr) {
|
|
|
|
|
|
int n = arr.size();
|
|
|
|
|
|
vector<int> dp(n);
|
|
|
|
|
|
dp[0] = arr[0];
|
|
|
|
|
|
int maxSum = dp[0];
|
|
|
|
|
|
|
|
|
|
|
|
for (int i = 1; i < n; i++) {
|
|
|
|
|
|
dp[i] = max(arr[i], dp[i-1] + arr[i]); // 从头开始 or 接在前面
|
|
|
|
|
|
maxSum = max(maxSum, dp[i]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return maxSum;
|
|
|
|
|
|
}
|
2026-06-10 11:17:10 +08:00
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
**解释**:对每个位置 i,我们做一个决策——是从当前位置重新开始子数组,还是接在前面的子数组后面。选择较大的那个作为 dp[i]。最后取所有 dp[i] 的最大值。
|
|
|
|
|
|
|
|
|
|
|
|
> [!question] Kadane算法的时间复杂度是多少?为什么?
|
|
|
|
|
|
> O(n),因为只需要一次遍历。对每个元素做常数次比较和赋值操作。
|
|
|
|
|
|
|
|
|
|
|
|
#### 5.2 n x n 矩阵最大路径和
|
|
|
|
|
|
|
|
|
|
|
|
**问题**:给定一个 n x n 的矩阵,从左上角走到右下角,每次只能向右或向下移动一步,求路径上的最大数字和。
|
|
|
|
|
|
|
|
|
|
|
|
**状态定义**:dp[i][j] = 从左上角到位置 (i,j) 的最大路径和
|
|
|
|
|
|
|
|
|
|
|
|
**状态转移方程**:
|
|
|
|
|
|
|
|
|
|
|
|
$$dp[i][j] = \max(dp[i-1][j], \ dp[i][j-1]) + matrix[i][j]$$
|
|
|
|
|
|
|
2026-06-10 11:19:43 +08:00
|
|
|
|
```cpp
|
|
|
|
|
|
#include <vector>
|
|
|
|
|
|
#include <algorithm>
|
|
|
|
|
|
using namespace std;
|
|
|
|
|
|
|
|
|
|
|
|
int maxMatrixPath(vector<vector<int>>& matrix, int n) {
|
|
|
|
|
|
vector<vector<int>> dp(n, vector<int>(n));
|
|
|
|
|
|
dp[0][0] = matrix[0][0];
|
2026-06-10 11:17:10 +08:00
|
|
|
|
|
|
|
|
|
|
// 填第一行(只能从左边来)
|
2026-06-10 11:19:43 +08:00
|
|
|
|
for (int j = 1; j < n; j++)
|
|
|
|
|
|
dp[0][j] = dp[0][j-1] + matrix[0][j];
|
2026-06-10 11:17:10 +08:00
|
|
|
|
|
|
|
|
|
|
// 填第一列(只能从上面来)
|
2026-06-10 11:19:43 +08:00
|
|
|
|
for (int i = 1; i < n; i++)
|
|
|
|
|
|
dp[i][0] = dp[i-1][0] + matrix[i][0];
|
2026-06-10 11:17:10 +08:00
|
|
|
|
|
|
|
|
|
|
// 填其余位置
|
2026-06-10 11:19:43 +08:00
|
|
|
|
for (int i = 1; i < n; i++)
|
|
|
|
|
|
for (int j = 1; j < n; j++)
|
|
|
|
|
|
dp[i][j] = max(dp[i-1][j], dp[i][j-1]) + matrix[i][j];
|
2026-06-10 11:17:10 +08:00
|
|
|
|
|
2026-06-10 11:19:43 +08:00
|
|
|
|
return dp[n-1][n-1];
|
|
|
|
|
|
}
|
2026-06-10 11:17:10 +08:00
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
**解释**:到达位置 (i,j) 只能从上方 (i-1,j) 或左方 (i,j-1) 来。取两者中较大的路径和,加上当前位置的值。边界条件是第一行和第一列只有一条路径。
|
|
|
|
|
|
|
|
|
|
|
|
#### 5.3 阶梯问题/爬楼梯
|
|
|
|
|
|
|
|
|
|
|
|
**问题**:有 n 阶楼梯,每次可以爬 1 阶或 2 阶,求到达第 n 阶有多少种方法。
|
|
|
|
|
|
|
|
|
|
|
|
**状态转移方程**:
|
|
|
|
|
|
|
|
|
|
|
|
$$f(n) = f(n-1) + f(n-2)$$
|
|
|
|
|
|
|
|
|
|
|
|
**解释**:到达第 n 阶的方法数 = 从第 n-1 阶走1步 + 从第 n-2 阶走2步。这是一个变体的 Fibonacci 数列。
|
|
|
|
|
|
|
2026-06-10 11:19:43 +08:00
|
|
|
|
```cpp
|
|
|
|
|
|
#include <vector>
|
|
|
|
|
|
using namespace std;
|
|
|
|
|
|
|
|
|
|
|
|
int climbStairs(int n) {
|
|
|
|
|
|
if (n <= 2) return n;
|
|
|
|
|
|
vector<int> dp(n + 1);
|
|
|
|
|
|
dp[1] = 1;
|
|
|
|
|
|
dp[2] = 2;
|
|
|
|
|
|
for (int i = 3; i <= n; i++)
|
|
|
|
|
|
dp[i] = dp[i-1] + dp[i-2];
|
|
|
|
|
|
return dp[n];
|
|
|
|
|
|
}
|
2026-06-10 11:17:10 +08:00
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
> [!question] 爬楼梯和Fibonacci有什么关系?
|
|
|
|
|
|
> 爬楼梯的状态转移方程与Fibonacci完全相同:f(n) = f(n-1) + f(n-2)。区别在于初始值不同:爬楼梯 f(1)=1, f(2)=2;Fibonacci F(0)=0, F(1)=1。所以爬楼梯本质上就是Fibonacci数列的平移。
|
|
|
|
|
|
|
|
|
|
|
|
#### 5.4 斐波那契数列(动态规划解法)
|
|
|
|
|
|
|
|
|
|
|
|
用动态规划可以将 Fibonacci 从 O(2ⁿ) 优化到 O(n):
|
|
|
|
|
|
|
2026-06-10 11:19:43 +08:00
|
|
|
|
```cpp
|
|
|
|
|
|
#include <vector>
|
|
|
|
|
|
using namespace std;
|
|
|
|
|
|
|
|
|
|
|
|
int fibDP(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];
|
|
|
|
|
|
}
|
2026-06-10 11:17:10 +08:00
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
> [!tip] 进一步优化空间
|
|
|
|
|
|
> 观察发现 dp[i] 只依赖 dp[i-1] 和 dp[i-2],因此不需要整个数组,只需要两个变量。空间复杂度可从 O(n) 降到 O(1)。
|
|
|
|
|
|
|
|
|
|
|
|
#### 5.5 最长公共子序列(LCS)
|
|
|
|
|
|
|
|
|
|
|
|
**问题**:给定两个序列 X 和 Y,找到它们的最长公共子序列。
|
|
|
|
|
|
|
|
|
|
|
|
**状态定义**:dp[i][j] = X 的前 i 个字符和 Y 的前 j 个字符的 LCS 长度
|
|
|
|
|
|
|
|
|
|
|
|
**状态转移方程**:
|
|
|
|
|
|
|
|
|
|
|
|
$$dp[i][j] = \begin{cases} dp[i-1][j-1] + 1 & \text{if } X[i] = Y[j] \\ \max(dp[i-1][j], \ dp[i][j-1]) & \text{if } X[i] \neq Y[j] \end{cases}$$
|
|
|
|
|
|
|
2026-06-10 11:19:43 +08:00
|
|
|
|
```cpp
|
|
|
|
|
|
#include <vector>
|
|
|
|
|
|
#include <string>
|
|
|
|
|
|
#include <algorithm>
|
|
|
|
|
|
using namespace std;
|
|
|
|
|
|
|
|
|
|
|
|
int LCS(string& X, string& Y, int m, int n) {
|
|
|
|
|
|
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 (X[i-1] == Y[j-1])
|
|
|
|
|
|
dp[i][j] = dp[i-1][j-1] + 1; // 字符匹配,LCS长度+1
|
|
|
|
|
|
else
|
|
|
|
|
|
dp[i][j] = max(dp[i-1][j], dp[i][j-1]); // 取两种情况的较大值
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return dp[m][n];
|
|
|
|
|
|
}
|
2026-06-10 11:17:10 +08:00
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
**解释**:如果当前字符匹配,则 LCS 长度等于去掉这两个字符后的 LCS 长度加 1;如果不匹配,则取"去掉 X 的当前字符"和"去掉 Y 的当前字符"两种情况的较大值。
|
|
|
|
|
|
|
|
|
|
|
|
#### 5.6 矩阵链乘法
|
|
|
|
|
|
|
|
|
|
|
|
**问题**:给定 n 个矩阵的链 A₁A₂...Aₙ,找到使标量乘法次数最少的加括号方式。
|
|
|
|
|
|
|
|
|
|
|
|
**状态定义**:dp[i][j] = 计算矩阵 Aᵢ 到 Aⱼ 的最少乘法次数
|
|
|
|
|
|
|
|
|
|
|
|
**状态转移方程**:
|
|
|
|
|
|
|
|
|
|
|
|
$$dp[i][j] = \min_{i \le k < j} \{ dp[i][k] + dp[k+1][j] + p_{i-1} \cdot p_k \cdot p_j \}$$
|
|
|
|
|
|
|
|
|
|
|
|
这是动态规划的经典应用,时间复杂度 O(n³)。
|
|
|
|
|
|
|
|
|
|
|
|
### 六、动态规划作为优化决策过程
|
|
|
|
|
|
|
|
|
|
|
|
> [!tip] 动态规划的本质
|
|
|
|
|
|
> 动态规划本质上是将一个**优化决策过程**分解为多个阶段,每个阶段做一次决策。通过存储每个阶段每个状态下的最优解,最终得到全局最优决策序列。
|
|
|
|
|
|
|
|
|
|
|
|
```mermaid
|
|
|
|
|
|
flowchart LR
|
|
|
|
|
|
Init["Initial State S0"]
|
|
|
|
|
|
Decision1["Decision d1"]
|
|
|
|
|
|
State1["State S1"]
|
|
|
|
|
|
Decision2["Decision d2"]
|
|
|
|
|
|
State2["State S2"]
|
|
|
|
|
|
Dots["..."]
|
|
|
|
|
|
DecisionN["Decision dn"]
|
|
|
|
|
|
Final["Final State Sn"]
|
|
|
|
|
|
|
|
|
|
|
|
Init --> Decision1
|
|
|
|
|
|
Decision1 --> State1
|
|
|
|
|
|
State1 --> Decision2
|
|
|
|
|
|
Decision2 --> State2
|
|
|
|
|
|
State2 --> Dots
|
|
|
|
|
|
Dots --> DecisionN
|
|
|
|
|
|
DecisionN --> Final
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
## 关联笔记
|
|
|
|
|
|
- [[算法设计与分析/试题册/index|试题册索引]]
|