OneCoder Avatar
OneCodercoderli.com · 937 篇博文
六级

C++ 算法考级专栏

真题分析、矩阵探测、递归回溯与基础语法

🎨 视觉封面

【GESP】C++六级考试大纲知识点梳理, (5) 动态规划与背包问题

📅 2025-12-07·✍️ OneCoder·计算中...·⏱️ 21 分钟
#GESP#C++#考试大纲#动态规划

GESP C++六级官方考试大纲中,第5条考点标志着我们正式跨入了“算法设计”的深水区——动态规划。

(5)掌握简单动态规划的算法思想,能够使用代码解决相应的一维动态规划问题和简单背包问题。 {: .prompt-info}

本人也是边学、边实验、边总结,且对考纲深度和广度的把握属于个人理解。因此本文更多的不是一个教程,而是个人知识梳理,如有遗漏、疏忽,欢迎指正、交流。 {: .prompt-warning}

动态规划(Dynamic Programming,简称 DP)往往是初学者最头疼的拦路虎。很多同学觉得它玄之又玄,状态转移方程像天书一样。其实,DP 的核心思想非常朴素,就是“拒绝重复劳动”。本文将带你拆解 DP 的套路,并攻克一维 DP 和经典的背包问题。 {: .prompt-info}

六级考点系列:


一、什么是动态规划 (DP)?

动态规划 (Dynamic Programming) 是一种解决复杂问题的算法思想。它将一个大问题分解成若干个相互重叠的子问题,并通过 存储(记忆化) 每个子问题的解,避免重复计算,从而提高效率。

简单来说,DP 的核心就是:分而治之,并记住你做过的事情。 当下次遇到相同的小问题时,直接查“备忘录”,而不是重新计算一遍。

1.1 从斐波那契数列说起

大家都很熟悉斐波那契数列:f(n)=f(n1)+f(n2)f(n) = f(n-1) + f(n-2)。如果我们用递归直接写:

CPP
int f(int n) {
    if (n <= 2) return 1;
    return f(n-1) + f(n-2);
}

这有什么问题?大量的重复计算!

计算 f(5)f(5) 需要 f(4)f(4)f(3)f(3);而计算 f(4)f(4) 又需要 f(3)f(3)f(2)f(2)。你看,f(3)f(3) 被算了两次!当 nn 变大时,这种重复会呈指数级爆炸。

动态规划的智慧

既然 f(3)f(3) 算过了,为什么不把它记下来呢?下次再需要 f(3)f(3),直接查表,不用再算一遍。

这就是 DP 的核心:记忆化 (Memoization)表格法 (Tabulation)

  • 大问题拆解:把复杂问题拆成一个个子问题。
  • 记录答案:每个子问题只算一次,算完存起来。
  • 查表复用:需要时直接取用,拒绝重复劳动。

1.2 DP 解题三部曲

做 DP 题,心里要有这三步:

  1. 定义状态 (DP 数组的含义)

dp[i] 代表什么?(例如:走到第 i 级台阶的方法数、前 i 个物品的最大价值等)

  1. 找状态转移方程 (递推公式)

