A 班 第 5 课:专题五·动态规划入门

适用对象: 完成第 4 课(前缀和与差分)的同学 使用方式: 自学讲义,Dev-C++ 5.11,练习按考场 freopen 格式写 学完本课,你应该能:

  1. 说清楚 DP 三件套——状态定义、转移方程、初始状态
  2. 独立做出一道线性 DP 经典题(数字三角形)和一道 01 背包真题(采药)
  3. 写出 01 背包的一维滚动数组模板,并解释"内层为什么必须倒序"

1. DP 是什么:把大问题拆成小问题,把答案存起来(15 分钟)

先别被"动态规划"这个名字吓到——上节课你已经写过 DP 了

preSum[i] = preSum[i - 1] + a[i]

这行代码干的事情就是:preSum[i](前 i 个数的和)这个答案,是靠 preSum[i-1](前 i-1 个数的和)这个更小规模的答案加一个数得来的,算完存进数组里,后面直接查。这就是 DP 的全部套路。

DP 三件套(考场上先在草稿纸上把这三条用中文写清楚,再动手写代码):

三件套 要回答的问题 前缀和的例子
① 状态定义 f[i] 到底表示什么?用一句中文说清楚 preSum[i] 表示前 i 个数的和
② 转移方程 f[i] 怎么由更小的 f[...] 算出来? preSum[i] = preSum[i-1] + a[i]
③ 初始状态 最小的那个 f 等于几?边界怎么定? preSum[0] = 0

最小的完整例子——爬楼梯: 一次能上 1 级或 2 级台阶,上 n 级台阶共有多少种走法?

#include <bits/stdc++.h>
using namespace std;

long long f[55];

int main() {
    int n = 10;
    f[1] = 1;
    f[2] = 2;
    for (int i = 3; i <= n; i++) f[i] = f[i - 1] + f[i - 2];
    cout << f[n] << endl;
    return 0;
}

输出:89

⚠️ 状态定义说不清楚,转移方程一定写不对。 如果你觉得"这题不知道怎么转移",十有八九是状态没定义好,回头重新想"f[i] 到底表示什么",而不是死盯着方程改。

📝 DP 和暴力搜索的区别: 暴力会把同一个小问题反复算很多遍(爬楼梯裸递归是 O(2ⁿ)),DP 把每个小问题的答案算一次就存下来,所以是 O(n)。"存下来别重算"就是 DP 省时间的全部秘密。


2. 例题一(线性 DP):数字三角形(洛谷 P1216,IOI 1994 Day1 T1 / USACO 1.5)

题意: 给一个 r 行的数字三角形,从顶点出发,每一步只能走到"左下"或"右下"相邻的数,一直走到底层。求路径上所有数之和的最大值。

样例(洛谷 P1216 官方样例):

输入样例:
5
7
3 8
8 1 0
2 7 4 4
4 5 2 6 5

输出样例:
30

先说一个陷阱:这题不能贪心。 如果每一步都选眼前较大的那个数:7 → 8 → 1 → 7 → 5,总和是 28;而真正的最优路径是 7 → 3 → 8 → 7 → 5,总和 30——第二步故意选了小的 3,才换来后面的大数。上节课(L02)讲贪心时说过:只有"每一步的局部最优不会挡住后面"的题才能贪心,这题恰恰不满足。

DP 三件套:

注意这题是自底向上算的(从最后一行往上推),答案就是 f[1][1]

#include <bits/stdc++.h>
using namespace std;

int a[1005][1005], f[1005][1005];

int main() {
    freopen("triangle.in", "r", stdin);
    freopen("triangle.out", "w", stdout);

    int r;
    cin >> r;
    for (int i = 1; i <= r; i++)
        for (int j = 1; j <= i; j++) cin >> a[i][j];

    for (int j = 1; j <= r; j++) f[r][j] = a[r][j];   // ③ 初始状态:最底层

    for (int i = r - 1; i >= 1; i--)                  // 从倒数第二行往上推
        for (int j = 1; j <= i; j++)
            f[i][j] = a[i][j] + max(f[i + 1][j], f[i + 1][j + 1]);

    cout << f[1][1] << endl;
    return 0;
}

📝 为什么自底向上比自顶向下省事? 自底向上时"底层的答案"是白送的(就是它自己),不用特判;如果反过来定义成"从顶走到 (i,j) 的最大和",也能做,但要处理每行第一个和最后一个位置没有左上/右上的边界情况,代码更啰嗦。状态定义换个方向,代码难度能差一倍——这是 DP 最值得练的手感。


3. 例题二(01 背包):采药(洛谷 P1048,NOIP 2005 普及组 T3)

