背包问题 (Knapsack problem
- 背包问题 (Knapsack problem)
- 二、完全背包
- [416. 分割等和子集](https://leetcode.cn/problems/partition-equal-subset-sum/)
- [1049. 最后一块石头的重量 II](https://leetcode.cn/problems/last-stone-weight-ii/)
- [494. 目标和](https://leetcode.cn/problems/target-sum/)
- [322. 零钱兑换](https://leetcode.cn/problems/coin-change/description/)
- [279. 完全平方数](https://leetcode.cn/problems/perfect-squares/description/)
- [518. 零钱兑换 II](https://leetcode.cn/problems/coin-change-ii/)
- [377. 组合总和 Ⅳ](https://leetcode.cn/problems/combination-sum-iv/description/)
- [139. 单词拆分](https://leetcode.cn/problems/word-break/)
- [474. 一和零](https://leetcode.cn/problems/ones-and-zeroes/)
- [879. 盈利计划](https://leetcode.cn/problems/profitable-schemes/)
- [805. 数组的均值分割](https://leetcode.cn/problems/split-array-with-same-average/)
- 三、多重背包
- 1、暴力拆分
- 2、二进制拆分
- 3、数组优化
- [30. 串联所有单词的子串](https://leetcode.cn/problems/substring-with-concatenation-of-all-words/)
- [1787. 使所有区间的异或结果为零](https://leetcode.cn/problems/make-the-xor-of-all-segments-equal-to-zero/)
- [2518. 好分区的数目](https://leetcode.cn/problems/number-of-great-partitions/)
- [1774. 最接近目标价格的甜点成本](https://leetcode.cn/problems/closest-dessert-cost/)
- 四、分组背包
- [1155. 掷骰子的N种方法](https://leetcode.cn/problems/number-of-dice-rolls-with-target-sum/)
背包问题 (Knapsack problem)
动态规划 是运筹学的一种最优化方法,核心是 穷举。
存在「重叠子问题」、具备「最优子结构」(子问题互相独立)和「无后效性」。
递归解法「自顶向下」;动态规划「自底向上」dp 数组的 迭代解法。
背包问题是经典的「动态规划」,本质上属于 组合优化的「 NP完全问题」,「无法直接求解」的问题,只能通过 「穷举」+「验证」 的方式进行求解。
背包问题 将物品放入一定 容积或重量 的背包中,物品有 体积、重量、价值 等属性,背包中物品的 价值之和最大。
简单的说就是在一个背包中放入物品,求放入的总价值和的最大值。
## 一、01 背包 有 n 种物品,每 **种** 只有一个,物品的重量 $w_i$,价值 $v_i$,背包容量为 W。求解在不超过 W 的情况下,将那些物品放入背包,使物品价值和最大。
回溯解法(暴力):一个物品取与不取,O(2n)。
二维 dp 数组: 其中一维代表当前「当前枚举到哪件物品」,另外一维「现在的剩余容量」,数组装的是「最大价值」。
1、DP 状态:
dp[i][j] 表示 前 i 种物品,放入 容量为 j 的背包所能达到的最大总价值。
对于第 i 件物品,有「选」和「不选」两种决策。
- 不放入背包时,最大价值为 dp[i - 1][j];
- 放入背包时,背包的剩余容量会减小 w[i],背包中物品的总价值会增大 v[i],最大价值为
选第 i 件放入背包的前提:「当前剩余的背包容量」≥「物品的体积」。
在「选」和「不选」之间取最大值。
2、状态转移方程:
注:和完全背包只差一点 ,即当前行,
3、初始化:
4、遍历顺序: 先物品 后容量
5、求解目标:
eg: 有 5 个物品,背包容量为 10,求放入背包的最大价值。答案 17 (1,3,5)注意:图中 w、v 下标从 1 开始。
for (int i = 1; i <= n; i++) // 物品 下标从 1 开始,实现 dp 下标 - 1
for (int j = 1; j <= W; j++) // 背包
if (j < w[i]) dp[i][j] = dp[i - 1][j];
else dp[i][j] = Math.max(dp[i - 1][j], dp[i - 1][j - w[i]] + v[i]);
最大值即最优值,如何求最优解?
第一步:初始时 ;
第二步:若 , 则第 i 种 物品被放入背包,令
;
若 , 则第 i 种 物品没有放入背包,令
;
第三步:i- -, 转向第二步,直到 。
空间优化:求解 时,只需要上一行 j 列或
列的结果。
当前行只依赖于上一行,直接用 dp[j] 来表示处理到当前物品时背包容量为 j 的最大价值,得出以下方程:
注意:使用一维数组必须倒推,或者使用滚动数组。
for (int i = 1; i <= n; i++)
for (int j = w; j >= w[i]; j--) // 逆向循环 倒推
dp[j] = Math.max(dp[j], dp[j - w[i]] + v[i]);
★ 正推?问题转换成完全背包问题。
二、完全背包
有 n 种物品,每种物品(重量 ,价值
)数量没有限制,背包容量为 W。求解在不超过 W 的情况下如何放置物品,使背包中物品的价值之和最大。
for (int i = 1; i <= n; i++) // 完全背包
for (int j = w[i]; j <= W; j++) // 正序循环
dp[j] = Math.max(dp[j], dp[j - w[i]] + v[i]);
1、最值问题:
2、存在问题(bool):
3、组合问题:
416. 分割等和子集
01 背包问题 w = v = nums (重量 = 价值),sum 为偶数 并且 最值 == sum / 2,即能否装满容量是 sum / 2 的背包。
01 背包 存在问题 / 最值问题,即能否正好装满一半 / 一半装的最大值是否等于一半
class Solution {
public boolean canPartition(int[] nums) {
int sum = 0;
for (int x : nums) sum += x;
if ((sum & 1) == 1) return false; // 过滤掉奇数
int target = sum / 2;
// dp[i]: 装满 i 的最大价值
int[] dp = new int[target + 1];
// 初始化:不选择任何元素,容量为 0
// dp[0] = 0; // 默认值
for (int x : nums){ // 顺序不能变 物品 背包
for (int j = target; j >= x; j--)
dp[j] = Math.max(dp[j], dp[j - x] + x);
// 优化
if (dp[target] == target) return true;
}
return false;
// return dp[target] == target;
}
}
dp[i][j] 表示数组在区间 [0, i] 的所有整数,能否选出一些数,使得这些数之和恰好为整数 j。
状态转移方程:
- 1、不选择 nums[i]:dp[i][j] = dp[i - 1][j];
- 2、选择 nums[i]:
- dp[i][j] = true; (nums[i] == j)
- dp[i][j] = dp[i - 1][j - nums[i]]; (j >= nums[i])
初始化:
dp[i][0] = true, i ∈ [0, len); // 不选 第一列
dp[0][nums[0]] = true; // 第一行
输出:dp[len - 1][sum / 2];
nums = [1,5,11,5]
class Solution {
public boolean canPartition(int[] nums) {
int sum = 0, len = nums.length;
for (int x : nums) sum += x;
if ((sum & 1) == 1) return false;
int target = sum / 2;
boolean[][] dp = new boolean[len][target + 1];
// 初始化 第一行 第一列
for (int i = 0; i < len; i++) dp[i][0] = true;
if (nums[0] <= target) dp[0][nums[0]] = true;
for (int i = 1; i < len; i++){
for (int j = 1; j <= target; j++){
dp[i][j] = dp[i - 1][j];
if (nums[i] <= j) dp[i][j] |= dp[i - 1][j - nums[i]];
}
// 优化
if (dp[i][target]) return true;
}
return dp[len - 1][target];
}
}
空间优化
class Solution {
public boolean canPartition(int[] nums) {
int sum = 0, max = 0;
for (int x : nums) {sum += x; max = Math.max(max, x);
if ((sum & 1) == 1 || sum / 2 < max) return false;
int target = sum / 2;
boolean[] dp = new boolean[target + 1];
// 初始化
dp[0] = true;
for (int x : nums){
for (int j = target; j >= x; j--)
dp[j] |= dp[j - x];
// 优化
if (dp[target]) return true;
}
return dp[target];
}
}
1049. 最后一块石头的重量 II
能否剩余为 0,即分成相等的两堆,同 416. 分割等和子集。
要使最后一块石头的重量尽可能地小,把石头分成 大小接近 的两堆,各选一块粉碎,剩余的放回原堆,两堆减少的重量一样,即保持差值不变。最后大堆剩余的一块即最后一块石头。假设大堆剩余的不是一块,说明上面的分法不是最接近的两堆。
两堆的重量分别为 x,sum - x,这两堆石头重量之差的绝对值为 diff。
x 需要在不超过
背包容量为 ,物品重量和价值均为
0/1 背包 最值问题 最多能装下多少的问题
class Solution {
public int lastStoneWeightII(int[] stones) {
int sum = 0;
for (int x : stones) sum += x;
int target = sum / 2;
int[] dp = new int[target + 1];
// dp[0] = 0; 默认值即初始值
for (int x : stones)
for (int j = target; j >= x; j--)
dp[j] = Math.max(dp[j], dp[j - x] + x);
return sum - 2 * dp[target];
}
}
494. 目标和
class Solution {
// 一、递归
public int findTargetSumWays(int[] nums, int target) {
return recursion(nums, 0, target);
}
int recursion(int[] nums, int i, int rest){
if (i == nums.length) return rest == 0 ? 1 : 0;
return recursion(nums, i + 1, rest - nums[i]) + recursion(nums, i + 1, rest + nums[i]);
}
// 二、记忆化递归 哈希表(哈希表)
public int findTargetSumWays(int[] nums, int target) {
return recursion(nums, 0, target, new HashMap());
}
int recursion(int[] nums, int i, int rest, HashMap<Integer, HashMap<Integer, Integer>> dp){
if (dp.containsKey(i) && dp.get(i).containsKey(rest))
return dp.get(i).get(rest);
int res = 0;
if (i == nums.length) return rest == 0 ? 1 : 0;
res = recursion(nums, i + 1, rest - nums[i], dp) + recursion(nums, i + 1, rest + nums[i], dp);
dp.computeIfAbsent(i, v -> new HashMap()).put(rest, res);
return res;
}
// 二、记忆化递归 数组,rest 可能为负,需要偏移。
int n, inf = Integer.MAX_VALUE;
public int findTargetSumWays(int[] nums, int target) {
this.n = nums.length;
int[][] dp = new int[n+1][4001];
for (int[] x : dp) Arrays.fill(x, inf);
return recursion(nums, 0, target, dp);
}
int recursion(int[] nums, int i, int rest, int[][] dp){
if (dp[i][rest+2000] != inf)
return dp[i][rest+2000];
int res = 0;
if (i == nums.length) return rest == 0 ? 1 : 0;
res = recursion(nums, i + 1, rest - nums[i], dp) + recursion(nums, i + 1, rest + nums[i], dp);
dp[i][rest+2000] = res;
return res;
}
}
0-1 背包 组合问题:装满背包一共有多少种方法。
class Solution {
public int findTargetSumWays(int[] nums, int target) {
// 提示:0 <= nums[i] <= 1000
int sum = 0;
for (int x : nums) sum += x;
// 优化一:sum < target, return 0;
if (sum < Math.abs(target)) return 0;
// 优化二:sum 与 target 同奇偶,+、- 号互换相当于差一个偶数
if ((sum - target) % 2 != 0) return 0;
// 优化三:把数组分成两组,P 为正数和 N 为负数和,P - N = target 得到 2P = target + sum。
// 问题转换成 01 背包问题:只要数组中的元素能组成 p
// 记数组的元素和为 sum,添加 - 号的元素之和为 neg,则其余添加 + 的元素之和为 sum − neg,得到的表达式的结果为 (sum − neg) − neg = sum − 2⋅neg = target 即 neg = sum − target >> 1
int n = sum + target >> 1; // sum - target >> 1 全部取反方法数一样
int[] dp = new int[n + 1]; // 装满容量为 j 背包有 dp[j] 种方法。
dp[0] = 1; // 不选择一种选择
for (int x : nums)
for (int i = n; i >= x; i--)
dp[i] += dp[i - x];
return dp[n];
}
}
322. 零钱兑换
记忆化递归
class Solution {
int[] coins, dp;
int n;
public int coinChange(int[] coins, int amount) {
this.coins = coins;
n = amount + 1;
dp = new int[n]; // 凑成 n 最少硬币数
Arrays.fill(dp, n); // 最大值填充
dp[0] = 0; // 金额为 0 需要 0 个硬币
return backtrack(amount);
}
int backtrack(int x) {
if (x < 0) return -1; // 不能凑成 x
// if (x == 0) return 0;
if (dp[x] != n) return dp[x]; // 已经计算过
int ans = n; // 记录最小值
for (int coin : coins) {
int tmp = backtrack(x - coin); // 凑成 x - coin 最少需要 tmp 个硬币
if (tmp >= 0) ans = Math.min(ans, 1 + tmp); // +1 需要加一个硬币 coin
}
dp[x] = ans != n ? ans : -1;
return dp[x];
}
}
完全背包 最值问题 二维数组
class Solution {
public int coinChange(int[] coins, int amount) {
// dp[i][j]:使用 coins[:i] 中硬币,凑成 j 的最小个数
int n = coins.length;
int[][] dp = new int[n + 1][amount + 1];
// 初始化首行
Arrays.fill(dp[0], amount + 1);
dp[0][0] = 0;
for (int i = 1; i <= n; i++) {
for (int j = 0; j <= amount; j++) {
if (j < coins[i - 1]) dp[i][j] = dp[i - 1][j]; // 上一行拉下来
else
// 完全背包,针对当前行(已经更新过的)比较, 01 背包 针对上一行比较
dp[i][j] = Math.min(dp[i - 1][j], dp[i][j - coins[i - 1]] + 1);
}
}
return dp[n][amount] == amount + 1 ? -1 : dp[n][amount];
}
}
一维数组
class Solution {
public int coinChange(int[] coins, int amount) {
int n = amount + 1;
int[] dp = new int[n];
Arrays.fill(dp, n);
dp[0] = 0;
for (int coin : coins)
for (int i = coin; i < n; i++) // 正推
dp[i] = Math.min(dp[i], dp[i - coin] + 1);
return (dp[amount] == n) ? -1 : dp[amount];
}
}
279. 完全平方数
完全背包的最值问题:完全平方数最小为 1,最大为 sqrt(n),故题目转换为在 nums = [1,2…sqrt(n)] 中选任意数平方和为 target = n。
class Solution {
public int numSquares(int n) {
int sqrt = (int)Math.sqrt(n);
int[][] dp = new int[sqrt + 1][n + 1];
// 初始化第一行 应该为无效值,因求最小,所以设为最大。
// dp[0][0] = 0; 和为 0 的完全平方数的最小数量为 0
Arrays.setAll(dp[0], i -> i);
for (int i = 1; i <= sqrt; i++) {
int t = i * i;
for (int j = 0; j <= n; j++) {
if (t > j) dp[i][j] = dp[i - 1][j]; // 上一行拉下来
else dp[i][j] = Math.min(dp[i - 1][j], dp[i][j - t] + 1);
}
}
return dp[sqrt][n];
}
}
class Solution {
public int numSquares(int n) {
// dp[i]:和为 i 的完全平方数的最小数量
int[] dp = new int[n + 1];
// Arrays.setAll(dp, i -> i);
Arrays.fill(dp, n); // 最大可取 n 个 1
dp[0] = 0; // 理解为 和为 0 的完全平方数的最小数量为 0
for (int i = 1, t; (t = i * i) <= n; i++)
for (int j = t; j <= n; j++)
dp[j] = Math.min(dp[j], dp[j - t] + 1);
return dp[n];
}
}
518. 零钱兑换 II
记忆化递归
class Solution {
int[] coins;
int[][] dp;
public int change(int amount, int[] coins) {
int n = coins.length;
dp = new int[n + 1][amount + 1];
this.coins = coins;
for (int[] a : dp) Arrays.fill(a, -1);
dp[0][0] = 1;
return dfs(0, amount);
}
int dfs(int i, int amount) {
if (i == coins.length) {
dp[i][amount] = amount == 0 ? 1 : 0;
return dp[i][amount];
}
if (dp[i][amount] != -1) return dp[i][amount];
int res = 0;
// 选 k 个 i 位置的硬币,然后从 i + 1 继续做选择
for (int k = 0; coins[i] * k <= amount; k++) {
res += dfs(i + 1, amount - coins[i] * k);
}
dp[i][amount] = res;
return res;
}
}
任选硬币凑成指定金额,求组合总数,完全背包 组合问题。
dp[i][j] 表示前 i 个硬币可以凑成总金额为 j 的硬币组合数。
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++) {
if (j < coins[i - 1]) dp[i][j] = dp[i - 1][j];
else dp[i][j] = dp[i - 1][j] + dp[i][j - coins[i - 1]];
}
}
return dp[n][amount];
}
}
外层遍历数组 coins,内层遍历不同的金额之和,在计算 dp[i] 的值时,可以确保金额之和等于 i 的硬币面额的顺序,因此不会重复计算不同的排列。
例如,coins = [1, 2],对于 dp[3] 的计算,一定是先遍历硬币面额 1 后遍历硬币面额 2,只会出现以下 2 种组合:
3 = 1 + 1 + 1
3 = 1 + 2
硬币面额 2 不可能出现在硬币面额 1 之前,即不会重复计算 3 = 2 + 1 的情况。
class Solution {
public int change(int amount, int[] coins) {
int[] dp = new int[amount + 1];
dp[0] = 1; // 不选方法为一种
for (int x : coins) // 物品
for (int j = x; j <= amount; j++) // 背包
dp[j] += dp[j - x];
return dp[amount];
}
}
377. 组合总和 Ⅳ
1、递归 参考 [322. 零钱兑换]
class Solution {
public int combinationSum4(int[] nums, int target) {
if (target < 0) return 0;
if (target == 0) return 1;
int res = 0;
for (int x : nums) {
res += combinationSum4(nums, target - x);
}
return res;
}
}
2、记忆化递归
class Solution {
int[] dp;
public int combinationSum4(int[] nums, int target) {
dp = new int[target + 1];
Arrays.fill(dp, -1); // 0 方法也是有效的,改成 -1 表示无效。
dp[0] = 1;
return dfs(nums, target);
}
int dfs(int[] nums, int target) {
if (target < 0) return 0;
if (dp[target] != -1) return dp[target];
int res = 0;
for (int x : nums) {
res += dfs(nums, target - x);
}
dp[target] = res;
return res;
}
}
3、完全背包
在 nums 中任选一些数,和为 target,顺序不同的序列被视作不同的组合。排列问题。
先遍历物品后遍历背包,得到的是 组合数;反之,得到的是 排列数。
class Solution {
public int combinationSum4(int[] nums, int target) {
int[] dp = new int[target + 1];
dp[0] = 1;
for (int j = 1; j <= target; j++) // 背包
for (int x : nums) // 物品
if (x <= j) dp[j] += dp[j - x];
return dp[target];
}
}
爬楼梯扩展: 共 n 个台阶,一次可以爬的台阶数为 [1,2,3,4…, m],求爬到楼顶有多少种方法?
n 就是 target,求的是排列数。
如果每次能上 1 ~ m 阶,那么就可以用完全背包问题解决,即背包 = 要上的阶数,物品 = 一次能上的阶数(1 ~ m),因为爬楼梯必然是有序的,使用排列:先背包后物品。
二维动态规划
忘记背包问题的限制,回归最原始的动态规划思想。
dp[i][j] 表示前 i 个数任取,恰好和为 j 的排列数
行和列的遍历顺序无所谓
需要初始化 dp[i][0] = 1 表示前 i 个数任取构成 0 的情况有一种(即前 i 个数字都不取构成 0 的情况算一种)
dp[i][j] = sum(dp[i][j - nums[k]]) (0 <= k < i) if (j >= nums[i - 1])
dp[i][j] = dp[i - 1][j] if (j < nums[i - 1])
class Solution {
public int combinationSum4(int[] nums, int target) {
int n = nums.length;
int[][] dp = new int[n + 1][target + 1];
for (int i = 0; i <= n; i++) dp[i][0] = 1;
for (int j = 1; j <= target; j++) { // 可以颠倒本行和下一行
for (int i = 1; i <= n; i++) {
if (j < nums[i - 1]) dp[i][j] = dp[i - 1][j];
else { // 每一条路径添加一个数
for (int k = 0; k < i; k++) {
if (j >= nums[k]) dp[i][j] += dp[i][j - nums[k]];
}
}
}
}
return dp[n][target];
}
}
139. 单词拆分
完全背包 排列 存在问题,因为单词的拼写是有序的,所以必须使用排列:先遍历背包再遍历物品。
class Solution {
public boolean wordBreak(String s, List<String> wordDict) {
// 背包容量为 s 的长度,物品为单词长度。
int n = s.length();
// dp[i] 表示 s 前 i 字符可以匹配
boolean[] dp = new boolean[n + 1];
dp[0] = true;
// 排列问题,必须先遍历背包,再遍历物品。
for(int i = 1; i <= n; i++) {
for(String word : wordDict) {
int len = word.length();
// 背包大小为 i,word 大小为 len,前置单词数为 i - len,起点为 i - len
if (i >= len && word.equals(s.substring(i - len, i))) {
dp[i] |= dp[i - len];
if (dp[i]) break; // 剪枝 3 ms
}
}
}
return dp[n];
}
}
队列
class Solution {
Deque<Integer> q = new ArrayDeque();
public boolean wordBreak(String s, List<String> wordDict) {
int n = s.length();
q.offer(0);
return bfs(s, wordDict);
}
boolean bfs(String s, List<String> wordDict) {
while (!q.isEmpty()) {
int i = q.poll();
if (i == s.length()) return true;
for (String word : wordDict) {
int m = word.length() + i;
if (m <= s.length() && word.equals(s.substring(i, m))) {
if (m == s.length()) return true;
if (!q.contains(m)) q.offer(m);
}
}
}
return false;
}
}
优先队列
class Solution {
public boolean wordBreak(String s, List<String> wordDict) {
int n = s.length();
PriorityQueue<Integer> q = new PriorityQueue();
q.offer(0);
while (!q.isEmpty()) {
int i = q.poll();
// 去重
while (!q.isEmpty() && q.peek() == i) q.poll();
for (String word : wordDict) {
int m = word.length() + i;
if (m <= n && word.equals(s.substring(i, m))) {
q.add(m);
if (m == n) return true;
}
}
}
return false;
}
}
474. 一和零
二维 01 背包问题 装满背包最多有多少个物品。
class Solution {
public int findMaxForm(String[] strs, int m, int n) {
// 总重量(容量) m 个 0,n 个 1
// dp[i][j] i 个 0,j 个 1, 最多装 dp[i][j] 个物品。
// 目标 dp[m][n]
int[][] dp = new int[m + 1][n + 1];
// dp[0][0] = 0;
for (String s : strs) {
int x = 0, y = 0;
for (char c : s.toCharArray()) {
if (c == '0') x++;
else y++;
}
for (int i = m; i >= x; i--)
for (int j = n; j >= y; j--)
dp[i][j] = Math.max(dp[i][j], dp[i - x][j - y] + 1);
}
return dp[m][n];
}
}
879. 盈利计划
多维费用背包问题
任务「物品」,完成任务所需要的人数「成本」,得到的利润「价值」。
定义 f[i][j][k] 为考虑前 i 个任务,使用人数不超过 j,所得利润至少为 k 的方案数。
class Solution {
public int profitableSchemes(int n, int minProfit, int[] group, int[] profit) {
int mod = (int)1e9 + 7;
int m = group.length;
long[][][] dp = new long[m + 1][n + 1][minProfit + 1];
for (int i = 0; i <= n; i++) dp[0][i][0] = 1;
for (int i = 1; i <= m; i++) {
int a = group[i - 1], b = profit[i - 1];
for (int j = 0; j <= n; j++) {
for (int k = 0; k <= minProfit; k++) {
dp[i][j][k] = dp[i - 1][j][k];
if (j >= a) {
int u = Math.max(k - b, 0);
dp[i][j][k] += dp[i - 1][j - a][u];
if (dp[i][j][k] >= mod) dp[i][j][k] -= mod;
}
}
}
}
return (int)dp[m][n][minProfit];
}
}
class Solution {
public int profitableSchemes(int n, int minProfit, int[] group, int[] profit) {
int mod = (int)1e9 + 7;
int m = group.length;
int[][] dp = new int[n + 1][minProfit + 1];
for (int i = 0; i <= n; i++) dp[i][0] = 1;
for (int i = 1; i <= m; i++) {
int a = group[i - 1], b = profit[i - 1];
for (int j = n; j >= a; j--) {
for (int k = minProfit; k >= 0; k--) {
int u = Math.max(k - b, 0);
dp[j][k] += dp[j - a][u];
if (dp[j][k] >= mod) dp[j][k] -= mod;
}
}
}
return dp[n][minProfit];
}
}
805. 数组的均值分割
方法一:折半查找 + 二进制枚举
和 sum,元素个数 n,平均值 ave。子数组 A:和 s,个数 m,那么子数组 B :和 sum - s,个数 n - m,可得:,即需要找出一个子数组 A,使得其平均值等于 ave。
将数组 nums 每个元素都减去 ave,问题就转化为在数组 nums 中找出一个子数组,使得其和为 0。
ave 可能是小数,浮点数计算可能存在精度问题,将数组 nums 中的每个元素都乘以 n,再减去 ave。
使用折半查找的方法,将数组 nums 分成左右两部分,那么子数组 A 可能存在三种情况:
- A 完全在 nums 的左半部分;
- A 完全在 nums 的右半部分;
- A 一部分在 nums 的左半部分,一部分在 nums 的右半部分。
二进制枚举,先枚举左半部分所有子数组的和,如果存在一个子数组和为 0,直接返回 true,否则将其存入哈希表 set 中;然后枚举右半部分所有子数组的和,如果存在一个子数组和为 0,直接返回 true,否则判断此时哈希表 set 中是否存在该和的相反数,如果存在,直接返回 true。
需要注意的是,不能同时全选左半部分和右半部分,因为这样会导致子数组 B 为空,这是不符合题意的。在实现上,只需要考虑数组的 n − 1 个数。
class Solution {
public boolean splitArraySameAverage(int[] nums) {
int n = nums.length, m = n >> 1, sum = 0;
if (n == 1) return false;
for (int x : nums) sum += x;
for (int i = 0; i < n; i++) nums[i] = nums[i] * n - sum;
Set<Integer> left = new HashSet<Integer>();
for (int i = 1; i < 1 << m; i++) {
int tot = 0;
for (int j = 0; j < m; j++) {
// i 第 j + 1 为 1
if ((i & 1 << j) != 0) tot += nums[j];
}
if (tot == 0) return true;
left.add(tot);
}
int rsum = 0;
for (int i = m; i < n; i++) {
rsum += nums[i];
}
for (int i = 1; i < 1 << n - m; i++) {
int tot = 0;
for (int j = m; j < n; j++) {
if ((i & 1 << j - m) != 0) tot += nums[j];
}
if (tot == 0 || (rsum != tot && left.contains(-tot))) return true;
}
return false;
}
}
解法二:动态规划
由于,那么其实需要寻找一个子集和 sumA 使得 sumA = avg * j 那么可以将该问题抽象与类型 01 背包问题。现在给定 n 个数,找出 j 个数是否能够
凑成 sumA = avg * j 类似于01背包问题的状态集合,从前 k 个数中选了 j 个数是否能够组合成 i
状态集合:dp[i][j] 对于目前已经遍历的数是否能够选出 j 个数组合成 i
状态计算:对于当前遍历的数 x 来说,dp[i][j] ∣= dp[i−x][j−1]
对于 avg(A) = avg(B), 可以知道其中子集的个数必然小于等于 n / 2,因此只需要寻找个数为 n / 2 即可。
class Solution {
public boolean splitArraySameAverage(int[] nums) {
int n = nums.length, sum = Arrays.stream(nums).sum();
boolean[][] dp = new boolean[sum + 5][n / 2 + 5];
dp[0][0] = true;
for (int x: nums) {
for (int i = sum; i >= x; i--) { //需要倒着计算 类似于01背包滚动数组优化,利用的其实是上一层的结果,从三维降到两维
for (int j = 1; j <= n / 2; j++) {
dp[i][j] |= dp[i - x][j - 1];
}
}
}
for (int j = 1; j <= n / 2; j++) if ((sum * j) % n == 0 && dp[sum * j / n][j]) return true;
return false;
}
}
解法三:动态规划 + 位运算
对于解法二,利用位运算进行优化,可以知道某个值i可以有多个可能的 j 组合成, 那么利用位运算来表示能够当前的 i 可以有多少种 j 来表示。
例如: 0110,代表可以有 1 个数,2 个数组合成 i,对于某一位的二进制位若为 1, 代表可以由几个数组合成,第 i 位位 1,代表可以由 i 个数组成。
状态计算:dp[i] |= dp[i - x] << 1; 以前状态为 dp[i−x],现在新添加一个数 x,那么以前的个数情况都要增加 1
class Solution {
public boolean splitArraySameAverage(int[] nums) {
int n = nums.length, sum = Arrays.stream(nums).sum();
int[] dp = new int[sum + 5];
dp[0] = 1; //00001代表用0个数
for (int x: nums) {
for (int i = sum; i >= x; i--) {
dp[i] |= dp[i - x] << 1;
}
}
for (int j = 1; j <= n / 2; j++) if ((sum * j) % n == 0 && ((dp[sum * j / n] & (1 << j)) != 0)) return true;
return false;
}
}
三、多重背包
有 n 种物品,第 i 种物品(重量 ,价值
)有
个,背包容量为 W。求解在不超过 W 的情况下,将那些物品放入背包,使物品价值和最大。
可以通过 暴力拆分 或 二进制拆分 将多重背包问题转化为 01 背包问题,也可以通过数组优化解决 可行性问题。
1、暴力拆分
暴力拆分指将第 i 种物品看作
for (int i = 1; i <= n; i++)
for (int k = 1; k <= c[i]; k++) // 多一层循环
for (int j = W; j >= w[i]; j--)
dp[j] = Math.max(dp[j], dp[j - w[i]] + v[i]);
2、二进制拆分
二进制优化可以使得能解决的多重背包问题数量级从 102 上升为 103。
二进制拆分,将 个物品拆分成若干种新物品。存在一个最大的整数
,使
。将剩余部分用
表示,
,将
拆分为
个数:
。
eg:,即拆分成 1,2,4,2,p + 2种新物品。
for (int i = 1; i <= n; i++)
if (c[i] * w[i] >= W) // 转化完全背包 相当于无穷多个
for (int j = w[i]; j <= W; j++)
dp[j] = Math.max(dp[j], dp[j - w[i]] + v[i]);
else
for int k = 1; c[i] > 0; k <<= 1) { // 二进制拆分
int x = Math.min(k, c[i]);
for (int j = W; j >= w[i] * x; j--) // 转化 01 背包,打包好的是一个新物品。
dp[j] = Math.max(dp[j], dp[j - w[i] * x] + v[i] * x);
c[i] -= x;
}
3、数组优化
若不要求 最优性,仅关注 可行性(如面值是否能拼成)可使用数组优化。
// dp[j] 表示前 i 种硬币是否可以拼成价格 j
int[] dp = new int[n];
int ans = 0;
dp[0] = true;
for (int i = 1; i <= n; i++) {
// nums[j] 表示拼成价格 j 时用了多少个第 i 种硬币
int[] nums = new int[];
for (int j = v[i]; j <= W; j++)
if (!dp[j] && dp[j - v[i]] && nums[j - v[i]] < c[i]) {
dp[j] = true;
nums[j] = nums[j - v[i]] + 1;
ans++;
}
}
「多重背包」优化
- 1、「扁平化」简单拆分成「01 背包」
- 2、二进制优化的本质,是对「物品」做分类,使得总数量为 m 的物品能够用更小的 log m 个数所组合表示出来。
- 3、单调队列优化,某种程度上也是利用「分类」实现优化。只不过不再是针对「物品」做分类,而是针对「状态」做分类。
f[i] 代表容量不超过 i 时的最大价值。
状态转移只会发生在「对当前物品体积取余相同」的状态之间。
即某个状态 f[i] 只能由 w mod i 相同(w 为当前物品体积,i 为当前背包容量),并且比 i 小,数量不超过「物品个数」的状态值所更新。
因此这其实是一个「滑动窗口求最值」问题。
定义一个 g 数组,用来记录上一次物品的转移结果;
定义一个 q 数组来充当队列,队列中存放本次转移的结果。
希望在 O(1) 复杂度内取得「能够参与转移的状态」中的最大值,自然期望能够在对队列头部或者尾部直接取得目标值来更新 f[i]。
如果希望始终从队头取值更新的话,需要维持「队列元素单调」和「特定的窗口大小」。
根据“取余”对状态做划分,然后转换为「滑动窗口」问题,配合某种数据结构(单调队列/哈希表)来实现优化。
class Solution {
public int maxValue(int N, int C, int[] s, int[] v, int[] w) {
int[] dp = new int[C + 1];
int[] g = new int[C + 1]; // 辅助队列,记录的是上一次的结果
int[] q = new int[C + 1]; // 主队列,记录的是本次的结果
// 枚举物品
for (int i = 0; i < N; i++) {
int vi = v[i];
int wi = w[i];
int si = s[i];
// 将上次算的结果存入辅助数组中
g = dp.clone();
// 枚举余数
for (int j = 0; j < vi; j++) {
// 初始化队列,head 和 tail 分别指向队列头部和尾部
int head = 0, tail = -1;
// 枚举同一余数情况下,有多少种方案。
// 例如余数为 1 的情况下有:1、vi + 1、2 * vi + 1、3 * vi + 1 ...
for (int k = j; k <= C; k+=vi) {
dp[k] = g[k];
// 将不在窗口范围内的值弹出
if (head <= tail && q[head] < k - si * vi) head++;
// 如果队列中存在元素,直接使用队头来更新
if (head <= tail) dp[k] = Math.max(dp[k], g[q[head]] + (k - q[head]) / vi * wi);
// 当前值比对尾值更优,队尾元素没有存在必要,队尾出队
while (head <= tail && g[q[tail]] - (q[tail] - j) / vi * wi <= g[k] - (k - j) / vi * wi) tail--;
// 将新下标入队
q[++tail] = k;
}
}
}
return dp[C];
}
}
30. 串联所有单词的子串
1787. 使所有区间的异或结果为零
2518. 好分区的数目
计算坏分区的数目,即第一个组或第二个组的元素和小于 k 的方案数。根据对称性,只需要计算第一个组的元素和小于 k 的方案数,然后乘 2 即可。
首先,如果 nums 的所有元素之和都小于 2k,则不存在好分区。
然后考虑计算坏分区的数目,即第一个组或第二个组的元素和小于 k 的方案数。根据对称性,只需要计算第一个组的元素和小于 k 的方案数,然后乘 2 即可。
因此原问题转换成从 nums 中选择若干元素,使得元素和小于 k 的方案数,这可以用 01 背包求解。
定义 f[i][j] 表示从前 i 个数中选择若干元素,和为 j 的方案数。
不选第 i 个数:f[i][j] = f[i-1][j];
选第 i 个数:f[i][j] = f[i−1][j − nums[i]]。
因此 f[i][j]=f[i−1][j]+f[i−1][j−nums[i]]。
初始值 f[0][0] = 1。
坏分区的数目 bad = (f[n][0]+f[n][1]+⋯+f[n][k−1])⋅2。
答案为所有分区的数目减去坏分区的数目,即 2n − bad,这里 n 为 nums 的长度。
代码实现时,可以用倒序循环的技巧来压缩空间。
那么原问题就转换为「从 nums 中选择若干元素,使得元素和小于 k 的方案数」, 01 背包了。
class Solution {
private static final int MOD = (int) 1e9 + 7;
public int countPartitions(int[] nums, int k) {
var sum = 0L;
for (var x : nums) sum += x;
if (sum < k * 2) return 0;
var ans = 1;
var f = new int[k];
f[0] = 1;
for (var x : nums) {
ans = ans * 2 % MOD;
for (var j = k - 1; j >= x; --j)
f[j] = (f[j] + f[j - x]) % MOD;
}
for (var x : f)
ans = (ans - x * 2 % MOD + MOD) % MOD; // 保证答案非负
return ans;
}
}
1774. 最接近目标价格的甜点成本
递归
class Solution {
int res, target;
int[] toppingCosts;
public int closestCost(int[] baseCosts, int[] toppingCosts, int target) {
this.toppingCosts = toppingCosts;
this.target = target;
res = baseCosts[0];
for (int b : baseCosts) backtrack(0, b);
return res;
}
public void backtrack(int i, int curCost) {
int x = Math.abs(target - res), y = Math.abs(target - curCost);
if (x > y) res = curCost;
else if ( x == y && curCost < res) res = curCost;
// 剪枝 curCost 非递减
if (curCost > target) return;
// if (i == toppingCosts.length) return;
// backtrack(i + 1, curCost + toppingCosts[i] * 2);
// backtrack(i + 1, curCost + toppingCosts[i]);
// backtrack(i + 1, curCost);
for (int j = i; j < toppingCosts.length; j++){
backtrack(j + 1, curCost + toppingCosts[j] * 2);
backtrack(j + 1, curCost + toppingCosts[j]);
}
}
}
class Solution {
public int closestCost(int[] baseCosts, int[] toppingCosts, int target) {
// int x = Arrays.stream(baseCosts).min().getAsInt();
int x = Integer.MAX_VALUE;
for (int b : baseCosts) x = Math.min(x, b);
if (x >= target) return x;
boolean[] can = new boolean[target + 1];
// target - x > y - target
// y < 2*target - x
int res = 2 * target - x;
for (int b : baseCosts) {
if (target == b) return b;
// 单独处理 > target
if (b < target) can[b] = true;
else res = Math.min(res, b);
}
for (int t : toppingCosts) {
// 可重复 两次
for (int cnt = 0; cnt < 2; ++cnt) {
for (int i = target; i > 0; --i) {
// 存在合理方案填滿容量 i
if (can[i] && i + t > target) res = Math.min(res, i + t);
if (i > t) can[i] |= can[i - t];
}
}
}
for (int i = 0; i <= res - target; ++i) {
// 最接近 target
if (can[target - i]) return target - i;
}
return res; // > target 最小的
}
}
四、分组背包
给定 n 个物品组,和容量为 C 的背包。
第 i 个物品组共有 S[i] 件物品,其中第 i 组的第 j 件物品的成本为 w[i][j],价值为 v[i][j]。
每组有若干个物品,同一组内的物品最多只能选一个。
求解将哪些物品装入背包可使这些物品的费用总和不超过背包容量,且价值总和最大。
输入:N = 2, C = 9, S = [2, 3], w = [[1,2,-1],[1,2,3]], v = [[2,4,-1],[1,3,6]]
输出:10
class Solution {
public int maxValue(int N, int C, int[] S, int[][] v, int[][] w) {
int[][] dp = new int[N + 1][C + 1];
for (int i = 1; i <= N; i++) {
int[] vi = v[i - 1];
int[] wi = w[i - 1];
int si = S[i - 1];
for (int j = 1; j <= C; j++) {
dp[i][j] = dp[i - 1][j];
for (int k = 0; k < si; k++) {
if (j >= vi[k]) {
dp[i][j] = Math.max(dp[i][j], dp[i - 1][j - vi[k]] + wi[k]);
}
}
}
}
return dp[N][C];
}
}
class Solution {
public int maxValue(int N, int C, int[] S, int[][] v, int[][] w) {
int[] dp = new int[C + 1];
for (int i = 1; i <= N; i++) {
int[] vi = v[i - 1];
int[] wi = w[i - 1];
int si = S[i - 1];
for (int j = C; j >= 0; j--) {
for (int k = 0; k < si; k++) {
if (j >= vi[k]) {
dp[j] = Math.max(dp[j], dp[j - vi[k]] + wi[k]);
}
}
}
}
return dp[C];
}
}
定义 dp[i][j] 为前 i 个物品组,背包容量不超过 j 的最大价值。
1155. 掷骰子的N种方法
分组背包的组合问题
问题转换为: n 个骰子(物品组),掷出总和(取得的总价值)为 t 的方案数。
f[i][j] 表示考虑前 i 个骰子,掷出总和为 j 的方案数。
骰子的编号从 1 开始,初始化条件 f[0][0] = 1。
代表在不考虑任何物品组的情况下,只有凑成总价值为 0 的方案数为 1,凑成其他总价值的方案不存在。
class Solution {
int mod = (int)1e9 + 7;
public int numRollsToTarget(int n, int k, int target) {
if (target > n * k || target < n) return 0;
int[][] f = new int[n + 1][target + 1];
f[0][0] = 1;
// 枚举物品组(每个骰子)
for (int i = 1; i <= n; i++) {
// 枚举背包容量(所掷得的总点数)
for (int j = 0; j <= target; j++) {
// 枚举决策(当前骰子所掷得的点数)
for (int L = 1; L <= k; L++) {
if (j >= L) {
f[i][j] = (f[i][j] + f[i - 1][j - L]) % mod;
}
}
}
}
return f[n][target];
}
}
class Solution {
int mod = (int)1e9 + 7;
public int numRollsToTarget(int n, int k, int target) {
if (target > n * k || target < n) return 0;
int[] f = new int[target + 1];
f[0] = 1;
for (int i = 1; i <= n; i++) {
for (int j = target; j >= 0; j--) {
f[j] = 0;
for (int L = 1; L <= k; L++) {
if (j >= L) {
f[j] = (f[j] + f[j - L]) % mod;
}
}
}
}
return f[target];
}
}