GESP 7级
01背包问题
7级 · DP/图论/搜索
📖 一句话理解
你有一个背包,最多能装 W 千克的东西。面前有 n 个物品,每个物品有自己的重量和价值。每个物品只能选或不选(01的含义:0代表不选,1代表选)。你要怎么装,才能让背包里物品的总价值最大?
💡 为什么重要?
背包问题是动态规划(DP)的入门经典。学好它,你就理解了DP的核心思想:"把大问题拆成小问题,记录每个小问题的答案,最终拼出大问题的答案"。几乎所有DP问题的思路都从这里开始。在GESP 7级考试中,背包问题是必考重点。
📋 前置知识(必须先掌握)
- 一维数组和二维数组:知道 int dp[100] 和 int dp[100][100] 怎么用
- for循环嵌套:至少会写两层for循环
- max()函数:取两个数中较大的那个
- 基本的递归概念:函数调用自己(不用很深,知道有这回事就行)
🧠 动态规划到底是什么?
想象你站在岔路口,要找一条到终点的最短路。你不知道哪条路最好,怎么办?
DP的做法是:从终点往回推,先算"离终点最近的每条路有多长",再算"再远一步的每条路有多长"……一步步推回到起点。
🔑 DP三步走(适用于所有DP问题)
- 定义状态:dp[i][w] 代表什么?(这里代表:前i个物品、背包容量为w时的最大价值)
- 找转移方程:dp[i][w] 怎么从更小的子问题得到?
- 确定初始值和遍历顺序:哪些值是已知的?先算谁后算谁?
📐 01背包的核心思路
第一步:定义状态
dp[i][w] = 从前 i 个物品中选,背包容量为 w 时,能获得的最大价值。
为什么是二维的?因为我们需要记录两个信息:用到了前几个物品?背包还剩多少容量?
第二步:转移方程(最关键!)
对于第 i 个物品,我们有两种选择:
- 不选第 i 个物品:最大价值 = dp[i-1][w](和前 i-1 个物品、容量 w 的结果一样)
- 选第 i 个物品:最大价值 = dp[i-1][w-weight[i]] + value[i](先空出 weight[i] 的容量,再加上第 i 个物品的价值)
两种选择取最大值:
📐 核心转移方程
dp[i][w] = max(dp[i-1][w], dp[i-1][w-w[i]] + v[i])
含义:不选第i个物品 vs 选第i个物品,取较大值
含义:不选第i个物品 vs 选第i个物品,取较大值
第三步:初始值
当没有任何物品可选时(i=0),最大价值是0;当背包容量为0时,最大价值也是0。所以 dp[0][w] = 0,dp[i][0] = 0。
🔢 手动推演:用例子理解
假设3个物品,背包容量5:
- 物品1:重量2,价值3
- 物品2:重量3,价值4
- 物品3:重量4,价值5
📊 dp表格推演(部分关键值)
dp[1][2] = max(dp[0][2], dp[0][0]+3) = max(0, 3) = 3 ✓
dp[2][5] = max(dp[1][5], dp[1][2]+4) = max(3, 3+4) = 7 ✓
dp[3][5] = max(dp[2][5], dp[2][1]+5) = max(7, 0+5) = 7 ✓
最终答案:dp[3][5] = 7(选物品1+物品2,总重5,总价值7)
💻 二维DP代码(最直观,建议先看这个)
💻 完整示例代码(二维版本)
#include <iostream>
#include <algorithm> // max()函数
using namespace std;
int main() {
int n = 3; // 物品数量
int W = 5; // 背包最大容量
int weight[] = {0, 2, 3, 4}; // 每个物品的重量(下标从1开始)
int value[] = {0, 3, 4, 5}; // 每个物品的价值(下标从1开始)
// dp[i][w] = 前i个物品、容量为w时的最大价值
// 多开一行一列,第0行和第0列初始为0
int dp[4][6] = {0}; // dp[0..3][0..5],全部初始化为0
// 外层循环:逐个考虑每个物品(i从1到n)
for (int i = 1; i <= n; i++) {
// 内层循环:逐个考虑每种容量(w从1到W)
for (int w = 1; w <= W; w++) {
// 先假设不选第i个物品,价值不变
dp[i][w] = dp[i-1][w];
// 如果背包容量够装第i个物品,考虑选它
if (w >= weight[i]) {
// 选第i个物品的价值 vs 不选的价值,取大的
dp[i][w] = max(dp[i][w], dp[i-1][w - weight[i]] + value[i]);
}
}
}
cout << "最大价值: " << dp[n][W] << endl; // 输出答案
return 0;
}⚡ 空间优化:一维DP(考试常用写法)
上面的二维代码中,第 i 行其实只用到了第 i-1 行的数据。所以我们可以只用一行数组,把空间从 O(n×W) 降到 O(W)。
⚠️ 关键:内层循环必须逆序!
为什么?如果正序遍历,dp[w-weight[i]] 可能已经在本轮被更新过了,相当于同一个物品被选了多次(那就变成完全背包了!)。
逆序保证每个物品只被选一次。
逆序保证每个物品只被选一次。
📐 一维空间优化
dp[w] = max(dp[w], dp[w-w[i]] + v[i])
内层循环:从大到小遍历 w(从 W 到 weight[i])
内层循环:从大到小遍历 w(从 W 到 weight[i])
💻 完整示例代码(一维优化版)
#include <iostream>
#include <algorithm> // max()函数
using namespace std;
int main() {
int n = 3; // 物品数量
int W = 5; // 背包最大容量
int weight[] = {2, 3, 4}; // 每个物品的重量
int value[] = {3, 4, 5}; // 每个物品的价值
int dp[6] = {0}; // 一维数组,dp[w] = 容量为w时的最大价值
// 初始全部为0
for (int i = 0; i < n; i++) { // 遍历每个物品
for (int w = W; w >= weight[i]; w--) { // ⚠️ 逆序!从大到小!
// dp[w]:不选当前物品(保持原值)
// dp[w-weight[i]] + value[i]:选当前物品
dp[w] = max(dp[w], dp[w - weight[i]] + value[i]);
}
}
cout << "最大价值: " << dp[W] << endl; // 输出答案
return 0;
}❓ 常见疑问
🤔 为什么逆序就保证每个物品只选一次?
逆序时,计算 dp[5] 时 dp[3] 还是上一轮的旧值(没被当前物品更新过),所以不会重复选择。如果正序,计算 dp[3] 时已经把当前物品放进去了一次,到 dp[5] 又用 dp[3] 来算,就等于同一个物品选了两次。
🚨 易错点
⚠️ 常见错误汇总
- 一维DP内层正序遍历:这是最常见的错误!01背包内层一定要逆序(从大到小),完全背包才正序。
- 数组越界:dp数组要开 W+1 大小(因为下标从0到W),忘记+1会越界。
- 忘记初始化:dp数组要初始化为0,否则会有随机值干扰结果。
- weight[i]越界判断:选物品前一定要检查 w >= weight[i],不然会访问负数下标。
- 下标混乱:二维版本从 i=1 开始,weight/value 下标也要从1开始;一维版本从 i=0 开始。两种写法下标规则不同,要分清!
🎯 练习建议
📝 循序渐进练习路径
- 第一步:先在纸上手动推一个小例子(比如3个物品、容量5),写出完整的dp表格。
- 第二步:照着二维代码,自己敲一遍,提交到 OJ 验证。
- 第三步:改写成一维版本,注意逆序遍历,再次提交验证。
- 第四步:做以下经典题目练习:
- P1048 采药(洛谷入门题)
- P1060 开心的金明
- P1049 装箱问题(变形:能否恰好装满)
- 第五步:对比01背包和完全背包的代码差异(内层循环方向!),加深理解。