背包问题初探
【前言】
背包问题是动态规划中的经典问题 ,特此总结三种背包问题的算法原理和一些基本实现,并且对每个问题提出了优化方案。目前只总结到初学者水平,以后拜读《背包九讲》之后如有新的体会再进行补充提升。欢迎读者批评指正。
【目录】
(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; } }
#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; } }
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; } }
二、完全背包
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; } }
三、多重背包
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; } }
4.二进制优化
如果数据规模比较大,那么用K循环枚举物品被放入0个到n[i]个的情况时间复杂度比较高,可以采用二进制压缩的方法,其实相当于一种倍增。
比如说有一件物品价值为3,数量为10,可以把10进行二进制拆分1,2,4,3。这4个数能够组成【1,10】内任意一个数,并且组合成的所有数恰好填满此区间,也就说不可能组合成除此区间外的任何一个数,这就保证了问题的性质不变。
推广到一般化,拆分方法为 把 n[i] 拆分成 20 21 22 23 ......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; } }
四、背包问题求解方案数
1. 问题概述:给定N个正整数x1 x2, ..., 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
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
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是有很大限制的
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; }
五、求选中的物品集合 以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; } }