目录
[二、01 背包:每个物品只能选一次的 "取舍艺术"](#二、01 背包:每个物品只能选一次的 “取舍艺术”)
[2.1 问题定义](#2.1 问题定义)
[2.2 暴力解法的困境](#2.2 暴力解法的困境)
[2.3 动态规划解法:从二维到一维](#2.3 动态规划解法:从二维到一维)
[2.3.1 第一步:定义状态](#2.3.1 第一步:定义状态)
[2.3.2 第二步:推导状态转移方程](#2.3.2 第二步:推导状态转移方程)
[2.3.3 第三步:初始化](#2.3.3 第三步:初始化)
[2.3.4 第四步:填表顺序](#2.3.4 第四步:填表顺序)
[2.3.5 代码实现(二维版本)](#2.3.5 代码实现(二维版本))
[2.3.6 空间优化:从二维到一维](#2.3.6 空间优化:从二维到一维)
[2.4 01 背包的变体:恰好装满背包](#2.4 01 背包的变体:恰好装满背包)
[2.5 实战案例:洛谷 P1048 采药](#2.5 实战案例:洛谷 P1048 采药)
[三、完全背包:每个物品可以选无限次的 "贪心博弈"](#三、完全背包:每个物品可以选无限次的 “贪心博弈”)
[3.1 问题定义](#3.1 问题定义)
[3.2 与 01 背包的核心区别](#3.2 与 01 背包的核心区别)
[3.3 动态规划解法:从二维到一维](#3.3 动态规划解法:从二维到一维)
[3.3.1 第一步:定义状态](#3.3.1 第一步:定义状态)
[3.3.2 第二步:推导状态转移方程](#3.3.2 第二步:推导状态转移方程)
[3.3.3 空间优化:一维数组](#3.3.3 空间优化:一维数组)
[3.3.4 代码实现(一维版本)](#3.3.4 代码实现(一维版本))
[3.4 完全背包的变体:恰好装满背包](#3.4 完全背包的变体:恰好装满背包)
[3.5 实战案例:洛谷 P1616 疯狂的采药](#3.5 实战案例:洛谷 P1616 疯狂的采药)
[四、01 背包与完全背包的对比总结](#四、01 背包与完全背包的对比总结)
[5.1 状态定义混淆](#5.1 状态定义混淆)
[5.2 容量枚举顺序错误](#5.2 容量枚举顺序错误)
[5.3 数据溢出问题](#5.3 数据溢出问题)
[5.4 初始化错误](#5.4 初始化错误)
在算法世界里,动态规划(DP)绝对是 "让人又爱又恨" 的存在 ------ 它像一把万能钥匙,能解开无数复杂的多阶段决策问题,但入门时的晦涩难懂又让很多初学者望而却步。而在动态规划的庞大体系中,背包问题无疑是最经典、最核心的分支,没有之一。
无论是面试高频题,还是算法竞赛中的基础模块,背包问题都占据着举足轻重的地位。它的核心思想 ------"拆分问题、存储子问题解、避免重复计算",不仅适用于背包本身,更能迁移到字符串匹配、路径规划、资源分配等诸多场景。
今天,我们就来深入剖析背包问题的两大基础模型:01 背包 和完全背包。这篇文章不会一上来就抛公式、堆代码,而是从实际场景出发,带你一步步拆解问题、定义状态、推导方程,再通过优化技巧提升效率。无论你是刚接触 DP 的新手,还是想巩固基础的老手,相信都能有所收获。现在就让我们正式开始吧!
一、背包问题的本质:资源分配的最优解
在聊具体模型之前,我们先搞懂一个核心问题:什么是背包问题?
通俗来讲,背包问题的本质是 "有限资源下的最优选择"。想象一个场景:你有一个容量有限的背包,面前有一堆物品,每个物品都有自己的重量(或体积)和价值。你需要决定选哪些物品放入背包,在不超过背包容量的前提下,让背包内物品的总价值最大。
这个场景可以衍生出多种变体:
- 每个物品只能选一次(01 背包);
- 每个物品可以选无限次(完全背包);
- 每个物品可以选有限次(多重背包);
- 物品分成几组,每组只能选一个(分组背包);
- 除了重量,还有体积等多个限制条件(多维背包)。
而我们今天要讲的 01 背包和完全背包,是所有变体的基础 ------ 吃透这两个模型,再学习其他变体就会水到渠成。
在正式开始前,我们先统一几个约定,避免后续混淆:
- 背包容量:通常用V表示(也可能是时间、金钱等资源);
- 物品数量:通常用n表示;
- 第
i个物品的重量(或消耗的资源):v[i];- 第
i个物品的价值:w[i];- 动态规划数组:**dp[j]**表示 "使用不超过
j的资源时,能获得的最大价值"(后续会详细解释状态定义)。
二、01 背包:每个物品只能选一次的 "取舍艺术"
2.1 问题定义
01 背包的核心约束是:每个物品要么选(1 次),要么不选(0 次),不存在选多次的情况。
举个生活化的例子:你要去旅行,背包容量是 5L。面前有 3 件物品:
- 物品 1:体积 2L,价值 10(一本绝版书);
- 物品 2:体积 4L,价值 5(一个普通水杯);
- 物品 3:体积 1L,价值 4(一个充电宝)。
请问如何选择物品,才能让背包内物品的总价值最大?
这就是典型的 01 背包问题 ------ 每个物品只能带一件,你需要在容量限制下做最优取舍。
2.2 暴力解法的困境
首先,我们想想最直接的暴力解法:枚举所有可能的选择组合,计算每个组合的总重量和总价值,筛选出符合容量限制的最大价值。
对于n个物品,总共有2^n种组合(每个物品都有选或不选两种状态)。当n=10时,组合数是 1024;当n=20时,组合数就达到了 100 多万;当n=30时,更是突破 10 亿 ------ 显然,暴力解法在n稍大时就会超时,完全不可行。
这时候,动态规划的优势就体现出来了:通过存储子问题的解,避免重复计算,将时间复杂度从O(2^n)降到O(nV),让问题变得可解。
2.3 动态规划解法:从二维到一维
2.3.1 第一步:定义状态
动态规划的核心是 "状态表示"------ 我们需要用一个数组来存储子问题的解。对于 01 背包,最直观的状态定义是二维数组:
dp[i][j]:从前i个物品中选择,且总重量不超过j时,能获得的最大价值。
这个定义包含两个关键信息:
- 选择范围:前
i个物品(不考虑后面的物品);- 资源限制:总重量≤
j;- 目标:最大价值。
有了这个状态定义,我们的最终答案就是dp[n][V]------ 从前n个物品中选择,总重量不超过V的最大价值。
2.3.2 第二步:推导状态转移方程
状态转移方程是动态规划的 "灵魂",它描述了子问题之间的递推关系。对于dp[i][j],我们可以根据 "第i个物品是否被选择" 分为两种情况:
- 不选第
i个物品 :此时的最大价值就等于**"从前i-1个物品中选择,总重量不超过j的最大价值"** ,即dp[i][j] = dp[i-1][j];- 选第
i个物品 :此时需要满足j ≥ v[i](背包容量足够装下第i个物品)。选择后,背包剩余容量为j - v[i],最大价值等于 "从前i-1个物品中选择,总重量不超过j - v[i]的最大价值" 加上第i个物品的价值,即dp[i][j] = dp[i-1][j - v[i]] + w[i]。
综合两种情况,状态转移方程为:
cpp
dp[i][j] = max(dp[i-1][j], (j >= v[i] ? dp[i-1][j - v[i]] + w[i] : 0))
简单理解:对于每个物品和每个容量,我们都在 "选" 和 "不选" 之间做最优决策,取两者的最大值。
2.3.3 第三步:初始化
初始化的目的是设置 "边界条件",让递推能够正常开始。对于二维数组dp:
- 当
i=0(没有物品可选)时,无论j是多少,最大价值都是 0,即dp[0][j] = 0;- 当
j=0(背包容量为 0,无法装任何物品)时,无论i是多少,最大价值都是 0,即dp[i][0] = 0。
2.3.4 第四步:填表顺序
二维数组的填表顺序很直观:
- 先枚举物品(从 1 到
n);- 再枚举容量(从 1 到
V)。
因为dp[i][j]只依赖于dp[i-1][...](前i-1个物品的状态),所以按行填表即可保证每个状态都能通过已计算的状态推导得出。
2.3.5 代码实现(二维版本)
我们用前面的旅行背包例子来实现二维版本的 01 背包:
cpp
#include <iostream>
#include <algorithm>
using namespace std;
const int N = 1010; // 物品数量和背包容量的最大值
int n, V; // n:物品数,V:背包容量
int v[N], w[N]; // v:物品重量,w:物品价值
int dp[N][N]; // 二维dp数组
int main() {
// 输入数据:3个物品,背包容量5
n = 3, V = 5;
v[1] = 2, w[1] = 10; // 物品1:体积2,价值10
v[2] = 4, w[2] = 5; // 物品2:体积4,价值5
v[3] = 1, w[3] = 4; // 物品3:体积1,价值4
// 初始化:dp[0][j]和dp[i][0]默认都是0,可省略显式初始化
// 填表:枚举物品和容量
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= V; j++) {
// 不选第i个物品
dp[i][j] = dp[i-1][j];
// 选第i个物品(如果容量足够)
if (j >= v[i]) {
dp[i][j] = max(dp[i][j], dp[i-1][j - v[i]] + w[i]);
}
}
}
// 输出结果:dp[3][5] = 14(选物品1和物品3:10+4=14)
cout << "二维版本01背包最大价值:" << dp[n][V] << endl;
return 0;
}
运行结果:14,与我们手动计算的最优解一致。
2.3.6 空间优化:从二维到一维
二维版本的时间复杂度是O(nV),空间复杂度也是O(nV)。当n和V都达到 1000 时,数组大小是 1e6,完全可以接受;但如果V达到 1e5,1e8 的数组大小就会超出内存限制。
因此,我们需要进行空间优化 ------ 将二维数组压缩为一维数组。
核心观察:
在二维数组中,dp[i][j]只依赖于dp[i-1][...](上一行的状态),而不依赖于dp[i][...](当前行的状态)。这意味着我们可以用一个一维数组**dp[j]**来存储状态,每次更新时覆盖旧值。
状态重定义:
dp[j]:当前考虑到第i个物品时,总重量不超过j的最大价值。
关键修改:容量枚举顺序
如果我们仍然按 "从左到右" 的顺序枚举容量,会出现一个问题:当计算**dp[j]**时,**dp[j - v[i]]**已经被当前行(第i个物品)更新过了,这会导致同一个物品被多次选择(变成完全背包的逻辑)。
为了避免这个问题,我们需要将容量枚举顺序改为从右到左 (从V到v[i])。这样,当计算**dp[j]**时,**dp[j - v[i]]**仍然是上一行(第i-1个物品)的状态,保证了每个物品只被选择一次。
优化后的状态转移方程:
cpp
for (int i = 1; i <= n; i++) {
// 从右到左枚举容量
for (int j = V; j >= v[i]; j--) {
dp[j] = max(dp[j], dp[j - v[i]] + w[i]);
}
}
一维版本代码实现:
cpp
#include <iostream>
#include <algorithm>
#include <cstring>
using namespace std;
const int N = 1010;
int n, V;
int v[N], w[N];
int dp[N]; // 一维dp数组
int main() {
n = 3, V = 5;
v[1] = 2, w[1] = 10;
v[2] = 4, w[2] = 5;
v[3] = 1, w[3] = 4;
// 初始化:dp[j]默认都是0
memset(dp, 0, sizeof dp);
// 填表:枚举物品,从右到左枚举容量
for (int i = 1; i <= n; i++) {
for (int j = V; j >= v[i]; j--) {
dp[j] = max(dp[j], dp[j - v[i]] + w[i]);
}
}
cout << "一维版本01背包最大价值:" << dp[V] << endl;
return 0;
}
运行结果同样是14,但空间复杂度从O(nV)降到了O(V),优化效果显著。
2.4 01 背包的变体:恰好装满背包
在实际问题中,有时会要求 "背包恰好装满",此时的解法需要对初始化和结果判断做一些修改。
核心思路:
- 初始化时,只有dp[0] = 0(容量为 0 时,恰好装满的价值为 0),其他dp[j]都初始化为
-∞(表示该容量无法恰好装满);- 状态转移方程不变;
- 最终结果如果dp[V] < 0,说明无法恰好装满背包,输出 0;否则输出dp[V]。
代码实现(恰好装满版本):
cpp
#include <iostream>
#include <algorithm>
#include <cstring>
using namespace std;
const int N = 1010;
const int INF = 0x3f3f3f3f; // 表示无穷大
int n, V;
int v[N], w[N];
int dp[N];
int main() {
n = 3, V = 5;
v[1] = 2, w[1] = 10;
v[2] = 4, w[2] = 5;
v[3] = 1, w[3] = 4;
// 初始化:dp[0] = 0,其他为-∞
memset(dp, -INF, sizeof dp);
dp[0] = 0;
// 填表
for (int i = 1; i <= n; i++) {
for (int j = V; j >= v[i]; j--) {
if (dp[j - v[i]] != -INF) { // 确保前一个状态合法
dp[j] = max(dp[j], dp[j - v[i]] + w[i]);
}
}
}
// 结果判断:如果dp[V] < 0,说明无法恰好装满
int res = dp[V] < 0 ? 0 : dp[V];
cout << "01背包恰好装满的最大价值:" << res << endl;
return 0;
}
在上面的例子中,背包容量 5 能否恰好装满?我们看看可能的组合:
- 物品 1(2L)+ 物品 3(1L)= 3L(未满);
- 物品 2(4L)+ 物品 3(1L)= 5L(恰好装满),价值 5+4=9;
- 物品 1 + 物品 2=6L(超容)。
因此,运行结果为9,符合预期。
2.5 实战案例:洛谷 P1048 采药
题目链接:https://www.luogu.com.cn/problem/P1048

题目描述:
辰辰是个天资聪颖的孩子,他的梦想是成为世界上最伟大的医师。为此,他想拜附近最有威望的医师为师。医师为了判断他的资质,给他出了一个难题。医师把他带到一个到处都是草药的山洞里对他说:"孩子,这个山洞里有一些不同的草药,采每一株都需要一些时间,每一株也有它自身的价值。我会给你一段时间,在这段时间里,你可以采到一些草药。如果你是一个聪明的孩子,你应该可以让采到的草药的总价值最大。"
输入:
第一行有两个整数T(总共能用来采药的时间)和M(草药的数目)。接下来M行,每行包括两个整数,分别表示采摘某株草药的时间和价值。
分析:
这是一道典型的 01 背包问题 ------ 每个草药只能采一次(01),时间就是背包容量,价值就是草药的价值。
代码实现:
cpp
#include <iostream>
#include <algorithm>
#include <cstring>
using namespace std;
const int N = 1010; // T最大为1000
int T, M; // T:总时间,M:草药数
int t[N], w[N]; // t:采药时间,w:草药价值
int dp[N]; // dp[j]:用不超过j的时间,能获得的最大价值
int main() {
cin >> T >> M;
for (int i = 1; i <= M; i++) {
cin >> t[i] >> w[i];
}
// 初始化:dp[j]默认0
memset(dp, 0, sizeof dp);
// 01背包一维解法
for (int i = 1; i <= M; i++) {
for (int j = T; j >= t[i]; j--) {
dp[j] = max(dp[j], dp[j - t[i]] + w[i]);
}
}
cout << dp[T] << endl;
return 0;
}
示例输入:
70 3
71 100
69 1
1 2
示例输出:
3
解释:
- 总时间 70,3 种草药;
- 草药 1:时间 71>70,无法采摘;
- 草药 2:时间 69,价值 1;
- 草药 3:时间 1,价值 2;
- 最优选择:采摘草药 2(69 时间,价值 1)+ 草药 3(1 时间,价值 2),总时间 70,总价值 3。
三、完全背包:每个物品可以选无限次的 "贪心博弈"
3.1 问题定义
完全背包的核心约束是:每个物品可以选择无限次(只要背包容量足够)。
还是用旅行背包的例子,假设物品可以无限选:
- 背包容量 5L;
- 物品 1:体积 2L,价值 10;
- 物品 2:体积 4L,价值 5;
- 物品 3:体积 1L,价值 4。
此时的最优解是什么?显然是选 5 个物品 3(5×1L=5L),总价值 5×4=20,这就是完全背包的逻辑。
3.2 与 01 背包的核心区别
完全背包和 01 背包的唯一区别的是物品的选择次数:
- 01 背包:每个物品最多选 1 次 → 容量枚举必须从右到左,避免重复选择;
- 完全背包:每个物品可以选无限次 → 容量枚举必须从左到右,允许重复选择。
这个区别直接导致了两者在代码上的唯一差异 ------容量枚举顺序的不同。
3.3 动态规划解法:从二维到一维
3.3.1 第一步:定义状态
完全背包的状态定义和 01 背包完全一致:
- 二维版本:**dp[i][j]**表示从前
i个物品中选择,总重量不超过j时的最大价值;- 一维版本:**dp[j]**表示当前考虑到第
i个物品时,总重量不超过j的最大价值。
3.3.2 第二步:推导状态转移方程
对于完全背包的二维版本,状态转移方程为:
cpp
dp[i][j] = max(dp[i-1][j], (j >= v[i] ? dp[i][j - v[i]] + w[i] : 0))
注意和 01 背包的区别:选第i个物品时,依赖的是dp[i][j - v[i]](当前行的状态),而不是dp[i-1][j - v[i]](上一行的状态)。这是因为选择第i个物品后,还可以继续选择它(无限次)。
3.3.3 空间优化:一维数组
由于二维版本中dp[i][j]依赖于dp[i][j - v[i]](当前行的左侧状态),因此一维版本的容量枚举顺序需要改为从左到右 (从v[i]到V)。
这样,当计算**dp[j]**时,**dp[j - v[i]]**已经是当前行更新后的值,相当于已经考虑了多次选择第i个物品的情况。
优化后的状态转移方程:
cpp
for (int i = 1; i <= n; i++) {
// 从左到右枚举容量
for (int j = v[i]; j <= V; j++) {
dp[j] = max(dp[j], dp[j - v[i]] + w[i]);
}
}
3.3.4 代码实现(一维版本)
我们用之前的旅行背包例子实现完全背包:
cpp
#include <iostream>
#include <algorithm>
#include <cstring>
using namespace std;
const int N = 1010;
int n, V;
int v[N], w[N];
int dp[N];
int main() {
n = 3, V = 5;
v[1] = 2, w[1] = 10;
v[2] = 4, w[2] = 5;
v[3] = 1, w[3] = 4;
memset(dp, 0, sizeof dp);
// 完全背包:从左到右枚举容量
for (int i = 1; i <= n; i++) {
for (int j = v[i]; j <= V; j++) {
dp[j] = max(dp[j], dp[j - v[i]] + w[i]);
}
}
cout << "完全背包最大价值:" << dp[V] << endl;
return 0;
}
运行结果:20,与我们手动计算的最优解(5 个物品 3,价值 20)一致。
3.4 完全背包的变体:恰好装满背包
和 01 背包类似,完全背包也可以要求 "恰好装满",解法同样是修改初始化:
- dp[0] = 0(容量 0 恰好装满,价值 0);
- 其他dp[j] = -∞(无法恰好装满);
- 最终结果若**
dp[V] < 0**,输出 0,否则输出dp[V]。
代码实现:
cpp
#include <iostream>
#include <algorithm>
#include <cstring>
using namespace std;
const int N = 1010;
const int INF = 0x3f3f3f3f;
int n, V;
int v[N], w[N];
int dp[N];
int main() {
n = 3, V = 5;
v[1] = 2, w[1] = 10;
v[2] = 4, w[2] = 5;
v[3] = 1, w[3] = 4;
memset(dp, -INF, sizeof dp);
dp[0] = 0;
for (int i = 1; i <= n; i++) {
for (int j = v[i]; j <= V; j++) {
if (dp[j - v[i]] != -INF) {
dp[j] = max(dp[j], dp[j - v[i]] + w[i]);
}
}
}
int res = dp[V] < 0 ? 0 : dp[V];
cout << "完全背包恰好装满的最大价值:" << res << endl;
return 0;
}
运行结果:20(5 个物品 3 恰好装满 5L,价值 20)。
3.5 实战案例:洛谷 P1616 疯狂的采药
题目链接:https://www.luogu.com.cn/problem/P1616

题目描述:
LiYuxiang 是个天资聪颖的孩子,他的梦想是成为世界上最伟大的医师。为此,他想拜附近最有威望的医师为师。医师为了判断他的资质,给他出了一个难题。医师把他带到一个到处都是草药的山洞里对他说:"孩子,这个山洞里有一些不同种类的草药,采每一种都需要一些时间,每一种也有它自身的价值。我会给你一段时间,在这段时间里,你可以采到一些草药。如果你是一个聪明的孩子,你应该可以让采到的草药的总价值最大。"
此题和原题的不同点:每种草药可以无限制地疯狂采摘。
输入:
第一行有两个整数t(总共能用来采药的时间)和m(草药的数目)。接下来m行,每行包括两个整数,分别表示采摘某一种草药的时间和价值。
分析:
这是一道典型的完全背包问题 ------ 每种草药可以采无限次,时间是背包容量,价值是草药价值。
代码实现:
cpp
#include <iostream>
#include <algorithm>
#include <cstring>
using namespace std;
typedef long long LL; // 注意:价值可能很大,需要用long long
const int N = 1e4 + 10; // m最大1e4
const int M = 1e7 + 10; // t最大1e7
int t, m; // t:总时间,m:草药数
int time[N], val[N]; // time:采药时间,val:草药价值
LL dp[M]; // 存储最大价值,避免溢出
int main() {
cin >> t >> m;
for (int i = 1; i <= m; i++) {
cin >> time[i] >> val[i];
}
memset(dp, 0, sizeof dp);
// 完全背包一维解法
for (int i = 1; i <= m; i++) {
for (int j = time[i]; j <= t; j++) {
dp[j] = max(dp[j], dp[j - time[i]] + val[i]);
}
}
cout << dp[t] << endl;
return 0;
}
示例输入:
70 3
71 100
69 1
1 2
示例输出:
140
解释:
- 总时间 70,草药 3 的时间 1,价值 2;
- 可以采 70 次草药 3,总价值 70×2=140,这是最优解。
四、01 背包与完全背包的对比总结
为了方便大家记忆,我们用表格总结两者的核心区别:
| 特征 | 01 背包 | 完全背包 |
|---|---|---|
| 物品选择次数 | 每个物品最多选 1 次 | 每个物品可以选无限次 |
| 二维状态转移方程 | dp[i][j] = max(dp[i-1][j], dp[i-1][j-v[i]]+w[i]) | dp[i][j] = max(dp[i-1][j], dp[i][j-v[i]]+w[i]) |
| 一维容量枚举顺序 | 从右到左(V → v [i]) | 从左到右(v [i] → V) |
| 核心代码差异 | j 从 V downto v [i] | j 从 v [i] to V |
| 适用场景 | 物品不可重复选择 | 物品可重复选择 |
关键口诀:
- 01 背包:选或不选,右到左填;
- 完全背包:无限可选,左到右填。
五、常见误区与注意事项
5.1 状态定义混淆
很多初学者容易把状态定义为 "恰好装满容量j的最大价值",但默认情况是 "不超过容量j的最大价值"。这两种定义的初始化和结果判断完全不同,一定要根据题目要求明确区分。
5.2 容量枚举顺序错误
这是最容易出错的地方:
- 01 背包用左到右枚举:导致物品重复选择,结果错误;
- 完全背包用右到左枚举:导致物品无法重复选择,退化为 01 背包。
5.3 数据溢出问题
当背包容量较大(如 1e7)或物品价值较高时,
int类型可能溢出,需要用long long存储dp数组。
5.4 初始化错误
- 非恰好装满:
dp[j]初始化为 0;- 恰好装满:
dp[0] = 0,其他dp[j] = -∞。
总结
背包问题是动态规划的入门经典,而 01 背包和完全背包则是背包问题的基石。掌握这两个模型,不仅能解决直接相关的题目,更能帮助我们理解动态规划的核心思想 ------"拆分问题、存储子问题解、避免重复计算"。
接下来,大家可以尝试解决多重背包、分组背包、多维背包等进阶问题,将基础背包的思想迁移过去。相信只要吃透了 01 背包和完全背包,后续的进阶内容都会迎刃而解。
最后,祝大家在算法的道路上越走越远,攻克更多难题!如果本文对你有帮助,别忘了点赞、收藏、转发三连~ 有任何疑问或建议,欢迎在评论区留言讨论!
