一.背包问题
背包问题简单来说就是选与不选的问题,其本质就是一种有限制条件的组合问题。
给你一个“背包”,这个“背包”的体积是V,给你一堆“物品”,这些“物品”每一个都有价值w和体积v,让你选择一些放入“背包”,得到最大(最小)价值。这里根据放入背包的物品的总体积可以分出三类,一个是放入背包的体积恰好是V,一种是放入背包的体积至多是V(也可以不是V),一种是放入背包的体积至少是V(这种类型后面有专门的例题)。
背包问题是一个顺序无关的问题,即与我们选的“物品”顺序无关。顺序无关是组合问题的一个特点。比如我们选物品a,b,c,跟选c,b,a是没有区别的。
那顺序有关是什么?那就是排列问题了。排列问题我们选物品a,b,c,跟选c,b,a是有区别的。
背包问题是一种DP模型,往往题目不会直接告诉你:给你一个背包,让你选物品巴拉巴拉。而是将这个问题转化成别的,这就是为什么我上面的背包和物品会加引号。具体怎么回事大家可看后面的例题。
我们要理解背包问题的本质,其本质就是一个组合问题。题目让我们求的往往是这个组合子集的数量,或者说用这个组合能得到的最大价值(至多型)、最小价值(至少型)。
最后,背包问题其实有很多种,这里只介绍两种最常见的类型。
二.01背包问题
1.介绍
简单来说01背包是给你一个体积为V的背包,给你一堆物品,每个物品的价值是w,每件物品至多有一个(每个物品就能选一次,也可以不选)。
2.分析
按照以往的经验,我们的状态表示是:dp[i],表示选前i个物品,使得价值最大。但这里还有一个限制条件:体积。怎么办?再加一个状态就行了。
所以正确的状态表示是:dp[i][j],表示选前i个物品,体积恰好(至多、至少)是j,所得的最大(最小)价值。
有了状态表示,下面推状态转移方程。我们知道,背包问题是选与不选的典型代表,因此我们就按照正常DP题的分析思路:
下面说一下初始化问题,这里根据不同的要求有不同的初始化,具体问题具体分析。
至多类型的dp[i][j],表示选前i个物品,体积至多是j,所得的最大价值。
//至多
int[][] dp=new int[w.length+1][V+1];
for(int i=1;i<= w.length;i++){
for (int j = 0; j <=V ; j++) {
dp[i][j]=dp[i-1][j];
if(j>=v[i-1]){
dp[i][j]=Math.max(dp[i][j],dp[i-1][j-v[i-1]]+w[i-1]);
}
}
}
恰好的类型的初始化要注意,我们的dp[i][j],表示选前i个物品,体积恰好是j,所得的最大价值。也就是说,这个体积要切切实实存在,如果我们通过已有的物品体积加起来得不到就不能算进来。
//恰好
int[][] dp=new int[w.length+1][V+1];
for (int i = 1; i <= V; i++) {
dp[0][i]=-1;
}
for(int i=1;i<= w.length;i++){
for (int j = 0; j <=V ; j++) {
dp[i][j]=dp[i-1][j];
if(j>=v[i-1] && dp[i-1][j-v[i-1]]!=-1){
dp[i][j]=Math.max(dp[i][j],dp[i-1][j-v[i-1]]+w[i-1]);
}
}
}
有人可能会问:至少的类型呢?这种类型会在后面有。
这些是模板,一定不要死记,看看就行,要理解其中的思路,做一做下面的例题。
#######
更新补充
#######
之前一直觉得背包问题为什么要空间优化呢,二维能怎么样呢?
今天做了一个题dp数组要三维,如果不空间优化的话内容就超了,因此下面给出空间优化的写法:
//至多
int[] dp=new int[V+1];
for(int i=1;i<= w.length;i++){
for (int j = V; j >=0 ; j--) {
if(j>=v[i-1]){
dp[j]=Math.max(dp[j],dp[j-v[i-1]]+w[i-1]);
}
}
}
3.例题
乍看这个题,好像跟01背包没有什么关系。这其实就是上面说的题目不可能明晃晃的告诉你这是背包问题,要我们转化一下。
题目让我们等和的分割原来的集合,换句话说分割出的两个集合的和就是原来集合的一半。再换句话说就是让我们从集合中选出部分元素,使得这些元素的和等于原来的一半。
这不就是典型的恰好类型的01背包问题嘛。背包的体积就是原来和的一半,物品就是集合中的数,物品的体积就是集合中数的大小,不过这里没有价值。
这里dp[i][0]为什么要初始化成true呢?
教大家一个技巧:自己举一下不存在情况的例子。当i=0的时候,即没有一个元素可以选的时候,j取值时的情况;当j=0的时候,即背包容量是0的时候,i取值时的情况。
当i=0,不论背包体积有多大,都完成不了恰好放满,都没有物品怎么放,所以全是false(j=0除外);
当j=0,背包容量为0,那我们就一个物品不选呗,所以说dp全都是true,全都可以做到。
class Solution {
public boolean canPartition(int[] nums) {
int n=nums.length;
int sum=0;
for (int i = 0; i < n; i++) {
sum+=nums[i];
}
if(sum%2!=0){
return false;
}
boolean[][] dp=new boolean[n+1][sum/2+1];
for (int i = 0; i <= n; i++) {
dp[i][0]=true;
}
for (int i = 1; i <= n; i++) {
for (int j = 0; j <= sum/2; j++) {
//不选第i个元素
dp[i][j]=dp[i-1][j];
if(j>=nums[i-1]){
dp[i][j]=dp[i][j] || dp[i-1][j-nums[i-1]];
}
}
}
return dp[n][sum/2];
}
}
从这个题大家也能感受到,我们真的不要被模板限制死,dp数组不一定是int类型,恰好的类型也不一定要有dp[i-1][j-v[i-1]]!=-1这个判断条件,初始化也是不同的。
三.完全背包问题
1.介绍
简单来说完全背包是给你一个体积为V的背包,给你一堆物品,每个物品的价值是w,每件物品可以选无数个,不只是1个,爱选几个选几个。
2.分析
有了上面的铺垫,这里直接给出状态表示:dp[i][j],表示选前i个物品,体积恰好(至多、至少)是j,所得的最大(最小)价值。跟01背包的状态表示是一样的。
但是状态转移方程就不一样了,我们选的时候就不是选一个了,可以选多个:
那我们真的要对每件物品都要循环一下,看看能选多少个,选出来的最大值是多少吗?
我们可以通过一个数学转换,将下面的问题转换一下:
从这个式子不难发下,上面画线的部分比下面画线的部分多了一个w[i-1],所以说状态转移方程可以用下面的方法来表示:
至多类型:
//至多
int[][] dp=new int[w.length+1][V+1];
for(int i=1;i<= w.length;i++){
for (int j = 0; j <=V ; j++) {
dp[i][j]=dp[i-1][j];
if(j>=v[i-1]){
dp[i][j]=Math.max(dp[i][j],dp[i][j-v[i-1]]+w[i-1]);
}
}
}
恰好类型:
//恰好
int[][] dp=new int[w.length+1][V+1];
for (int i = 1; i <= V; i++) {
dp[0][i]=-1;
}
for(int i=1;i<= w.length;i++){
for (int j = 0; j <=V ; j++) {
dp[i][j]=dp[i-1][j];
if(j>=v[i-1] && dp[i][j-v[i-1]]!=-1){
dp[i][j]=Math.max(dp[i][j],dp[i][j-v[i-1]]+w[i-1]);
}
}
}
3.例题
例题一:322. 零钱兑换
这是一个求最小价值的题目。这个题相比于上面的题目有点过于直球,不用转换就能看出这个背包问题,而且是完全背包问题的恰好类型。
这个题的初始化我们给dp表的第一行(除j=0)全初始化成0x3f3f3f3f。在我给出的模板中,我将dp表的第一行(除j=0)全初始化成-1,是因为模板中求的是最大价值。使用0x3f3f3f3f对取最小值无影响。
class Solution {
public int coinChange(int[] coins, int amount) {
if(amount==0){
return 0;
}
int n=coins.length;
int[][] dp=new int[n+1][amount+1];
for (int i = 1; i <= amount; i++) {
dp[0][i]=0x3f3f3f3f;
}
for (int i = 1; i <= n; i++) {
for(int j=1;j<=amount;j++){
//不选
dp[i][j]=dp[i-1][j];
//选
if(j>=coins[i-1]){
dp[i][j]=Math.min(dp[i][j],dp[i][j-coins[i-1]]+1);
}
}
}
return dp[n][amount]>=0x3f3f3f3f?-1:dp[n][amount];
}
}
例题二:518. 零钱兑换 II
这个题就是我们上面提到过的求组合数的问题。我们原来求最大值的时候,就是给选和不选取个max,那求组合数的时候就是选与不选加起来就行了,本质上是没有什么区别的。
对于这种求组合数问题的初始化,我们同样采用我们上面的技巧,当有0个物品且背包体积为0时的组合数,这个显然是一个空集,算一种组合。
class Solution {
public int change(int amount, int[] coins) {
int n=coins.length;
int[][] dp=new int[n+1][amount+1];
dp[0][0]=1;
for (int i = 1; i <= n; i++) {
for (int j = 0; j <= amount; j++) {
dp[i][j]+=dp[i-1][j];
if(j>=coins[i-1]){
dp[i][j]+=dp[i][j-coins[i-1]];
}
}
}
return dp[n][amount];
}
}
四.二维费用背包问题
二维费用背包问题就是在一维费用背包问题的基础上在加上一个限制条件,这个单说不形象,直接给出例题:879. 盈利计划
这个题非常重要,不仅是二维费用背包问题,还引出了上面一直提到的至少类型的背包。
面对这种题目,我们发现原来的dp[][]根本无法表示全状态。本题要表示的状态有任务,人数,利润。
因此我们使用dp[i][j][k],表示选前i个任务,人数至多有j个,利润至少是k,这时的组合数。
状态转移方程与01背包问题相同。
对于这种求组合数的问题,我们肯定要初始化,后面没有+1,dp表内的数据永远是0。
那要怎么初始化?跟上面零钱兑换II一样,找到一个有效的集合。当i=0时,即没有任务,此时利润必然是0,那么人数是多少都没有影响,没有任务,利润是0的组合数只有1。
class Solution {
public int profitableSchemes(int n, int minProfit, int[] group, int[] profit) {
int m= group.length;
int mod=1_000_000_007;
int[][][] dp=new int[m+1][n+1][minProfit+1];
for (int i = 0; i < n+1; i++) {
dp[0][i][0]=1;
}
for (int i = 1; i <= m; i++) {
for (int j = 0; j <= n; j++) {
for (int k = 0; k <= minProfit; k++) {
dp[i][j][k]=(dp[i][j][k]+dp[i-1][j][k])%mod;
if(j>=group[i-1]){
//区别,这里的k不用>=profit[i-1]
dp[i][j][k]=(dp[i][j][k]+dp[i-1][j-group[i-1]][Math.max(0,k-profit[i-1])])%mod;
}
}
}
}
return dp[m][n][minProfit];
}
}
这里说明一下至少问题。
为什么至少问题不用k>=profit[i-1]?
首先我们要明白k表示什么,k表示利润至少是k,如果profit[i-1]大于等于k,说明我们选择了第i个任务,这个任务的利润已经比k大了,已经满足利润至少是k这个要求了,所以说我们选这一个就够了,选这个在加上k=0时的情况就可以了。
如果profit[i-1]小于k,直接取前面的情况找就行了。