← 返回博客列表

一、什么是动态规划

动态规划(Dynamic Programming,DP)是一种把复杂问题拆成重叠子问题来求解的算法思想。它通过记录每个子问题的答案,避免在递归求解时重复计算,从而把指数级复杂度压到多项式级。

一句话定义
DP = 递推 + 记忆——找到"大问题"与"小问题"之间的递推关系,存下小问题答案,按顺序填表。

它的核心思想可以用三个关键词概括:

1.1 DP 与其他算法的对比

维度 动态规划 DP 贪心 分治
子问题是否重叠 重叠(必须记忆) 无(每次独立选最优) 不重叠(独立子问题)
当前选择是否影响未来 影响(需全局考虑) 不影响(局部最优即全局最优) 不影响(各子问题独立)
实现方式 递推填表 / 记忆化搜索 单层循环选最优 递归分治
典型问题 背包、LCS、编辑距离 找零(特定面额)、区间调度 归并排序、最近点对
⚠ 与贪心的核心差异
贪心一旦做了选择就不能反悔,所以"当前贪心的最优未必是全局最优"。DP 则把所有可能的子问题答案都算出来,再选最优——所以 DP 一定求出全局最优,但通常比贪心慢一个数量级。

二、核心原理与图解

掌握 DP 的关键是抓住"三要素:状态、转移、边界"。把这三件事想清楚,代码就自然写出来了。

2.1 DP 三要素图解

DP 三要素:状态 + 转移 + 边界 ① 状态定义 dp[i] 表示什么? 通常是"前 i 个/到第 i 步的答案" ② 状态转移方程 dp[i] = f(dp[j]) 从哪些更小状态推出当前状态 ③ 边界条件 dp[0] = ?,dp[1] = ? 最小的、可以直接确定的初值 示例:爬楼梯(爬 n 阶,每次 1 或 2 步) 状态: dp[i] = 爬到第 i 阶的方法数 转移: dp[i] = dp[i-1] + dp[i-2] 边界: dp[0]=1, dp[1]=1 前 7 项填表结果: i: 0 1 2 3 4 5 6 dp: 1 1 2 3 5 8 13 这就是斐波那契数列!每项 = 前两项之和 把"状态定义"想清楚,转移方程和边界就水到渠成
图 1:DP 三要素——状态定义、转移方程、边界条件

2.2 自底向上填表过程

DP 最常见的实现方式是自底向上(Bottom-Up)——从最小的子问题开始,按顺序填一张 DP 表,每次填一个格子时它依赖的格子已经填好了。以爬楼梯为例:

自底向上填表:从 dp[0] 开始向右推进 时间 1 dp[0] ① 初始 1 dp[1] ② 初始 2 dp[2] ③ =1+1 3 dp[3] ④ =1+2 5 dp[4] ⑤ =2+3 8 dp[5] ⑥ =3+5 答案 紫色虚线箭头表示"依赖关系":每个 dp[i] 依赖 dp[i-1] 和 dp[i-2]
图 2:自底向上填表——从已知初值按顺序推导到目标

2.3 与记忆化搜索的对比

DP 还有另一种实现:自顶向下记忆化搜索(Top-Down with Memoization)。从大问题出发递归,遇到子问题时先查表,没算过才递归。两种实现等价但风格不同:

自底向上(填表) vs 自顶向下(记忆化搜索) 自底向上 Bottom-Up 从小到大,按序填表 dp0 dp1 dp2 dp3 dp4 特点: 无递归,循环填表 省栈空间,常数小 需要按序计算所有状态 推荐 ✅ 自顶向下 Top-Down 从大到小递归 + 记忆 dp4 dp3 dp2 dp2* dp1 → 命中缓存 已算 特点: 写法自然,思路直接 有递归栈开销,部分子问题可跳过
图 3:自底向上填表 vs 自顶向下记忆化搜索

2.4 复杂度分析

⏱ 时间复杂度:O(状态数 × 转移代价)
一维 DP 通常 O(n),二维 DP 通常 O(n²),背包 O(nW);每种状态转移的常数操作数决定系数。

💾 空间复杂度:O(状态数)
滚动数组优化可降到 O(上一行/上两行),如一维 DP 用两个变量、二维 DP 用一维数组。

三、通用模板

下面给出两种最常见的 DP 模板框架——一维 DP 和二维 DP,分别覆盖 80% 的题目。

3.1 一维 DP 模板

// 一维 DP 通用模板:状态只依赖前几个状态
int solve(int n) {
    if (n <= 1) return n;  // 特判小数据

    vector<int> dp(n + 1);  // dp[i] 表示第 i 个状态的答案
    // ① 边界条件
    dp[0] = 0;
    dp[1] = 1;

    // ② 按顺序填表,写出转移方程
    for (int i = 2; i <= n; ++i) {
        dp[i] = dp[i-1] + dp[i-2];  // 例:斐波那契式转移
    }
    return dp[n];
}

