跳转至

动态规划

概述

动态规划(Dynamic Programming,DP)是一种通过将复杂问题分解为**重叠子问题**,并存储子问题的解以避免重复计算的算法设计技术。它适用于具有**最优子结构**性质的问题,是算法设计中最重要、最强大的技术之一。

核心思想:动态规划通过状态定义状态转移方程,将问题分解为子问题,并使用表格(DP表)存储中间结果,实现以空间换时间的优化。递归+记忆化 = 动态规划。

动态规划的重要性

graph TB
    subgraph "动态规划的应用领域"
        A["动态规划"] --> B["最优化问题<br/>最短路径/最大收益"]
        A --> C["计数问题<br/>方案数统计"]
        A --> D["序列问题<br/>LCS/LIS/编辑距离"]
        A --> E["背包问题<br/>0-1背包/完全背包"]
        A --> F["图论问题<br/>TSP/最短路"]
    end
    
    style A fill:#E3F2FD,stroke:#2196F3,stroke-width:2px

动态规划三要素

1. 最优子结构

问题的最优解包含子问题的最优解。

graph TB
    A["问题的最优解"] --> B["子问题1的最优解"]
    A --> C["子问题2的最优解"]
    
    style A fill:#E8F5E9
    style B fill:#FFF3E0
    style C fill:#FFF3E0

示例:最短路径问题 - 从 A 到 C 的最短路径 = 从 A 到 B 的最短路径 + 从 B 到 C 的最短路径

2. 重叠子问题

子问题会被多次计算,这是动态规划发挥作用的关键。

graph TB
    subgraph "斐波那契递归的重叠子问题"
        A["F 5"] --> B["F 4"]
        A --> C["F 3"]
        B --> D["F 3"]
        B --> E["F 2"]
        C --> F["F 2"]
        C --> G["F 1"]
    end
    
    style C fill:#FFCDD2
    style D fill:#FFCDD2
    style E fill:#FFCDD2
    style F fill:#FFCDD2
重叠子问题的价值:F(3)、F(2) 被重复计算,使用DP存储后只需计算一次,将 O(2^n) 优化为 O(n)。

3. 无后效性

当前状态一旦确定,之前的状态不影响之后的决策(未来与过去无关)。

Text Only
1
2
3
4
5
6
无后效性示例 - 爬楼梯:

当前在第 i 阶:
- 不需要知道是如何到达第 i 阶的
- 只需要知道从第 i 阶可以跳到第 i+1 或 i+2 阶
- 过去的路径不影响未来的选择

解题步骤

动态规划的标准解题流程:

flowchart TB
    A["1. 定义状态<br/>dp[i] 表示什么?"] --> B["2. 建立状态转移方程<br/>dp[i] = f dp[...]"]
    B --> C["3. 确定初始条件<br/>dp[0], dp[1] 等"]
    C --> D["4. 确定计算顺序<br/>自底向上/自顶向下"]
    D --> E["5. 求解最终结果<br/>返回 dp[n] 或 max dp"]
    
    style A fill:#E3F2FD
    style B fill:#E8F5E9
    style C fill:#FFF3E0
    style D fill:#F3E5F5
    style E fill:#FCE4EC
步骤 说明 关键点
定义状态 确定 dp[i] 的含义 状态要能描述问题
状态转移 建立递推关系 利用子问题的解
初始条件 边界情况 保证转移方程正确
计算顺序 保证依赖已计算 通常从小到大
求解结果 返回最终答案 可能需要额外处理

经典问题详解

1. 斐波那契数列

问题:求第 n 个斐波那契数,F(n) = F(n-1) + F(n-2)

状态定义:dp[i] = 第 i 个斐波那契数

状态转移:dp[i] = dp[i-1] + dp[i-2]

DP表格填充过程:

Text Only
计算 F(6):

初始: dp[0]=0, dp[1]=1

i=2: dp[2] = dp[1] + dp[0] = 1 + 0 = 1
i=3: dp[3] = dp[2] + dp[1] = 1 + 1 = 2
i=4: dp[4] = dp[3] + dp[2] = 2 + 1 = 3
i=5: dp[5] = dp[4] + dp[3] = 3 + 2 = 5
i=6: dp[6] = dp[5] + dp[4] = 5 + 3 = 8

