完全背包问题

背包问题概述

背包问题 Knapsack Problem 是经典的 NP-Hard 问题。核心思想是:给定若干物品的体积(或重量)和价值,在背包容量有限的情况下。如何选择放入背包的物品,使得背包内的物品总价值最大。

常见形式有:

  1. 0-1背包 0-1 Knapsack: 每种物品只有一个(只能选 0 或 1 次)
  2. 完全背包 Complete Knapsack: 每种物品有无限个(可以选无线次,也即是不限次数)
  3. 多重背包 Multiple Knapsack: 每种物品有指定的数量限制
  4. 分数背包(Fractional Knapsack):物品可以拆分(通常使用贪心解决)
  5. ...

背包问题是一个动态规划问题

动态规划问题回顾

动态规划的思维模式是将一个复杂问题分解为多个简单的子问题求解的方法。子问题的答案会被存储在数据结构中,当子问题再次需要解决的时候,只需要查表查看结果,而不需要重复计算,因此节约了时间。

动态规划问题的特点

  1. 最优子结构:一个问题的最优解可以由其子问题的最优解推导
  2. 重叠子问题:不同子问题在递归拆解时可能会出现重复计算
  3. 无后效性:子问题的状态(或者说,解)一旦确定,其结果不再受后续决策影响

暴论:最优化问题通常可以考虑使用动态规划求解

动态规划的求解关键

  1. 拆解原问题为子问题
  2. 保存并复用子问题的结果
  3. 递推/填表或记忆化递归求解

完全背包问题

本文考虑完全背包问题

完全背包问题的基本形式

\(n\) 件物品,每件物品重量(体积) \(w_i\),价值 \(v_i\)。背包容量是 \(W\)。每个物品可以选无限次,但总重量(体积)不能超过背包容量 \(W\)。求背包中物品的最大价值。

例题1:洛谷 B3635 硬币问题

https://www.luogu.com.cn/problem/B3635

题目描述

今有面值为 1、5、11 元的硬币各无限枚。想要凑出 \(n\) 元,问需要的最少硬币数量。

问题分析

因为每一种面额的硬币有无限个,这个问题就是标准的完全背包问题。只不过并不是求价值最大值,而是求最少硬币个数。

背包容量:总金额
物品:各种硬币
物品代价:就是硬币面额

动规求解

Step 1 明确状态

一般是定义一维或多维数组来表示子问题的状态。通常

\[dp[i] = 以 i 为结尾 / 容量 / 长度 等的最优解 \]

\[dp[i][j] = 处理前 i 个元素,在容量/总和/其他约束为 j 时的最优解 \]

在本题中,我们可以定义\(dp[i][j]\)为,只考虑前 \(i\) 种硬币的情况下,背包总价值为 \(j\) 时,需要的最少的硬币数量。

Step 2 写出转移方程

在本题中,状态转移方程是

\[dp[i][j] = \min \{ dp[i][j], dp[i][j - coins[i]] + 1\} \]

因为前 \(i-1\) 种硬币的情况已经在前面的迭代中充分考虑。所以我们只考虑第 \(i\) 种硬币。

  1. 考虑剩余容量。如果背包剩余容量不够,那么 dp[i][j] = dp[i-1][j] 不变
  2. 如果背包剩余容量足够,那么如果取用一枚 coins[i] 面额的硬币,那么 dp[i][j] = dp[i][j-coins[i]] + 1
  3. 也可能,虽然容量足够,但不取用硬币。那么 dp[i][j] = dp[i-1][j] 不变

由于需要考虑最少硬币数量,所以整体采用 min 函数处理

