【算法修炼】动态规划专题二:背包类型问题

学习自:https://labuladong.gitee.io/algo/3/25/85/

一、0-1背包问题

给你一个可装载重量为 W 的背包和 N 个物品,每个物品有重量和价值两个属性。其中第 i 个物品的重量为 wt[i],价值为 val[i],现在让你用这个背包装物品,最多能装的价值是多少?
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
需要注意的是dp数组的含义,因为我们需要描述出背包的性质:物品数 + 重量,所以得用二维DP数组来描述,dp[i][j]代表前 i 个物品,在背包容量为 j 的情况下,能够装下的最大价值。

再来看状态转移方程,对于每一个物品有两种状态,装它、不装它,这两种情况我们要取最大值,dp[i][j]= max(dp[i - 1][j], dp[i - 1][j - wt[i]] + val[i])。

base case:当物品数 = 0 或 背包当前容量 = 0时,价值都 = 0。

背包问题的关键就在于:base case的考虑,以及是否选择当前物品的考虑。

1.1数字组合(简单)

在这里插入图片描述
首先考虑用一维数组还是二维,因为需要记录整数个数、需要凑成的和,所以选用二维数组,dp[i][j]表示,前 i 个数,凑成和为 j 的组合方式。

考虑和为0的情况,那么对所有 i 而言,dp[i][0] = 1,什么数都不拿,就一种情况能凑成0(给出的数都是正整数,并且每个数只能用一次)。

状态转移方程,对于某个数具有两种状态:取它、不取它,我们需要的结果应该是两种情况的加和,因为可能取它能凑成和,不取它也能凑成,就属于不同的情况。dp[i][j] = dp[i - 1][j] + dp[i - 1][j - nums[i]]。如果,nums[i]大于 j ,那么当前的dp[i][j]只能取决于之前的dp[i - 1][j]。

属于是背包问题中,求解装满背包的方案数问题,在背包问题中可能考察:最值 和 方案数问题,都需要掌握。

import java.util.Scanner;

public class Main {
    public static void main(String[] args) {
        int n, t;
        Scanner scan =  new Scanner(System.in);
        n = scan.nextInt();
        t = scan.nextInt();
        // dp[i][j],前 i 个数,和为 j 的组合方式
        // 对当前数有两种状态:选它,不选它
        // dp[i][j] = dp[i - 1][j] + dp[i - 1][j - nums[i]]
        int[] nums = new int[n];
        for (int i = 0; i < n; i++) {
            nums[i] = scan.nextInt();
        }
        int[][] dp = new int[n + 1][t + 1];
        // 不管有几个数,如果需要凑成的数 = 0,那么组合方式都 = 1
        for (int i = 0; i <= n ; i++) {
            dp[i][0] = 1;
        }
        for (int i = 1; i <= n; i++) {
            for (int j = 1; j <= t; j++) {
                if (nums[i - 1] <= j) {
                    dp[i][j] = dp[i - 1][j] + dp[i - 1][j - nums[i - 1]];
                } else {
                    dp[i][j] = dp[i - 1][j];
                }
            }
        }
        System.out.println(dp[n][t]);
    }
}
1.2分割等和子集(中等)

在这里插入图片描述
分成两个和相等的子集(只包含正整数),那么两个子集的和 = sum(nums) / 2,我们只用判断nums能否凑出 sum / 2即可,一旦可以,那么另一个子集也可以,因为数组的和是固定的。

因为需要存储数的个数 和 子集和,所以需要二维DP数组,dp[i][j],前 i 个数能否凑成和为 j。

dp[i][0],对所有 i 而言,都为true,0都能凑成。

dp[i][j] = dp[i - 1][j] || dp[i - 1][j - nums[i]],因为对一个数而言,可以取或者不取,我们只需要凑成 j 即可,所以可能不取它能凑成,也可能取它才能凑成。

当 nums[i] > j时,dp[i][j] = dp[i - 1][j],当前数只能不选。

