Bootstrap

【动态规划篇】01 背包的逆袭:如何用算法装满你的 “财富背包”

                            嘿!01 背包超有趣,来看算法如何装满你的 “财富包”,快收藏哦~                 

欢迎拜访:羑悻的小杀马特.-CSDN博客

本篇主题:带你细节剖析01背包的满选与非满选及滚动数组优化下的解法

制作日期:2025.01.06

隶属专栏:C/C++题海汇总

目录

 一·01背包问题描述:

二.01背包问题举例说明:

三.01背包解决思路及例子分析 :

3.1暴力搜索法(穷举法):

3.2贪心算法:

3.2.1价值优先策略:

3.2.2重量优先策略:

3.2.3价值密度优先策略:

 3.3动态规划法:

四.实际应用举例:

4.1资源分配问题:

4.2任务分配问题:

4.3盗贼的选择问题:

五·01背包经典模版(装满及非装满版本):

 5.1题目叙述:

非装满状态(二维dp非优化版):

 装满状态(二维dp非优化版):

二维下的解答代码汇总:

 

 非装满状态(一维dp滚动数组优化版):

  装满状态(一维dp滚动数组优化版):

滚动数组优化后解答代码汇总: 

六·01背包总结:

 开启这场旅行吧,下面我们先简单通过生动的举例来说明一下啥是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 的价值密度为3/2=1.5 ,物品 2 的价值密度为4/3 \approx1.33 ,物品 3 的价值密度为 5/4=1.25,物品 4 的价值密度为 8/5=1.6,物品 5 的价值密度为 10/9=1.11。按照价值密度从大到小排序,先选物品 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完成后),来减少一些代码及降低复杂度等;后面会出有关完全背包的后序希望大家多多关注呀!!!

;