题意: 山洞里有 M 株草药,第 i 株需要花 t[i] 分钟采摘,价值 v[i]。总共只有 T 分钟。每株草药要么采、要么不采,不能采一半,也不能采两次。求能拿到的最大总价值。

样例(洛谷 P1048 官方样例):

输入样例:
70 3
71 100
69 1
1 2

输出样例:
3

(第 1 株要 71 分钟,超过总时间 70,采不了;剩下两株时间 69 + 1 = 70 刚好够,价值 1 + 2 = 3。)

这就是最经典的 01 背包——"01"指的是每件物品只有"取(1)"和"不取(0)"两种选择。

3.1 先写清楚二维版本

#include <bits/stdc++.h>
using namespace std;

int t[105], v[105];
int f[105][1005];

int main() {
    int T = 70, M = 3;                 // 直接用官方样例的数据,方便你跟着跑
    int tt[4] = {0, 71, 69, 1};
    int vv[4] = {0, 100, 1, 2};
    for (int i = 1; i <= M; i++) { t[i] = tt[i]; v[i] = vv[i]; }

    for (int i = 1; i <= M; i++) {
        for (int j = 0; j <= T; j++) {
            f[i][j] = f[i - 1][j];                                   // 不采第 i 株
            if (j >= t[i])
                f[i][j] = max(f[i][j], f[i - 1][j - t[i]] + v[i]);   // 采第 i 株
        }
    }
    cout << f[M][T] << endl;
    return 0;
}

输出:3

3.2 压缩成一维(考场标准写法)

注意转移方程里 f[i][...] 只用到了 f[i-1][...],上上一行根本不需要——那就干脆只留一行,原地更新

for (int i = 1; i <= M; i++)
    for (int j = T; j >= t[i]; j--)          // ⚠️ 注意 j 是倒着走的
        f[j] = max(f[j], f[j - t[i]] + v[i]);

⚠️ 一维 01 背包的内层循环必须倒序,这是本课最重要的一条规矩。

为什么? 手工走两步就明白了。假设当前处理第 i 株草药,t[i] = 1, v[i] = 2

拿官方样例试:正确答案是 3,把内层写成正序会输出 140(把那株"1 分钟换 2 价值"的草药重复采了 70 次),而且编译器完全不会报错——这种"跑得通但答案偏大"的错误最难查,必须靠记规矩来防。

📝 一句话记法:01 背包(每件只能拿一次)内层倒序;完全背包(每件能拿无限次)内层正序——后者以后学,但你现在已经知道正序会发生什么了。


4. 读错误:3 个典型坑(10 分钟)

错误 1:一维 01 背包内层写成了正序

for (int i = 1; i <= M; i++)
    for (int j = t[i]; j <= T; j++)          ← 正序!
        f[j] = max(f[j], f[j - t[i]] + v[i]);
// 编译通过、运行不崩,答案却偏大——每件物品被重复选了很多次

📝 写完一维背包,第一件事就是回头确认内层是 for (int j = T; j >= t[i]; j--)

错误 2:忘了写初始状态

// 漏掉了这一行: for (int j = 1; j <= r; j++) f[r][j] = a[r][j];
for (int i = r - 1; i >= 1; i--)
    for (int j = 1; j <= i; j++)
        f[i][j] = a[i][j] + max(f[i + 1][j], f[i + 1][j + 1]);

f[r][...] 全是 0,等于把最后一行的数字全都白白丢掉了。拿官方样例试:正确答案 30,漏写初始化会输出 25(正好少了最后一行的那个 5)。

📝 DP 三件套里最容易漏的就是第 ③ 条。 写完转移方程,立刻回头问自己一句:"最小的那个状态我赋值了吗?" 另外 DP 数组一律开成全局——全局数组自动清零,局部数组不会(int f[1005]; 写在 main 里就全是垃圾值,背包会直接算错)。

错误 3:状态数组开小了

int f[100];               // T 最大是 1000
for (int j = T; j >= t[i]; j--) f[j] = ...;   // j 能取到 1000,越界!

📝 DP 数组的大小按"状态的最大取值 + 一点余量"来开:背包开 f[T + 5],数字三角形开 f[r + 5][r + 5]。开之前先回头看题目的数据范围那一段。


5. 本课练习

