背包问题初探

【前言】

 背包问题是动态规划中的经典问题 ,特此总结三种背包问题的算法原理和一些基本实现,并且对每个问题提出了优化方案。目前只总结到初学者水平,以后拜读《背包九讲》之后如有新的体会再进行补充提升。欢迎读者批评指正。

【目录】

(1)0 - 1背包问题

(2)完全背包问题

(3)多重背包问题

(4)背包问题求方案数

(5)求选中的物品集合

【正文】

一、0 - 1背包

  1.问题概述:有N种物品,每种物品只有一件,第i种物品的体积为c[i] , 价值为w[i] ,现有一个总体积为V的背包,求放入哪些物品能够使背包获得的体积最大。

  2.问题分析:首先考虑贪心,一个容易想到的是如果我们按照 价值/体积 对物品进行排序,然后贪心地取前K种物品。但是这种策略对不对呢,显然是错误的。因为没有充分考虑到背包的容积特性,它是一个确切的数字,如果按照上述策略放物品,有可能使得背包还有大量未填充的空间。举个栗子,背包空间是8,物品有(3,3)(4,2)(3,3)(5,4),(x,y)代表价值为x,体积为y。怎么放呢?按照贪心,则先放(4,2) 再放(5,4),然后发现再也放不下了。这样得到的总价值是4+5 = 9,背包剩余空间是2。显然我们有一种更优的解决办法:放入(3,3)(4,2)(3,3),这样得到总价值为3+4+3 = 10,背包剩余空间是0.

  因此我们需要用动态规划的眼光来解决这个问题,首先我们容易想到,如果先对前i件物品进行选择,dp[i][j]表示前i种物品装入体积为j的背包所能获得的最大值,那么我们最终的目的就是求dp[N][V]

  状态转移方程:dp[i][j] = max{ dp[i-1][ j - c[i] ] + w[i] , dp[i-1][j] }    也就是说对于第i件物品我们要么选要么不选,取最终价值最大的那个。

  注:这里的i 从 1~N j可以从0~V 也可以从V~0

  3.【AC代码】正向 vs 逆向

  HDU 2602 Bone Collector 模板题 

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

const int maxn = 1e3+10;
int w[maxn];
int c[maxn];
int n,v;
int dp[maxn][maxn];
int main(){
    int t;
    cin>>t;
    while(t--){
        cin>>n>>v;
        memset(dp, 0 ,sizeof dp);
        for(int i=1; i<=n; i++){
            cin>>w[i];
        }
        for(int i=1; i<=n; i++){
            cin>>c[i];
        }
        //逆推,从背包空间为V开始 
        for(int i=1; i<=n; i++){
            for(int j=v; j>=0; j--){
                if(j >= c[i])
                    dp[i][j] = max( dp[i-1][j], dp[i-1][j-c[i]] + w[i]);
                else
                    dp[i][j] = dp[i-1][j];
            }
        }
        
        cout<<dp[n][v]<<endl;
    }
}
View Code

  

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

const int maxn = 1e3+10;
int w[maxn];
int c[maxn];
int n,v;
int dp[maxn][maxn];
int main(){
    int t;
    cin>>t;
    while(t--){
        cin>>n>>v;
        memset(dp, 0 ,sizeof dp);
        for(int i=1; i<=n; i++){
            cin>>w[i];
        }
        for(int i=1; i<=n; i++){
            cin>>c[i];
        }
        
        for(int i=1; i<=n; i++){
            //正向递推,背包体积从0开始到V 
            for(int j=0; j<=v; j++){
                if(j-c[i] >= 0)
                    dp[i][j] = max( dp[i-1][j], dp[i-1][j-c[i]] + w[i]);
                else
                    dp[i][j] = dp[i-1][j];
            }
        }
        
        cout<<dp[n][v]<<endl;
    }
}
View Code

 

 

  4. 空间优化:我们可以注意到,如果把dp二维数组画成一个矩阵,那么求dp[i][j]的时候,我们其实只用到了它头顶上的一个元素dp[i-1][j],以及头顶上元素的左边某一个元素dp[i-1][j-c[i]],这两个元素都在dp[i][j]头顶上的那一行,逐渐往下递推的时候其实每次用的都是它头顶上的那一行,那么我们可不可以只用一维数组并且不断更新这个数组呢?答案是可以的,这个数组还有一个名字,叫滚动数组。也就是说我们用的数组始终保存的是“它头顶上的那一行”,不断覆盖。设dp[i]表示背包体积为i时最多能获得的价值,我们最终的目的是求的dp[N]。

  需要注意的是,用滚动数组求dp[i][j]时会发生覆盖,那么什么时候才能覆盖那个元素呢,那就是那个元素已经没有用了,所以我才能覆盖它,那么它什么时候没有用了呢?首先我们考虑它的用处,它的用处是求它脚底下一行的元素,以及求它脚底下右边某处的元素,当这两个元素都已经被求出来的时候,它就没用了。按照这样的思想,我们只能从右往左倒着求dp[i],先把右边的元素求出来。

  图解:

 

