CC++ & Algorithm

背包问题(0-1背包)

较难38
语言版本:C++Python
概述:0-1背包问题是最经典的背包问题,每个物品要么选要么不选,通过动态规划找到最大价值。

背包问题(0-1背包)—— 选或不选,做出最优决策

1. 问题引入:从“带零食去春游”说起

小明要去春游,他有一个容量为 5 公斤 的背包,想带一些零食。家里有 4 种零食:

零食重量(公斤)喜爱程度(价值)
薯片23
巧克力12
饼干34
果冻22

每种零食只有一包(不能拆开,也不能带半包)。小明想:在不超过背包总重量的前提下,怎样组合才能让总喜爱程度(价值)最高?

这就是经典的 0-1 背包问题:有 n 件物品,每件物品只能选择 0 次或 1 次(不能重复拿),背包容量为 m。第 i 件物品重量 w[i],价值 v[i]。求在不超过容量的前提下,能获得的最大总价值。

输入示例:

n = 4, m = 5
物品1: w=2, v=3
物品2: w=1, v=2
物品3: w=3, v=4
物品4: w=2, v=2

输出: 最大价值 7(选薯片和饼干,总重2+3=5,价值3+4=7)。
(巧克力+果冻+薯片也行?总重1+2+2=5,价值2+2+3=7,同样得到7。所以可能存在多种最佳方案,但价值相同。)


2. 基本思路:二维动态规划(把大问题拆成小问题)

2.1 状态定义 —— 用表格记录“已经考虑了多少物品”

我们要解决的是:从所有物品中挑选,但直接思考很困难。动态规划的思路是 逐步考虑:先只看第一件物品,再看前两件……每考虑一件,就更新所有可能容量下的最佳价值。

定义 dp[i][j] 表示 考虑前 i 个物品(即物品1到物品i),在背包容量为 j 的情况下,能够获得的最大价值。

  • i0ni=0 表示还没有考虑任何物品(空手)。
  • j0mj=0 表示背包容量为0(什么都装不了)。

这样,最终答案就是 dp[n][m]:考虑完所有物品,容量为 m 时的最大价值。

2.2 状态转移方程 —— 选还是不选?

对于第 i 个物品,我们只有两种选择:

  1. 不选:那么背包里的物品完全来自前 i-1 个物品,容量不变,价值也不变:dp[i][j] = dp[i-1][j]
  2. :前提是当前容量 j 至少能装下这个物品(j >= w[i])。如果选,那么背包要腾出 w[i] 的空间来放这个物品,剩下的容量 j - w[i] 用来装前 i-1 个物品的最优组合:dp[i][j] = dp[i-1][j - w[i]] + v[i]

因为我们要最大价值,所以取两者中的较大值。

公式(简洁版):

如果 j < w[i]:  // 容量不够,只能不选
    dp[i][j] = dp[i-1][j]
否则:
    dp[i][j] = max( dp[i-1][j] , dp[i-1][j - w[i]] + v[i] )

边界条件:

  • dp[0][j] = 0(没有物品时,任何容量价值都是0)
  • dp[i][0] = 0(容量为0时,任何物品都放不下,价值也是0)

2.3 生活中的类比

想象你正在排队买奶茶,你手里有 5 元,前面有 4 种奶茶杯(每种只有一杯),每杯有价格和美味值。你只能选其中几杯,总价不超过 5 元。你可以这样思考:

  • 先看第一杯(2元,美味3):是拿还是不拿?
  • 再看第二杯(1元,美味2):在考虑了第一杯的基础上,再决定是否加这杯。
  • ……

动态规划表格就是帮你记录“到目前为止,某个价格下最多能获得多少美味”。


3. 实例推演(填二维表格)

我们还是用春游零食的例子。构建一个 (n+1) × (m+1) 的表格,行 i 从0到4,列 j 从0到5。

初始状态(i=0行全0):

i \ j012345
0000000
1
2
3
4

3.1 填第1行(i=1,薯片:w=2, v=3)

  • 容量0和1:装不下薯片,直接继承上一行的0 → dp[1][0]=0, dp[1][1]=0
  • 容量2:可以选薯片,比较不选(0)和选(dp[0][0]+3=3),取3 → dp[1][2]=3
  • 容量3:选薯片后剩余容量1,dp[0][1]=0,价值3;不选是0,取3 → dp[1][3]=3
  • 容量4、5同理:都是3