class Solution {
    public boolean canPartition(int[] nums) {
        // 分成两堆,每堆的和都一样
        int sum = 0;
        int n = nums.length;
        for (int i = 0; i < n; i++) {
            sum += nums[i];
        }
        // 和为奇数,不可能
        if (sum % 2 != 0) {
            return false;
        }
        sum /= 2;
        // 类似于0-1背包问题,但这里必须恰好凑出 sum/2
        // 如果前n个数可以凑出 sum/2,那么另外一个子集也可以凑出 sum/2
        boolean[][] dp = new boolean[n + 1][sum + 1];
        // dp[i][j] 表示,前i个数,能否凑出j
        for (int i = 0; i <= n; i++) {
            dp[i][0] = true;
        }
        for (int i = 1; i <= n; i++) {
            for (int j = 1; j <= sum; j++) {
                if (nums[i - 1] <= j) {
                    dp[i][j] = dp[i - 1][j] || dp[i - 1][j - nums[i - 1]];
                } else {
                    dp[i][j] = dp[i - 1][j];
                }
            }
        }
        return dp[n][sum];
    }
}
1.3最后一块石头的重量Ⅱ(中等)(改编题)

在这里插入图片描述
整体观念,就是把石头分成两堆,让两堆的差最小,怎么才能最小呢? 我们看 sum / 2,尽可能让一堆石头凑成 sum / 2,这样另一堆石头相减的差才最小。所以本题和上一题是一样的。遇到分两堆的问题都可以往这方面思考,每次选两个实际还是分两堆。

class Solution {
    public int lastStoneWeightII(int[] stones) {
        int n = stones.length;
        int sum = 0;
        for (int i = 0; i < n; i++) {
            sum += stones[i];
        }
        int m = sum / 2;
        // 整体思考,尽可能让石头凑成石头重量的一半,这样剩下的另一半与其相减剩下的重量最小
        // 粉碎就是相当于减法
        boolean[][] dp = new boolean[n + 1][m + 1];
        // dp[i][j]代表前i个数,能否凑成j(和)
        for (int i = 0; i <= n; i++) {
            dp[i][0] = true;
        }
        for (int i = 1; i <= n; i++) {
            for (int j = 1; j <= m; j++) {
                if (stones[i - 1] <= j) {
                    dp[i][j] = dp[i - 1][j] || dp[i - 1][j - stones[i - 1]];
                } else {
                    dp[i][j] = dp[i - 1][j];
                }
            }
        }
        int max = 0;
        for (int j = 0; j <= m; j++) {
            if (dp[n][j]) {
                max = Math.max(max, j);
            }
        }
        return sum - max - max;
    }
}
1.4※目标和(中等)

在这里插入图片描述

按照前面题目的思路,还是拆分成两坨,一整个集合分为负数集合和正数集合,假设负数的绝对值之和=neg,那么正数的和=sum - neg,sum - neg - neg = target,所以neg = (sum - target) / 2,我们只需要让前n个数凑出neg即可,凑出neg的这几个数的符号就是 - ,剩下的数的符号就是 +。

同时要注意,neg不可能小于0,也不可能为小数,所以这两种情况都返回0。

class Solution {
    public int findTargetSumWays(int[] nums, int target) {
        int sum = 0;
        for (int num : nums) {
            sum += num;
        }
        // 为负数的绝对值的和为neg,那么正数的和 = sum - neg(注意nums[i]都是>=0,但target可以小于0)
        // sum - neg - neg = target
        // neg = (sum - target) / 2
        int diff = sum - target;
        // 负数绝对值的和不可能小于0,同时sum - target必须得能被2整除,不能整除的话说明是小数,不可能是小数
        if (diff < 0 || diff % 2 != 0) {
            return 0;
        }
        int n = nums.length, neg = diff / 2;
        int[][] dp = new int[n + 1][neg + 1];
        dp[0][0] = 1;
        for (int i = 1; i <= n; i++) {
            for (int j = 0; j <= neg; j++) {
                if (j >= nums[i - 1]) {
                    dp[i][j] = dp[i - 1][j - nums[i - 1]] + dp[i - 1][j];
                } else {
                    dp[i][j] = dp[i - 1][j];
                }
            }
        }
        return dp[n][neg];
    }
}
1.5一和零(中等)(正常的多维0-1背包)