虽然在实际情况中,只要容量允许,可以重复取用面额为 coins[i] 的硬币无限枚。但在状态转移方程中,我们只考虑一次取1枚硬币。然后结合算法循环遍历顺序,来实现取无限次的效果。这个特质体现在 dp[i][j-coins[i]] + 1 上。(而在 0-1 背包中,dp 数组的第一维度通常是 i-1

Step 3 边界初始化

dp[0][0] = 0

Step 4 确定遍历顺序

在完全背包问题中,遍历顺序为

for(int i = 0; i < n; i++){
	for(int j = 0; j <= W; j++){
		... // 状态转移方程
	}
}

假设当前硬币面额 coins[i] = 5 , 状态转移方程为 dp[i][j] = dp[i][j-coins[i]] + 1 结合遍历顺序 \(j: 0\rightarrow W\) 不难得知,当循环变量 j=18 时,需要计算 dp[i][18],根据状态转移方程,我们需要读取 dp[i][13] 对应的硬币个数,然后再多加 1 枚 5 元硬币即可。
但是,在前面的循环,也就是 j=13 时,我们根据状态转移方程,会考虑 dp[i][13-5] = dp[i][8] 的值。即我们只需要读取 dp[i][8] 对应的硬币个数,然后再加 1 枚 5 元硬币即可。
这样一来,我们在迭代的过程中,就考虑了 5 元硬币可以取无限次的情况。虽然每一次状态转移方程都只取 1 枚硬币,但通过循环,取硬币的次数在叠加。

Step 5 输出答案

dp[n-1][W]

滚动数组优化

实际上状态转移方程只与 dp 矩阵第 i 和第 i-1 行有关,可以进行滚动数组优化。

image

假设在某一轮运行中,w[i] = 2 按照循环顺序从 \(j: 0\rightarrow W\) 更新 dp 数组如图。当更新 dp[i][3] (红色块)时,根据状态转移方程,dp[i][3]只与 dp[i-1][3] (不取这个物品)以及 dp[i][1] (取了这个物品)有关。

如图,更新任意 dp[i][j](红色块) 只与绿色色块部分有关。因此,可以进行状态压缩。使用一维 dp 数组即可表达。当更新 dp[i][j] (红色块)时,新值将会覆盖旧值 dp[i-1][j]

也即是说,一维的 dp[j] 数组中, dp[0] ... dp[j-1] 都是在第i轮已经更新过的,而dp[j+1]往后,是还没有更新的。

结合图示,可以对循环顺序有更深的理解。因为当 j=5 时,dp[i][5] 的更新依赖于 dp[i][3] 的值,而 dp[i][3] 在本轮已经被更新过,也就是说, dp[i][3] 的计算已经考虑过是否拾取物品i,而我们在计算dp[i][5]的时候又将物品i的存在性考虑了一遍。随着循环推进,我们会事实上无限次地考虑取用物品i

例题2: 洛谷 P13015 (GESP 202506 六级 学习小组)

题目描述

班主任计划将班级里的 \(n\) 名同学划分为若干个学习小组,每名同学都需要分入某一个学习小组中。观察发现,如果一个学习小组中恰好包含 \(k\) 名同学,则该学习小组的讨论积极度为 \(a_k\)

给定讨论积极度 \(a_1, a_2, \cdots, a_n\),请你计算将这 \(n\) 名同学划分为学习小组的所有可能方案中,讨论积极度之和的最大值。

问题分析

本题可以转化为完全背包问题。这种转化在初见时,不容易想到

背包容量:\(n\) 同学数
物品(小组): 小组按人数分,有 \(n\) 种情况。
对于 \(k\) 人小组,代价(体积)是 \(k\) (因为要有 \(k\) 名同学组成小组);价值是 \(a_k\) (就是讨论积极度)
只要同学足够多,小组数量是无限的

动规求解

明确状态

状态即为,定义 dp[j] 为背包容量为 j 时,最大的讨论积极度(价值)。

状态转移方程

\(dp[j] = \max \{dp[j], dp[j-k] + a[k]\}\)
其中,第一项代表人数不够组成 k 人小组,或者虽然人数够,但不这么组。
第二项代表组成了一个 k 人小组,获得价值(讨论积极度) a[k]

边界值确定

dp[0]=0, dp[1] = a_1

确定循环方向

小组人数(物品):\(k: 1\rightarrow n\)
背包容量 \(j: 0\rightarrow W\)

输出答案

dp[W]

代码参考(伪码,以例2为例)

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

int n;
const int MAXN = 1E5 + 10;
vector<int> a(MAXN, 0)
vector<int> dp(MAXN, 0)

int main(){
	cin >> n;
	for(int i = 1; i <= n; i++){
		cin >> a[i];
	}

	for(int k = 1; k < n; k++){
		for(int j = 1; j <= W; j++){ // W = n
			dp[j] = max(dp[j], dp[j-k] + a[k]);
		}
	}

	cout << dp[W];
	return 0;
}
posted @ 2025-07-09 14:32  Crimson深红  阅读(355)  评论(0)    收藏  举报