i \ j012345
0000000
1003333
2
3
4

3.2 填第2行(i=2,巧克力:w=1, v=2)

  • 容量0:装不下,继承0 → dp[2][0]=0
  • 容量1:可以选巧克力,比较不选(0)和选(dp[1][0]+2=2),取2 → dp[2][1]=2
  • 容量2:不选(dp[1][2]=3) vs 选巧克力后剩余1(dp[1][1]+2=0+2=2),取3 → dp[2][2]=3
  • 容量3:不选3 vs 选后剩余2(dp[1][2]+2=3+2=5),取5 → dp[2][3]=5(薯片+巧克力,价值3+2=5)
  • 容量4:不选3 vs 选后剩余3(dp[1][3]+2=3+2=5),取5
  • 容量5:不选3 vs 选后剩余4(dp[1][4]+2=3+2=5),取5
i \ j012345
0000000
1003333
2023555
3
4

3.3 填第3行(i=3,饼干:w=3, v=4)

  • 容量0~2:装不下,直接继承上一行:0,2,3
  • 容量3:不选(dp[2][3]=5) vs 选饼干后剩余0(dp[2][0]+4=4),取5 → dp[3][3]=5
  • 容量4:不选5 vs 选后剩余1(dp[2][1]+4=2+4=6),取6 → dp[3][4]=6(巧克力+饼干,价值2+4=6)
  • 容量5:不选5 vs 选后剩余2(dp[2][2]+4=3+4=7),取7 → dp[3][5]=7(薯片+饼干,价值3+4=7)
i \ j012345
0000000
1003333
2023555
3023567
4

3.4 填第4行(i=4,果冻:w=2, v=2)

  • 容量0~1:继承0和2
  • 容量2:不选(dp[3][2]=3) vs 选果冻后剩余0(dp[3][0]+2=2),取3 → dp[4][2]=3
  • 容量3:不选5 vs 选后剩余1(dp[3][1]+2=2+2=4),取5
  • 容量4:不选6 vs 选后剩余2(dp[3][2]+2=3+2=5),取6
  • 容量5:不选7 vs 选后剩余3(dp[3][3]+2=5+2=7),取7

最终表格:

i \ j012345
0000000
1003333
2023555
3023567
4023567

最终答案:dp[4][5] = 7,对应方案:薯片+饼干(或薯片+巧克力+果冻)。


4. 二维DP代码实现(C++)

下面是一个完整的 C++ 程序,读入物品数和容量,再读入每个物品的重量和价值,输出最大价值。

#include <iostream>
#include <algorithm> // 为了使用 max 函数
using namespace std;

const int MAXN = 100;   // 物品最大数量
const int MAXM = 1000;  // 背包最大容量

int dp[MAXN+1][MAXM+1]; // dp[i][j] 表示前i个物品,容量j的最大价值
int w[MAXN+1], v[MAXN+1]; // w[i]重量,v[i]价值

int main() {
    int n, m;
    cin >> n >> m; // 输入物品个数和背包容量
    for (int i = 1; i <= n; ++i) {
        cin >> w[i] >> v[i]; // 输入每个物品的重量和价值
    }

    // 初始化:dp[0][*] 和 dp[*][0] 默认都是0,全局变量自动为0,不需要额外赋值

    for (int i = 1; i <= n; ++i) {          // 遍历每个物品
        for (int j = 0; j <= m; ++j) {      // 遍历所有可能的容量
            if (j < w[i]) {                 // 容量不够,只能不选
                dp[i][j] = dp[i-1][j];
            } else {
                // 不选 vs 选
                dp[i][j] = max(dp[i-1][j], dp[i-1][j - w[i]] + v[i]);
            }
        }
    }

    cout << dp[n][m] << endl; // 输出最大价值
    return 0;
}

运行示例: 输入:

4 5
2 3
1 2
3 4
2 2

输出:

7

5. 复杂度分析(二维)

  • 时间复杂度O(n * m),因为有一个外循环(n个物品)和内循环(m种容量)。
  • 空间复杂度O(n * m),用一个二维数组存储所有状态。

nm 较大时(比如 n=1000, m=100000),二维数组会占用约 1000×100000×4字节 ≈ 400MB,可能超出内存限制。因此我们需要 一维优化


6. 一维优化(空间优化 —— 滚动数组思想)

6.1 优化原理

