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

8.8 KiB
Raw Blame History

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

二、两个关键性质

  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]),要么是接在之前的最大子数组后面。

#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

关联笔记