DP表: [0, 1, 1, 2, 3, 5, 8]
C
long long fibonacciDP(int n) {
    if (n <= 1) return n;
    
    long long 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];
}

// 空间优化版本(只保留前两个状态)
long long fibonacciOptimized(int n) {
    if (n <= 1) return n;
    
    long long a = 0, b = 1;
    for (int i = 2; i <= n; i++) {
        long long temp = a + b;
        a = b;
        b = temp;
    }
    return b;
}

2. 爬楼梯问题

问题:每次可以爬 1 或 2 个台阶,问爬到第 n 阶有多少种方法?

graph LR
    subgraph "爬楼梯状态转移"
        A["第n-2阶"] -->|"跳2阶"| B["第n阶"]
        C["第n-1阶"] -->|"跳1阶"| B
    end
    
    style B fill:#E8F5E9

分析: - 要到达第 n 阶,只能从第 n-1 阶跳 1 阶,或从第 n-2 阶跳 2 阶 - 状态定义:dp[i] = 到达第 i 阶的方法数 - 状态转移:dp[n] = dp[n-1] + dp[n-2]

Text Only
1
2
3
4
5
6
7
8
9
爬到第 5 阶的方法数:

dp[1] = 1  (1)
dp[2] = 2  (1+1, 2)
dp[3] = 3  (1+1+1, 1+2, 2+1)
dp[4] = 5  (1+1+1+1, 1+1+2, 1+2+1, 2+1+1, 2+2)
dp[5] = 8

实际上就是斐波那契数列!
C
int climbStairs(int n) {
    if (n <= 2) return n;
    
    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];
}

3. 0-1 背包问题

问题:有 n 个物品,每个物品有重量 w[i] 和价值 v[i],背包容量为 W,求最大价值。

flowchart TB
    A["考虑第 i 个物品"] --> B{"剩余容量 ≥ w[i]?"}
    B -->|是| C["选择: 选或不选"]
    B -->|否| D["不能选<br/>dp i w = dp i-1 w"]
    C --> E["选: v[i] + dp i-1 w-w[i]"]
    C --> F["不选: dp i-1 w"]
    E --> G["取最大值"]
    F --> G
    
    style G fill:#E8F5E9

状态定义:dp[i][w] = 考虑前 i 个物品,容量为 w 时的最大价值

状态转移

Text Only
dp[i][w] = max(dp[i-1][w], dp[i-1][w-w[i]] + v[i])  如果 w >= w[i]
dp[i][w] = dp[i-1][w]                                如果 w < w[i]

DP表格示例:

Text Only
物品: [(w=2, v=3), (w=3, v=4), (w=4, v=5), (w=5, v=6)]
背包容量: W = 8

DP表格(行=物品,列=容量):

      w=0  w=1  w=2  w=3  w=4  w=5  w=6  w=7  w=8
i=0    0    0    0    0    0    0    0    0    0
i=1    0    0    3    3    3    3    3    3    3
i=2    0    0    3    4    4    7    7    7    7
i=3    0    0    3    4    5    7    8    9    9
i=4    0    0    3    4    5    7    8    9   10

最大价值 = dp[4][8] = 10
选择方案: 物品1(w=2,v=3) + 物品4(w=5,v=6) = 总价值10
C
int knapsack(int weights[], int values[], int n, int W) {
    int dp[n + 1][W + 1];
    
    // 初始化
    for (int i = 0; i <= n; i++) {
        for (int w = 0; w <= W; w++) {
            if (i == 0 || w == 0) {
                dp[i][w] = 0;
            } else if (weights[i - 1] <= w) {
                // 可以选,取选或不选的最大值
                int include = values[i - 1] + dp[i - 1][w - weights[i - 1]];
                int exclude = dp[i - 1][w];
                dp[i][w] = include > exclude ? include : exclude;
            } else {
                // 不能选
                dp[i][w] = dp[i - 1][w];
            }
        }
    }
    
    return dp[n][W];
}

// 空间优化:一维数组
int knapsackOptimized(int weights[], int values[], int n, int W) {
    int dp[W + 1];
    
    for (int w = 0; w <= W; w++) dp[w] = 0;
    
    for (int i = 0; i < n; i++) {
        // 逆序遍历,保证每个物品只选一次
        for (int w = W; w >= weights[i]; w--) {
            int include = values[i] + dp[w - weights[i]];
            if (include > dp[w]) dp[w] = include;
        }
    }
    
    return dp[W];
}

