【GESP】C++六级考试大纲知识点梳理, (5) 动态规划与背包问题
GESP C++六级官方考试大纲中,第5条考点标志着我们正式跨入了“算法设计”的深水区——动态规划。
(5)掌握简单动态规划的算法思想,能够使用代码解决相应的一维动态规划问题和简单背包问题。
本人也是边学、边实验、边总结,且对考纲深度和广度的把握属于个人理解。因此本文更多的不是一个教程,而是个人知识梳理,如有遗漏、疏忽,欢迎指正、交流。
动态规划(Dynamic Programming,简称 DP)往往是初学者最头疼的拦路虎。很多同学觉得它玄之又玄,状态转移方程像天书一样。其实,DP 的核心思想非常朴素,就是“拒绝重复劳动”。本文将带你拆解 DP 的套路,并攻克一维 DP 和经典的背包问题。
六级考点系列:
一、什么是动态规划 (DP)?
动态规划 (Dynamic Programming) 是一种解决复杂问题的算法思想。它将一个大问题分解成若干个相互重叠的子问题,并通过 存储(记忆化) 每个子问题的解,避免重复计算,从而提高效率。
简单来说,DP 的核心就是:分而治之,并记住你做过的事情。 当下次遇到相同的小问题时,直接查“备忘录”,而不是重新计算一遍。
1.1 从斐波那契数列说起
大家都很熟悉斐波那契数列:$f(n) = f(n-1) + f(n-2)$。如果我们用递归直接写:
1
2
3
4
int f(int n) {
if (n <= 2) return 1;
return f(n-1) + f(n-2);
}
这有什么问题?大量的重复计算!
计算 $f(5)$ 需要 $f(4)$ 和 $f(3)$;而计算 $f(4)$ 又需要 $f(3)$ 和 $f(2)$。你看,$f(3)$ 被算了两次!当 $n$ 变大时,这种重复会呈指数级爆炸。
动态规划的智慧:
既然 $f(3)$ 算过了,为什么不把它记下来呢?下次再需要 $f(3)$,直接查表,不用再算一遍。
这就是 DP 的核心:记忆化 (Memoization) 或 表格法 (Tabulation)。
- 大问题拆解:把复杂问题拆成一个个子问题。
- 记录答案:每个子问题只算一次,算完存起来。
- 查表复用:需要时直接取用,拒绝重复劳动。
1.2 DP 解题三部曲
做 DP 题,心里要有这三步:
- 定义状态 (DP 数组的含义)
dp[i] 代表什么?(例如:走到第 i 级台阶的方法数、前 i 个物品的最大价值等)
- 找状态转移方程 (递推公式)
dp[i] 怎么由前面的 dp[i-1]、dp[i-2] 推导出来?(例如:$dp[i] = dp[i-1] + dp[i-2]$)
- 确定边界条件 (初始化)
也就是“多米诺骨牌”的第一张牌。(例如:$dp[0]=0, dp[1]=1$)
二、一维动态规划实战:爬楼梯
2.1 题目描述
假设你正在爬楼梯。需要 $n$ 阶你才能到达楼顶。 每次你可以爬 $1$ 或 $2$ 个台阶。 你有多少种不同的方法可以爬到楼顶呢?
2.2 三部曲分析
- 定义状态:
dp[i] 表示到达第 i 阶楼顶的方法总数。
- 状态转移:
想要到达第 i 阶,只有两种可能:
- 从第
i-1阶跨 1 步上来。 - 从第
i-2阶跨 2 步上来。
所以:$dp[i] = dp[i-1] + dp[i-2]$ (这不就是斐波那契数列吗?是的,很多简单 DP 本质就是递推)
- 边界条件:
dp[1] = 1(只有一种跳法:1)dp[2] = 2(两种跳法:1+1 或 2)
2.3 代码实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <iostream>
#include <vector>
int climbStairs(int n) {
if (n <= 2) return n;
// 1. 定义 DP 数组
std::vector<int> dp(n + 1);
// 2. 初始化边界
dp[1] = 1;
dp[2] = 2;
// 3. 开始递推 (从小到大)
for (int i = 3; i <= n; i++) {
dp[i] = dp[i - 1] + dp[i - 2]; // 状态转移方程
}
return dp[n];
}
三、背包问题 (Knapsack Problem)
背包问题是动态规划中最经典的模型。在六级考试中,主要考察最基础的0/1 背包问题。
3.1 什么是 0/1 背包?
问题描述:你有一个背包,最大载重为 $W$。商店里有 $N$ 件物品,第 $i$ 件物品的重量是 $w[i]$,价值是 $v[i]$。每件物品只有一件,你只能选择 “拿” 或者 “不拿”(这就是 0/1 的含义,不能切开拿一半)。 目标:在不超过背包载重的前提下,让包里物品的总价值最大。
3.2 0/1 背包的二维解法 (理解原理)
- 状态定义:
dp[i][j] 表示:当我们只考虑前 i 件物品(也就是从第 1 件到第 i 件物品),并且背包的最大容量是 j 时,我们能够装入背包的物品的总价值最大是多少。
例如:如果 dp[2][5] 的值为 12,就代表当我们只在物品 1 和物品 2 之间做选择,并且背包最多只能装下 5 单位重量时,我们能得到的最高总价值是 12。
- 状态转移:对于第
i件物品,我们面临两个选择:
- 不拿:那就只能指望前
i-1件物品,且背包容量j没变。$\rightarrow dp[i-1][j]$ - 拿:前提是背包得装得下($j \ge w[i]$)。如果拿了,价值增加 $v[i]$,背包容量剩下 $j - w[i]$,剩下的容量留给前
i-1件物品去发挥。$\rightarrow dp[i-1][j - w[i]] + v[i]$
我们要取两者的最大值: \(dp[i][j] = \max(dp[i-1][j], dp[i-1][j - w[i]] + v[i])\)
二维数组代码实现 (便于理解但耗内存)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
const int MAX_N = 105;
const int MAX_W = 1005;
int w[MAX_N], v[MAX_N];
int dp[MAX_N][MAX_W]; // 定义在全局,自动初始化为 0
int main() {
int N, W;
cin >> N >> W;
for (int i = 1; i <= N; i++) cin >> w[i] >> v[i];
// 外层枚举物品 (i 从 1 到 N)
for (int i = 1; i <= N; i++) {
// 内层枚举容量 (j 从 0 到 W)
for (int j = 0; j <= W; j++) {
if (j < w[i]) {
// 装不下,只能继承上一轮的值 (相当于不拿)
dp[i][j] = dp[i-1][j];
} else {
// 能装下,在“不拿”和“拿”之间选最大值
dp[i][j] = max(dp[i-1][j], dp[i-1][j - w[i]] + v[i]);
}
}
}
cout << dp[N][W] << endl;
return 0;
}
3.3 一维优化 (滚动数组 - 考试推荐)
1. 为什么一定要优化?(算笔账)
假设题目给出 $N=5000$(物品数),$W=20000$(载重)。
- 二维方案 (
dp[5000][20000]):需要 1 亿个int,占用约 400 MB 内存。考试通常限制 128 MB,直接 爆内存 (MLE)。 - 一维方案 (
dp[20000]):只需要 2 万个int,占用约 80 KB 内存。非常安全。
2. 优化思路:一块黑板擦了写
观察二维公式:dp[i][j] 的值只依赖于 dp[i-1][...](也就是上一行)的值。 我们可以把 dp 数组看作唯一的一块黑板。
- 第 1 轮:在黑板上计算并写下处理完“物品 1”后的结果。
- 第 2 轮:直接在这块黑板上修改数据,计算“物品 2”的结果。
新公式: \(dp[j] = \max(dp[j], dp[j - w[i]] + v[i])\)
- 等号左边的
dp[j]:新数据(当前正在计算的,相当于第i行)。 - 等号右边的
dp[j]和dp[j-w]:我们希望读取到的是旧数据(黑板上原本写的,相当于第i-1行)。
3. 核心难点:要倒序遍历
既然是在同一块黑板上“擦了写”,就存在一个先后顺序问题。
假设场景:
黑板上写的全是 0(初始状态)。现在我们要处理 物品 i:重量 $w=1$,价值 $v=2$。
❌ 错误演示:从左往右写 (正序 $j: 1 \rightarrow 2$)
我们要计算 dp[1] 和 dp[2]。
| 步骤 | 目标 | 动作描述 | 黑板状态 (dp 数组) | 结果分析 |
|---|---|---|---|---|
| 初始 | [0, 0, 0, ...] | |||
| Step 1 | 计算 dp[1] | 需要用到 dp[1-1] 即 dp[0]。目前 dp[0]=0 (旧数据)。计算: 0 + 2 = 2。 | [0, 2, 0, ...] | 更新 dp[1] 为 2。含义:容量 1 装了物品 i。 |
| Step 2 | 计算 dp[2] | 需要用到 dp[2-1] 即 dp[1]。关键点:回头看黑板, dp[1] 已经是 2 了! (新数据)计算: 2 + 2 = 4。 | [0, 2, 4, ...] | 更新 dp[2] 为 4。含义:容量 2 装了 2个 物品 i。 (错误!变成了完全背包) |
问题出在哪? 当我们算
dp[2]时,我们需要的是旧的dp[1](应该是 0)。 但因为是从左往右算,dp[1]已经被 Step 1 覆盖成新的 2 了。我们用新数据推导新数据,导致物品被重复计算。
✅ 正确演示:从右往左写 (倒序 $j: 2 \rightarrow 1$)
| 步骤 | 目标 | 动作描述 | 黑板状态 (dp 数组) | 结果分析 |
|---|---|---|---|---|
| 初始 | [0, 0, 0, ...] | |||
| Step 1 | 计算 dp[2] | 需要用到 dp[2-1] 即 dp[1]。关键点:回头看黑板, dp[1] 还是 0 (旧数据)。计算: 0 + 2 = 2。 | [0, 0, 2, ...] | 更新 dp[2] 为 2。含义:容量 2 装了 1 个物品 i。 (正确!) |
| Step 2 | 计算 dp[1] | 需要用到 dp[1-1] 即 dp[0]。目前 dp[0]=0。计算: 0 + 2 = 2。 | [0, 2, 2, ...] | 更新 dp[1] 为 2。含义:容量 1 装了 1 个物品 i。 |
结论: 倒序遍历 保证了我们在计算
dp[j]时,它左边的dp[j-w]还没有被修改,依然是上一轮留下的旧数据。这就完美模拟了二维数组中“读取上一行”的效果。
4. 一维优化代码模板
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
const int MAX_W = 1005; // 只需要定义最大容量
int dp[MAX_W]; // 一维数组
int main() {
int N, W;
cin >> N >> W;
// 两个循环嵌套:
// 1. 外层枚举物品 (i 从 1 到 N)
// 2. 内层枚举容量 (j 从 W 到 w[i]) -> 必须倒序!
for (int i = 1; i <= N; i++) {
int w, v; // 可以边读边算,连 w[], v[] 数组都省了
cin >> w >> v;
// 倒序遍历:从背包最大容量 W 开始,直到放不下当前物品为止
for (int j = W; j >= w; j--) {
dp[j] = max(dp[j], dp[j - w] + v);
}
}
cout << dp[W] << endl;
return 0;
}
四、总结与技巧
| 知识点 | 核心思想 | 关键公式/技巧 |
|---|---|---|
| 动态规划 (DP) | 拆分子问题 + 记忆化 | 状态转移方程 |
| 一维 DP (爬楼梯) | 斐波那契数列变种 | $dp[i] = dp[i-1] + dp[i-2]$ |
| 0/1 背包 | 每种物品仅选一次 | 1. 状态压缩一维数组 2. 内层循环倒序 (防止重复选) |
💡 核心思想辨析:DP vs 递归 你可能会觉得:“DP 的公式 $f(n) = f(n-1) + f(n-2)$ 不就是递归吗?” 没错,数学逻辑是一样的,但执行效率天差地别:
- 普通递归 (暴力):像是健忘的人。问他 $f(3)$ 等于几,他算一遍;过一会为了算 $f(4)$ 又问他 $f(3)$,他忘记自己算过了,又重新算一遍。这会导致大量重复计算。
- DP (动态规划):像是带备忘录的人。算出 $f(3)$ 后,他立马记在纸上(DP 数组)。下次再需要 $f(3)$,他直接看纸,绝不重复劳动。
- 总结:DP 本质上就是 “拒绝重复计算的递归”(通常通过自底向上填表来实现)。
备考建议:
- 不要死记代码,要理解
dp数组每个格子的含义。 - 画表辅助:初学时,在纸上画出二维表格,模拟填表过程,能极快帮助理解。
- 区分 0/1 背包与完全背包:0/1 背包内层倒序,完全背包(每种物品无限取)内层正序。虽然六级重点是 0/1,但了解区别有助于防止写错。
所有代码已上传至Github:https://github.com/lihongzheshuai/yummy-code
GESP 学习专题站:GESP WIKI
“luogu-”系列题目可在洛谷题库进行在线评测。
“bcqm-”系列题目可在编程启蒙题库进行在线评测。
欢迎加入:Java、C++、Python技术交流QQ群(982860385),大佬免费带队,有问必答
欢迎加入:C++ GESP/CSP认证学习QQ频道,考试资源总结汇总
欢迎加入:C++ GESP/CSP学习交流QQ群(688906745),考试认证学员交流,互帮互助
