8.8 KiB
tags, create time
| tags | create time | |||
|---|---|---|---|---|
|
2026-06-10 11:11 |
动态规划
概述
动态规划(Dynamic Programming, DP)是解决多阶段决策最优化问题的重要方法。与贪心算法的"不回头"不同,动态规划通过系统地考察所有可能的子问题解,利用存储机制避免重复计算,从而高效地求得全局最优解。本文将从基本思想出发,讲解动态规划的五个步骤和多个经典应用。
正文
一、动态规划基本思想
动态规划的核心思想是:通过存储子问题的解,避免重复计算,将优化问题转化为决策过程。
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
二、两个关键性质
-
最优子结构(Optimal Substructure):问题的最优解包含子问题的最优解。可以通过组合子问题的最优解得到原问题的最优解。
-
重叠子问题(Overlapping Subproblems):递归求解时,相同的子问题被反复计算。动态规划通过存储已解决的子问题解来避免重复计算。
[!question] 动态规划的两个关键性质是什么? 最优子结构和重叠子问题。最优子结构说明子问题的最优解可以组合成全局最优解;重叠子问题说明存在重复计算,需要存储子问题的解。
三、动态规划五个基本步骤
这是解决动态规划问题的标准流程:
- 分析最优子结构:证明原问题的最优解包含子问题的最优解
- 定义状态(子问题):明确 dp[i] 或 dp[i][j] 代表什么含义
- 写出状态转移方程:dp[i] 如何由之前的状态推导而来
- 自底向上计算(填表):从小规模子问题开始,逐步填表
- 构造最优解:根据dp表回溯得到具体的最优解方案(不总是需要)
[!warning] 状态定义是最关键的一步 动态规划的难点往往在于正确地定义状态。状态必须能完整描述一个子问题,且能通过之前的状态推导出来。
四、与分治法的区别
| 特性 | 分治法 | 动态规划 |
|---|---|---|
| 子问题关系 | 相互独立、无重叠 | 有重叠子问题 |
| 是否需要存储 | 不需要(子问题只算一次) | 需要(避免重复计算) |
| 计算方向 | 自顶向下递归 | 自底向上填表(也可记忆化递归) |
| 典型应用 | 归并排序、快速排序 | 0-1背包、LCS、矩阵链乘法 |
五、经典应用
5.1 最大子数组和(Kadane算法)
问题:给定一个整数数组,找到一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。
状态定义:dp[i] = 以第 i 个元素结尾的最大子数组和
状态转移方程:
dp[i] = \max(arr[i], \ dp[i-1] + arr[i])
含义:以第 i 个元素结尾的最大子数组和,要么是从头开始(只取 arr[i]),要么是接在之前的最大子数组后面。
#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]
#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 数列。
#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):
#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}
#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] 动态规划的本质 动态规划本质上是将一个优化决策过程分解为多个阶段,每个阶段做一次决策。通过存储每个阶段每个状态下的最优解,最终得到全局最优决策序列。
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