4. 最长公共子序列(LCS)

问题:求两个字符串的最长公共子序列长度。

状态定义:dp[i][j] = X[0..i-1] 和 Y[0..j-1] 的 LCS 长度

状态转移

Text Only
if X[i-1] == Y[j-1]: dp[i][j] = dp[i-1][j-1] + 1
else: dp[i][j] = max(dp[i-1][j], dp[i][j-1])

graph TB
    subgraph "LCS 状态转移"
        A["dp i j"] --> B{X i == Y j ?}
        B -->|是| C["dp i j = dp i-1 j-1 + 1"]
        B -->|否| D["dp i j = max dp i-1 j, dp i j-1"]
    end
    
    style C fill:#E8F5E9
    style D fill:#FFF3E0

DP表格示例:

Text Only
X = "ABCDGH"
Y = "AEDFHR"

      ""   A   E   D   F   H   R
""     0   0   0   0   0   0   0
A      0   1   1   1   1   1   1
B      0   1   1   1   1   1   1
C      0   1   1   1   1   1   1
D      0   1   1   2   2   2   2
G      0   1   1   2   2   2   2
H      0   1   1   2   2   3   3

LCS = 3,为 "ADH"
C
int lcs(char *X, char *Y, int m, int n) {
    int dp[m + 1][n + 1];
    
    for (int i = 0; i <= m; i++) {
        for (int j = 0; j <= n; j++) {
            if (i == 0 || j == 0) {
                dp[i][j] = 0;
            } else if (X[i - 1] == Y[j - 1]) {
                dp[i][j] = dp[i - 1][j - 1] + 1;
            } else {
                dp[i][j] = dp[i - 1][j] > dp[i][j - 1] ? 
                           dp[i - 1][j] : dp[i][j - 1];
            }
        }
    }
    
    return dp[m][n];
}

5. 最长递增子序列(LIS)

问题:求数组的最长严格递增子序列长度。

状态定义:dp[i] = 以 arr[i] 结尾的 LIS 长度

状态转移:dp[i] = max(dp[j] + 1),其中 j < i 且 arr[j] < arr[i]

Text Only
数组: [10, 9, 2, 5, 3, 7, 101, 18]

dp[0] = 1  (10)
dp[1] = 1  (9)
dp[2] = 1  (2)
dp[3] = 2  (2, 5)
dp[4] = 2  (2, 3)
dp[5] = 3  (2, 5, 7) 或 (2, 3, 7)
dp[6] = 4  (2, 5, 7, 101) 或 (2, 3, 7, 101)
dp[7] = 4  (2, 5, 7, 18) 或 (2, 3, 7, 18)

LIS = 4
C
int lis(int arr[], int n) {
    int dp[n];
    
    // 初始化:每个元素自身构成长度为1的LIS
    for (int i = 0; i < n; i++) dp[i] = 1;
    
    for (int i = 1; i < n; i++) {
        for (int j = 0; j < i; j++) {
            if (arr[j] < arr[i] && dp[j] + 1 > dp[i]) {
                dp[i] = dp[j] + 1;
            }
        }
    }
    
    int max = 0;
    for (int i = 0; i < n; i++) {
        if (dp[i] > max) max = dp[i];
    }
    
    return max;
}

6. 编辑距离

问题:将字符串 s1 转换为 s2 的最少操作次数(插入、删除、替换)。

状态定义:dp[i][j] = s1[0..i-1] 转换为 s2[0..j-1] 的最少操作数

状态转移

Text Only
1
2
3
if s1[i-1] == s2[j-1]: dp[i][j] = dp[i-1][j-1]
else: dp[i][j] = 1 + min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1])
       (删除)         (插入)         (替换)

