背包问题
分类
首先,笔者将背包问题分成如下几类:
当然这并不涵盖全部的背包问题,只是以笔者准备面试、看面经及自己的亲身经历而言,熟练掌握上图这几种背包问题不仅能快速入门,相信在面试手撕中遇到的背包问题也不会超出这个范围。
1. 0 1 背包
01背包的定义是有背包一个、物品若干,并且一个物品只能被选择一次,问满足某某条件下的最大/最小。这种描述基本上就是背包问题了。背包问题的关键就是只能选择一次,因此这里先抛出结论,处理01背包时“外层遍历物品从前往后,内层遍历背包,从后往前”
这里笔者又将01背包分成了“明显的”、和“需要转换的”,顾名思义,明显的就是大白话告诉你此题什么是背包什么是物品,像这种直接套模板就好了,而需要转换的往往看上去和背包毫无关系,但你又无从下手,这个时候要是能敏锐的发现它是个背包问题,题目就会变得很简单。
1-1 明显的背包问题
如题:
这种题型,明显的定义了什么是背包什么是物品。
import java.util.Scanner;
public class Main {
public static void main(String[] args) {
Scanner sc = new Scanner(System.in);
int N = sc.nextInt();
int V = sc.nextInt();
int[] vs = new int[N+1];
int[] ws = new int[N+1];
for(int i = 1;i <= N;i++){
vs[i] = sc.nextInt();
ws[i] = sc.nextInt();
}
int[] dp = new int[V+1];
for(int i = 1;i <= N;i++){
for(int j = V;j>=vs[i];j--){
dp[j] = Math.max(dp[j],dp[j-vs[i]]+ws[i]);
}
}
System.out.println(dp[V]);
}
}
显然,此题就是给定物品若干,在满足某某条件的情况下往背包装,被问最值。显然的背包问题,直接套模板,外层遍历物品,将物品从前往后逐一尝试,这是容易理解的,但为何里面遍历背包是从后往前呢,这里不妨手推一下,假如一个物品重量为1,从前往后去尝试这个物品的时候,内存从前往后遍历背包,那背包容积为1的时候能背的重量是dp[0] + 1 = 1,当遍历到2时同理变成了2,这就矛盾了,一个物品相当于被选择了多次,这里只能选择一次物品的话无论容量是1还是2甚至1000,能背下的重量都是1而已。因此遍历的顺序是从后往前遍历背包。
1-2 需要转换的背包问题
此题的关键在于如何将其转成背包问题,试想,当我们有一个容积为总的石头重量的二分之一的一个背包,假如我们能将其装满,那么所有的石头都会被相互砸碎,假如装不满,那么我们就想尽办法将它装到尽可能的满,那么背包背住的最大值乘以2就是能砸碎的最大值!
因此,定义背包为dp[总重量二分之一],价值和重量都为石头重量,故状态转移方程dp[i] = max(dp[i],dp[i-stones[i]] + stones[i])。
public int lastStoneWeightII(int[] stones) {
int sum = 0;
for(int i = 0;i < stones.length;i++){
sum += stones[i];
}
int target = (sum >> 1);
int[] dp = new int[target + 1];
for(int i = 0;i < stones.length;i++){
for(int j = target;j >= stones[i];j--){
dp[j] = Math.max(dp[j],dp[j - stones[i]] + stones[i]);
}
}
return sum - dp[target] * 2;
}
将问题转换成背包思想以后,套模板即可。
再试一个类似的:
首先如果零要加的数之和是left,要减的数的和是right,那么有如下等式成立:
left + right = sum;left - right = target;因此left = (sum + target)/2;
问题就变成了在nums中找若干个数,只能以加的形式组合成left的方案数。
状态表示dp[i] = x,就是用nums来表示出i的方案数是x
因此定义int dp[] = new int[left] ,dp[left]就是答案
初始化dp[0] = 1,也就是啥也没有得时候组成0得方式也有1种那就是啥也没有,这才能保证后面递推的时候有值。
确定遍历顺序,外层从头遍历nums的值,内层从left开始遍历dp(为了防止重复利用)
状态转移方程:dp[j] += dp[j - nums[i]],能组成j - nums[i]的方案就肯定能组成j,因为当前的数就是nums[i]
class Solution {
public int findTargetSumWays(int[] nums, int target) {
if(nums.length == 1){
if(target == nums[0] || target == -nums[0]){
return 1;
}
return 0;
}
int sum = 0;
for(int i = 0;i < nums.length;i++){
sum += nums[i];
}
int left = (sum + target) >> 1;
if((sum + target) % 2 != 0)return 0;
int[] dp = new int[left + 1];
dp[0] = 1;
for(int i = 0;i < nums.length;i++){
for(int j = left;j >= nums[i];j--){
dp[j] += dp[j - nums[i]];
}
}
return dp[left];
}
}
完全背包
0,1背包的关键就在于一个物品只能选择一次,因此在内嵌套循环中对背包进行遍历时需要从后往前进行遍历(如此便保证了一个物品只能被选择一次)。在0,1背包中物品(题目中给的数组在外循环进行遍历),背包(根据题意设出的dp数组)在内循环进行遍历。完全背包是给定了物品求满足某种条件下的最大方案数,这种是物品可以无限次进行选择,与0,1背包一样,题目给定的数组为物品,而要求的东西可设为dp数组(背包),外循环同样对物品进行遍历而内循环则对背包从前往后进行遍历,如此的目的是为了将一个物品选择无数次。
在完全背包中又分为两种:
1、不考虑顺序求方案数,也即是求组合数的问题,见下面例1,在这种情况物品只能被选择一次(因为2,1与1,2只能算一种,因此只能选择一次)此时,为了保证物品只被选择一次,因此外循环负责对物品进行遍历而内循环负责对背包进行遍历。
2、考虑顺序求排列数的完全背包,此种情况下1,2与2,1便要算两种,仍然在外层遍历物品的话就会导致1,2与2,1只能出现一次(因为是按顺序在外层只能遍历一次,因此1,2与2,1谁在前面就只能出现谁)。对于这种情况下的完全(物品可选n次)排列(不同的选择顺序导致不同方案)背包就需要在外层遍历背包而内层遍历物品,如此一来相当于内层循环会被遍历多次,也就是1,2与2,1都会出现到。见例2:
不考虑顺序的完全背包
该题是一种组合问题,也就是2,2,1和2,1,2算做一种,这种情况外层对物品进行遍历即可,否则上面这种就会被记作两种,内存对背包进行遍历时为了可以重复利用物品(完全背包)则从前往后遍历而非从后往前。
总金额定义为背包dp[amount + 1],dp[amount]就是方案数。
物品就是硬币集合。
状态转移方程:dp[j] = dp[j - coins[i]] + dp[j];也就是能组成dp[j - coins[i]]的就一定能组成dp[j]。
class Solution {
public int change(int amount, int[] coins) {
int[] dp = new int[amount + 1];
dp[0] = 1;
for(int i = 0;i < coins.length;i++){
if(coins[i] > amount)continue;
for(int j = coins[i];j <= amount;j++){
dp[j] = dp[j - coins[i]] + dp[j];
}
}
return dp[amount];
}
}
考虑顺序的完全背包
这种类型的完全背包问题和不考虑顺序的区别就在于1,2,1这种算一种方案、1,1,2又算一种方案,假如还是外层遍历物品就会导致这二种始终只能出现一种,因为物品的添加顺序已被固定。故一共外层遍历背包内层遍历物品,这样才能出现不同的组合。
确定背包:dp[target + 1],定义就是组成j的方案数;
状态转移:dp[j] += dp[j-nums[i]] ;
遍历顺序,外层背包,内层物品,从前往后:
class Solution {
public int combinationSum4(int[] nums, int target) {
int dp[] = new int[target + 1];
dp[0] = 1;
for(int i = 1;i <= target;i++){
for(int j = 0;j < nums.length;j++){
if(i - nums[j] >= 0){
dp[i] += dp[i-nums[j]];
}
}
}
return dp[target];
}
}
总结
读到这里,基本的背包问题基本就熟悉了,但这并不是全部的背包问题,此文章仅仅针对手撕时的问题,手撕通常不会出现极难的背包。掌握这些题目再稍加练习即可。
最后总结:背包分为01背包和完全背包,01背包又分为显然的和需要转换的,模板都是外层物品内层背包,背包从前往后。完全背包又被分为不考虑顺序的和考虑顺序的,为了保证物品的多次选择,遍历顺序都是从前往后,区别在于要考虑顺序时外层遍历背包内层遍历物品,以保证出现不同的顺序。
完。