// 空间优化版:只用两个变量,O(1) 空间
int solveOpt(int n) {
    if (n <= 1) return n;
    int prev2 = 0, prev1 = 1;
    for (int i = 2; i <= n; ++i) {
        int cur = prev1 + prev2;
        prev2 = prev1;
        prev1 = cur;
    }
    return prev1;
}

3.2 二维 DP 模板

// 二维 DP 通用模板:dp[i][j] 依赖 dp[i-1][j], dp[i][j-1] 等
int solve2D(const string& s1, const string& s2) {
    int m = s1.size(), n = s2.size();
    vector<vector<int>> dp(m + 1, vector<int>(n + 1, 0));

    // ① 边界:空串情况
    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 {
                dp[i][j] = min({dp[i-1][j], dp[i][j-1], dp[i-1][j-1]}) + 1;
            }
        }
    }
    return dp[m][n];
}
模板三要点
1. dp 数组大小多开一位:通常是 dp[n+1] 或 dp[m+1][n+1],用 0 下标表示"空"这种边界情况
2. 循环顺序:必须保证填 dp[i][j] 时它依赖的所有 dp[...] 已算好——二维通常 i 从小到大, j 从小到大
3. 滚动数组优化:若 dp[i] 只依赖 dp[i-1] 和 dp[i-2],可以把数组压缩到一维甚至两个变量

四、示例一:爬楼梯

题目(LeetCode 70):假设你正在爬楼梯。需要 n 阶你才能到达楼顶。每次你可以爬 1 或 2 个台阶。你有多少种不同的方法爬到楼顶?

4.1 思路:斐波那契式递推

到达第 n 阶有两种方式:从第 n-1 阶跨 1 步,或从第 n-2 阶跨 2 步。所以方法数 = 两者之和,这正是斐波那契数列的定义。

爬楼梯:dp[i] = dp[i-1] + dp[i-2] 第 0 阶 dp[0]=1 第 1 阶 dp[1]=1 第 2 阶 dp[2]=2 第 3 阶 dp[3]=3 第 4 阶 dp[4]=5 第 5 阶 dp[5]=8 跨 2 步 跨 1 步 到第 4 阶只能从第 3 阶跨 1 步(绿)或从第 2 阶跨 2 步(红)
图 4:爬楼梯状态转移——到达 i 阶只可能来自 i-1 或 i-2 阶

4.2 C++ 代码

// LeetCode 70:爬楼梯
class Solution {
public:
    int climbStairs(int n) {
        if (n <= 2) return n;  // 1 阶 1 种,2 阶 2 种

        // 状态:dp[i] = 爬到第 i 阶的方法数
        // 转移:dp[i] = dp[i-1] + dp[i-2]
        // 边界:dp[1]=1, dp[2]=2
        int prev2 = 1, prev1 = 2;  // dp[1], dp[2]

        for (int i = 3; i <= n; ++i) {
            int cur = prev1 + prev2;  // 当前 = 上一步 + 上上步
            prev2 = prev1;
            prev1 = cur;
        }
        return prev1;
    }
};
💡 复杂度
时间 O(n),空间 O(1)(滚动变量优化)。
易错点:n = 1 时直接返回 1,不要写 dp[2],否则会越界。所以小数据特判是 DP 题的"防雷第一步"。

五、示例二:最长公共子序列 LCS

题目(LeetCode 1143):给定两个字符串 text1 和 text2,返回它们的最长公共子序列的长度。如果不存在公共子序列,返回 0。

5.1 思路:二维 DP

定义 dp[i][j] 表示 text1[0..i-1] 和 text2[0..j-1] 的最长公共子序列长度。两种情况:

LCS 二维 DP 表填表过程 text1 = "abcde",text2 = "ace" ""(空) a c e "" a b c d e 0 0 0 0 0 1 1 1 0 1 1 1 0 1 2 2 0 1 2 2 0 1 2 3 答案 = 3 填表规律: ① 第 0 行/列:空串边界,全 0 ② 字符相同:dp[i][j] = dp[i-1][j-1]+1 ③ 字符不同:dp[i][j] = max(上, 左) ④ 紫色加深格:字符匹配 ⑤ 绿色格:最终答案 LCS 子序列: "a c e" 长度 3 从左上角 (0,0) 一格格填到右下角,每格依赖左、上、左上三个邻居
图 5:LCS 二维 DP 填表——字符匹配时取"左上+1",不匹配时取"上/左"较大者

5.2 C++ 代码

#include <string>
#include <vector>
using namespace std;