在这里插入图片描述
终于遇到了正常的0-1背包问题,不用再脑筋急转弯拆分成两堆,不易不易。把一个个子串看成一个个物品,m和n就是背包的容量,问背包在给定容量下最多能装下多少物品?

class Solution {
    public int findMaxForm(String[] strs, int m, int n) {
        // 每个二进制串看成一个个物品
        // 0 和 1 的个数看成背包的容量  
        int len = strs.length;
        int[][][] dp = new int[len + 1][m + 1][n + 1];
        // dp[i][j][k],前 i 个串,在有m个0和n个1的条件下最多能够装下的子串个数
        for (int i = 1; i <= len; i++) {
            String tmp = strs[i - 1];
            int one = check(tmp);
            int zero = tmp.length() - one;
            // 要注意 0 和 1 的个数从0开始遍历!
            for (int j = 0; j <= m; j++) {
                for (int k = 0; k <= n; k++) {
                    if (zero <= j && one <= k) {
                        // 题目是求最长长度
                        dp[i][j][k] = Math.max(dp[i - 1][j - zero][k - one] + 1, dp[i - 1][j][k]);
                    } else {
                        dp[i][j][k] = dp[i - 1][j][k];
                    }
                }
            }
        }
        return dp[len][m][n];
    }
    static int check(String str) {
        int cnt = 0;
        for (int i = 0; i < str.length(); i++) {
            if (str.charAt(i) == '1') {
                cnt++;
            }
        }
        // 统计串中1的个数
        return cnt;
    }
}

注意如果题目中要求的是求方案数、种数:dp[i - 1][j] + dp[i - 1][j - nums[i]],如果求最值:max(dp[i - 1][j], dp[i - 1][j - nums[i]] + val[i])

三维数组 转 二维数组,进行空间压缩:

class Solution {
    public int findMaxForm(String[] strs, int m, int n) {
        int len = strs.length;
        // dp[i][j][k]
        // 考虑前 i 个字符串,子集中最多有m个0,n个1
        int[][] dp = new int[m + 1][n + 1];
        // 根据转移方程的特性,设计for循环的遍历顺序
        for (int i = len; i >= 1; i--) {
            String cur = strs[i - 1];
            int one = getOne(cur);
            int zero = cur.length() - one; 
            for (int j = m; j >= 0; j--) {
                for (int k = n; k >= 0; k--) {
                    if (j >= zero && k >= one) {
                        dp[j][k] = Math.max(dp[j][k], dp[j - zero][k - one] + 1);
                    }
                }
            }
        }
        return dp[m][n];
    }
    int getOne(String str) {
        int cnt = 0;
        for (int i = 0; i < str.length(); i++) {
            if (str.charAt(i) == '1') cnt++;
        }
        return cnt;
    }
}

二、完全背包问题

有N件物品和一个最多能背重量为W的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。每件物品都有无限个(也就是可以放入背包多次),求解将哪些物品装入背包里物品价值总和最大。
在这里插入图片描述

完全背包和01背包问题唯一不同的地方就是,每种物品有无限件。

1.6零钱兑换(中等)

在这里插入图片描述
这道题就是完全背包问题,硬币可以重复使用。一定要注意0-1背包和完全背包的状态转移方程的区别:主要是能够放下物品时有区别
01 背 包 : d p [ i ] [ j ] = m a x ( d p [ i − 1 ] [ j ] , d p [ i − 1 ] [ j − w [ i ] ] + v [ i ] ) 完 全 背 包 : d p [ i ] [ j ] = m a x ( d p [ i − 1 ] [ j ] , d p [ i ] [ j − w [ i ] ] + v [ i ] ) 01背包:dp[i][j] = max(dp[i - 1][j], dp[i -1][j-w[i]]+v[i])\\ 完全背包:dp[i][j] = max(dp[i - 1][j],dp[i][j-w[i]] + v[i]) 01dp[i][j]=max(dp[i1][j],dp[i1][jw[i]]+v[i])dp[i][j]=max(dp[i1][j],dp[i][jw[i]]+v[i])
完全背包如果可以放下第 i 个物品,它还能选择继续放第 i 个物品,而不用回到第 i - 1个物品上。