dp[i] 怎么由前面的 dp[i-1]dp[i-2] 推导出来?(例如:dp[i]=dp[i1]+dp[i2]dp[i] = dp[i-1] + dp[i-2]

  1. 确定边界条件 (初始化)

也就是“多米诺骨牌”的第一张牌。(例如:dp[0]=0,dp[1]=1dp[0]=0, dp[1]=1


二、一维动态规划实战:爬楼梯

2.1 题目描述

假设你正在爬楼梯。需要 nn 阶你才能到达楼顶。 每次你可以爬 1122 个台阶。 你有多少种不同的方法可以爬到楼顶呢?

2.2 三部曲分析

  1. 定义状态

dp[i] 表示到达第 i 阶楼顶的方法总数。

  1. 状态转移

想要到达第 i 阶,只有两种可能:

  • 从第 i-1 阶跨 1 步上来。
  • 从第 i-2 阶跨 2 步上来。

所以:dp[i]=dp[i1]+dp[i2]dp[i] = dp[i-1] + dp[i-2] (这不就是斐波那契数列吗?是的,很多简单 DP 本质就是递推)

  1. 边界条件
  • dp[1] = 1 (只有一种跳法:1)
  • dp[2] = 2 (两种跳法:1+1 或 2)

2.3 代码实现

CPP
#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 背包?

问题描述:你有一个背包,最大载重为 WW。商店里有 NN 件物品,第 ii 件物品的重量是 w[i]w[i],价值是 v[i]v[i]每件物品只有一件,你只能选择 “拿” 或者 “不拿”(这就是 0/1 的含义,不能切开拿一半)。 目标:在不超过背包载重的前提下,让包里物品的总价值最大。

3.2 0/1 背包的二维解法 (理解原理)

  1. 状态定义

dp[i][j] 表示:当我们只考虑前 i 件物品(也就是从第 1 件到第 i 件物品),并且背包的最大容量是 j 时,我们能够装入背包的物品的总价值最大是多少

例如:如果 dp[2][5] 的值为 12,就代表当我们只在物品 1 和物品 2 之间做选择,并且背包最多只能装下 5 单位重量时,我们能得到的最高总价值是 12。

  1. 状态转移:对于第 i 件物品,我们面临两个选择:
  • 不拿:那就只能指望前 i-1 件物品,且背包容量 j 没变。dp[i1][j]\rightarrow dp[i-1][j]
  • :前提是背包得装得下(jw[i]j \ge w[i])。如果拿了,价值增加 v[i]v[i],背包容量剩下 jw[i]j - w[i],剩下的容量留给前 i-1 件物品去发挥。dp[i1][jw[i]]+v[i]\rightarrow dp[i-1][j - w[i]] + v[i]

我们要取两者的最大值:

dp[i][j]=max(dp[i1][j],dp[i1][jw[i]]+v[i]) dp[i][j] = \max(dp[i-1][j], dp[i-1][j - w[i]] + v[i])

二维数组代码实现 (便于理解但耗内存)

CPP
#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=5000N=5000(物品数),W=20000W=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[jw[i]]+v[i]) 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=1w=1,价值 v=2v=2


❌ 错误演示:从左往右写 (正序 j:12j: 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:21j: 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. 一维优化代码模板

CPP
#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[i1]+dp[i2]dp[i] = dp[i-1] + dp[i-2]
0/1 背包 每种物品仅选一次 1. 状态压缩一维数组
2. 内层循环倒序 (防止重复选)

💡 核心思想辨析:DP vs 递归 你可能会觉得:“DP 的公式 f(n)=f(n1)+f(n2)f(n) = f(n-1) + f(n-2) 不就是递归吗?” 没错,数学逻辑是一样的,但执行效率天差地别:

  • 普通递归 (暴力):像是健忘的人。问他 f(3)f(3) 等于几,他算一遍;过一会为了算 f(4)f(4) 又问他 f(3)f(3),他忘记自己算过了,又重新算一遍。这会导致大量重复计算
  • DP (动态规划):像是带备忘录的人。算出 f(3)f(3) 后,他立马记在纸上(DP 数组)。下次再需要 f(3)f(3),他直接看纸,绝不重复劳动。
  • 总结:DP 本质上就是 “拒绝重复计算的递归”(通常通过自底向上填表来实现)。

备考建议

  1. 不要死记代码,要理解 dp 数组每个格子的含义。
  2. 画表辅助:初学时,在纸上画出二维表格,模拟填表过程,能极快帮助理解。
  3. 区分 0/1 背包与完全背包:0/1 背包内层倒序,完全背包(每种物品无限取)内层正序。虽然六级重点是 0/1,但了解区别有助于防止写错。

💡 OneCoder 资源指引

所有代码开源上传至 GitHub:yummy-code 仓库 · GESP 专题站:GESP WIKI

🤝 技术交流与答疑

欢迎加入:C++ GESP/CSP 考级答疑群(688906745)Java/Python交流群(982860385),点击可直接加群。

📚

猜你想读 · 相关文章推荐

GESP 编程与算法 · 六级⏱️ 19 分钟

【GESP】C++六级考试大纲知识点梳理, (1) 树的概念与遍历

GESP C++六级官方考试大纲中,包含了对更高级数据结构(如树)和基础算法的深入要求。本文针对第1条考点进行分析介绍。 (1)掌握树的基本概念,掌握其构造与遍历的相关算法。 {: .prompt-info} 本人也是边学、边实验、边总结,且对考纲深度和广度的把握属于个人理解。因此本文更多的不是一个教程,而是个人知识梳...

阅读全文 →
GESP 编程与算法 · 六级⏱️ 15 分钟

【GESP】C++六级考试大纲知识点梳理, (2) 哈夫曼树、完全二叉树与二叉排序树

GESP C++六级官方考试大纲中,第2条考点要求深入掌握几种特殊的树形结构。 (2)掌握哈夫曼树、完全二叉树、二叉排序树的相关概念和应用。 {: .prompt-info} 本人也是边学、边实验、边总结,且对考纲深度和广度的把握属于个人理解。因此本文更多的不是一个教程,而是个人知识梳理,如有遗漏、疏忽,欢迎指正、交流...

阅读全文 →
GESP 编程与算法 · 六级⏱️ 21 分钟

【GESP】C++六级考试大纲知识点梳理, (3) 哈夫曼编码与格雷码

GESP C++六级官方考试大纲中,第3条考点要求掌握两种具体的编码方式及其原理。 (3)理解哈夫曼编码、格雷编码相关原理并能进行简单应用。 {: .prompt-info} 本人也是边学、边实验、边总结,且对考纲深度和广度的把握属于个人理解。因此本文更多的不是一个教程,而是个人知识梳理,如有遗漏、疏忽,欢迎指正、交流...

阅读全文 →
OneCoder

OneCoder (lihongzheshuai)

一个中年人的自留地,记录学习 C++、GESP/NOI、Java、Python 与算法架构的心得体会。本站唯一网址:coderli.com

💬 读者留言与交流

0 条讨论
✨ 支持 Markdown 语法格式
还没有留言,快来成为第一个讨论者吧!