【空间优化后的代码】

 

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

const int maxn = 1e3+10;
int w[maxn];
int c[maxn];
int n,v;
int dp[maxn];//dp[i]表示背包体积为i时获得的最大价值 
int main(){
    int t;
    cin>>t;
    while(t--){
        cin>>n>>v;
        memset(dp, 0 ,sizeof dp);
        //读入价值 
        for(int i=1; i<=n; i++){
            cin>>w[i];
        }
        //读入代价 
        for(int i=1; i<=n; i++){
            cin>>c[i];
        }
        
        for(int i=1; i<=n; i++){
            for(int j=v; j>=c[i]; j--){
               dp[j] = max(dp[j-c[i]] + w[i], dp[j]);
            }
        }
        
        cout<<dp[v]<<endl;
    }
}
View Code

 

二、完全背包

1.问题概述:有N种物品和一个容量为V的背包,每种物品都有无限件可用。第i种物品的费用是c[i],价值是w[i]。求如何放置物品到背包中使得获得的价值最大,最大价值是多少。

2.问题分析: 完全背包问题和0-1背包问题的区别在0-1背包问题中所有物品只有一件,或者说只能取一次,而完全背包中所有种类的物品均可以取无限次,次数k的范围是 [0, V/c[i] ] 那么递推方程就转化为:

dp[i][j] = max( dp[i-1][j], dp[i-1][ j - k*c[i] ] + k*w[i] | 0=< k*c[i] <= j)

当然,k的范围是可求的所以也可以把它转化成0-1背包问题,那就是第种物品有Ki件,每件一毛一样,每件只能被取一次,多加一层K循环即可,但是这样一来复杂度相当高。

3.空间优化:那么能不能继续使用滚动数组呢?

回想一下,0-1背包问题为什么要逆序循环,因为在算下一次的 i 的 dp[j]时,必须先算右边的(即j大的)以解决覆盖问题。每次求dp[v]时,使用的是dp[v - c[i]],这是上一轮所算出来的,不是这一轮算出来的所以保证了每一轮算dp[v]时使用的都是上一轮的结果,也就保证了每件物品在每轮计算中都只被选一次。如果我们正序计算dp[v], 则算dp[v]时使用的dp[v - c[i]]是这一轮的,也就是第i件物品又被选了一次,它可以被选择多次。

其实也可以这样理解,在第一轮中,背包中放的都是第一种物品,放了0件,1,件,2件...... V/c[1]件的结果都知道;第二轮时,在第一轮的基础上,放入若干第二种物品,更新dp[i]使其暂时最优,一直放到最后一种,不断更新dp[i]使其最优。

 

【代码】

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

const int maxn = 1e3+10;
int w[maxn];
int c[maxn];
int n,v;
int dp[maxn];//dp[i]表示背包体积为i时获得的最大价值 
int main(){
    int t;
    cin>>t;
    while(t--){
        cin>>n>>v;
        memset(dp, 0 ,sizeof dp);
        //读入价值 
        for(int i=1; i<=n; i++){
            cin>>w[i];
        }
        //读入代价 
        for(int i=1; i<=n; i++){
            cin>>c[i];
        }
        //正序递推 
        for(int i=1; i<=n; i++){
            for(int j=c[i]; j<=v; j++){
               dp[j] = max(dp[j-c[i]] + w[i], dp[j]);
            }
        }
        
        cout<<dp[v]<<endl;
    }
}
View Code

 

