第 6.1 章:动态规划入门
📝 前置条件: 确保理解递归(第 2.3 章)、数组/向量(第 2.3–3.1 章)和基本循环模式(第 2.2 章)。DP 直接建立在递归概念之上。
动态规划(DP)常被描述为「带记忆的聪明递归」。让我们从最简单的例子——斐波那契数列——从零建立这种直觉。
💡 核心思路: DP 解决具有两个性质的问题:
- 重叠子问题 —— 相同的子计算出现多次
- 最优子结构 —— 大问题的最优解可以由小问题的最优解构建
两者同时成立时,DP 将指数时间转化为多项式时间。
6.1.1 朴素递归的问题
斐波那契数列:0, 1, 1, 2, 3, 5, 8, 13, 21, ...
定义: F(0) = 0,F(1) = 1,F(n) = F(n-1) + F(n-2)(n ≥ 2)。
图示:斐波那契递归树和记忆化
fib(5) 的递归树暴露了问题:fib(3) 被计算了两次(红色节点)。记忆化在第一次计算时缓存每个结果,将 2^N 次调用减少到仅 N 次唯一调用——这是动态规划背后的基本洞察。
朴素递归实现:
int fib(int n) {
if (n == 0) return 0;
if (n == 1) return 1;
return fib(n-1) + fib(n-2); // 递归
}
这是正确的,但慢得可怕:
📄 这是正确的,但**慢得可怕**:
fib(5)
├── fib(4)
│ ├── fib(3)
│ │ ├── fib(2)
│ │ │ ├── fib(1) = 1
│ │ │ └── fib(0) = 0
│ │ └── fib(1) = 1
│ └── fib(2) ← 再次计算!
│ ├── fib(1) = 1
│ └── fib(0) = 0
└── fib(3) ← 再次计算!
├── fib(2) ← 再次计算!
│ ├── fib(1) = 1
│ └── fib(0) = 0
└── fib(1) = 1
fib(3) 被计算了两次,fib(2) 三次。对 fib(50),调用次数超过 10^10。这是指数时间:O(2^n)。
核心洞察:我们在一遍遍重复计算相同的子问题。DP 解决了这个问题。
6.1.2 记忆化(自顶向下 DP)
记忆化 = 递归 + 缓存。计算之前,检查是否已经计算过这个值。若是,返回缓存的结果;若否,计算它、缓存它、返回它。
📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 100;
long long memo[MAXN];
long long fib(int n) {
if (n <= 1) return n;
if (memo[n] != -1) return memo[n];
return memo[n] = fib(n-1) + fib(n-2);
}
int main() {
fill(memo, memo + MAXN, -1LL); // 将所有值初始化为 -1(「未计算」标记)
cout << fib(50) << "\n"; // 12586269025
return 0;
}
现在每个值被计算恰好一次。时间复杂度:O(N)。🎉
6.1.3 递推(自底向上 DP)
递推从头开始构建答案——先计算小子问题,用它们计算更大的问题。
📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;
int main() {
int n = 50;
vector<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]; // 使用已计算的值
}
cout << dp[n] << "\n"; // 12586269025
return 0;
}
甚至可以优化空间:由于每个斐波那契数只依赖前两个,只需 O(1) 空间:
long long a = 0, b = 1;
for (int i = 2; i <= n; i++) {
long long c = a + b;
a = b;
b = c;
}
cout << b << "\n";
记忆化 vs 递推
对 fib(4) 两种方式对比:
💡 核心区别: 自顶向下按需计算(只算用到的子问题),自底向上全量填表(按顺序算所有子问题)。两者时间复杂度相同,但自底向上没有递归栈开销。
| 方面 | 记忆化(自顶向下) | 递推(自底向上) |
|---|---|---|
| 方式 | 递归加缓存 | 迭代填表 |
| 内存使用 | 只有已计算的状态 | 所有状态(包括未用到的) |
| 实现 | 通常更直观 | 可能需要想清楚填充顺序 |
| 栈溢出风险 | 有(深度递归) | 无 |
| 速度 | 稍慢(函数调用开销) | 稍快 |
| USACO 偏好 | 适合理解和思考 | 适合最终提交 |
🏆 USACO 技巧: 竞赛中自底向上递推略有优势,因为它避免了潜在的栈溢出(在 N = 10^5 的题目中很关键),通常也更快。但若难以看清递推关系,先用自顶向下——这是一种很好的思考方式。
6.1.4 DP 四步法
每道 DP 题都遵循相同的做法:
DP 四步法——从状态定义到空间优化:
- 定义状态: 什么信息能唯一描述一个子问题?
- 定义递推:
dp[状态]如何依赖更小的状态? - 确定初始条件: 最简单子问题的答案是什么?
- 确定顺序: 以什么顺序填表?
应用到斐波那契:
- 状态:
dp[i]= 第 i 个斐波那契数 - 递推:
dp[i] = dp[i-1] + dp[i-2] - 初始条件:
dp[0] = 0,dp[1] = 1 - 顺序: i 从 2 到 n(每个依赖更小的 i)
6.1.5 硬币找零——经典 DP
题目: 有面额为 coins[] 的硬币,凑出金额 W 最少需要多少枚?每种面额可以无限次使用。
示例: coins = [1, 5, 6, 9],W = 11
先试试贪心(每次选最大的 ≤ 剩余金额):
- 贪心:9 + 1 + 1 = 3 枚 ← 不是最优!
- 最优:5 + 6 = 2 枚 ← DP 能找到
这就是为什么贪心在这里失败,需要 DP。
图示:硬币找零 DP 表
DP 表展示了 dp[i](凑出金额 i 的最少硬币数)从左到右的填写过程。对硬币 {1,3,4},注意 dp[3]=1(直接用硬币 3)和 dp[6]=2(用两个 3)。
DP 定义
对 coins = {1, 5, 6} 的状态转移:
- 状态:
dp[w]= 凑出恰好金额w的最少硬币数 - 递推:
dp[w] = 1 + min(对所有 c ≤ w 的硬币 c:dp[w - c])(使用硬币 c,然后最优地解决剩余的 w-c) - 初始条件:
dp[0] = 0(凑出金额 0 需要 0 枚) - 答案:
dp[W] - 顺序: w 从 1 到 W
完整演示:coins = [1, 5, 6, 9],W = 11
📄 查看代码:完整演示:coins = [1, 5, 6, 9],W = 11
dp[0] = 0 (初始条件)
dp[1]:用硬币 1:dp[0]+1=1 → dp[1] = 1
dp[2]:用硬币 1:dp[1]+1=2 → dp[2] = 2
...
dp[5]:用硬币 1:dp[4]+1=5
用硬币 5:dp[0]+1=1 → dp[5] = 1 ← 用 5 分硬币!
dp[6]:用硬币 1:dp[5]+1=2
用硬币 5:dp[1]+1=2
用硬币 6:dp[0]+1=1 → dp[6] = 1 ← 用 6 分硬币!
...
dp[11]:用硬币 5:dp[6]+1=2
用硬币 6:dp[5]+1=2 → dp[11] = 2 ← 5+6 或 6+5!
dp 表:[0, 1, 2, 3, 4, 1, 1, 2, 3, 1, 2, 2]
答案:dp[11] = 2(硬币 5 和 6)✓
📄 C++ 完整代码
// 最少硬币找零 — O(N × W)
#include <bits/stdc++.h>
using namespace std;
int main() {
ios_base::sync_with_stdio(false);
cin.tie(NULL);
int n, W;
cin >> n >> W;
vector<int> coins(n);
for (int &c : coins) cin >> c;
const int INF = 1e9;
vector<int> dp(W + 1, INF); // dp[w] = 凑出 w 的最少硬币数
dp[0] = 0; // 初始条件
for (int w = 1; w <= W; w++) {
for (int c : coins) {
if (c <= w && dp[w - c] != INF) {
dp[w] = min(dp[w], dp[w - c] + 1); // ← 关键行
}
}
}
if (dp[W] == INF) {
cout << "Impossible\n";
} else {
cout << dp[W] << "\n";
}
return 0;
}
复杂度分析:
- 时间:
O(N × W)—— 对每个金额 w(1..W),尝试所有 N 种硬币 - 空间:
O(W)—— 只需 dp 数组
6.1.6 方法数——硬币找零变体
题目: 用给定硬币凑出金额 W 有多少种不同方法?
有序(排列——顺序重要):[1,5] 和 [5,1] 是不同的
vector<long long> ways(W + 1, 0);
ways[0] = 1; // 凑出 0:一种方法(不用硬币)
for (int w = 1; w <= W; w++) {
for (int c : coins) {
if (c <= w) {
ways[w] += ways[w - c]; // ← 关键行
}
}
}
无序(组合——顺序不重要):[1,5] 和 [5,1] 是同一种
vector<long long> ways(W + 1, 0);
ways[0] = 1;
for (int c : coins) { // 外层循环:硬币(每种硬币只考虑一次)
for (int w = c; w <= W; w++) { // 内层循环:金额
ways[w] += ways[w - c];
}
}
💡 核心思路: 循环顺序决定了计数的是组合还是排列!硬币在外层循环时,每种硬币只被「引入」一次,忽略了顺序。金额在外层循环时,每个金额每次重新形成,允许所有排列。
⚠️ 第 6.1 章常见错误
- 最小化问题用 0 而非 INF 初始化 dp:
dp[w] = 0表示「0 枚硬币」,永远不会被改善。用dp[w] = INF,只有dp[0] = 0。 - 使用
dp[w-c]前不检查dp[w-c] != INF:INF + 1会溢出!始终检查子问题是否可解。 - 背包变体的循环顺序错误: 无界背包(硬币无限),金额正向循环;0/1 背包(每个只用一次),金额反向循环。搞错这一点会给出静默的错误答案。
- 用
INT_MAX作为 INF 然后加 1:INT_MAX + 1溢出成负数。用1e9或1e18作为 INF。 - 忘记初始条件:
dp[0] = 0至关重要,没有它什么都设不好。
本章总结
📌 核心要点
| 概念 | 要点 | 何时使用 |
|---|---|---|
| 重叠子问题 | 相同计算指数级重复 | 递归树中有重复调用 |
| 记忆化(自顶向下) | 缓存递归结果;易于编写 | 递归结构清晰时 |
| 递推(自底向上) | 迭代填表;无栈溢出 | 最终竞赛提交;大 N |
| DP 状态 | 唯一标识子问题的信息 | 仔细定义——决定了一切 |
| DP 递推 | dp[状态] 如何依赖更小状态 | 「转移方程」 |
| 初始条件 | 最简单子问题的已知答案 | 通常 dp[0] = 某个平凡值 |
🧩 DP 四步法速查
| 步骤 | 问题 | 斐波那契示例 |
|---|---|---|
| 1. 定义状态 | "dp[i] 代表什么?" | dp[i] = 第 i 个斐波那契数 |
| 2. 写递推 | "dp[i] 依赖哪些更小的状态?" | dp[i] = dp[i-1] + dp[i-2] |
| 3. 确定初始条件 | "最小子问题的答案是什么?" | dp[0]=0,dp[1]=1 |
| 4. 确定填充顺序 | "i 从小到大?从大到小?" | i 从 2 到 n |
❓ 常见问题
Q1:怎么判断一道题是 DP 题?
A:两个信号:① 题目问「最优值」或「方法数」(不是「输出具体方案」);② 存在重叠子问题(暴力递归中相同子问题被计算多次)。若贪心能被证明正确,通常不需要 DP;否则很可能是 DP。
Q2:应该用自顶向下还是自底向上?
A:学习时用自顶向下(更自然地表达递归思维);竞赛提交用自底向上(更快,无栈溢出)。两者都正确。若能快速写出自底向上,直接用它。
Q3:什么是「最优子结构」(无后效性)?
A:DP 的核心前提条件——一旦
dp[i]确定,后续计算不会「回来」修改它。换句话说,dp[i]的值只依赖于「过去」(更小的状态),而不是「未来」。若违反这个性质,不能用 DP。
Q4:INF 应该设为多少?
A:
int类型用1e9(= 10^9),long long类型用1e18(= 10^18)。不要用INT_MAX,因为INT_MAX + 1溢出成负数。
🔗 与后续章节的联系
- 第 6.2 章(经典 DP):扩展到 LIS、背包、网格路径——都是本章四步 DP 法的应用
- 第 6.3 章(进阶 DP):进入状压 DP、区间 DP、树形 DP——更复杂的状态定义,但思路相同
- 第 3.2 章(前缀和):差分数组有时可以替代简单 DP,前缀和数组可以加速 DP 中的区间计算
- 第 4.1 章(贪心)vs DP:贪心可解的问题是 DP 的特例(每步局部最优 = 全局最优);贪心失败时需要 DP
练习题
题目 6.1.1 — 爬楼梯 🟢 简单 每次可以爬 1 或 2 级台阶,有多少种方法爬 N 级台阶?
提示
这就是斐波那契!ways[1]=1,ways[2]=2。或从 ways[0]=1, ways[1]=1 开始,then ways[n] = ways[n-1] + ways[n-2]。✅ 完整题解
核心思路: ways[n] = 到达台阶 n 的方法数。你从台阶 n-1(1 步)或台阶 n-2(2 步)到达。
#include <bits/stdc++.h>
using namespace std;
int main() {
int n; cin >> n;
if (n == 1) { cout << 1; return 0; }
vector<long long> dp(n + 1);
dp[1] = 1; dp[2] = 2;
for (int i = 3; i <= n; i++)
dp[i] = dp[i-1] + dp[i-2];
cout << dp[n] << "\n";
}
复杂度: O(N) 时间,O(N) 空间(可用两个变量降为 O(1))。
题目 6.1.2 — 最少硬币找零 🟡 中等 给定硬币面额 [1, 3, 4] 和目标 6,找最少硬币数。(期望答案:2 枚——用 3+3)
✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
int main() {
int n, W; cin >> n >> W;
vector<int> coins(n);
for (int& c : coins) cin >> c;
const int INF = 1e9;
vector<int> dp(W + 1, INF);
dp[0] = 0;
for (int w = 1; w <= W; w++) {
for (int c : coins) {
if (c <= w && dp[w - c] != INF)
dp[w] = min(dp[w], dp[w - c] + 1);
}
}
cout << (dp[W] == INF ? -1 : dp[W]) << "\n";
}
贪心选 4 → 4+1+1 = 3 枚;DP 找 3+3 = 2 枚。复杂度: O(N × W)。
题目 6.1.3 — 瓷砖铺设 🟡 中等 用 1×2 多米诺骨牌(水平或垂直放置)铺满 2×N 的棋盘,有多少种方法?
提示
递推与斐波那契相同!关键洞察:在第 N 列放一块竖排骨牌,递归到 n-1;在第 N-1 和 N 列放两块横排骨牌,递归到 n-2。✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
const long long MOD = 1e9 + 7;
int main() {
int n; cin >> n;
if (n == 1) { cout << 1; return 0; }
vector<long long> dp(n + 1);
dp[1] = 1; dp[2] = 2;
for (int i = 3; i <= n; i++)
dp[i] = (dp[i-1] + dp[i-2]) % MOD;
cout << dp[n] << "\n";
}
复杂度: O(N)。
题目 6.1.4 — 有限次使用的硬币找零 🔴 困难 与硬币找零相同,但每种硬币最多用一次(0/1 背包),找最少硬币数。
提示
0/1 背包变体,关键技巧:将 w 从 W 反向迭代到 coins[i],防止重复使用同一枚硬币。✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
int main() {
int n, W; cin >> n >> W;
vector<int> coins(n);
for (int& c : coins) cin >> c;
const int INF = 1e9;
vector<int> dp(W + 1, INF);
dp[0] = 0;
for (int i = 0; i < n; i++) {
// 反向顺序:防止硬币 i 被用超过一次
for (int w = W; w >= coins[i]; w--) {
if (dp[w - coins[i]] != INF)
dp[w] = min(dp[w], dp[w - coins[i]] + 1);
}
}
cout << (dp[W] == INF ? -1 : dp[W]) << "\n";
}
为什么反向? 正向时可能用更新过的 dp[w] 来更新自身——等于把同一枚硬币用了两次。复杂度: O(N × W)。
题目 6.1.5 — USACO Bronze:干草堆叠放 🔴 困难 N 次操作「给位置 L 到 R 的所有位置加 1」,求每个位置的最终值。
提示
差分数组:`diff[L]++`,`diff[R+1]--`,然后取前缀和得到最终值。✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
int main() {
ios_base::sync_with_stdio(false); cin.tie(NULL);
int n, q; cin >> n >> q;
vector<long long> diff(n + 2, 0);
while (q--) {
int l, r; cin >> l >> r;
diff[l]++;
diff[r + 1]--;
}
long long cur = 0;
for (int i = 1; i <= n; i++) {
cur += diff[i];
cout << cur << " \n"[i == n];
}
}
复杂度: O(N + Q)。
🏆 挑战题:有障碍的唯一路径 N×M 网格有「.」格子和「#」障碍,统计从 (1,1) 到 (N,M) 只向右或向下移动的路径数,答案对 10^9+7 取模。(N, M ≤ 1000)
✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
const long long MOD = 1e9 + 7;
int main() {
int n, m; cin >> n >> m;
vector<string> grid(n);
for (auto& row : grid) cin >> row;
vector<vector<long long>> dp(n, vector<long long>(m, 0));
if (grid[0][0] == '.') dp[0][0] = 1;
for (int i = 0; i < n; i++) {
for (int j = 0; j < m; j++) {
if (grid[i][j] == '#') { dp[i][j] = 0; continue; }
if (i > 0) dp[i][j] = (dp[i][j] + dp[i-1][j]) % MOD;
if (j > 0) dp[i][j] = (dp[i][j] + dp[i][j-1]) % MOD;
}
}
cout << dp[n-1][m-1] << "\n";
}
复杂度: O(N × M) 时间和空间。
图示:斐波那契递归树
上图展示了 fib(6) 的朴素递归。红色虚线节点是重复子问题——被多次计算。绿色节点展示记忆化缓存结果的位置。不用记忆化:O(2^N);用记忆化:O(N)。这是动态规划背后的基本洞察。