各种背包问题描述
背包问题其实就是将最合适物品放入背包中的问题,最值问题的一般解法都是利用动态规划,背包问题主要有以下3种:
1. 01背包
有n种物品与承重为m的背包。每种物品只有一件,每个物品都有对应的重量weight[i]与价值value[i],求解如何装包使得价值最大。
2. 完全背包
有n种物品与承重为m的背包。每种物品有无限多件,每个物品都有对应的重量weight[i]与价值value[i],求解如何装包使得价值最大。
3. 多重背包
有n种物品与承重为m的背包。每种物品有有限件num[i],每个物品都有对应的重量weight[i]与价值value[i],求解如何装包使得价值最大。
其实主要就是约束条件(每种物品的数量)差异。
01背包
01背包之所以称为01,是因为针对每个物品就两种状态:装与不装。装进去就是1,不装进去就是0。也就是说每种物品都只有一件,放了就没了。
状态转移方程:
dp[i][j] = max( dp[i-1][j] , dp[i-1][ j - weight[i] ] + value[i] ), j >= weight[i];
- dp[i,j] 表示在前 i 件物品中选择若干件放在承重为 j 的背包中,可以取得的最大价值。
- value[i] 表示第 i 件物品的价值。
- 决策:为了背包中物品总价值最大化,第 i 件物品应不应该该放入背包中?
例题:有编号分别为a,b,c,d,e的五件物品,它们的重量分别是2,2,6,5,4,它们的价值分别是6,3,5,4,6,现在给你个承重为10的背包,如何让背包里装入的物品具有最大的价值总和?
这张表表示不同容量的背包在不同的可选物品数目下的可装入最大价值,自底向上,从左向右 。其实准确来说最底下还有一行全为 0 的行,代表一种物品都没有时,即使容量无限大价值也零。
static int[][] get01Bag(int[] w,int[] v,int n,int m) {
int[][] dp = new int[n+1][m+1];
//从有1个物品可选到有N个物品,0个物品可选的情况最大价值必然为0,已初始化
for (int i = 1; i <= n; i++) {
//有i个物品可选时,容量为1 到 m的情况
for (int j = m; j >= 1; j--) {
if (j >= w[i]) //如果容量大于当前的物品,说明可以放
//然后取放或者不放中的最大值
dp[i][j] = Math.max(dp[i-1][j],dp[i-1][j-w[i]]+v[i]);
else //如果放不进去,就等于当前这个物品不存在
dp[i][j] = dp[i-1][j];
}
}
return dp;
}
public static void main(String[] args) {
int[] w = {0,2,2,6,5,4};
int[] v = {0,6,3,5,4,6};
int m = 10,n = 5;
int[][] dp = get01Bag(w,v,n,m);
for (int i = 0; i <= n; i++) {
for (int j = 0; j <= m; j++) {
System.out.print(dp[i][j]);
System.out.print(" ");
}
System.out.println();
}
System.out.println(dp[n][m]);
}
例题改:有编号分别为a,b,c,d,e的五件物品,它们的重量分别是2,2,6,5,3,5,现在给你个承重为10的背包,如何让背包里装入的物品数量为最少或最多?
最多
最多件很好理解,因为可以放入重量大的必然可以放入重量小的物品,所以直接排升序,然后一个个放直到放不进去为止。
最少
最少的情况其实很初始01背包十分类似,只需要将状态转移方程中的max改为min即可,即
dp[j] = min( dp[j] , dp[ j - weight[i] ] + 1), j >= weight[i];
需要注意的是,最小问题和最大问题有一点是不同的:初始值的不同,求最大值的时候我们利用了数组默认初始为0的条件,因为价值不可能小于0,所以在第一次装入时背包的价值肯定是增加的,所以可以成功装入即会装入已达到max最大值;
而对于最小的件数,在状态转移时会产生这样一个问题:能装下此物品而不装入,即使我容量足够大但是因为min的比较导致总是选择不装入这个决策,最后最小的件数都是0,因为一个都不装入肯定是最小的。但这显然是不符合逻辑的,最小件数的限制应该是能放入必须放,剩余的容量不能大于最小重量的物品,不然就还可以放,所以我们可以这样认为:只要有剩余容量大于最小重量的物品,那就认为可装件数是无限大的。
static int get01BagCount(int[] w,int n,int m) {
//存储不同容量下最小件数的数组0-m
int[] dp = new int[m+1];
//初始化,当一个容量大于0的背包没装物品时,认为可以装无限多件(无限这里表示n+1件,因为总共只有n件物品)
for (int i = 1; i <= m; i++) {
dp[i] = n+1;
}
//有i件物品可选的情况
for (int i = 1; i <= n; i++) {
//能装进去时,取两种状态最小值
for (int j = m; j >= w[i]; j--) {
dp[j] = Math.min(dp[j],dp[j-w[i]]+1);
}
}
return dp[m];
}
public static void main(String[] args) {
int[] w = {0,1,2,3,5,5,6,10};
int m = 10,n = 7;
System.out.print( get01BagCount(w,n,m));
}
优化空间复杂度
因为上面01背包采用的是二维的数组,开销是很大的,万一哪个数据一大,很容易内存超限,因此有了转化为一维的解法。
首先可以肯定是有一个主循环i=1...N,每次算出来二维数组f[i][0..V]的所有值。那么,如果只用一个数组f[0..V],能不能保证第i次循环结束后f[v]中表示的就是我们定义的状态f[i][v]呢?
f[i][v]是由f[i-1][v]和f[i-1][v-c[i]]两个子问题递推而来,能否保证在推f[i][v]时(也即在第i次主循环中推f[v]时)能够得到f[i-1][v]和f[i-1][v-c[i]]的值呢?事实上,这要求在每次主循环中我们以v=V....0的顺序推f[v],这样才能保证推f[v]时f[v-c[i]]保存的是状态f[i-1][v-c[i]]的值。
static int get01Bag(int[] w,int[] v,int n,int m) {
int[] dp = new int[m+1];
for (int i = 1; i <= n; i++) {
for (int j = m; j >= w[i]; j--) {
dp[j] = Math.max(dp[j],dp[j-w[i]]+v[i]);
}
}
return dp[m];
}
初始化的细节问题
我们看到的求最优解的背包问题题目中,事实上有两种不太相同的问法。有的题目要求“恰好装满背包”时的最优解,有的题目则并没有要求必须把背包装满。一种区别这两种问法的实现方法是在初始化的时候有所不同。
如果是第一种问法,要求恰好装满背包,那么在初始化时除了f[0]为0其它f[1..V]均设为∞,这样就可以保证最终得到的f[N]是一种恰好装满背包的最优解。
如果并没有要求必须把背包装满,而是只希望价格尽量大,初始化时应该将f[0..V]全部设为0。
为什么呢?可以这样理解:初始化的f数组事实上就是在没有任何物品可以放入背包时的合法状态。如果要求背包恰好装满,那么此时只有容量为0的背包可能被价值为0的nothing“恰好装满”,其它容量的背包均没有合法的解,属于未定义的状态,它们的值就都应该是∞了。如果背包并非必须被装满,那么任何容量的背包都有一个合法解“什么都不装”,这个解的价值为0,所以初始时状态的值也就全部为0了。
需要注意的是,这里的 ∞无穷 的选取也是有技巧的:
-
当需要求的是最大值或最多时,此无穷为负无穷 -∞,因为max函数会始终取最大值,自动淘汰负无穷;同理,若需要求的是最小值或最小件数时,此无穷为正无穷 +∞,min函数取最小值淘汰正无穷。
-
由于Java中无穷是需要浮点数来表示比较麻烦,又因为这里的题目中(物品价格和重量)一般都是正整数,所以这里的无穷可以采取 M+1 即可,M为背包容量,即使最小的正整数1也只能装下M件物品,所以M+1就视为是无穷的。
完全背包
有N种物品和一个容量为M的背包,每种物品都有无限件可用。第i种物品的费用是w[i],价值是v[i]。求解将哪些物品装入背包可使这些物品的费用总和不超过背包容量,且价值总和最大。
最简单的想法就是转化为01背包再来计算,因为每个物品的费用是w[i]且无限取,但是容量为M的背包最多也只可以装 M/w[i] 件此物品,所以干脆直接假设对于每种物品 i 都有 M/w[i] 件,再就是直接套用01背包的计算公式。
但这样的做法想想都很蠢,假如一个物品的花费w[i]和价值v[i]都很小,而M很大时,外层循环能计算到天荒地老。
static int[] getBagComplete(int[] w,int[] v,int n,int m) {
int[] dp = new int[m+1];
for (int i = 1; i <= n; i++) {
for (int j = w[i]; j <= m; j++) {
dp[j] = Math.max(dp[j],dp[j-w[i]]+v[i]);
}
}
return dp;
}
可以看出这与上述01背包的一维状态转移方程几乎一样,唯一的不同是完全背包的内层表示背包容量的循环是从小到大。
为什么这样一改就可行呢?首先想想为什么01背包中要按照v=V..0的逆序来循环。这是因为要保证第i次循环中的状态f[i][v]是由状态f[i-1][v-c[i]]递推而来。换句话说,这正是为了保证每件物品只选一次,保证在考虑“选入第i件物品”这件策略时,依据的是一个绝无已经选入第i件物品的子结果f[i-1][v-c[i]]。
而现在完全背包的特点恰是每种物品可选无限件,所以在考虑“加选一件第i种物品”这种策略时,却正需要一个可能已选入第i种物品的子结果f[i][v-c[i]],所以就可以并且必须采用v=0..V的顺序循环。这就是这个简单的程序为何成立的道理。
需要注意的是,上面的代码中两层for循环的次序可以颠倒。这个结论有可能会带来算法时间常数上的优化。
一个LeetCode例题
给定不同面额的硬币 w[i] 和一个总金额 m。编写一个函数来计算可以凑成总金额所需的最少的硬币个数。如果没有任何一种硬币组合能组成总金额,返回 -1。你可以认为每种硬币的数量是无限的。
一个典型的完全背包的最小值问题,先设置初始化一个“无穷大值” m+1,然后按照完全背包的公式套用即可:
public int coinChange(int[] w, int m) {
int n = w.length;
int[] dp = new int[m+1];
for (int i = 1; i <= m; i++) {
dp[i] = m+1;
}
for (int i = 1; i <= n; i++) {
for (int j = w[i-1]; j <= m; j++) {
dp[j] = Math.min(dp[j],dp[j-w[i-1]]+1);
}
}
return dp[m] > m ? -1 : dp[m];
}
多重背包
有N种物品和一个容量为M的背包。第i种物品最多有n[i]件可用,每件费用是w[i],价值是v[i]。求解将哪些物品装入背包可使这些物品的费用总和不超过背包容量,且价值总和最大。
这里同样可以把同一种类的多个物品拆开成多个不同种类的花费和价值一样的物品,然后按01背包计算,代码如下:
static int[] getMultipleBag(int[] w,int[] v,int n[],int n,int m) {
int[] dp = new int[m+1];
for (int i = 1; i <= n; i++) {
for(int k=0; k<n[i]; k++)//其实就是把这类物品展开,调用n[i]次01背包代码
for (int j = m; j >= w[i]; j++) {
dp[j] = Math.max(dp[j],dp[j-w[i]]+v[i]);
}
}
return dp[m];
}
优化时间复杂度的更复杂的方法:同时利用01背包和完全背包。
#include<bits/stdc++.h>
using namespace std;
const int N = 1005;
int dp[N];
int c[N],w[N],num[N];
int n,m;
void ZeroOne_Pack(int cost,int weight,int n)//吧01背包封装成函数
{
for(int i=n; i>=cost; i--)
dp[i] = max(dp[i],dp[i-cost] + weight);
}
void Complete_Pack(int cost,int weight,int n)//把完全背包封装成函数
{
for(int i=cost; i<=n; i++)
dp[i] = max(dp[i],dp[i-cost] + weight);
}
int Multi_Pack(int c[],int w[],int num[],int n,int m)//多重背包
{
memset(dp,0,sizeof(dp));
for(int i=1; i<=n; i++)//遍历每种物品
{
if(num[i]*c[i] > m)
Complete_Pack(c[i],w[i],m);
//如果全装进去已经超了重量,相当于这个物品就是无限的
//因为是取不光的。那么就用完全背包去套
else
{
int k = 1;
//取得光的话,去遍历每种取法
//这里用到是二进制思想,降低了复杂度
//为什么呢,因为他取的1,2,4,8...与余数个该物品,打包成一个大型的该物品
//这样足够凑出了从0-k个该物品取法
//把复杂度从k变成了logk
//如k=11,则有1,2,4,4,足够凑出0-11个该物品的取法
while(k < num[i])
{
ZeroOne_Pack(k*c[i],k*w[i],m);
num[i] -= k;
k <<= 1;
}
ZeroOne_Pack(num[i]*c[i],num[i]*w[i],m);
}
}
return dp[m];
}
int main()
{
int t;
cin>>t;
while(t--)
{
cin>>m>>n;
for(int i=1; i<=n; i++)
cin>>c[i]>>w[i]>>num[i];
cout<<Multi_Pack(c,w,num,n,m)<<endl;
}
return 0;
}
参考文章:
https://blog.youkuaiyun.com/tinyguyyy/article/details/51203935