```mermaid graph LR A[Mermaid Keeper] --> B[保持激活] ```

前言

上一章 图论 里我们处理了最短路、连通性、匹配这些”图上的问题”。但有一类问题它们不太一样——需要做一系列决策,每个决策影响后续的选择。比如:

  • 青蛙站在台阶 1,每次跳 1 或 2 步,怎么跳到台阶 NN 花费最少?
  • 从网格左上角走到右下角,只能往右或往下,有多少种走法?
  • 字符串 “atcoder” 作为子序列出现了多少次?

这些问题有一个共同特征:大问题的答案可以从小问题的答案推导出来。这不就是递推吗?没错,DP(动态规划)本质上就是”带备忘录的递推”——先算小问题,存起来,用小问题的答案算大问题。

这篇文章会用三个经典模型建立 DP 的思维框架:一维最优化二维计数匹配计数

问题的本质

从暴力到 DP:一个关键观察

假设你站在台阶 5,之前从台阶 1 跳过来。你只关心一件事:到达台阶 5 的最小花费是多少? 你不关心中间具体经过了哪些台阶——这就是”无后效性”。

暴力枚举所有跳法:每步 1 或 2 格,组合数是斐波那契级别,O(2N)O(2^N)

DP 的思路完全不同:只存”到达第 ii 个台阶的最小花费”,用第 i1i-1 和第 i2i-2 个的结果推出来。 这样只需要 O(N)O(N) 时间。

这就是 DP 的三步法:

  1. 定义状态dp[i]dp[i] 表示什么?
  2. 写转移方程dp[i]dp[i] 从哪些状态转移过来?
  3. 确定初始值和计算顺序

状态设计的核心原则

无后效性:当前状态一旦确定,未来的决策不需要知道”你是怎么到达当前状态的”。青蛙跳台阶满足无后效性——到达台阶 5 的最小花费只取决于台阶 3 和 4 的结果,不取决于你从 1→3→5 还是 1→2→4→5。

最优子结构:大问题的最优解包含小问题的最优解。如果到达台阶 5 的最优路径经过台阶 4,那么到达台阶 4 的部分一定也是到达台阶 4 的最优路径——否则你可以替换成更优的,矛盾。

理论 + 代码

模型一:一维最优化——青蛙跳台阶

NN 个台阶,第 ii 个台阶高度 hih_i。从台阶 ii 跳到台阶 jj 的花费是 hihj|h_i - h_j|。每次只能跳 1 或 2 步。求从台阶 1 到台阶 NN 的最小花费。

状态dp[i]dp[i] = 从台阶 1 到台阶 ii 的最小花费。

转移方程

dp[i]=min(dp[i1]+hihi1, dp[i2]+hihi2)dp[i] = \min(dp[i-1] + |h_i - h_{i-1}|,\ dp[i-2] + |h_i - h_{i-2}|)

初始值dp[1]=0dp[1] = 0(已经在起点),dp[2]=h1h2dp[2] = |h_1 - h_2|(只有一种跳法)。

为什么是对的?到达台阶 ii 的最后一步只有两种可能:从 i1i-1 跳过来,或者从 i2i-2 跳过来。枚举这两种,取较小的。这就穷尽了所有情况——因为每次只允许跳 1 或 2 步。

#include <cstdio>
#include <algorithm>
using namespace std;

int main() {
    int N;
    scanf("%d", &N);
    int h[100009];
    for (int i = 1; i <= N; i++) scanf("%d", &h[i]);

    int dp[100009];
    dp[1] = 0;                            // ① 起点
    dp[2] = abs(h[1] - h[2]);             // ② 只能从 1 跳过来
    for (int i = 3; i <= N; i++) {
        dp[i] = min(dp[i-1] + abs(h[i] - h[i-1]),   // ③ 从 i-1 跳
                     dp[i-2] + abs(h[i] - h[i-2]));  // ④ 从 i-2 跳
    }
    printf("%d\n", dp[N]);
}

逐行解析

  • ① 起点不用跳,花费 0。
  • ② 第 2 个台阶只能从第 1 个跳过来,别无选择。
  • ③④ 枚举两种可能的”最后一步”。这里没有遗漏——因为你不可能从 i3i-3 直接跳到 ii(规则不允许)。

模拟N=5N = 5, h=[8,6,9,2,5]h = [8, 6, 9, 2, 5]

