背包问题

背包容量为 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背包变形:分割等和子集

416. 分割等和子集 - 力扣(LeetCode)

只包含正整数

这道题可以换一种表述:给定一个只包含正整数的非空数组,判断是否可以从数组中选出一些数字,使得这些数字的和等于整个数组的元素和的一半。因此这个问题可以转换成「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背包变形:最后一块石头的重量

1049. 最后一块石头的重量 II

问题转换为:

  1. 为 stones 中的每个数字添加 +/-,使得形成的「计算表达式」结果绝对值最小。
  2. 进一步转换为两堆石子(正数堆与负数堆)相减总和的绝对值最小(即两者之间的差值最小,最小的差就是题目的答案)
  3. 从 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

  1. 将arr转为非负数组;
  2. 设取正组P,取负组N,假设存在P-N=target,
  3. 已知P+N=sum,P-N=target两边同加P+N,得到P =( target + sum)/ 2
  4. 则 只要有一个集合的累加和为( 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到累加和的连续区间

  1. 排序数组,并从左到右划过数组
  2. 设当前数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]];
            }
        }
    }
    ......
}

完全背包变形:货币组成面值的最小货币数

又称322. 零钱兑换

如果没有任何一种硬币组合能组成总金额,返回 -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];
}

完全背包变形:货币组成面值的方法数

又称518. 零钱兑换 II

如果任何硬币组合都无法凑出总金额,返回 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];
}