int longestCommonSubsequence(const string& text1, const string& text2) {
    int m = text1.size(), n = text2.size();
    // 状态:dp[i][j] = text1[0..i-1] 与 text2[0..j-1] 的 LCS 长度
    vector<vector<int>> dp(m + 1, vector<int>(n + 1, 0));

    // 边界:dp[0][j] = dp[i][0] = 0(空串与任意串 LCS 为 0)
    // 已在初始化时填好

    for (int i = 1; i <= m; ++i) {
        for (int j = 1; j <= n; ++j) {
            if (text1[i-1] == text2[j-1]) {
                // 当前字符相同,加入 LCS 末尾
                dp[i][j] = dp[i-1][j-1] + 1;
            } else {
                // 不同:跳过 text1 的当前字符或 text2 的当前字符
                dp[i][j] = max(dp[i-1][j], dp[i][j-1]);
            }
        }
    }
    return dp[m][n];
}
💡 复杂度与优化
时间 O(m × n),空间 O(m × n)。因为 dp[i] 只依赖 dp[i-1] 和 dp[i],可以用滚动数组把空间压到 O(n):
vector<int> dp(n+1, 0); 外层每轮 i 复用同一数组,注意 j 从左往右填时需要先存下旧的 dp[j-1]。

六、示例三:0-1 背包问题

题目:有 n 个物品和一个容量为 W 的背包。第 i 个物品的重量为 weight[i],价值为 value[i]。每个物品只能选一次(要么拿要么不拿)。求在不超过容量的前提下能获得的最大价值。

6.1 思路:经典二维 DP

定义 dp[i][w] 表示从前 i 个物品中选,总重量不超过 w 时的最大价值。对第 i 个物品,有两种选择:

取两者较大值。

0-1 背包 DP 表:3 个物品,容量 W=4 物品:(重1值2)(重2值3)(重3值4) 物品\容量 w=0 w=1 w=2 w=3 w=4 前 0 个 0 0 0 0 0 前 1 个 (重1,值2) 0 2 2 2 2 前 2 个 (重2,值3) 0 2 3 5 5 前 3 个 (重3,值4) 0 2 3 5 6 答案 = 6 填表规律: ① 第 0 行 = 0(无物品) ② w=0 列 = 0(无容量) ③ 装不下:dp[i][w] = dp[i-1][w] ④ 装得下: max(不拿, 拿) 最优解分析: 选第 1+2 个: 重 1+2=3, 值 2+3=5 选第 1+3 个: 重 1+3=4 ≤ 4 ✓ 值 2+4=6 ✓ 最优 表格中颜色越深表示价值越大 最优:物品 1 + 物品 3,总重 4,总价值 6
图 6:0-1 背包二维 DP 表——每格决策"拿"或"不拿"第 i 个物品

6.2 C++ 代码

#include <vector>
using namespace std;

// 0-1 背包:二维 DP 版本
int knapsack(const vector<int>& weight,
                const vector<int>& value,
                int W) {
    int n = weight.size();
    // 状态:dp[i][w] = 从前 i 个物品中选,总重 ≤ w 的最大价值
    vector<vector<int>> dp(n + 1, vector<int>(W + 1, 0));

    // 边界:dp[0][w] = 0, dp[i][0] = 0 已由初始化完成
    for (int i = 1; i <= n; ++i) {
        for (int w = 0; w <= W; ++w) {
            // 不拿第 i 个物品(下标 i-1)
            dp[i][w] = dp[i-1][w];
            // 拿(前提:装得下)
            if (w >= weight[i-1]) {
                int take = dp[i-1][w - weight[i-1]] + value[i-1];
                dp[i][w] = max(dp[i][w], take);
            }
        }
    }
    return dp[n][W];
}

// 空间优化:一维 DP,从右往左填表避免覆盖
int knapsackOpt(const vector<int>& weight,
                  const vector<int>& value,
                  int W) {
    int n = weight.size();
    vector<int> dp(W + 1, 0);  // 只保留"容量"维度

    for (int i = 0; i < n; ++i) {
        // 关键:w 必须从大到小!否则 dp[w-wi] 会被本物品的更新覆盖
        for (int w = W; w >= weight[i]; --w) {
            dp[w] = max(dp[w], dp[w - weight[i]] + value[i]);
        }
    }
    return dp[W];
}
⚠ 一维优化的核心陷阱
w 必须从大到小遍历!因为 dp[i][w] 依赖的是 dp[i-1][w-wi](上一轮的值)。如果从小到大遍历,dp[w-wi] 已经被本物品的更新覆盖,相当于"一个物品被拿了多次"——这就退化成完全背包了。

七、总结与适用场景

DP 核心要点

维度 要点
本质 用记忆化避免重叠子问题重复计算
时间复杂度 O(状态数 × 转移代价),常见 O(n²)
空间复杂度 O(状态数),滚动数组可降到 O(上一行)
识别信号 求最值(最大/最小/方案数),含"分步决策"
三要素 状态定义 + 转移方程 + 边界条件
常见类型 线性 DP、区间 DP、背包 DP、树形 DP、状态压缩 DP

典型题目清单

🎯 记忆口诀
"定义状态,写出转移;先填边界,再推答案。"
写 DP 题永远按这个顺序来:先想清楚 dp[i] 是什么意思,再思考 dp[i] 怎么从 dp[...] 推出来,最后填上 dp[0] 这种小数据作为起跑线,剩下的交给循环。