B2174 完全背包

B2174 完全背包

GESP C++六级练习。完全背包DP模板题,是学习动态规划背包系列的入门必刷题。本题直接对应完全背包的标准模型,适合初次接触背包DP的同学理解状态定义、转移方程以及正序遍历的含义。难度⭐⭐。洛谷难度等级普及-

B2174 完全背包

题目要求

题目描述

你有一个容量为 VV 的背包,以及 nn 种物品。第 ii 种物品的体积为 wiw_i,价值为 valival_i

每种物品都有无限件,你可以选择任意多件放入背包,但总容量不能超过 VV

请你求出在不超过背包容量的前提下,能获得的最大总价值。

形式化题意:设第 ii 种物品选择 xix_i 件(xix_i 为非负整数),则需要满足:

i=1nxiwiV,xi0\sum_{i=1}^{n} x_i \cdot w_i \le V,\quad x_i \ge 0

最大化目标:

maxi=1nxivali\max \sum_{i=1}^{n} x_i \cdot val_i

输入格式

第一行两个整数 n,Vn, V,表示物品种类数与背包容量。

接下来 nn 行,每行两个整数 wi,valiw_i, val_i,分别表示第 ii 种物品的体积与价值。

输出格式

输出一个整数,表示最大总价值。

输入输出样例 #1

输入 #1
3 10
2 3
3 4
4 5
输出 #1
15

说明/提示

样例说明 #1

一种最优方案是选择第 1 种物品 55 件:

  • 总体积 5×2=10105 \times 2 = 10 \le 10
  • 总价值 5×3=155 \times 3 = 15
数据范围

对于 40%40\% 的数据,1n81\le n \le 81V121\le V \le 12,且对所有 ii1wi10001\le w_i \le 10000vali10000\le val_i \le 1000

对于 100%100\% 的数据,1n10001\le n \le 10001V10001\le V \le 1000,且对所有 ii1wi10001\le w_i \le 10000vali10000\le val_i \le 1000


题目分析

本题是完全背包问题的标准模板题。如果你已经理解了 01 背包或者刚做完六级真题中的「条形蛋糕」(P17012),那么本题可以帮助你进一步巩固完全背包的模型——事实上,条形蛋糕本质上就是一个完全背包问题的等价建模。

1. 01 背包 vs 完全背包:区别在哪?

01 背包 中,每种物品只有 1 件,选了就没了;在 完全背包 中,每种物品有无限件,同一种物品可以反复选取。

两者的状态定义完全一样,但转移时的遍历顺序不同:

对比项01 背包完全背包
每种物品可选数量最多 11无限件
一维 DP 遍历容量的方向从大到小(逆序)从小到大(正序)

仅仅是内层循环方向的一字之差,就从"每种物品最多选一件"变成了"每种物品可选无限件"。下文会详细解释其中的原理。

2. 状态定义

定义 dp[j]dp[j] 表示:背包容量为 jj 时,能获得的最大总价值

初始状态:dp[0]=0dp[0] = 0,其余全为 00(表示什么都不装时价值为 00)。

3. 状态转移方程

对于第 ii 种物品(体积 wiw_i,价值 valival_i),对每个容量 jjwijVw_i \le j \le V):

dp[j]=max(dp[j], dp[jwi]+vali)dp[j] = \max(dp[j],\ dp[j - w_i] + val_i)

方程含义:面对容量 jj,我们有两种选择:

  1. 不选ii 种物品:价值保持 dp[j]dp[j] 不变
  2. 选一件ii 种物品:腾出 wiw_i 的空间后,剩余容量 jwij - w_i 的最大价值为 dp[jwi]dp[j - w_i],加上本件价值 valival_i

取两者的最大值。

4. 从二维到一维——如何理解一维 dp[j]dp[j] 的含义?

如果你觉得二维 dp[i][j]dp[i][j] 很直观,但一维 dp[j]dp[j] 突然就看不懂了,那是因为这中间缺了一个推导过程。

二维 DP 的完整图景

二维数组 dp[i][j]dp[i][j] 是一张 (n+1)×(V+1)(n+1) \times (V+1) 的表格。外层循环从第 11 行填到第 nn 行,每一行代表"考虑前 ii 种物品时各容量的最优解"。