C
int editDistance(char *s1, char *s2, int m, int n) {
    int dp[m + 1][n + 1];
    
    // 初始化
    for (int i = 0; i <= m; i++) dp[i][0] = i;  // 全部删除
    for (int j = 0; j <= n; j++) dp[0][j] = j;  // 全部插入
    
    for (int i = 1; i <= m; i++) {
        for (int j = 1; j <= n; j++) {
            if (s1[i - 1] == s2[j - 1]) {
                dp[i][j] = dp[i - 1][j - 1];  // 字符相同,无需操作
            } else {
                int insert = dp[i][j - 1];
                int delete = dp[i - 1][j];
                int replace = dp[i - 1][j - 1];
                
                int min = insert < delete ? insert : delete;
                min = min < replace ? min : replace;
                dp[i][j] = 1 + min;
            }
        }
    }
    
    return dp[m][n];
}

7. 最大子数组和(Kadane算法)

问题:求连续子数组的最大和。

状态定义:dp[i] = 以 arr[i] 结尾的最大子数组和

状态转移:dp[i] = max(arr[i], dp[i-1] + arr[i])

graph LR
    subgraph "Kadane算法核心"
        A["dp i-1 > 0?"] -->|是| B["dp i = dp i-1 + arr i<br/>继续累加"]
        A -->|否| C["dp i = arr i<br/>重新开始"]
    end
    
    style B fill:#E8F5E9
    style C fill:#FFF3E0
C
int maxSubArray(int arr[], int n) {
    int dp[n];
    dp[0] = arr[0];
    int max = dp[0];
    
    for (int i = 1; i < n; i++) {
        if (dp[i - 1] > 0) {
            dp[i] = dp[i - 1] + arr[i];  // 继续累加
        } else {
            dp[i] = arr[i];              // 重新开始
        }
        if (dp[i] > max) max = dp[i];
    }
    
    return max;
}

// 空间优化版本
int maxSubArrayOptimized(int arr[], int n) {
    int sum = arr[0], max = arr[0];
    
    for (int i = 1; i < n; i++) {
        if (sum > 0) {
            sum += arr[i];
        } else {
            sum = arr[i];
        }
        if (sum > max) max = sum;
    }
    
    return max;
}

记忆化搜索(自顶向下)

记忆化搜索是递归形式的动态规划,通过缓存避免重复计算:

flowchart TB
    A["递归调用"] --> B{"结果已缓存?"}
    B -->|是| C["直接返回缓存"]
    B -->|否| D["计算结果"]
    D --> E["存入缓存"]
    E --> F["返回结果"]
    
    style C fill:#E8F5E9
C
#define MAX 1000
int memo[MAX][MAX];

int lcsMemo(char *X, char *Y, int m, int n) {
    // 基本情况
    if (m == 0 || n == 0) return 0;
    
    // 查缓存
    if (memo[m][n] != -1) return memo[m][n];
    
    // 计算并存缓存
    if (X[m - 1] == Y[n - 1]) {
        memo[m][n] = 1 + lcsMemo(X, Y, m - 1, n - 1);
    } else {
        int left = lcsMemo(X, Y, m - 1, n);
        int right = lcsMemo(X, Y, m, n - 1);
        memo[m][n] = left > right ? left : right;
    }
    
    return memo[m][n];
}

动态规划 vs 其他方法

方法 特点 适用场景
暴力递归 简单直接,效率低 问题规模小
记忆化搜索 递归+缓存,直观 状态转移复杂
动态规划 迭代,效率高 标准DP问题
贪心 局部最优→全局最优 特殊最优化问题

DP问题分类

类型 典型问题 状态定义 复杂度
线性DP 最大子数组和、LIS、爬楼梯 一维状态 dp[i] O(n) ~ O(n²)
二维DP LCS、编辑距离、0-1背包 二维状态 dp[i][j] O(n²) ~ O(n²W)
区间DP 矩阵链乘法、回文分割 区间 dp[i][j] O(n³)
树形DP 树的最大独立集 树上递推 O(n)
状态压缩DP TSP、棋盘覆盖 位运算表示状态 O(n·2ⁿ)
数位DP 数字统计问题 按位处理 O(log n)

复杂度分析

问题 时间复杂度 空间复杂度 优化后空间
斐波那契 O(n) O(n) O(1)
爬楼梯 O(n) O(n) O(1)
0-1背包 O(nW) O(nW) O(W)
LCS O(mn) O(mn) O(min(m,n))
LIS O(n²) O(n) -
编辑距离 O(mn) O(mn) O(min(m,n))

参考资料