嘿!01 背包超有趣,来看算法如何装满你的 “财富包”,快收藏哦~
欢迎拜访:羑悻的小杀马特.-CSDN博客本篇主题:
带你细节剖析01背包的满选与非满选及滚动数组优化下的解法制作日期:2025.01.06
隶属专栏:C/C++题海汇总
目录
开启这场旅行吧,下面我们先简单通过生动的举例来说明一下啥是01背包吧:
一·01背包问题描述:
01 背包问题是一个经典的组合优化问题,可以描述为:给定一个固定容量为C 的背包和n 个物品,每个物品i有其对应的重量wi和vi价值 ,要求在不超过背包容量的前提下,选择若干物品放入背包,使得放入背包中的物品总价值最大。这里的 “01” 表示对于每个物品,我们只有两种选择:放入背包(用 1 表示)或不放入背包(用 0 表示)。
二.01背包问题举例说明:
假设我们有一个容量 C=10 的背包,以及以下 5 个物品:
各个重量价值如图所示:
三.01背包解决思路及例子分析 :
下面我们分别从暴搜,贪心,以及动归三个算法开始去分析,最终会得到动归算法占明显优势:
3.1暴力搜索法(穷举法):
思路:
尝试所有可能的物品组合,计算每种组合的总重量和总价值,找出不超过背包容量且总价值最大的组合。
下面看展示:
对于上述 5 个物品,我们可以考虑所有的组合:
组合 1:不选任何物品,总重量为 0,总价值为 0。
组合 2:只选物品 1,总重量为 2,总价值为 3。
组合 3:只选物品 2,总重量为 3,总价值为 4。
组合 4:选物品 1 和物品 2,总重量为2+3=5 ,总价值为 3+4=7.
以此类推,最终会找出总重量不超过 10 且总价值最大的组合。
然而这样一定好嘛,可想而知当数据特别大肯定就不行了。
缺点:时间复杂度为O(2^n) ,当物品数量 n较大时,计算量巨大,效率极低。
3.2贪心算法:
思路:
有多种贪心策略,常见的有价值优先、重量优先和价值密度(价值 / 重量)优先。
下面我们还是以上面那个图开始举例子说明:
3.2.1价值优先策略:
首先选择价值最大的物品,即物品 5,其价值为 10,但重量为 9。放入背包后,背包容量还剩10-9=1 ,无法再放入其他物品。总价值为 10。
3.2.2重量优先策略:
首先选择重量最小的物品,即物品 1,其重量为 2,价值为 3。然后考虑物品 2,重量为 3,价值为 4,此时背包容量还剩10-2-3=5 。继续考虑物品 3,重量为 4,价值为 5,总重量变为 2+3+4=9,总价值为 3+4+5=12,此时背包容量还剩 1,无法再放入物品 4 或物品 5。总价值为 12。
3.2.3价值密度优先策略:
计算每个物品的价值密度:物品 1 的价值密度为 ,物品 2 的价值密度为 ,物品 3 的价值密度为 ,物品 4 的价值密度为 ,物品 5 的价值密度为 。按照价值密度从大到小排序,先选物品 4,重量为 5,价值为 8,背包容量还剩 5。再选物品 1,重量为 2,价值为 3,背包容量还剩 3。此时无法再放入其他物品,总价值为8+3=11 。
到这里,细心的小伙伴是不是,早就发现,并不是我们预期的,甚至得不到我们想要的答案,因此这种对于一般01背包要精确求取最佳问题的时候就不合适了。
缺点:贪心算法不能保证一定能找到最优解,只能找到近似最优解。
3.3动态规划法:
思路:
使用一个二维数组dp[i][j]表示前 个物品放入容量为i的背包的最大价值j状态转移方程为:
那么下面我们还是根据上面的表模拟一下(dp表还是多开一个空间):
对于dp[i][0] 和 dp[0][j] 都初始化为 0(表示没有物品或背包容量为 0 时的最大价值为 0)
当i==1(也就是第一个物品):
当i==2(也就是第二个物品):
依次类推,填满整个 dp数组,最终 dp[5][10]就是最终答案 .
最终得到的dp[5][10]的值将是该问题的最优解,其时间复杂度为O(nC),空间复杂度为O(nC) ,对于n和 C不是特别大的情况,是一种比较高效的方法。
四.实际应用举例:
下面我们简单说一下关于01背包模版有那些变形应用:
4.1资源分配问题:
假设你是一个工厂的老板,有一笔预算 用于购买新设备,市场上有 种设备,每种设备 有购买成本 和预期收益 ,你需要决定购买哪些设备才能使总收益最大,同时不超过预算,这就是一个 01 背包问题。
4.2任务分配问题:
你是一个项目经理,有一个总的工作时长 ,有 个任务,每个任务 需要耗费的时间为 且能带来的收益为 ,你需要选择哪些任务来完成,以达到总收益最大,这可以转化为 01 背包问题。
4.3盗贼的选择问题:
一个盗贼进入一个房子,他的背包容量有限(比如体积或重量限制),房子里有各种宝物,每个宝物有相应的体积和价值,盗贼要选择哪些宝物带走,能使他带走的宝物总价值最大,这是 01 背包问题的经典场景。
五·01背包经典模版(装满及非装满版本):
下面我们就以动态规划的解法来解答01背包问题吧;以一道模版题为例:
测试用例1:
输入:3 5 输出:14
2 10 9
4 5
1 4
解释:装第一个和第三个物品时总价值最大,但是装第二个和第三个物品可以使得背包恰好装满且总价值最大。
测试用例2:
输入:3 8 输出:8
12 6 0
11 8
6 8
解释: 装第三个物品时总价值最大但是不满,装满背包无解。
牛客网原题链接:【模板】01背包_牛客题霸_牛客网
下面我们就用动归思想来完成它吧:
5.1题目叙述:
由题目要求可知,此题要通过两个dp状态表示等来解决;一个是小于等于,一个一定等于(对应我们的装满和非装满)。下面我们一个个分析:
非装满状态(二维dp非优化版):
首先我们可以看出它要求的状态有两个(如果我们直接搞dp[i]要么体积无法推导,要么价值无法;故这里我们先假设它是二维dp然后去定义再去推导,看看成不成立)。
因此我们假设:
dp[i][j]表示到i位置时候,背包体积是不超过j时的最大价值。
这个第一问其实是比较容易得,不用一定要装满;下面我们模拟一下过程得出状态转移方程:
得到状态转移方程:
难道这就完了吗?当然没有还要考虑初始化以及有没有越界行为的处理:
因此我们还是多开一行:
分析下标为0的行(也就是价值是0,但是体积不断增大)由定义得出:这的dp就是0.
分析下标是0的列(也就是体积是0,但是价值可以有很多):我们没体积故价值也就是0.
其次还有个极为细节的处理:
想必大家在写状态方程时候就想到了,下标可能越界;也就是我们的j减去i位置的体积是可能存在负数;即当i-1处不超过的体积放入i位置的物品后就超过了;因此我们还要判断一下是否成立如果不,我们就只能选择不放了。
其次就是还要注意我们增加了虚拟节点;要时刻注意原数组与dp数组下标交错时的对应关系。
这样就可以表示了:
int N,V;
cin>>N>>V;
vector<pair<int,int>>v(N);
//ans1:
for(int i=0;i<N;i++) cin>>v[i].first>>v[i].second;
for(int i=1;i<=N;i++){
for(int j=1;j<=V;j++){
dp[i][j]=max(dp[i-1][j],j>=v[i-1].first?dp[i-1][j-v[i-1].first]+v[i-1].second:0);
}
输出答案dp[N][V]即可。
装满状态(二维dp非优化版):
装满状态相当于比上面的状态加大了一点难度:
下面我们先读题目要求:
这也是需要注意的;当遍历到i位置的选择要么是可以装满状态要么是不能装满两个状态要区分,并注意答案输出:
这里只是把j的含义改成等于即可:
dp[i][j]只是修改了j的含义变成等于j时的最大价值。
这里我们来做个约定:
因为我们无价值肯定dp值是0;但是个当前位置不能装满;也就是我们就不可以是0了;首先可以装满j的价值肯定是个非负数;因此这里如果不能装满我们假设是-1即可咯。
这么一看是不是和刚才非装满大差不多:是的,但是还是要处理细节问题的(由于多了装满的限制),下面我们就分析刚刚的状态转移方程来做细节处理:
这就完了嘛?
当然,还要考虑我们的初始化;肯定是和上面都是0不同了:
我们根据这一题的输出提醒一下:因为我们定义的全局dp;而第二次用就要memset清空dp表了。
我们根据上面的定义初始化dp表有两种写法:
//写法一:
// dp[i][j]=max(dp[i-1][j],//这里是博主采用的最爱的三目运算符完成的
// j>=v[i-1].first&&dp[i-1][j-v[i-1].first]!=-1?
// dp[i-1][j-v[i-1].first]+v[i-1].second:-1);
//写法二:
// // dp[i][j]=dp[i-1][j];//直接先变成上一个(无论是-1还是不是,如果是-1后面选的话符合要求就会替换掉,如果后者前后两种情况都不成立就自然是-1了)
// // if( dp[i-1][j-v[i-1].first]!=-1&&j>=v[i-1].first)
// //dp[i][j]=max(dp[i-1][j],dp[i-1][j-v[i-1].first]+v[i-1].second);
// }
最后到了输出答案的时候可千万不要忘了-1代表着输出0哦!!
解答代码:
memset(dp,0,sizeof dp);
// for(int i=1;i<=V;i++)dp[0][i]=-1;//由定义可知,当0个位置处,无法填满背包
// for(int i=1;i<=N;i++){
// for(int j=1;j<=V;j++){
//此外每次都要判断要用到的之前的位置是否能填满,也就是dp值不能是-1才能用否则无意义
//写法一:
// dp[i][j]=max(dp[i-1][j],
// j>=v[i-1].first&&dp[i-1][j-v[i-1].first]!=-1?
// dp[i-1][j-v[i-1].first]+v[i-1].second:-1);
//写法二:
// // dp[i][j]=dp[i-1][j];
// // if( dp[i-1][j-v[i-1].first]!=-1&&j>=v[i-1].first)
// //dp[i][j]=max(dp[i-1][j],dp[i-1][j-v[i-1].first]+v[i-1].second);
// }
// }
// cout<<(dp[N][V]==-1?0:dp[N][V])<<endl;
二维下的解答代码汇总:
解法1(普通二维):
#include <bits/stdc++.h>
using namespace std;
const int n=1005;
//dp[i][j]表示到i位置时候,背包体积是不超过j时的最大价值
// int dp[n][n]{0};
// int main()
// {
// int N,V;
// cin>>N>>V;
// vector<pair<int,int>>v(N);
// //ans1:
// for(int i=0;i<N;i++) cin>>v[i].first>>v[i].second;
// for(int i=1;i<=N;i++){
// for(int j=1;j<=V;j++){
// dp[i][j]=max(dp[i-1][j],j>=v[i-1].first?dp[i-1][j-v[i-1].first]+v[i-1].second:0);
// }
// }
// cout<<dp[N][V]<<endl;
// //ans2:
///dp[i][j]只是修改了j的含义变成等于j时的最大价值;规定无法填满背包为dp值为-1
// memset(dp,0,sizeof dp);
// for(int i=1;i<=V;i++)dp[0][i]=-1;//由定义可知,当0个位置处,无法填满背包
// for(int i=1;i<=N;i++){
// for(int j=1;j<=V;j++){
//此外每次都要判断要用到的之前的位置是否能填满,也就是dp值不能是-1才能用否则无意义
//写法一:
// dp[i][j]=max(dp[i-1][j],
// j>=v[i-1].first&&dp[i-1][j-v[i-1].first]!=-1?
// dp[i-1][j-v[i-1].first]+v[i-1].second:-1);
//写法二:
// // dp[i][j]=dp[i-1][j];
// // if( dp[i-1][j-v[i-1].first]!=-1&&j>=v[i-1].first)
// //dp[i][j]=max(dp[i-1][j],dp[i-1][j-v[i-1].first]+v[i-1].second);
// }
// }
// cout<<(dp[N][V]==-1?0:dp[N][V])<<endl;
// return 0;
// }
下面我们来进行滚动数组的优化(这里要改的地方也不多,我们来做一下分析):
非装满状态(一维dp滚动数组优化版):
原理:
这里相当于把上面的dp二维给覆盖式填表了;由于每次要用到i-1位置的dp值故这里采取的是从右到左的覆盖式填表;可以保证每次取到的是i-1的dp值而并非是i之前填写的dp值。
下面是不是还是有点迷糊?我们来画图分析一下(这里我们每一行展示的是遍历到的i行时候的(相当于只在原行操作了)):
这么一优化,我们的判断条件也就少了:比如:
我们不是如果选的话还是要判断下标不能越界(也就是j和当前i的体积之间关系):
那么还有什么要注意的嘛? 就是我们如果不选的话它是还是dp[j](之前i-1的值,相当于不变) 。
int N,V;
cin>>N>>V;
vector<pair<int,int>>v(N);
//ans1:
for(int i=0;i<N;i++) cin>>v[i].first>>v[i].second;
for(int i=1;i<=N;i++){
for(int j=V;j>=v[i-1].first;j--){
dp[j]=max(dp[j],dp[j-v[i-1].first]+v[i-1].second);
}
}
cout<<dp[V]<<endl;
下面还要注意情况全局dp值呀!
装满状态(一维dp滚动数组优化版):
这里装满问题比上面就多了一个判断是否装满(是否为-1)的情况而已,这里就不多说了,直接展示代码:
for(int i=1;i<=V;i++)dp[i]=-1;
for(int i=1;i<=N;i++){
for(int j=V;j>=v[i-1].first;j--){
dp[j]=max(dp[j],
dp[j-v[i-1].first]!=-1?
dp[j-v[i-1].first]+v[i-1].second:-1);
}
}
cout<<(dp[V]==-1?0:dp[V])<<endl;
下面我们总结下对滚动优化后是如何对代码优化的:
总结过渡修改方法:1.反向遍历填表
2、把dp有关i下标的都干掉
3·这里还有个小优化:防止下标越界(如果拿i位置,背包就不会够):
如果选它不合适那就不选;也就是对于下标j<v[i-1]即不选让它就是上一次i-1对 应的值 即可;由于我们是覆盖式填表(覆盖上次i-1结果)因此,直接做不操作,即第二次循环只到v[i-1]即可。
滚动数组优化后解答代码汇总:
#include <bits/stdc++.h>
using namespace std;
const int n=1005;
int dp[n]{0};
int main()
{
int N,V;
cin>>N>>V;
vector<pair<int,int>>v(N);
//ans1:
for(int i=0;i<N;i++) cin>>v[i].first>>v[i].second;
for(int i=1;i<=N;i++){
for(int j=V;j>=v[i-1].first;j--){
dp[j]=max(dp[j],dp[j-v[i-1].first]+v[i-1].second);
}
}
cout<<dp[V]<<endl;
//ans2:
memset(dp,0,sizeof dp);
for(int i=1;i<=V;i++)dp[i]=-1;
for(int i=1;i<=N;i++){
for(int j=V;j>=v[i-1].first;j--){
dp[j]=max(dp[j],
dp[j-v[i-1].first]!=-1?
dp[j-v[i-1].first]+v[i-1].second:-1);
}
}
cout<<(dp[V]==-1?0:dp[V])<<endl;
return 0;
}
六·01背包总结:
归根结底还是动归的过程(参考博客动归做法小结:【动态规划篇】步步带你深入解答成功AC最优包含问题(通俗易懂版)-CSDN博客);其次就是根据它的题意分析填表及初始化问题(根据选还是不选),以及它类似的衍生题(就是保证最大还要有限制:从选还是不选入手);然后可选的去做好滚动数组优化(注:这里如果可以进行滚动数组降维覆盖实的优化,那么它的状态表示只能和上一个即i-1有关才可)(在二维dp完成后),来减少一些代码及降低复杂度等;后面会出有关完全背包的后序希望大家多多关注呀!!!