题目中要求最小值,所以数组应该初始化为无穷大,不然会影响后续状态转移。

注意:在进行数组压缩时,从dp[i-1][j]到dp[i][j]过渡时,dp[i][j]还是dp[i-1][j]的值,但由于另一半需要dp[i][j-w[i]]的值,这就需要 i、j 都从正向进行更新。

class Solution {
    public int coinChange(int[] coins, int amount) {
        int n = coins.length;
        // 完全背包,dp[i][j],前 i个硬币组成 j金额所需最少硬币数
        int[][] dp = new int[n + 1][amount + 1];
        // 因为求最小值,要用最大值填(注意二维数组得用for填)
        for (int i = 0; i <= n; i++) {
            Arrays.fill(dp[i], amount + 1);
            // 凑成金额0都只需要0个硬币
            dp[i][0] = 0;
        }
        for (int i = 1; i <= n; i++) {
            for (int j = 1; j <= amount; j++) {
                if (j >= coins[i - 1]) {
                    // 注意能放下时,继续考虑 i 而不是考虑 i - 1
                    dp[i][j] = Math.min(dp[i - 1][j], dp[i][j - coins[i - 1]] + 1);
                } else {
                    dp[i][j] = dp[i - 1][j];
                }
            }
        }
        return dp[n][amount] == amount + 1 ? -1 : dp[n][amount];
    }
}
1.7零钱兑换Ⅱ(中等)

在这里插入图片描述
和上一题一样,都是完全背包问题,只不过这题求的是方案数。 考虑金额 j ,dp[i][j] = dp[i - 1][j] + dp[i][j -coins[i]],因为它可能由前 i - 1个硬币组成 j,如果是01背包,后面应该加上dp[i -1][j - coins[i]],第 i 个硬币只能用一次,必须要考虑前 i - 1个硬币了;但是这里是完全背包,第 i 个硬币可以一直用,所以继续考虑前 i 个硬币能否凑成 j,就是dp[i][j - coinst[i]]。

求方案数就是两个状态求和,求极值就是两个状态求max或min。求最大值时要记得数组初始化为无穷大。

class Solution {
    public int change(int amount, int[] coins) {
        // 完全背包问题
        int n = coins.length;
        int[][] dp = new int[n + 1][amount + 1];
        // dp[i][j]前 i 个硬币,组成 j金额的方案数
        // 无论有几个硬币,组成 0 金额的方案都是 1
        for (int i = 0; i <= n; i++) {
            dp[i][0] = 1;
        }
        for (int i = 1; i <= n; i++) {
            for (int j = 1; j <= amount; j++) {
                if (coins[i - 1] <= j) {
                    // 注意是完全背包
                    dp[i][j] = dp[i - 1][j] + dp[i][j - coins[i - 1]];
                } else {
                    dp[i][j] = dp[i - 1][j];
                }
            }
        }
        return dp[n][amount];
    }
}
1.8买书(简单)

在这里插入图片描述
还是完全背包问题,书可以多买,钱必须得花完(全部用来买书),当输入15时,结果是0。

import java.util.Scanner;

public class Main {
    public static void main(String[] args) {
        int n;
        Scanner scan =  new Scanner(System.in);
        n = scan.nextInt();
        // 完全背包问题,求方案数就是两个状态相加
        // 记录书的价格
        int[] price = new int[] {10, 20, 50, 100};
        int[][] dp = new int[5][n + 1];
        // dp[i][j],前 i 本书,金额为 j 时,购书方案
        for (int i = 0; i <= 4 ; i++) {
            dp[i][0] = 1;
        }
        for (int i = 1; i <= 4 ; i++) {
            for (int j = 1; j <= n ; j++) {
                if (price[i - 1] <= j) {
                    // 完全背包,还能转移到 i 的情况
                    dp[i][j] = dp[i - 1][j] + dp[i][j - price[i - 1]];
                } else {
                    // 不能放第 i 本书,那就看前 i - 1本书
                    dp[i][j] = dp[i - 1][j];
                }
            }
        }
        System.out.println(dp[4][n]);
    }
}