观察状态转移方程:

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

i 行的数据只依赖于第 i-1 行的数据。也就是说,我们不需要保留所有历史行,只需要 当前行和上一行。进一步,如果我们 倒序更新,就可以用同一个一维数组来覆盖旧数据,同时保证用到的 dp[j - w[i]] 还是上一轮的值。

关键点:

  • 定义 dp[j] 表示 当前已经处理完前 i 个物品后,容量为 j 的最大价值
  • 对于每个物品 i,我们需要保证在更新 dp[j] 时,dp[j - w[i]] 还是没考虑当前物品的值(即上一轮的结果)。
  • 如果正序遍历 jw[i]m,那么 dp[j - w[i]] 可能已经被当前物品更新过了(变成了 dp[i][j - w[i]]),这样就会导致同一个物品被多次使用,变成 完全背包。所以必须 倒序遍历 jmw[i]

新转移方程(一维):

for (int j = m; j >= w[i]; --j) {
    dp[j] = max(dp[j], dp[j - w[i]] + v[i]);
}

其中 dp[j] 初始全为0,表示没有物品时价值为0。

6.2 一维推导表格(用相同例子验证)

初始化 dp[0..5] = [0,0,0,0,0,0]

处理物品1(薯片,w=2, v=3):

  • j=5: dp[5] = max(0, dp[3]+3=0+3) = 3
  • j=4: dp[4] = max(0, dp[2]+3=0+3) = 3
  • j=3: dp[3] = max(0, dp[1]+3=0+3) = 3
  • j=2: dp[2] = max(0, dp[0]+3=0+3) = 3
  • j=1,0 不更新 结果:dp = [0,0,3,3,3,3] (与二维第1行一致)

处理物品2(巧克力,w=1, v=2):

  • j=5: dp[5] = max(3, dp[4]+2=3+2=5) = 5
  • j=4: dp[4] = max(3, dp[3]+2=3+2=5) = 5
  • j=3: dp[3] = max(3, dp[2]+2=3+2=5) = 5
  • j=2: dp[2] = max(3, dp[1]+2=0+2) = 3
  • j=1: dp[1] = max(0, dp[0]+2=2) = 2 结果:dp = [0,2,3,5,5,5] (与二维第2行一致)

处理物品3(饼干,w=3, v=4):

  • j=5: dp[5] = max(5, dp[2]+4=3+4=7) = 7
  • j=4: dp[4] = max(5, dp[1]+4=2+4=6) = 6
  • j=3: dp[3] = max(5, dp[0]+4=0+4) = 5 结果:dp = [0,2,3,5,6,7] (与二维第3行一致)

处理物品4(果冻,w=2, v=2):

  • j=5: dp[5] = max(7, dp[3]+2=5+2=7) = 7
  • j=4: dp[4] = max(6, dp[2]+2=3+2=5) = 6
  • j=3: dp[3] = max(5, dp[1]+2=2+2=4) = 5
  • j=2: dp[2] = max(3, dp[0]+2=0+2) = 3 结果:dp = [0,2,3,5,6,7] (与二维第4行一致)

最终 dp[m] = 7


7. 一维优化代码(C++)

#include <iostream>
#include <algorithm>
using namespace std;

const int MAXM = 1000; // 背包最大容量
int dp[MAXM+1];        // 一维数组,dp[j]表示容量j的最大价值
int w[105], v[105];    // 物品重量和价值,假设物品数不超过100

int main() {
    int n, m;
    cin >> n >> m; // 输入物品个数和背包容量
    for (int i = 1; i <= n; ++i) {
        cin >> w[i] >> v[i]; // 输入每个物品的重量和价值
    }

    // 初始化:dp[0..m]默认为0(全局变量)

    for (int i = 1; i <= n; ++i) {          // 遍历每个物品
        for (int j = m; j >= w[i]; --j) {   // 倒序遍历容量
            dp[j] = max(dp[j], dp[j - w[i]] + v[i]);
        }
    }

    cout << dp[m] << endl; // 输出最大价值
    return 0;
}

注意: 内层循环 jmw[i],不能写反。如果写成 for (int j = w[i]; j <= m; ++j) 就变成了完全背包(每件物品可以拿无限次)。


8. 复杂度分析(一维优化后)

  • 时间复杂度O(n * m),与二维相同,但常数略小(少了一层 if 分支)。
  • 空间复杂度O(m),大幅降低,可以处理更大规模的数据。