ih[i]dp[i-1] + |h[i]-h[i-1]|dp[i-2] + |h[i]-h[i-2]|dp[i]
180
26|8-6| = 2
392 + |9-6| = 50 + |9-8| = 11
421 + |2-9| = 82 + |2-6| = 66
556 + |5-2| = 91 + |5-9| = 55

路径:1 → 3 → 5(跳 2 步,跳 2 步),花费 1 + 4 = 5。✓

模型二:二维计数——网格路径

H×WH \times W 的网格,从 (1,1)(1,1) 走到 (H,W)(H,W),只能往右或往下。有些格子是障碍。求路径数。

状态dp[i][j]dp[i][j] = 从 (1,1)(1,1) 走到 (i,j)(i,j) 的路径数。

转移方程dp[i][j]=dp[i1][j]+dp[i][j1]dp[i][j] = dp[i-1][j] + dp[i][j-1](如果 (i,j)(i,j) 不是障碍)。

为什么?到达 (i,j)(i,j) 的最后一步,要么从上方 (i1,j)(i-1,j) 往下走,要么从左方 (i,j1)(i,j-1) 往右走。两种情况不重叠,所以直接相加。

初始值dp[1][1]=1dp[1][1] = 1(如果起点不是障碍)。

#include <cstdio>
using namespace std;
const int MOD = 1000000007;

int main() {
    int H, W;
    scanf("%d%d", &H, &W);
    char grid[1009][1009];
    for (int i = 1; i <= H; i++) scanf("%s", grid[i] + 1);

    int dp[1009][1009] = {};
    dp[1][1] = 1;                                    // ① 起点
    for (int i = 1; i <= H; i++)
        for (int j = 1; j <= W; j++) {
            if (grid[i][j] == '#') { dp[i][j] = 0; continue; } // ② 障碍
            if (i > 1) dp[i][j] = (dp[i][j] + dp[i-1][j]) % MOD; // ③ 从上方
            if (j > 1) dp[i][j] = (dp[i][j] + dp[i][j-1]) % MOD; // ④ 从左方
        }
    printf("%d\n", dp[H][W]);
}

逐行解析

  • ① 起点到起点有 1 条路径(原地不动)。
  • ② 障碍格子不可能到达,路径数置 0。
  • ③④ 分别从上方和左方累加路径数。注意边界检查(i > 1, j > 1)。

递推 vs 记忆化搜索

上面两种 DP 都是”递推”——从小问题算到大问题,按固定顺序填表。

另一种方式是”记忆化搜索”——从大问题出发递归,遇到已算过的直接返回:

int memo[100009];

int solve(int i) {
    if (i == 1) return 0;
    if (i == 2) return abs(h[1] - h[2]);
    if (memo[i] != -1) return memo[i];        // ① 已算过,直接返回
    return memo[i] = min(solve(i-1) + abs(h[i]-h[i-1]),  // ② 递归算
                          solve(i-2) + abs(h[i]-h[i-2]));
}

两种方式等价,时间都是 O(N)O(N)。递推通常更快(无递归开销),记忆化更直觉(不用想计算顺序)。实际比赛中,状态转移顺序明确时用递推,图上 DP 或状态间有复杂依赖时用记忆化。

例题

例题 1:M&A 028 — Frog 1

题目NN 个台阶,高度 h1,,hNh_1, \ldots, h_N。从台阶 1 出发,每次跳 1 或 2 步,花费 hihj|h_i - h_j|。求到达台阶 NN 的最小花费。

数据范围2N1052 \le N \le 10^5

—— AtCoder M&A 028

分析:就是上面讲的模型一。直接套模板。

输入样例

5
8 6 9 2 5

输出5

代码

#include <cstdio>
#include <algorithm>
using namespace std;

int main() {
    int N;
    scanf("%d", &N);
    int h[100009];
    for (int i = 1; i <= N; i++) scanf("%d", &h[i]);

    int dp[100009];
    dp[1] = 0;
    dp[2] = abs(h[1] - h[2]);
    for (int i = 3; i <= N; i++)
        dp[i] = min(dp[i-1] + abs(h[i] - h[i-1]),
                     dp[i-2] + abs(h[i] - h[i-2]));
    printf("%d\n", dp[N]);
}

逐行解析

  • 状态 dp[i]dp[i] = 到达第 ii 个台阶的最小花费。
  • 转移:枚举最后一步的来源(i1i-1i2i-2),取最小值。
  • 初始值 dp[1]=0dp[1]=0dp[2]=h1h2dp[2]=|h_1-h_2|
  • 复杂度 O(N)O(N)