※三、排列还是组合?

1.9※组合总和Ⅳ

在这里插入图片描述
给了n个数,给了目标和,数可以无限拿,欸?不就是完全背包?

这么想就错了,这道题是全排列动态规划问题(排列强调位置的不同,组合不考虑不同位置的情况),压根不是完全背包问题(题解中有用完全背包问题的一维数组实现的,但这有点生搬硬套的意味),更好地解释如下:
在这里插入图片描述
可以翻翻上面的题目,我们求的都是组合数,没有求排列数!!!

先看看爬楼梯,再回到这道题就知道怎么做了!
在这里插入图片描述

class Solution {
    public int climbStairs(int n) {
        if (n <= 2 ) {
            return n;
        }
        int[] ans = new int[n + 1];
        ans[1] = 1;
        ans[2] = 2;
        for (int i = 3; i <= n; i++) {
            ans[i] = ans[i - 1] + ans[i - 2];
        }
        return ans[n];
    }
}

爬到第 n 阶台阶,可以看成爬到第 n - 1阶 + 第 n - 2阶的方案数和。

上面是用递推公式的解法,也可以用下面一种写法:

class Solution {
    public int climbStairs(int n) {
        if (n <= 2 ) {
            return n;
        }
        int[] ans = new int[n + 1];
        ans[1] = 1;
        ans[2] = 1;
        for (int i = 2; i <= n; i++) {
            for (int j = 1; j <= 2; j++) {
                if (j <= i) {
                    ans[i] += ans[i - j];
                }
            } 
        }
        return ans[n];
    }
}

回到本题中,类似于爬楼梯,但是每次可以走nums[i]的步数,需要target阶梯爬到楼顶,第 i 个阶梯,可以由 i - nums[j] 的台阶 + nums[j]爬上来!

class Solution {
    public int combinationSum4(int[] nums, int target) {
        int n = nums.length;
        // 类似于爬楼梯,但是每次可以走nums[i]的步数,需要target阶梯爬到楼顶
        // 问能爬到楼顶的方案数
        int[] dp = new int[target + 1];
        // 对于每种可能爬上的台阶数,至少都有一种方案
        // 当然这里也可以直接令dp[0] = 1,加和出来的结果一样
        for (int i = 0; i < n; i++) {
            if (nums[i] <= target) {
                dp[nums[i]] = 1;
            }
        }
        // 第 i 个阶梯,可以由 i - nums[j] 的台阶 + nums[j]爬上来!
        for (int i = 1; i <= target; i++) {
            for (int j = 0; j < n; j++) {
                if (nums[j] <= i) {
                    dp[i] += dp[i - nums[j]];
                }
            }
        }
        return dp[target];
    }
}

涉及排列和组合的问题,一般都是题目中要求“种类数”,如果是求最值就不用管这个问题,用排列、组合求都可以。(用组合的话考虑的情况更少,所以一般是组合,组合问题就是普通的完全背包问题。遇到排列问题,要学会转换为爬楼梯问题。)


四、二维DP压缩一维DP

针对完全背包问题(组合问题),可以简化二维DP数组为一维,这种想法其实在做很多题的时候就有感觉了,例如最开始的零钱兑换问题,如果没有学过完全背包,直接想法都是声明一个一维DP数组,dp[i]表示 i 金额的组合方式(看题目是排列还是组合)。

对于完全背包问题的一维DP数组解法,一定要注意内外遍历的顺序,如果是求最值不影响,影响的是求种类、方案数。
在这里插入图片描述
自己还是更喜欢二维DP数组,更好理解,没有必要压缩为一维数组,不方便理解,还必须要记这些东西,把动态规划限制住了。