关键观察:填第 ii 行时,只需要读第 i1i-1 行的数据(对于 01 背包)或i1i-1 行和第 ii 行已填好的部分(对于完全背包)。更早的第 1,2,,i21, 2, \ldots, i-2 行在填第 ii 行时已经完全用不到了。

既然旧行用不到,那就不存了

这就像考试交卷——交完上一科的试卷就可以丢掉草稿纸了,下一科重新用同一张纸。

我们只保留一行,让这一行在外层循环的每一轮中被"原地覆盖更新":

  • 外层循环开始处理第 11 种物品之前dp[j]dp[j] 全为 00,等价于 dp[0][j]dp[0][j](不考虑任何物品)
  • 处理完第 11 种物品之后dp[j]dp[j] 被覆盖为 dp[1][j]dp[1][j](只考虑前 11 种物品的最优解)
  • 处理完第 22 种物品之后dp[j]dp[j] 被覆盖为 dp[2][j]dp[2][j](只考虑前 22 种物品的最优解)
  • ……
  • 处理完第 nn 种物品之后dp[j]dp[j] 被覆盖为 dp[n][j]dp[n][j](考虑全部 nn 种物品的最优解)
一维 dp[j]dp[j] 的真正含义

所以,一维 dp[j]dp[j] 并不是一个"固定含义"的状态——它的含义随着外层循环的推进而变化

在外层循环处理完前 kk 种物品之后,dp[j]dp[j] 的含义等价于 dp[k][j]dp[k][j],即"只考虑前 kk 种物品、背包容量为 jj 时的最大总价值"。

当整个外层循环结束时(k=nk = n),dp[V]dp[V] 就等价于 dp[n][V]dp[n][V],即最终答案。

理解了这一点之后,下面讨论的"正序/逆序遍历"就容易理解了——它解决的是:在覆盖更新的过程中,如何保证读到的是正确的值?

5. 正序 vs 逆序遍历——一个方向的差别,为什么结果完全不同?

这是理解背包 DP 的最核心要点。

从二维 DP 的转移方程说起

要理解一维 DP 中遍历方向的逻辑,最好的切入点是先回到二维 DP 的转移方程,看看 01 背包和完全背包在逻辑上到底有什么不同。

01 背包的二维转移方程:

dp[i][j]=max(dp[i1][j], dp[i1][jwi]+vali)dp[i][j] = \max(dp[i-1][j],\ dp[i-1][j - w_i] + val_i)

完全背包的二维转移方程:

dp[i][j]=max(dp[i1][j], dp[i][jwi]+vali)dp[i][j] = \max(dp[i-1][j],\ dp[i][j - w_i] + val_i)

唯一的区别在于"选一件"时查找剩余容量的最优值用的是哪一行

  • 01 背包用 dp[i1][jwi]dp[i-1][j - w_i]:剩余容量 jwij - w_i 的最优方案不包含物品 ii → 我选的这一件就是唯一的一件
  • 完全背包用 dp[i][jwi]dp[i][j - w_i]:剩余容量 jwij - w_i 的最优方案可能已经包含了若干件物品 ii → 我在已有的基础上再加一件,自然就实现了无限选取

这就是两种背包在算法逻辑上的本质区别。

一维压缩时,遍历方向如何实现这个逻辑?

当我们把二维数组压缩成一维时,dp[j]dp[j] 在不同时刻代表不同含义:

  • 更新前dp[j]dp[j] 保存的是"上一轮"的值,即 dp[i1][j]dp[i-1][j]
  • 更新后dp[j]dp[j] 被覆盖为"本轮"的值,即 dp[i][j]dp[i][j]

因此,问题变成了:当我们计算 dp[j]dp[j] 时,dp[jw]dp[j - w] 处于"更新前"还是"更新后"?

  • 逆序遍历(从大到小):jj 从大到小处理,而 jw<jj - w < j,所以 dp[jw]dp[j - w]没轮到更新,保存的仍是上一轮的值 dp[i1][jw]dp[i-1][j-w] → 对应 01 背包的转移方程
  • 正序遍历(从小到大):jj 从小到大处理,而 jw<jj - w < j,所以 dp[jw]dp[j - w] 已经被更新过,保存的是本轮的值 dp[i][jw]dp[i][j-w] → 对应完全背包的转移方程

