Files
final-exam/算法设计与分析/复习文档/动态规划.md
T

265 lines
8.8 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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]),要么是接在之前的最大子数组后面。
```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;
}
```
**解释**:对每个位置 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]$$
```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];
// 填第一行(只能从左边来)
for (int j = 1; j < n; j++)
dp[0][j] = dp[0][j-1] + matrix[0][j];
// 填第一列(只能从上面来)
for (int i = 1; i < n; i++)
dp[i][0] = dp[i-1][0] + matrix[i][0];
// 填其余位置
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];
return dp[n-1][n-1];
}
```
**解释**:到达位置 (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 数列。
```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];
}
```
> [!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):
```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];
}
```
> [!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}$$
```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];
}
```
**解释**:如果当前字符匹配,则 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|试题册索引]]