9. 常见误区与总结

9.1 新手易犯的错误

  1. 一维数组内层正序遍历
    错误代码:

    for (int j = w[i]; j <= m; ++j) {  // 正序!
        dp[j] = max(dp[j], dp[j - w[i]] + v[i]);
    }
    

    后果:同一个物品会被多次放入,变成完全背包,结果偏大。
    正确做法:一定要倒序:for (int j = m; j >= w[i]; --j)

  2. 忘记处理容量不足的情况
    二维代码中,如果 j < w[i] 需要直接继承。一维代码中,由于倒序且只从 w[i] 开始更新,容量小于 w[i] 的部分自动不变,所以不用特殊处理。但有时新手会在外面写一个 if (j >= w[i]) 条件,也可以,但倒序循环天然保证了这一点。

  3. 数组下标越界
    一维代码中 dp[j - w[i]]j = w[i] 时,j - w[i] = 0,是合法的。但如果物品重量为0(实际中不可能),需要特殊处理。

  4. 初始化问题
    全局变量自动初始化为0,所以 dp[0]=0 没问题。但在某些情况下(比如求最小值),需要初始化为 INF。0-1背包求最大值,0初始化是正确的。

9.2 总结

  • 0-1背包是动态规划中最经典的问题,核心思想就是 “选或不选”
  • 二维DP容易理解,适合初学;一维DP节省空间,是竞赛中的标准写法。
  • 掌握状态定义、转移方程和遍历顺序后,可以轻松解决很多变种问题,比如:
    • 求方案数dp[j] += dp[j - w[i]](初始化 dp[0]=1
    • 求最小价值dp[j] = min(...)(初始化 dp[0]=0,其他为 INF)
    • 恰好装满:初始化 dp[0]=0,其他为 -INF,最后判断 dp[m] 是否为 -INF。

10. 相关知识点指引

如果你已经掌握了 0-1 背包,接下来可以学习:

  • 完全背包:每件物品可以取无限次。只需要把一维内层循环改为 正序 即可。
  • 多重背包:每件物品有有限数量 c[i]。可以用二进制拆分或单调队列优化。
  • 混合背包:三种背包混合在一起。
  • 分组背包:物品分成若干组,每组最多选一个。
  • 二维费用背包:每个物品有两种费用(比如重量和体积),背包有两个限制。

这些都是在 0-1 背包基础上扩展而来的,理解了核心的“状态转移”思想,就能举一反三。


延伸思考:如果物品数量较多但背包容量较小,或者价值范围较小而重量范围很大,可以考虑另一种 DP 方法:把价值作为维度,求最小重量。比如 dp[v] 表示达到价值 v 所需的最小重量。这叫做 “换维DP”,在处理某些题目时非常有用。

例题精讲

1单选题

在0-1背包问题中,每个物品最多能被选择几次?

A0次或1次
B任意次
C0次
D1次
2单选题

在用一维数组优化0-1背包问题时,内层循环对容量j的遍历顺序应该是?

A从小到大
B从大到小
C从中间到两端
D任意顺序
3判断题

在0-1背包问题中,贪心算法(按单位价值降序选择)一定能得到最优解。

4填空题
以下代码是0-1背包问题的二维动态规划实现,请补全空缺处的代码。

int n, V;
int w[105], v[105];
int dp[105][1005];

int main() {
    // 输入n和V,以及每个物品的重量w[i]和价值v[i](下标从1开始)
    for(int i = 0; i <= V; i++) dp[0][i] = 0;
    for(int i = 1; i <= n; i++) {
        for(int j = 0; j <= V; j++) {
            if(j < w[i]) dp[i][j] = dp[i-1][j];
            else dp[i][j] = max(dp[i-1][j], ___);
        }
    }
    // 输出dp[n][V]
    return 0;
}
5填空题
以下代码是0-1背包问题的一维数组优化实现,请补全空缺处的代码。

int n, V;
int w[105], v[105];
int dp[1005];

int main() {
    // 输入n和V,以及每个物品的重量w[i]和价值v[i](下标从1开始)
    memset(dp, 0, sizeof(dp));
    for(int i = 1; i <= n; i++) {
        for(int j = V; j >= w[i]; j--) {
            dp[j] = max(dp[j], ___);
        }
    }
    // 输出dp[V]
    return 0;
}