背包问题完全指南
-
算法
-
背包问题
-
01 背包
-
完全背包
-
动态规划 categories:
-
算法入门
背包问题是动态规划最经典的入门题型,也是算法竞赛、刷题中最常考的考点之一。本文从最基础的 01 背包开始,带你一步步搞定完全背包、多重背包、分组背包、二维费用背包等所有常见的背包变种,配套完整可直接套用的 C++ 代码,看完就能上手刷题。
一、什么是背包问题?
背包问题的核心场景非常简单:
你有一个容量有限的背包,还有一堆物品,每个物品有重量、价值等属性,你要选一些物品放进背包,在不超过背包容量的前提下,最大化总价值。
根据物品的不同限制,背包问题衍生出了非常多的变种,我们一个个来看。
二、01 背包:最基础的背包
问题描述
有 N 件物品和一个容量为 V 的背包。第 i 件物品的重量是w[i],价值是v[i]。每件物品最多只能用 1 次。 求解:将哪些物品装入背包,可使这些物品的重量总和不超过背包容量,且价值总和最大。
这是所有背包问题的基础,所有其他的背包都是从它衍生出来的。
状态定义
dp[i][j]:前 i 件物品,背包容量为 j 时,能获得的最大价值。
转移方程
对于第 i 件物品,我们有两种选择:
-
不选这件物品 :那么状态就是前 i-1 件物品,容量 j 的状态,也就是
dp[i][j] = dp[i-1][j] -
选这件物品 :如果背包容量够的话,那么我们就把这件物品放进去,状态就是前 i-1 件物品,容量减去当前物品重量的状态,加上当前物品的价值,也就是
dp[i][j] = dp[i-1][j-w[i]] + v[i]
所以最终的转移方程是:
dp[i][j] = max(dp[i-1][j], dp[i-1][j-w[i]] + v[i])
空间优化
我们发现,每次计算 i 的状态,只依赖 i-1 的状态,所以我们完全可以把二维的 dp 数组,优化成一维的,用dp[j]直接表示容量为 j 时的最大价值,这样可以把空间复杂度从 O (nV) 降到 O (V)。
但是要注意:必须逆序遍历 j! 因为逆序遍历的话,我们计算 dp [j] 的时候,dp [j-w [i]] 还是上一轮 i-1 的状态,这样就保证了每个物品只会被选一次,不会重复选。
完整代码
cpp
#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
const int maxn=1010;
ll n,V,w[maxn],v[maxn],dp[maxn];
void sol(){
cin>>n>>V;
for(ll i=1;i<=n;i++)cin>>w[i]>>v[i];
for(ll i=1;i<=n;i++){
for(ll j=V;j>=w[i];j--){
dp[j]=max(dp[j],dp[j-w[i]]+v[i]);
}
}
cout<<dp[V]<<endl;
}
int main(){
sol();
return 0;
}
三、完全背包:物品可以无限选
问题描述
有 N 件物品和一个容量为 V 的背包。第 i 件物品的重量是w[i],价值是v[i]。每件物品可以用无限次。 求解:最大总价值。
核心区别
和 01 背包的区别就是,物品可以选无限次,所以当我们选了这件物品之后,还可以再选它。
所以空间优化之后,我们只需要把逆序遍历改成顺序遍历就可以了! 顺序遍历的话,计算 dp [j] 的时候,dp [j-w [i]] 已经是当前 i 轮的状态了,也就是我们已经选过当前物品了,这样就可以重复选,刚好符合完全背包的要求。
完整代码
cpp
#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
const int maxn=1010;
ll n,V,w[maxn],v[maxn],dp[maxn];
void sol(){
cin>>n>>V;
for(ll i=1;i<=n;i++)cin>>w[i]>>v[i];
for(ll i=1;i<=n;i++){
for(ll j=w[i];j<=V;j++){
dp[j]=max(dp[j],dp[j-w[i]]+v[i]);
}
}
cout<<dp[V]<<endl;
}
int main(){
sol();
return 0;
}
四、多重背包:物品有有限个
问题描述
有 N 件物品和一个容量为 V 的背包。第 i 件物品的重量是w[i],价值是v[i],最多有 s[i] 个。 求解:最大总价值。
二进制优化
如果我们直接暴力,把每个物品拆成s[i]个 01 背包的物品,时间复杂度会很高,所以我们用二进制优化来优化这个过程。
二进制优化的原理是:任何一个数,都可以拆成 2 的幂次的和,比如s=5,我们可以拆成1,2,2,这样这三个数就可以组合出 0~5 的所有数量,刚好覆盖了选 0 个、1 个、2 个、3 个、4 个、5 个的所有情况。
这样我们就把s[i]个物品,拆成了log s[i]个物品,然后把这些拆出来的物品当成 01 背包的物品,就可以用 01 背包解决了,时间复杂度从 O (nVs) 降到了 O (nV log s)。
完整代码
cpp
#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
const int maxn=20010;
ll n,V,w[maxn],v[maxn],dp[1010];
void sol(){
cin>>n>>V;
ll cnt=0;
for(ll i=1;i<=n;i++){
ll wi,vi,si;
cin>>wi>>vi>>si;
for(ll j=1;j<=si;j<<=1){
cnt++;
w[cnt]=wi*j;
v[cnt]=vi*j;
si-=j;
}
if(si>0){
cnt++;
w[cnt]=wi*si;
v[cnt]=vi*si;
}
}
for(ll i=1;i<=cnt;i++){
for(ll j=V;j>=w[i];j--){
dp[j]=max(dp[j],dp[j-w[i]]+v[i]);
}
}
cout<<dp[V]<<endl;
}
int main(){
sol();
return 0;
}
五、分组背包:每组最多选一个
问题描述
有 N 件物品,分成了 k 组,每组里的物品最多选一个,背包容量为 V,求最大总价值。
核心思路
分组背包的核心是:把每一组当成一个整体,遍历每一组,然后对于组里的物品,做 01 背包的操作,保证每组最多选一个。
遍历顺序是:先遍历组,然后逆序遍历容量,最后遍历组内的物品,这样就保证了每组只会选一个物品。
完整代码
cpp
#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
const int maxn=110;
ll n,V,group[maxn][maxn],w[maxn],v[maxn],dp[maxn];
void sol(){
cin>>n>>V;
ll cnt=0;
for(ll i=1;i<=n;i++){
ll s;
cin>>s;
group[i][0]=s;
for(ll j=1;j<=s;j++){
cnt++;
cin>>w[cnt]>>v[cnt];
group[i][j]=cnt;
}
}
for(ll i=1;i<=n;i++){
for(ll j=V;j>=0;j--){
for(ll k=1;k<=group[i][0];k++){
ll id=group[i][k];
if(j>=w[id])dp[j]=max(dp[j],dp[j-w[id]]+v[id]);
}
}
}
cout<<dp[V]<<endl;
}
int main(){
sol();
return 0;
}
六、二维费用背包:两个限制条件
问题描述
背包有两个限制条件,比如不仅有重量的限制,还有体积的限制,物品也有对应的两个重量,求最大总价值。
这就是我们之前做过的 01 字符串选物品的题,刚好是二维费用背包的经典应用。
核心思路
状态定义加一维就可以了,dp[j][k]表示重量为 j,体积为 k 的时候,最大的价值,然后转移和 01 背包一样,逆序遍历两个维度就可以了。
完整代码
cpp
#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
const int maxn=610;
ll N,m,n,dp[110][110],z[maxn],o[maxn];
void sol(){
cin>>N>>m>>n;
for(ll i=1;i<=N;i++){
string s;
cin>>s;
for(char c:s)z[i]+=(c=='0'),o[i]+=(c=='1');
}
for(ll i=1;i<=N;i++){
for(ll j=m;j>=z[i];j--){
for(ll k=n;k>=o[i];k--){
dp[j][k]=max(dp[j][k],dp[j-z[i]][k-o[i]]+1);
}
}
}
cout<<dp[m][n]<<endl;
}
int main(){
sol();
return 0;
}
七、背包问题的常见坑点
-
遍历顺序:01 背包逆序,完全背包顺序,分组背包先组后物品,这个绝对不能搞反,搞反了答案就错了。
-
数据溢出:背包的价值是累加的,很容易超过 int 的范围,所以一定要用 long long,不然会溢出。
-
初始化:如果是求最大价值,初始化为 0 就可以,如果是求恰好装满的最小价值,要初始化为无穷大。
-
二进制拆分:多重背包的二进制拆分,最后剩下的余数不要忘了加进去。
八、总结
背包问题是动态规划的基础,它的核心思想非常简单:选或者不选,根据物品的限制调整转移的顺序和条件。
学会了这五种最常见的背包,你就已经搞定了 99% 的背包问题,不管是 LeetCode 刷题,还是算法竞赛,都能轻松应对。
如果你觉得这篇文章对你有帮助,欢迎点赞、收藏、关注,我会持续更新算法入门的系列文章~