背包问题常见bug
关于背包问题的常见问题的讨论
不同背包问题的遍历顺序问题
01背包问题和完全背包问题的遍历顺序相反,主要是因为它们在状态转移过程中对物品的使用方式不同。以下是详细的分析:
1. 01背包问题
-
问题描述:每个物品最多只能使用一次。
-
状态定义:dp[i][j] 表示在前 i 个物品中,选择若干个物品放入容量为 j 的背包中,能够获得的最大价值。
-
状态转移方程
:
- 如果不选择第 i 个物品:dp[i][j] = dp[i - 1][j]
- 如果选择第 i 个物品:dp[i][j] = dp[i - 1][j - w[i]] + v[i](前提是 j >= w[i])
-
遍历顺序
:
- 外层循环:遍历物品(从第1个到第 n 个)。
- 内层循环:遍历背包容量(从大到小)。这是因为如果从大到小遍历,每次更新 dp[j] 时,dp[j - w[i]] 仍然是上一轮的状态(即不包含当前物品的状态),从而避免重复计算。
2. 完全背包问题
-
问题描述:每个物品可以使用任意多次。
-
状态定义:dp[i][j] 表示在前 i 个物品中,选择若干个物品放入容量为 j 的背包中,能够获得的最大价值。
-
状态转移方程
:
- 如果不选择第 i 个物品:dp[i][j] = dp[i - 1][j]
- 如果选择第 i 个物品:dp[i][j] = dp[i][j - w[i]] + v[i](前提是 j >= w[i])
-
遍历顺序
:
- 外层循环:遍历物品(从第1个到第 n 个)。
- 内层循环:遍历背包容量(从小到大)。这是因为如果从小到大遍历,每次更新 dp[j] 时,dp[j - w[i]] 已经包含了当前物品的状态,从而可以多次使用当前物品。
3. 遍历顺序不同的原因
-
01背包问题
:
- 每个物品只能使用一次,为了避免重复计算,需要确保在更新状态时,当前物品的状态只被使用一次。因此,从大到小遍历背包容量,确保每次更新 dp[j] 时,dp[j - w[i]] 是未包含当前物品的状态。
-
完全背包问题
:
- 每个物品可以使用多次,需要在更新状态时,允许当前物品多次被使用。因此,从小到大遍历背包容量,确保每次更新 dp[j] 时,dp[j - w[i]] 已经包含了当前物品的状态,从而可以多次累加。
4. 总结
- 01背包问题:从大到小遍历背包容量,避免重复计算。
- 完全背包问题:从小到大遍历背包容量,允许多次使用当前物品。
这种遍历顺序的差异是基于问题的约束条件和状态转移方程的设计,确保算法能够正确地处理物品的使用限制。
例题
- 混合背包问题
有 N 种物品和一个容量是 V 的背包。
物品一共有三类:
- 第一类物品只能用1次(01背包);
- 第二类物品可以用无限次(完全背包);
- 第三类物品最多只能用 si 次(多重背包);
每种体积是 vi,价值是 wi。
求解将哪些物品装入背包,可使物品体积总和不超过背包容量,且价值总和最大。
输出最大价值。
输入格式
第一行两个整数,N,V用空格隔开,分别表示物品种数和背包容积。
接下来有 NN 行,每行三个整数 vi,wi,si,用空格隔开,分别表示第 i 种物品的体积、价值和数量。
- si=−1 表示第 i 种物品只能用1次;
- si=0 表示第 i 种物品可以用无限次;
- si>0 表示第 i种物品可以使用si 次;
输出格式
输出一个整数,表示最大价值。
数据范围
0<N,V≤10000<N,V≤1000
0<vi,wi≤10000<vi,wi≤1000
−1≤si≤1000−1≤si≤1000
输入样例
4 5
1 2 -1
2 4 1
3 4 0
4 5 2
输出样例:
8
解析:在这个问题中我们就要清楚完全背包,01背包的遍历顺序的区别
#include<iostream>
#include<cstring>
#include<algorithm>
using namespace std;
const int N = 1010; // 定义一个常量N,表示背包容量和物品数量的上限
int dp[N]; // 定义一个动态规划数组,用于存储每个容量下的最大价值
int v, w, s, n, m; // 定义变量:v表示物品体积,w表示物品价值,s表示物品数量,n表示物品总数,m表示背包容量
int main() {
cin >> n >> m; // 输入物品总数n和背包容量m
for (int i = 0; i < n; i++) { // 遍历每个物品
cin >> v >> w >> s; // 输入当前物品的体积v、价值w和数量s
if (!s) { // 如果s为0,表示这是一个完全背包问题
for (int j = v; j <= m; j++) { // 从体积v开始,遍历所有可能的背包容量
dp[j] = max(dp[j], dp[j - v] + w); // 更新dp[j],表示在容量j下,可以放入当前物品的最大价值
}
}
if (s == -1) s = 1; // 如果s为-1,将其转换为1,表示这是一个01背包问题
for (int k = 1; k <= s; k *= 2) { // 使用二进制拆分法,将多重背包问题转化为多个01背包问题
for (int j = m; j >= v * k; j--) { // 从容量m开始,逆序遍历所有可能的背包容量
dp[j] = max(dp[j], dp[j - k * v] + k * w); // 更新dp[j],表示在容量j下,可以放入k个当前物品的最大价值
}
s -= k; // 减去已经处理过的物品数量
}
if (s) { // 如果还有剩余的物品数量
for (int j = m; j >= s * v; j--) { // 从容量m开始,逆序遍历所有可能的背包容量
dp[j] = max(dp[j], dp[j - s * v] + s * w); // 更新dp[j],表示在容量j下,可以放入剩余数量的当前物品的最大价值
}
}
}
cout << dp[m] << endl; // 输出最终结果,即背包容量为m时的最大价值
return 0;
}
状态更新的完整性问题
在这里我们讨论的主要是关于边界dp[0][0] = 0的更新问题;
例题
- 潜水员
潜水员为了潜水要使用特殊的装备。
他有一个带2种气体的气缸:一个为氧气,一个为氮气。
让潜水员下潜的深度需要各种数量的氧和氮。
潜水员有一定数量的气缸。
每个气缸都有重量和气体容量。
潜水员为了完成他的工作需要特定数量的氧和氮。
他完成工作所需气缸的总重的最低限度的是多少?
例如:潜水员有5个气缸。每行三个数字为:氧,氮的(升)量和气缸的重量:
3 36 120
10 25 129
5 50 250
1 45 130
4 20 119
如果潜水员需要5升的氧和60升的氮则总重最小为249(1,2或者4,5号气缸)。
你的任务就是计算潜水员为了完成他的工作需要的气缸的重量的最低值。
输入格式
第一行有2个整数 m,n。它们表示氧,氮各自需要的量。
第二行为整数 kk 表示气缸的个数。
此后的 kk 行,每行包括ai,bi,ci3个整数。这些各自是:第 i 个气缸里的氧和氮的容量及气缸重量。
输出格式
仅一行包含一个整数,为潜水员完成工作所需的气缸的重量总和的最低值。
数据范围
1≤m≤21
1≤n≤79
1≤k≤1000
1≤ai≤21
1≤bi≤79
1≤ci≤800
输入样例:
5 60
5
3 36 120
10 25 129
5 50 250
1 45 130
4 20 119
输出样例:
249
错误代码:
#include<iostream>
#include<cstring>
using namespace std;
int dp[22][80];
int m,n,k,a,b,c;
int main(){
cin>>m>>n>>k;
memset(dp,0x3f,sizeof(dp));
dp[0][0]=0;
for(int i=0;i<k;i++){
cin>>a>>b>>c;
for(int j=m;j>=a;j--){
for(int l=n;l>=b;l--){
dp[j][l]=min(dp[j][l],dp[j-a][l-b]+c);
}
}
}
cout<<dp[m][n]<<endl;
return 0;
}
输入
5 60
5
3 36 120
10 25 129
5 50 250
1 45 130
4 20 119
输出
1061109567
正确代码:
#include<iostream>
#include<cstring>
using namespace std;
int dp[22][80];
int m,n,k,a,b,c;
int main(){
cin>>m>>n>>k;
memset(dp,0x3f,sizeof(dp));
dp[0][0]=0;
for(int i=0;i<k;i++){
cin>>a>>b>>c;
for (int j = m; j >= 0; j--) {
for (int l = n; l >= 0; l--) {
dp[j][l] = min(dp[j][l], dp[max(0, j - a)][max(0, l - b)] + c);
}
}
}
cout<<dp[m][n]<<endl;
return 0;
}
输入
5 60
5
3 36 120
10 25 129
5 50 250
1 45 130
4 20 119
输出
249
他们的主要区别就是当我们把将循环条件改写为:
for (int j = m; j >= a; j--) {
for (int l = n; l >= b; l--) {
dp[j][l] = min(dp[j][l], dp[j - a][l - b] + c);
}
}
虽然在某些情况下可以工作,但这种写法存在潜在问题,可能会导致错误的结果。以下是具体原因:
1. 状态更新的完整性问题
在动态规划中,我们需要确保所有可能的状态都被正确更新。原代码:
for (int j = m; j >= 0; j--) {
for (int l = n; l >= 0; l--) {
dp[j][l] = min(dp[j][l], dp[max(0, j - a)][max(0, l - b)] + c);
}
}
这种方式确保了以下几点:
- 边界条件的处理:
max(0, j - a)
和max(0, l - b)
确保即使j - a
或l - b
小于0
,也不会出现数组越界问题。这使得状态转移更加安全。 - 所有状态的更新:即使某些状态(如
j < a
或l < b
)无法通过当前分配方式直接到达,这些状态仍然会被考虑(通过其他方式或初始状态)。
而改写后的代码:
for (int j = m; j >= a; j--) {
for (int l = n; l >= b; l--) {
dp[j][l] = min(dp[j][l], dp[j - a][l - b] + c);
}
}
- 循环范围的限制:
j >= a
和l >= b
的条件限制了循环范围,这意味着当j < a
或l < b
时,这些状态不会被更新。这可能导致某些状态被遗漏。 - 边界状态的忽略:如果
a
或b
很大,可能会直接跳过一些边界状态(如j = 0
或l = 0
),导致这些状态的值没有被正确更新。
2. 状态转移的正确性问题
在动态规划中,状态转移的正确性依赖于所有相关状态都被正确处理。原代码通过 max(0, j - a)
和 max(0, l - b)
确保了即使某些状态无法直接到达,也会被正确初始化(如 dp[0][0] = 0
),从而保证了状态转移的正确性。
而改写后的代码直接跳过了 j < a
和 l < b
的状态,可能会导致以下问题:
- 状态依赖的破坏:某些状态可能依赖于边界状态(如
dp[0][0]
),如果这些边界状态没有被正确更新,可能会导致错误的结果。 - 状态更新的不完整性:某些状态可能无法通过当前分配方式到达,但仍然需要通过其他方式更新。改写后的代码可能会遗漏这些状态。
3. 具体例子
假设:
m = 5, n = 5
(资源总量)a = 3, b = 3, c = 1
(分配方式)
原代码:
for (int j = 5; j >= 0; j--) {
for (int l = 5; l >= 0; l--) {
dp[j][l] = min(dp[j][l], dp[max(0, j - 3)][max(0, l - 3)] + 1);
}
}
- 这会正确更新所有状态,包括边界状态(如
dp[2][2]
会从dp[0][0]
更新)。
改写后的代码:
for (int j = 5; j >= 3; j--) {
for (int l = 5; l >= 3; l--) {
dp[j][l] = min(dp[j][l], dp[j - 3][l - 3] + 1);
}
}
- 这会跳过
j < 3
和l < 3
的状态,导致dp[2][2]
没有被更新,最终结果可能会不正确。
4. 总结
改写后的代码虽然在某些情况下可以工作,但存在以下问题:
- 边界状态的忽略:可能导致某些状态没有被正确更新。
- 状态更新的不完整性:可能会遗漏一些状态,导致最终结果不正确。
因此,原代码通过 max(0, j - a)
和 max(0, l - b)
确保了所有状态都被正确处理,是更安全和更完整的实现方式。
- 源代码适用于状态转移可能会涉及到边界值的情况,且边界值是合法的,并且需要处理数组越界问题。
- 修改后的代码适用于状态转移不会涉及到边界值的情况,且边界值是非法的,或者不需要参与状态转移。它更简洁,但需要确保状态转移过程中不会出现越界问题。