总结:遍历方向决定了 dp[jw]dp[j - w] 读到的是"旧行"还是"新行"的数据,从而精确地实现了两种背包各自的转移逻辑。

一句话概括:逆序遍历让 dp[jw]dp[j-w] 保持为 dp[i1][jw]dp[i-1][j-w](不含物品 ii)→ 最多选一次;正序遍历让 dp[jw]dp[j-w] 变为 dp[i][jw]dp[i][j-w](可能已含物品 ii)→ 允许无限选取。

用同一组数据验证

理解了上面的逻辑后,我们用具体数据来验证。假设只有一种物品:体积 w=2w = 2,价值 val=3val = 3,背包容量 V=6V = 6。初始 dpdp 数组全为 00

正序遍历(完全背包)—— jj2266

步骤更新 dp[j]dp[j]读取 dp[j2]dp[j-2]该值是否已被本轮更新?更新后 dp[j]dp[j]实际效果
dp[2]dp[2]dp[0]=0dp[0] = 0否(边界值)0+3=30 + 3 = 3选了 11
dp[4]dp[4]dp[2]=3dp[2] = 3(步骤①刚更新)3+3=63 + 3 = 6选了 22
dp[6]dp[6]dp[4]=6dp[4] = 6(步骤②刚更新)6+3=96 + 3 = 9选了 33

dp[j2]dp[j - 2] 读到的是本轮新值(即 dp[i][j2]dp[i][j-2]),所以每一步都"站在前一步的肩膀上",物品被不断叠加。

逆序遍历(01 背包)—— jj6622

步骤更新 dp[j]dp[j]读取 dp[j2]dp[j-2]该值是否已被本轮更新?更新后 dp[j]dp[j]实际效果
dp[6]dp[6]dp[4]=0dp[4] = 0(还没轮到)0+3=30 + 3 = 3选了 11
dp[4]dp[4]dp[2]=0dp[2] = 0(还没轮到)0+3=30 + 3 = 3选了 11
dp[2]dp[2]dp[0]=0dp[0] = 0否(边界值)0+3=30 + 3 = 3选了 11

dp[j2]dp[j - 2] 读到的是上轮旧值(即 dp[i1][j2]dp[i-1][j-2]),每一步的决策互不干扰,物品最多只被选了 11 次。

5. 与「条形蛋糕」(P17012)的关联

六级真题「条形蛋糕」(P17012)的转移方程为:

dp[i]=max1ji{pj+dp[ij]}dp[i] = \max_{1 \le j \le i}\{p_j + dp[i - j]\}

如果我们把蛋糕总长度 nn 视为背包容量,长度为 jj 的蛋糕块视为一种物品(体积 =j= j,价值 =pj= p_j),每种物品可以无限使用,那么条形蛋糕就是一个完全背包问题。两道题的代码结构几乎相同,理解了一道就能理解另一道。

6. 样例验证

以样例为例(n=3,V=10n = 3, V = 10):

物品编号体积 ww价值 valval性价比 val/wval / w
1122331.501.50
2233441.331.33
3344551.251.25

物品 11 的性价比最高。选 55 件物品 11:总体积 =5×2=10= 5 \times 2 = 10,总价值 =5×3=15= 5 \times 3 = 15

DP 数组的完整更新过程(仅展示处理完所有物品后的最终结果):

jj001122334455667788991010
dp[j]dp[j]000033446677991010121213131515

最终 dp[10]=15dp[10] = 15,与样例输出一致。

注意:虽然本例中性价比最高的贪心策略恰好得到了正确答案,但贪心策略并非对所有背包问题都成立。动态规划才能保证找到全局最优解。

7. 复杂度分析

  • 时间复杂度O(n×V)O(n \times V),外层循环 nn 种物品,内层循环容量 VV
  • 空间复杂度O(V)O(V),一维滚动数组

在本题数据范围下(n,V1000n, V \le 1000),运算次数约为 10610^6,远在时间限制内。

