背包问题
背包容量为 bag 物品共有 num 个 物品的重量为 weights :[w_0,w_1,…,w_num-1] 物品的价值为 values:[v_0,v_1,…,v_num-1]
完全背包问题与01背包的区别:
01背包最多只能拿一件物品
完全背包一种物品我可以拿n件
01背包问题
第一种尝试方式:
以i位置开始,考虑剩余空间(换说前i个物品已经解决,i及其之后的物品填满rest的最大价值)
递归版本:
public static int bagProblem(int[] w, int[] v, int bag) {
return process(w, v, 0, bag);
}
public static int process(int[] w, int[] v, int index, int rest) {
if (rest < 0) { // bc1
return Integer.MIN_VALUE;
}
if(rest == 0){
return 0;
}
if (index == w.length) { // bc2
return 0;
}
return Math.max(process(w, v, index + 1, rest),
process(w, v, index + 1, rest - w[index]) + v[index]);
}
动态规划版本:
dp[index][rest]
从index位置开始选,填满背包剩余rest空间的最大价值
public static int bagProblem(int[] w, int[] v, int bag) {
int n = w.length;
int[][] dp = new int[n + 1][bag + 1];
//最大价值问题中初始化可以省略,但方案数、存在性中不行
for (int i = 0; i < n + 1; i++) {
dp[i][0] = 0;
}
for (int j = 0; j < bag + 1; j++) {
dp[n][j] = 0;
}
for (int index = n - 1; index >= 0; index--) {
for (int rest = 1; rest <= bag; rest++) {
dp[index][rest] = dp[index + 1][rest];
if (rest >= w[index]) {
dp[index][rest] = Math.max(dp[index][rest],
dp[index + 1][rest - w[index]]);
}
}
}
return dp[0][bag];
}
===================================
第二种尝试方式:
以i位置开始,考虑已用空间(前 i个物品凑出体积cost时的最大价值)
递归版本:
public static int process1(int[] c, int[] p, int i, int cost, int bag) {
if (cost > bag) {
return Integer.MIN_VALUE;
}
if (i == c.length) {
return 0;
}
return Math.max(process1(c, p, i + 1, cost, bag),
p[i] + process1(c, p, i + 1, cost + c[i], bag));
}
动态规划版本:
dp[index][cost]
从index位置开始选,背包已用cost空间
public static int maxValue2(int[] w, int[] v, int bag) {
int[][] dp = new int[w.length + 1][bag + 1];
for (int i = w.length - 1; i >= 0; i--) {
for (int j = bag; j >= 0; j--) {
dp[i][j] = dp[i + 1][j];
if (j + w[i] <= bag) {
dp[i][j] = Math.max(dp[i][j],
v[i] + dp[i + 1][j - w[i]]);
}
}
}
return dp[0][0];
}
=====================
第三种尝试方式:
dp[i][j]
用0——i的数组,让体积正好为j的方法数
=====================
第四种尝试方式:
dp[i][j]
用前i的数组,让体积正好为j的方法数
总结:
不同的尝试方案会影响dp数组的含义
进而影响dp数组的维度和状态转移方程,但都能有效解决问题
对于背包类所有问题,这里统一使用第一种尝试方案
简记
dp[index][rest]从index位置开始选,填满背包剩余rest空间的最大价值
dp[i][0]:第一列初始化为0
dp[arr.length][j]:最后一行初始化为0
index位置选不选,分成两种情况
dp[i][j] = dp[i + 1][j];
dp[i][j] = v[i] + dp[i + 1][j - w[i]]
由递推公式得遍历顺序:
先下到上,再从左到右(初始化了的行列不用)
for (int index = n - 1; index >= 0; index--) {
for (int rest = 1; rest <= bag; rest++) {
完全背包问题
递归
public static int process(int[] w, int[] v, int index, int rest) {
if (rest < 0) { // bc1
return Integer.MIN_VALUE;
}
if(rest == 0){
return 0;
}
if (index == w.length) { // bc2
return 0;
}
return Math.max(process(w, v, index + 1, rest),
process(w, v, index, rest - w[index]) + v[index]);// 区别
}
动态
dp[index][rest]
从index位置开始选,填满背包剩余rest空间的最大价值
public static int bagProblem(int[] w, int[] v, int bag) {
int n = w.length;
int[][] dp = new int[n + 1][bag + 1];
//最大价值问题中初始化可以省略,但方案数、存在性中不行
for (int i = 0; i < n + 1; i++) {
dp[i][0] = 0;
}
for (int j = 0; j < bag + 1; j++) {
dp[n][j] = 0;
}
for (int index = n - 1; index >= 0; index--) {
for (int rest = 1; rest <= bag; rest++) {
dp[index][rest] = dp[index + 1][rest];
if (rest >= w[index]) {
dp[index][rest] = Math.max(dp[index][rest],
dp[index][rest - w[index]]);// 区别
}
}
}
return dp[0][bag];
}
简记
与01背包唯一区别:
dp[i][j] = dp[i + 1][j];
dp[i][j] = v[i] + dp[i][j - w[i]]
01背包变形:从数组任选数字,能不能累加得到 aim
给你一个数组 arr,和一个整数 aim。如果可以任意选择 arr 中的数字,能不能累加得到 aim,返回 true 或者 false。
注意该问题下不区分重量(int[] w)和价值(int[] v)数组,统一为arr数组,换句话说「成本」&「价值」均为数值本身。
以i位置开始,考虑已累加的和(从i开始能不能累加得到rest)
递归:
public static boolean IsSumToAim(int[] arr, int aim){
if(arr == null || arr.length ==0){
return false;
}
return process(arr, 0, 0, aim);
}
public static boolean process(int[] arr, int i, int rest){
if(i == arr.length){
return rest == 0;
}
if(rest < 0){
return false;
}
if(rest == 0){
return true;
}
// rest > 0
// 位置i有两种选择:要或不要,有一个为true,即返回true
return process(arr, i + 1, rest) ||
process(arr, i + 1, rest - arr[i]);
}
动态规划(完全仿照01背包):
dp[i][j]
表示从i开始选,能否累加得到j
public static boolean isSumToAim(int[] arr, int aim) {
if (arr == null || arr.length == 0) {
return false;
}
int n = arr.length;
boolean[][] dp = new boolean[n + 1][aim + 1];
for (int i = 0; i < n; i++) {
dp[i][0] = true;
}
for (int i = n - 1; i >= 0; i--) {
for (int j = 1; j <= aim; j++) {
dp[i][j] = dp[i + 1][j];
if (j - arr[i] >= 0) {
dp[i][j] = dp[i][j] || dp[i + 1][j - arr[i]];
}
}
}
return dp[0][aim];
}
与01背包一样,也有另一个以i位置开始,考虑已累加和的版本,详见:
【搞定左神算法初级班】第7节:暴力递归、动态规划 - it610.com
简记
与01背包的区别:能否问题
dp[i][j]表示从i开始选,能否累加得到j,正是原问题的子问题
dp[i][0]:第一列初始化为true(表示什么也不选能得到0)
dp[arr.length][j]:最后一行初始化为false
两种情况一种为true就是true,所以||运算
dp[i][j] = dp[i + 1][j];
dp[i][j] = dp[i + 1][j - arr[i]];
01背包变形:从数组任选数字,累加得到 aim 的方案数
未优化动态规划:
dp[i][j]
表示从i开始选,累加得到j的方案数
public static int SumToAimWay(int[] arr, int aim) {
if (arr == null || arr.length == 0) {
return 0;
}
int n = arr.length;
int[][] dp = new int[n + 1][aim + 1];
for (int i = 0; i < n; i++) {
dp[i][0] = 1;
}
for (int i = n - 1; i >= 0; i--) {
for (int j = 1; j <= aim; j++) {
dp[i][j] = dp[i + 1][j];
if (j - arr[i] >= 0) {
dp[i][j] += dp[i + 1][j - arr[i]];
}
}
}
return dp[0][aim];
}
上题的进一步变形如下:
dp[i][j]
表示从i开始选,累加和正好为j的方案数
而下面要不超过w的方法数,则在dp[0][j]
行中将j<=w的dp值累加就是本题答案
简记
`dp[i][j]`表示从i开始选,累加得到j的方案数
dp[i][0]:第一列初始化为1(表示什么也不选的方案)
dp[arr.length][j]:最后一行初始化为0
两种可能性的方案的相加的
01背包变形:分割等和子集
只包含正整数
这道题可以换一种表述:给定一个只包含正整数的非空数组,判断是否可以从数组中选出一些数字,使得这些数字的和等于整个数组的元素和的一半。因此这个问题可以转换成「01 背包问题」
public boolean canPartition(int[] nums) {
int n = nums.length;
if (n < 2) {
return false;
}
int sum = 0, maxNum = 0;
for (int num : nums) {
sum += num;
maxNum = Math.max(maxNum, num);
}
if (sum % 2 != 0) {
return false;
}
int target = sum / 2;
if (maxNum > target) {
return false;
}
// 以上全是边界情况
// 完全同“从数组任选数字,能不能累加得到 aim”
boolean[][] dp = new boolean[n + 1][aim + 1];
for (int i = 0; i < n + 1; i++) {
dp[i][0] = true;
}
for (int i = n - 1; i >= 0; i--) {
for (int j = 1; j <= aim; j++) {
dp[i][j] = dp[i + 1][j];
if (j - arr[i] >= 0) {
dp[i][j] = dp[i][j] || dp[i + 1][j - arr[i]];
}
}
}
return dp[0][aim];
}
01背包变形:最后一块石头的重量
问题转换为:
- 为 stones 中的每个数字添加 +/-,使得形成的「计算表达式」结果绝对值最小。
- 进一步转换为两堆石子(正数堆与负数堆)相减总和的绝对值最小(即两者之间的差值最小,最小的差就是题目的答案)
- 从 stones数组中选择,凑成总和不超过sum/2中的最大价值。
sum - 2 * j = (sum - j) - j
sum - j是总和超过sum/2的部分
j是总和不超过sum/2的部分
两者差值就是绝对值最小
public static int lastStoneWeight(int[] stones) {
int sum = 0;
for (int weight : stones) {
sum += weight;
}
// 完全同“从数组任选数字,能不能累加得到 aim”
int n = stones.length;
int aim = sum / 2;
boolean[][] dp = new boolean[n + 1][aim + 1];
for (int i = 0; i < n + 1; i++) {
dp[i][0] = true;
}
for (int i = n - 1; i >= 0; i--) {
for (int j = 1; j <= aim; j++) {
dp[i][j] = dp[i + 1][j];
if (j - stones[i] >= 0) {
dp[i][j] = dp[i][j] || dp[i + 1][j - stones[i]];
}
}
}
// 用到dp[0][]这一行的值
// 这里dp[0][j]表示从0开始选,能否累加得到j
for (int j = aim;; j--) {
// 逆序第一个能凑出的i就是“总和不超过sum/2中的最大价值”
if (dp[0][j]) {
return sum - 2 * j;
}
}
}
另一种尝试方式:
dp[i][i]
表示前 i个石头能否凑出重量 j
具体代码见leetcode题解
总结:
最后一块石头的最小重量
就是将数组分为累加和尽量接近的两组,最好的分法(两者差值越小越好)
01背包变形:以+或-连接数组得到target的方法数
又称494. 目标和
sum < target
((target & 1) ^ (sum & 1)) != 0 ( target 与sum奇偶性不同)
以上都是特殊情况,能减少常数时间,不加也行
核心是将用所有数凑出target 转变为 用部分数累加得到(target + sum) >> 1
- 将arr转为非负数组;
- 设取正组P,取负组N,假设存在P-N=target,
- 已知P+N=sum,P-N=target两边同加P+N,得到P =( target + sum)/ 2
- 则 只要有一个集合的累加和为( target + sum)/ 2,就意味着一种得到target的方法
public static int findTargetSumWay(int[] arr,int target){
int sum = 0;
for(int i = 0; i < arr.length; i++){
if(arr[i] < 0) {
arr[i] = -arr[i];
}
sum += arr[i];
}
return sum < target || ((target & 1) ^ (sum & 1)) != 0 ? 0 : SumToAimWay(arr, (target + sum) >> 1);
// return SumToAimWay(arr, (target + sum) >> 1);
}
01背包变形:最小不可组成和
本质是:从数组任选数字,能不能累加得到 aim的变形
唯一多出的判断是 从最小值到sum,第一个dp为false的列号(代表累加和)就是最小不可组成和
public static int unformedSum(int[] arr) {
int sum = 0;
int min = Integer.MAX_VALUE;
int n = arr.length;
for (int i = 0; i < n; i++) {
sum += arr[i];
if (min > arr[i]) {
min = arr[i];
}
// 或 min = Math.min(min, arr[i]);
}
boolean[][] dp = new boolean[n + 1][sum + 1];
for (int i = 0; i < n; i++) {
dp[i][0] = true;
}
for (int i = n - 1; i >= 0; i--) {
for (int j = 1; j <= sum; j++) {
dp[i][j] = dp[i + 1][j];
if (j - arr[i] >= 0) {
dp[i][j] = dp[i][j] || dp[i + 1][j - arr[i]];
}
}
}
for(int ans = min; ans <= sum; ans++){
if(!dp[0][ans]){
return ans;
}
}
return sum + 1;
}
本题的进一步变形:已知arr中一定有1,再求最小不可组成和
上题会大为简化,因为有1就保证能有一个从1到累加和的连续区间
- 排序数组,并从左到右划过数组
- 设当前数cur,当前可组成和的最大值为range
cur > range + 1是否成立
没有大于,则range = range + cur
大于了,则返回 range + 1(最小不可组成和)
如 1,2,4,9
最小不可组成和8,因为9>7+1,使连续区间中断
// 已知arr中有1
public static int unformedSum(int[] arr){
Array.sort(arr);
int range = 1; //因为一定有1,初始最大可组成和为1
for(int i = 1; i < arr.length; i++){
if(arr[i] > range + 1){
return range + 1;
} else {
range += arr[i];
}
return range + 1;
}
}
已知arr中一定有1,再求最小不可组成和 还有进一步变形:
public static int minPatched(int[] arr, int range){
int patches = 0;
long touch = 0;
for(int 0; i < arr.length;i++){
// 相当于上面的if部分,只是要循环
while(arr[i] > touch + 1){
touch += touch + 1; // touch + 1整体是缺的数
patches++;
if(touch >= range){
return patches;
}
}
// 相当于上面的else部分
touch += arr[i];
if(touch >= range){
return patches;
}
}
// 处理range远大于数组累加和的情况
while(range >= touch + 1){
touch += touch + 1;
patched++;
}
return patches;
}
换一种dp含义
dp[i][j]
表示arr[0...i]自由选择能否累加出j
public static int unformedSum(int[] arr){
int min =Integer.MAX_VALUE;
int sum =0;
for(int i =0;i< arr.length; i++){
min = Math.min(min, arr[i]);
sum += arr[i];
}
int n = arr.length;
boolean[][] dp = new boolean[n][sum + 1];
dp[0][arr[0]]=true;
for(int i = 1; i<n;i++){
for(int j; j<sum + 1;j++){
dp[i][j] = dp[i - 1][j];
if (j - arr[i] >= 0) {
dp[i][j] = dp[i][j] || dp[i - 1][j - arr[i]];
}
}
}
......
}
完全背包变形:货币组成面值的最小货币数
如果没有任何一种硬币组合能组成总金额,返回 -1
与完全背包思考方式基本雷同,只是最小货币数与无效返回的-1在进行Math.min时会干扰,所以多加每一子过程的判断
递归方法:
public static int minCoins(int[] arr, int aim){
process(arr, 0, aim);
}
public static int process(int[] w, int[] v, int index, int rest) {
if (rest < 0) {
return -1;
}
if(rest == 0){
return 0;
}
if (index == w.length) {
return -1;
}
//rest > 0 且有硬币
int p1 = process(arr, index + 1, rest);
int p2 = process(arr, index + 1, rest - arr[index]);
if(p1 == -1 && p2 == -1){
return -1;
} else {
if(p1 == -1){
return p2 + 1;
}
if(p2 == -1){
return p1;
}
return Math.min(p1,p2 + 1);
}
}
动态规划:
public minCoins(int[] arr, int aim){
int N = arr.length;
int[][] dp = new int[N + 1][aim + 1];
// 初始条件
for(int index = 0; index <= N; index++){
dp[index][0] = 0;
}
for(int rest = 0; rest <= aim; rest++){
dp[0][j] = -1;
}
// 改写递归
for(int index = N-1; index >= 0; index--){
for(int rest = 1; rest <= aim; rest++){
int p1 = dp[index + 1][rest];
int p2 = -1;
if(rest - arr[index] >= 0){
p2 = dp[index + 1][rest - arr[index]];
}
if(p1 == -1 && p2 ==-1){
dp[index][rest] = -1;
} else{
if(p1 == -1){
dp[index][rest] = p2 + 1;
}
if(p2 == -1){
dp[index][rest] = p1;
}
dp[index][rest] = Math.min(p1, p2 + 1);
}
}
}
return dp[0][aim];
}
完全背包变形:货币组成面值的方法数
如果任何硬币组合都无法凑出总金额,返回 0
// 直接套用 完全背包(已用对数器验证)
public static int changeWay(int[] arr, int bag) {
int n = arr.length;
int[][] dp = new int[n + 1][bag + 1];
for (int i = 0; i < n + 1; i++) {
dp[i][0] = 1;
}
for (int index = n - 1; index >= 0; index--) {
for (int rest = 1; rest <= bag; rest++) {
dp[index][rest] = dp[index + 1][rest];
if (rest >= arr[index]) {
dp[index][rest] += dp[index][rest - arr[index]];
}
}
}
return dp[0][bag];
}