例题 2:M&A 031 — Taro’s Vacation

题目NN 天假期,第 ii 天有三个活动,幸福值分别为 ai,bi,cia_i, b_i, c_i。每天选一个活动,相邻两天不能选同一活动。求最大总幸福值。

数据范围1N1051 \le N \le 10^5

—— AtCoder M&A 031

分析:状态需要多加一维——“今天选了哪个活动”。

状态dp[i][j]dp[i][j] = 第 ii 天选活动 jjj=0,1,2j=0,1,2)时,前 ii 天的最大幸福值。

转移dp[i][j]=maxkj(dp[i1][k])+value[i][j]dp[i][j] = \max_{k \ne j}(dp[i-1][k]) + \text{value}[i][j]。昨天选了活动 kk,今天选活动 jj,要求 kjk \ne j

代码

#include <cstdio>
#include <algorithm>
using namespace std;

int main() {
    int N;
    scanf("%d", &N);
    int a[100009], b[100009], c[100009];
    for (int i = 1; i <= N; i++)
        scanf("%d%d%d", &a[i], &b[i], &c[i]);

    int dp[100009][3];
    dp[1][0] = a[1]; dp[1][1] = b[1]; dp[1][2] = c[1]; // ① 第一天直接选
    for (int i = 2; i <= N; i++) {
        dp[i][0] = max(dp[i-1][1], dp[i-1][2]) + a[i]; // ② 今天选 a
        dp[i][1] = max(dp[i-1][0], dp[i-1][2]) + b[i]; // ③ 今天选 b
        dp[i][2] = max(dp[i-1][0], dp[i-1][1]) + c[i]; // ④ 今天选 c
    }
    printf("%d\n", max({dp[N][0], dp[N][1], dp[N][2]}));
}

逐行解析

  • ① 第一天不需要考虑”昨天选了什么”,三个活动各自独立。
  • ②③④ 今天选活动 jj,昨天只能是另外两个活动之一,取较大的加上今天的幸福值。
  • 最终答案:最后一天三个活动取最大值。

模拟N=3N=3,活动值 (10,40,70),(20,50,80),(30,60,90)(10, 40, 70), (20, 50, 80), (30, 60, 90)

dp[i][0] (选a)dp[i][1] (选b)dp[i][2] (选c)
1104070
2max(40,70)+20=90max(10,70)+50=120max(10,40)+80=120
3max(120,120)+30=150max(90,120)+60=180max(90,120)+90=210

答案 = max(150, 180, 210) = 210。路径:c(70) → b(120) → c(210)。✓


例题 3:TB A25 — Number of Routes(网格路径计数)

题目H×WH \times W 网格,有些格子是障碍。从 (1,1)(1,1) 走到 (H,W)(H,W),只能往右或往下,求路径数。

数据范围1H,W10001 \le H, W \le 1000

—— AtCoder Tessoku Book A25

分析:模型二的原题。dp[i][j]=dp[i1][j]+dp[i][j1]dp[i][j] = dp[i-1][j] + dp[i][j-1]

代码

#include <cstdio>
using namespace std;
const int MOD = 1000000007;

int main() {
    int H, W;
    scanf("%d%d", &H, &W);
    char grid[1009][1009];
    for (int i = 1; i <= H; i++) scanf("%s", grid[i] + 1);

    int dp[1009][1009] = {};
    dp[1][1] = (grid[1][1] == '.');              // ① 起点不是障碍才有 1 条路
    for (int i = 1; i <= H; i++)
        for (int j = 1; j <= W; j++) {
            if (grid[i][j] == '#') continue;     // ② 障碍跳过
            if (i > 1) dp[i][j] = (dp[i][j] + dp[i-1][j]) % MOD;
            if (j > 1) dp[i][j] = (dp[i][j] + dp[i][j-1]) % MOD;
        }
    printf("%d\n", dp[H][W]);
}

逐行解析

  • ① 如果起点就是障碍,路径数为 0。
  • ② 障碍格子的 dpdp 值保持 0(初始化时已清零),不会贡献给后续格子。
  • 答案对 109+710^9+7 取模。如果题目要求 998244353998244353,改 MOD 即可。

例题 4:T90 008 — AtCounter(子序列匹配计数)

题目:长度 NN 的字符串 SS,求有多少个子序列(不要求连续)等于 “atcoder”。答案对 109+710^9+7 取模。

数据范围1N1051 \le N \le 10^5

—— AtCoder Typical 90 008