三、多重背包

1.问题概述:有N种物品和一个体积为V的背包,第i种物品最多有n[i]件,花费为c[i], 价值为w[i], 问怎样放使得背包总价值最大?输出最大总价值。

2.问题分析: 多了一个限制条件,每种物品最多有n[i]件,其实不还是和完全背包一样,完全背包每件物品最多V/c[i]个,这里是给定的n[i]个。

dp[i][j] = max( dp[i-1][j], dp[i-1][ j - k*c[i] ] + k*w[i] | 0=< k*c[i] <= min(n[i] , V/c[i]))

或者这样说,把这n[i]件物品看做是n[i]种物品,每种物品只能取一次,这样不就和0-1背包一毛一样了吗?

3.例题:庆功宴 http://ybt.ssoier.cn:8088/problem_show.php?pid=1269

【AC代码】

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

const int maxn = 1e3+10;
int w[maxn];
int c[maxn];
int s[maxn]; 
int n,v;
int dp[6*maxn];//dp[i]表示背包体积为i时获得的最大价值 
int main(){
    //int t;
    //cin>>t;
    while(cin>>n>>v){
        memset(dp, 0 ,sizeof dp);
        //读入价值 
        for(int i=1; i<=n; i++){
            cin>>c[i]>>w[i]>>s[i];
           }

        for(int i=1; i<=n; i++){
            for(int j=v; j>=c[i]; j--){
                for(int k=1; k<=s[i]; k++){
                    if(j >= k*c[i]){
                        dp[j] = max(dp[j-k*c[i]] + k*w[i], dp[j]);
                    }
                    else
                        break;
                }
            }
        }
        cout<<dp[v]<<endl;
    }
}
View Code

 

4.二进制优化

如果数据规模比较大,那么用K循环枚举物品被放入0个到n[i]个的情况时间复杂度比较高,可以采用二进制压缩的方法,其实相当于一种倍增。

比如说有一件物品价值为3,数量为10,可以把10进行二进制拆分1,2,4,3。这4个数能够组成【1,10】内任意一个数,并且组合成的所有数恰好填满此区间,也就说不可能组合成除此区间外的任何一个数,这就保证了问题的性质不变。

推广到一般化,拆分方法为 把 n[i] 拆分成 20 21 22 2......2k  , t初始值为 n[i]  每拆一个就更新t = t - 2k  最后不能再拆时满足 t  <  2k+1  

【AC代码】

//二进制优化
#include<iostream>
#include<cstring>
using namespace std;

const int maxn = 1e3+10;
int w[maxn];
int c[maxn];
int s[maxn];
int n,v;
int cnt;//二进制转化 后的物品数量 
int dp[6*maxn];//dp[i]表示背包体积为i时获得的最大价值 
int main(){
    int t;
    int price, value, quantity; 
    while(cin>>n>>v){
        cnt = 0;
        memset(dp, 0 ,sizeof dp);
        //读入价值 
        for(int i=1; i<=n; i++){
            cin>>price>>value>>quantity;
            
            //进行二进制优化 转化成0-1背包 
            int t = 1;
            while(quantity >= t){
                c[++cnt] = price * t;
                w[cnt] = value * t;
                quantity -= t;
                t*=2;
            }
            
            //别忘了最后可能有剩余
            if(quantity > 0) {
                c[++cnt] = price * quantity;
                w[cnt] = value * quantity;
            }
        }
        //注意i的上限是cnt不是n了 
        for(int i=1; i<=cnt; i++){
            for(int j=v; j>=c[i]; j--){
               dp[j] = max(dp[j-c[i]] + w[i], dp[j]);
            }
        }
        
        cout<<dp[v]<<endl;
    }
}
View Code

 

 

四、背包问题求解方案数

1. 问题概述:给定N个正整数xx2, ..., xN,给这些数添加正负号,使它们的和为E,求有多少种选择方案。