8. 注意事项

  • dpdp 数组初始化为全 00 即可,因为我们求的是"不超过容量 VV“的最大价值,空背包价值自然为 00
  • 内层循环起点为 wiw_i(而非 00),因为容量小于 wiw_i 时无法放入该物品,无需更新
  • 最大总价值不会超过 n×max(vali)×V/min(wi)n \times \max(val_i) \times \lfloor V / \min(w_i) \rfloor,在本题数据范围下 int 类型足以存储

示例代码

通过一维 DP 数组正序遍历求解完全背包的最大价值。

#include <iostream>

// dp[j] 表示背包容量为 j 时能获得的最大总价值
int dp[1005];

int main() {
    int n, V;
    std::cin >> n >> V;

    // 外层循环:枚举每一种物品
    for (int i = 0; i < n; i++) {
        int w, val;
        std::cin >> w >> val;

        // 内层循环:正序遍历容量(完全背包的关键)
        // 从 w 开始,因为容量小于 w 时无法放入该物品
        for (int j = w; j <= V; j++) {
            // 状态转移:选或不选第 i 种物品(可以重复选)
            // dp[j - w] 可能已经包含了本轮选过的物品 i,从而实现无限选取
            if (dp[j - w] + val > dp[j]) {
                dp[j] = dp[j - w] + val;
            }
        }
    }

    // dp[V] 即为背包容量为 V 时的最大总价值
    std::cout << dp[V] << std::endl;
    return 0;
}

二维数组解法(更直观)

上面的一维滚动数组写法虽然简洁高效,但对初学者来说不太容易理解"正序遍历为什么能实现重复选取”。下面给出二维数组的写法,用 dp[i][j]dp[i][j] 明确表示"前 ii 种物品、背包容量为 jj 时的最大总价值",状态含义一目了然。

#include <iostream>

// dp[i][j] 表示:前 i 种物品、背包容量为 j 时的最大总价值
int dp[1005][1005];
int w[1005], val[1005];

int main() {
    int n, V;
    std::cin >> n >> V;

    for (int i = 1; i <= n; i++) {
        std::cin >> w[i] >> val[i];
    }

    // 外层循环:枚举前 i 种物品
    for (int i = 1; i <= n; i++) {
        for (int j = 0; j <= V; j++) {
            // 不选第 i 种物品:继承前 i-1 种物品的结果
            dp[i][j] = dp[i - 1][j];
            // 选(至少)一件第 i 种物品
            // 注意这里是 dp[i][j - w[i]],而不是 dp[i-1][j - w[i]]
            // 用的是"本行"已更新的值,所以同一物品可以被反复选取
            if (j >= w[i] && dp[i][j - w[i]] + val[i] > dp[i][j]) {
                dp[i][j] = dp[i][j - w[i]] + val[i];
            }
        }
    }

    std::cout << dp[n][V] << std::endl;
    return 0;
}

二维写法的核心理解点:转移时用的是 dp[i][j - w[i]]本行已更新的值),而非 dp[i-1][j - w[i]](上一行的旧值)。如果把 dp[i] 改成 dp[i-1],就变成了 01 背包。通过下标就能直观看出两种背包的本质区别。

对比项二维 dp[i][j]dp[i][j]一维 dp[j]dp[j](上文)
状态含义ii 种物品、容量 jj 的最大价值容量 jj 的最大价值(滚动覆盖)
空间复杂度O(n×V)O(n \times V)O(V)O(V)
可读性更直观,两个维度含义清晰需理解滚动数组的覆盖机制
核心区别转移时用 dp[i][jw]dp[i][j-w](本行)vs dp[i1][j]dp[i-1][j](上一行)正序遍历自然实现"本行覆盖"

小提示:二维写法空间开销较大(O(n×V)O(n \times V)),在本题数据范围下(n,V1000n, V \le 1000)约需 44 MB,仍可通过。但在数据范围更大的题目中,建议使用一维滚动数组优化空间。

本文由coderli.com原创,按照CC BY-NC-SA 4.0 进行授权

所有代码已上传至Github:https://github.com/lihongzheshuai/yummy-code

luogu-”系列题目可在 洛谷题库 在线评测。

bcqm-”系列题目可在 编程启蒙题库 在线评测。

GESP/CSP认证交流QQ群: 688906745

GESP/CSP 认证学习微信公众号
GESP/CSP 认证学习微信公众号
最后更新于