1.10完全平方数(中等)

在这里插入图片描述
本题是完全背包题目,每个完全平方数可以多次取,背包容量是n,物品是一个个完全平方数,属于求组合类型,一维DP数组应该先遍历物品再遍历背包容量(也就是最普通的情况),由于求的是最值,所以组合数、排列数都可以求,遍历顺序也就不影响了。

class Solution {
    public int numSquares(int n) {
        int[] dp = new int[n + 1];
        // dp[i] 整数 i 所需的最少数量
        Arrays.fill(dp, n);
        dp[0] = 0;
        for (int i = 1; i <= Math.sqrt(n); i++) { // 先遍历物品
            for (int j = 1; j <= n; j++) {  // 再遍历背包
                if (i * i <= j) {
                    dp[j] = Math.min(dp[j], dp[j - i * i] + 1);
                }
            }
        }
        return dp[n];
    }
}

二维DP数组压缩为一维DP数组的实质,就是把dp[i][j]的 i 去掉,就是不存储物品了,只存储背包容量,但是for循环遍历时,还是要遍历物品和背包,01背包的背包容量要从大到小遍历,完全背包的背包容量要从小到大遍历,它们的物品都从小到大遍历,注意完全背包内外循环遍历不同时,一个代表组合数、一个代表排列数。

1.11单词拆分(中等)

在这里插入图片描述
这道题也是一道完全背包问题,并且是求排列数,因为s串要求顺序一致。
用一维DP,外层遍历背包,内层遍历物品。

class Solution {
        public boolean wordBreak(String s, List<String> wordDict) {
            // 单词可以重复使用:完全背包问题
            // 物品:每个单词,背包:s
            int s_len = s.length();
            int size = wordDict.size();
            boolean[] dp = new boolean[s_len + 1];
            dp[0] = true;
            // 先遍历背包再遍历物品,因为字符串中的单词有顺序要求
            // 背包能放下物品不光光是大小合适,还需要字符串相等!
            for (int i = 1; i <= s_len; i++) {  // 背包
                for (int j = 1; j <= size; j++) {   // 物品
                    int len = wordDict.get(j - 1).length();
                    if (i >= len && wordDict.get(j - 1).equals(s.substring(i - len, i))) {
                        dp[i] = dp[i] || dp[i - len];
                    }
                }
            }
        }
}

在解决较难问题时,一维DP数组的好处也得以体现,题目不是简单的背包问题,而是有多重面具的,这时候使用一维DP数组更好处理问题。

五、多重背包

有N种物品和一个容量为V 的背包。第i种物品最多有Mi件可用,每件耗费的空间是Ci ,价值是Wi 。求解将哪些物品装入背包可使这些物品的耗费的空间 总和不超过背包容量,且价值总和最大。

多重背包和01背包是非常像的, 为什么和01背包像呢?

每件物品最多有Mi件可用,把Mi件摊开,其实就是一个01背包问题了。

public void testMultiPack1(){
    List<Integer> weight = new ArrayList<>(Arrays.asList(1, 3, 4));
    List<Integer> value = new ArrayList<>(Arrays.asList(15, 20, 30));
    List<Integer> nums = new ArrayList<>(Arrays.asList(2, 3, 2));
    int bagWeight = 10;

    for (int i = 0; i < nums.size(); i++) {
        while (nums.get(i) > 1) { // 把物品展开为i
            weight.add(weight.get(i));
            value.add(value.get(i));
            nums.set(i, nums.get(i) - 1);
        }
    }

    int[] dp = new int[bagWeight + 1];
    for(int i = 0; i < weight.size(); i++) { // 遍历物品
        for(int j = bagWeight; j >= weight.get(i); j--) { // 遍历背包容量
            dp[j] = Math.max(dp[j], dp[j - weight.get(i)] + value.get(i));
        }
        System.out.println(Arrays.toString(dp));
    }
}
  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

@u@

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值