2. 问题分析,先全部加起来等于S再说,再选出一些正号变成负号将S变成E,

问题转化为: 拣选出若干个数字 使得其和为E' = (S-E) / 2,即等价问题:给定N个正整数x1 x2, ..., xN,从中选出若干个数,使它们的和为E,求有多少种选择方案。

dp[i,j]表示在前i个物品里选,和恰好为j的方案数,则dp[i][j] = dp[i-1][j] + dp[i-1][j-x[i]], 思路还是一样,第i个物品要么选要么不选

3. dfs暴力搜索

/*
  Name:
  CopyRight:
  Author: Cz
  Date: 2021/4/5 10:13:01
  Description:
*/


#include<iostream>
using namespace std;
int arr[1005]; 
int N;
int E;
int ans = 0;
void dfs(int pos, int sum){
    if(pos == N){
        if(sum == E){
            ans++;
        }
        return; //如果到了最后一个位置 必须要回溯了 
    }
    dfs(pos+1, sum + arr[pos]); //转移到正负两个状态 
    dfs(pos+1, sum - arr[pos]);
}

 
int main(){
    cin>>N>>E;
    for(int i=0; i<N; i++) cin>>arr[i];
    dfs(0, 0);
    cout<<ans<<endl;
    return 0;
}
// 5 3 1 1 1 1 1
View Code

4. 排序后剪枝

/*
  Name:
  CopyRight:
  Author: Cz
  Date: 2021/4/5 10:13:01
  Description:
*/

#include<iostream>
#include<algorithm>

using namespace std;

int post_sum[1005] = {0}; //后缀和 
int arr[1005]; 
int N;
int E;
int ans = 0;
void dfs(int pos, int sum){
    if(pos == N){
        if(sum == E){
            ans++;
        }
        return; //到了最后一个位置 必须要回溯
    }
    if(abs( E - sum ) > post_sum[pos]) return; //如果E与当前和的绝对值差距大于后续所有数字的和 肯定是不可能了 
    
    dfs(pos+1, sum + arr[pos]); //转移到正负两个状态 
    dfs(pos+1, sum - arr[pos]);
}

 
int main(){
    cin>>N>>E;
    for(int i=0; i<N; i++) cin>>arr[i];
    sort(arr, arr + N);
    
    for(int i=N-1; i>=0; i--) 
        post_sum[i] = post_sum[i+1] + arr[i]; 
    dfs(0, 0);
    cout<<ans<<endl;
    return 0;
}
// 5 3 1 1 1 1 1    ans = 5
// 5 3 1 2 3 4 5    ans = 3
View Code

5. 0-1背包

/*
  Name:
  CopyRight:
  Author: Cz
  Date: 2021/4/5 10:13:01
  Description:
*/



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

int dp[1005][1005] = {0}; // 用dp[i,j]表示在前i个物品里选,和恰好为j的方案数 
int arr[1005]; 
int N;
int E;
int S = 0;
// 先全部加起来等于S再说,再选出一些正号变成负号将S变成E 
// 问题转化为: 拣选出若干个数字 使得其和为E' = (S-E) / 2  
// 用dp[i,j]表示在前i个物品里选,和恰好为j的方案数 
// 状态转移就是考虑当前数字arr[i]到底选不选,两种情况,考虑dp[i][j]究竟是从那种状态转移过来的 
 
int main(){
    cin>>N>>E;
    for(int i=1; i<=N; i++) { //从1开始计数 
        cin>>arr[i];
        S += arr[i];
    }
    if((S - E) & 1 ) return 0; //如果S-E不是偶数 方案数肯定为0 
    E = (S - E) / 2; //定义新的目标值 拣选出若干个 使得其和为E

    dp[0][0] = 1; //前0个物品,总价值恰好为0,本身就是一种方案 
    
    for(int i=1; i<=N; i++){ //从前往后枚举物品 
        for(int j=0; j<=E; j++){ //计算在前i种物品中,各种价值的方案数 
            dp[i][j] += dp[i-1][j]; //不选第i件物品  那就是从前一种状态直接转移过来 
            if(j - arr[i] >= 0) dp[i][j] += dp[i-1][j-arr[i]]; //选第i件物品 
        }
    }
    
    cout<<dp[N][E]<<endl;//最后的结果就是在前N种物品中选出若干物品,价值总和恰好为E的方案数 
    return 0;
}

