--- 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 #include using namespace std; int maxSubarraySum(vector& arr) { int n = arr.size(); vector 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 #include using namespace std; int maxMatrixPath(vector>& matrix, int n) { vector> dp(n, vector(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 using namespace std; int climbStairs(int n) { if (n <= 2) return n; vector 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 using namespace std; int fibDP(int n) { if (n <= 1) return n; vector 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 #include #include using namespace std; int LCS(string& X, string& Y, int m, int n) { vector> dp(m + 1, vector(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|试题册索引]]