分析:状态需要两维——“处理了前 ii 个字符”和”已经匹配了目标串的前 jj 个字符”。

状态dp[j]dp[j] = 匹配了 “atcoder” 前 jj 个字符的方案数(省略 ii 维,用滚动数组)。

转移:遍历字符串 SS 的每个字符 cc。如果 cc 等于 “atcoder”[j],则 dp[j+1]+=dp[j]dp[j+1] \mathrel{+}= dp[j]——新匹配了第 j+1j+1 个字符。

关键:必须倒序枚举 jj。如果正序,同一个字符 cc 可能被多次使用(比如 cc = ‘a’,先从 dp[0]dp[0] 更新 dp[1]dp[1],然后又从 dp[1]dp[1] 更新 dp[2]dp[2]),但实际上一个字符只能用一次。倒序避免了这个问题。

代码

#include <cstdio>
#include <cstring>
using namespace std;
const int MOD = 1000000007;

int main() {
    int N;
    scanf("%d", &N);
    char S[100009];
    scanf("%s", S);
    const char* T = "atcoder";
    int len = strlen(T);

    int dp[8] = {};
    dp[0] = 1;                                  // ① 空串匹配完成,1 种方案
    for (int i = 0; i < N; i++) {
        for (int j = len - 1; j >= 0; j--) {    // ② 倒序!
            if (S[i] == T[j])
                dp[j+1] = (dp[j+1] + dp[j]) % MOD; // ③ 匹配成功
        }
    }
    printf("%d\n", dp[len]);                    // ④ 完整匹配 "atcoder" 的方案数
}

逐行解析

  • dp[0]=1dp[0] = 1:匹配了 0 个字符(空匹配),有 1 种方案——什么都不选。
  • ② 倒序枚举 jj 确保每个字符只被使用一次。
  • ③ 当前字符 S[i]S[i] 匹配目标串第 jj 个字符,方案数累加。
  • dp[7]dp[7] 是匹配完整 “atcoder”(7 个字符)的方案数。

模拟SS = “aatc”,目标 “at”。

字符dp[0]dp[1] (匹配了 ‘a’)dp[2] (匹配了 ‘at’)
初始100
’a’10+1=10
’a’11+1=20
’t’120+2=2
’c’122

“at” 出现了 2 次(第 1 个 ‘a’+第 3 个 ‘t’,或第 2 个 ‘a’+第 3 个 ‘t’)。✓


例题 5:M&A 029 — Climb Stairs(爬楼梯计数)

题目NN 阶楼梯,每次可以走 1 或 2 步。从第 0 阶走到第 NN 阶,有多少种走法?答案对 109+710^9+7 取模。

数据范围1N1061 \le N \le 10^6

—— AtCoder M&A 029

分析:和青蛙跳台阶的区别在于:这里求的是方案数而非最小花费。转移从 min 变成加法。

状态dp[i]dp[i] = 从第 0 阶到第 ii 阶的走法数。

转移dp[i]=dp[i1]+dp[i2]dp[i] = dp[i-1] + dp[i-2](最后一步走 1 步或 2 步,方案数相加)。

初始值dp[0]=1dp[0] = 1(已经在第 0 阶,1 种走法——不动),dp[1]=1dp[1] = 1(只能走 1 步)。

这其实就是斐波那契数列

代码

#include <cstdio>
using namespace std;
const int MOD = 1000000007;

int main() {
    int N;
    scanf("%d", &N);
    int dp[1000009];
    dp[0] = 1;             // ① 0 阶:1 种走法(不动)
    dp[1] = 1;             // ② 1 阶:只能走 1 步
    for (int i = 2; i <= N; i++)
        dp[i] = (dp[i-1] + dp[i-2]) % MOD;  // ③ 斐波那契!
    printf("%d\n", dp[N]);
}

逐行解析

  • ①② 边界条件。dp[0]=1dp[0]=1 是一个常见的约定——“什么都不做”也是一种方案。
  • ③ 到达第 ii 阶的最后一步要么从 i1i-1 走上来,要么从 i2i-2 走上来。走法数相加。

参考文献

教材讲解 — 競技プログラミングの鉄則 第 4 章

基础练习 — アルゴリズムと数学 演習問題集

系统练习 — 競技プログラミングの鉄則

实战练习 — 競プロ典型 90 問


系列索引

第零章 基础工具

第一章 搜索技术

第二章 数学基础

第三章 数据结构

第四章 图论

第五章 动态规划

第六章 贪心

第七章 字符串

第八章 进阶