//有个缺点就是dp数组的第二维只能开到1005,所以实际上对E是有很大限制的 
View Code

6. 滚动数组优化

/*
  Name:
  CopyRight:
  Author: Cz
  Date: 2021/4/5 10:13:01
  Description:
*/


#include<iostream>
using namespace std;

int dp[1000005] = {0}; // 用dp[i,j]表示在前i个物品里选,和恰好为j的方案数 这里是将i隐藏了,每次都是覆盖式赋值 
int arr[1005]; 
int N;
int E;
int S = 0;
// 滚动数组优化 实质上覆盖式赋值,可以降一维空间 
// 原状态转移: dp[i][j] = dp[i-1][j] + dp[i-1][j-arr[i]]
// 如果把dp这个二维数组看成一张表格,可以知道dp[i][j]总是由其头顶上一个元素和头顶左侧某个元素计算而来
//  也就是说 一个元素,只为它下面的元素和下面元素的右边某个元素服务,这两个元素没算出来这个元素就不能被覆盖 
// 所以,如果从右往左计算,即使该元素被覆盖了,也影响不到左边的元素 
//  dp[j] = dp[j] + dp[j-arr[i]]; 实际上是把[i-1]省掉了,右边的dp[j]是第i-1轮意义下的dp[j],左边的dp[j]是第i轮意义下的dp[j] 
int main(){
    cin>>N>>E;
    for(int i=1; i<=N; i++) { //从1开始计数 
        cin>>arr[i];
        S += arr[i];
    }
    if((S - E) & 1 ) return 0; //如果S-E不是偶数 方案数肯定为0 
    E = (S - E) / 2; //定义新的目标值 拣选出若干个 使得其和为E

    dp[0] = 1; //前0个物品,总价值恰好为0,本身就是一种方案 
    
    for(int i=1; i<=N; i++){ //从前往后枚举物品 
        for(int j=E; j>=0; j--){ //计算在前i种物品中,各种价值的方案数  倒推 
            if(j - arr[i] >= 0) dp[j] += dp[j-arr[i]]; //选第i件物品 
        }
    }
    
    cout<<dp[E]<<endl;//最后的结果就是在前N种物品中选出若干物品,价值总和恰好为E的方案数 
    return 0;
}
View Code

 

五、求选中的物品集合 以0-1背包为例子

#include<iostream>
#include<cstring>
using namespace std;
const int maxn = 1e3+10;
int w[maxn];
int c[maxn];
int n,v;
int dp[maxn][maxn];
int x[maxn] = {0};
void traceback()  
{  
    for(int i=n;i>1;i--)  
    {  
        if(dp[i][v] == dp[i-1][v])  
            x[i] = 0;  
        else  
        {  
            x[i] = 1;  
            v -= c[i];  
        }  
    }  
    x[1]=(dp[1][v] > 0) ? 1 : 0;  
}   
int main(){
    int t;
    cin>>t; //test case
    while(t--){
        cin>>n>>v;
        memset(dp, 0 ,sizeof dp);
        for(int i=1; i<=n; i++){
            cin>>w[i];
        }
        for(int i=1; i<=n; i++){
            cin>>c[i];
        }
        
        for(int i=1; i<=n; i++){
            //正向递推,背包体积从0开始到V 
            for(int j=0; j<=v; j++){
                if(j-c[i] >= 0)
                    dp[i][j] = max( dp[i-1][j], dp[i-1][j-c[i]] + w[i]);
                else
                    dp[i][j] = dp[i-1][j];
            }
        }
        
        cout<<dp[n][v]<<endl;
        traceback();
        for(int i=1; i<=n; i++) cout<<x[i]<<" ";
        cout<<endl;
        return 0;
    }
}
View Code

 

posted @ 2018-09-08 23:10  西风show码  阅读(202)  评论(0编辑  收藏  举报