⭐ 基础题 1:爬楼梯(文件名 t1.cpp,freopen stairs.in/stairs.out

一次能上 1 级或 2 级台阶,求上 n 级台阶的方案数(1 ≤ n ≤ 50)。

输入样例:
10

输出样例:
89

⚠️ 这题故意把 n 开到 50,答案是 20365011074——超过 int 的范围(约 21 亿),必须用 long long。自己试一下用 int 会输出什么。

参考答案
#include <bits/stdc++.h>
using namespace std;

long long f[55];

int main() {
    freopen("stairs.in", "r", stdin);
    freopen("stairs.out", "w", stdout);

    int n;
    cin >> n;
    f[1] = 1;
    f[2] = 2;
    for (int i = 3; i <= n; i++) f[i] = f[i - 1] + f[i - 2];
    cout << f[n] << endl;
    return 0;
}

⚠️ 如果 n 可能等于 1,f[2] 虽然被赋了值但不会被输出,没有影响;但要是题目允许 n = 0,就得单独想清楚 f[0] 定义成几。DP 题写完一定要单独检查最小的那几个 n。


⭐⭐ 实战题 2:数字三角形(洛谷 P1216 原题,文件名 t2.cpp,freopen triangle.in/triangle.out

题面见上文例题一,1 ≤ r ≤ 1000,每个数在 0 ~ 100 之间。

输入样例:
5
7
3 8
8 1 0
2 7 4 4
4 5 2 6 5

输出样例:
30
参考答案
#include <bits/stdc++.h>
using namespace std;

int a[1005][1005], f[1005][1005];

int main() {
    freopen("triangle.in", "r", stdin);
    freopen("triangle.out", "w", stdout);

    int r;
    cin >> r;
    for (int i = 1; i <= r; i++)
        for (int j = 1; j <= i; j++) cin >> a[i][j];

    for (int j = 1; j <= r; j++) f[r][j] = a[r][j];
    for (int i = r - 1; i >= 1; i--)
        for (int j = 1; j <= i; j++)
            f[i][j] = a[i][j] + max(f[i + 1][j], f[i + 1][j + 1]);

    cout << f[1][1] << endl;
    return 0;
}

估一下规模:r = 1000 时三角形有 1000×1001÷2 ≈ 50 万个数,DP 每格 O(1),总共 5×10⁵ 次运算,随便过。两个 1005 × 1005 的全局 int 数组各约 4 MB、合计 8 MB,在通常 128 MB 的内存限制下够用。


⭐⭐⭐ 冲刺题 3:采药(洛谷 P1048 原题,文件名 t3.cpp,freopen medic.in/medic.out

题面见上文例题二,1 ≤ T ≤ 10001 ≤ M ≤ 100,每株草药的时间和价值都在 1 ~ 100 之间。要求写一维写法。

输入样例:
70 3
71 100
69 1
1 2

输出样例:
3
参考答案
#include <bits/stdc++.h>
using namespace std;

int t[105], v[105];
int f[1005];

int main() {
    freopen("medic.in", "r", stdin);
    freopen("medic.out", "w", stdout);

    int T, M;
    cin >> T >> M;
    for (int i = 1; i <= M; i++) cin >> t[i] >> v[i];

    for (int i = 1; i <= M; i++)
        for (int j = T; j >= t[i]; j--)              // 倒序!
            f[j] = max(f[j], f[j - t[i]] + v[i]);

    cout << f[T] << endl;
    return 0;
}

复杂度 O(M × T) = 100 × 1000 = 10⁵,毫无压力。写完检查三件事:内层倒序了吗?f 是全局数组(自动清零)吗?f 开到 T + 5 了吗?


本课要点速查

DP 三件套(草稿纸上先用中文写):

内容
① 状态定义 f[...] 表示什么,一句中文说清
② 转移方程 f[...] 怎么由更小规模的 f 算出来
③ 初始状态 最小规模的 f 等于几,边界在哪

两道题的三件套:

题目 状态定义 转移方程
数字三角形 P1216 f[i][j] = 从 (i,j) 走到底层的最大和 f[i][j] = a[i][j] + max(f[i+1][j], f[i+1][j+1])
采药 P1048 f[j] = 时间不超过 j 时的最大价值 f[j] = max(f[j], f[j - t[i]] + v[i])

01 背包一维模板(背下来):

for (int i = 1; i <= M; i++)
    for (int j = T; j >= t[i]; j--)      // 内层必须倒序
        f[j] = max(f[j], f[j - t[i]] + v[i]);

易错点: 内层正序 → 变成完全背包,答案偏大且不报错;DP 数组一律开全局(自动清零);数组大小按数据范围开到 最大值 + 5;方案数类的题注意 long long

以后学: 完全背包、多重背包、最长上升子序列、区间 DP、树形 DP——今天这三件套的思路完全通用,只是状态更复杂。


结束前的自我检查

  1. 不看讲义,写出 01 背包的一维三行模板,并解释内层为什么倒序
  2. 用一句中文分别说出数字三角形和采药的状态定义
  3. 练习 1、2 全部通过;⭐⭐⭐ 采药能独立写出一维版本
  4. 向别人解释:数字三角形为什么不能贪心?用官方样例里 7→8→1→7→5 这条路举例