【算法】01背包和完全背包

本文主要用于通过两个例题记录 01背包完全背包 问题的模板。

背包问题概览

直接先去看他的:动态规划:01背包理论基础
在这里插入图片描述
在这里插入图片描述

相关资料:
https://oi-wiki.org/dp/knapsack/#

01背包(例题:494. 目标和)

494. 目标和
在这里插入图片描述
这道题目等价于:选一部分数字,前面是正号,剩下一部分数字前面是负号。
我们记这两部分数字集合的和分别是 a 和 b ,那么有:
a + b = t o t a l S u m a + b = totalSum a+b=totalSum a − b = t a r g e t a - b = target ab=target ,得到 a = ( t o t a l S u m + t a r g e t ) / 2 a = (totalSum + target) / 2 a=(totalSum+target)/2
除此之外,还有两个额外条件,1 是 (totalSum + target) 需要是偶数,2 是 totalSum 至少也要达到 target 的绝对值(也就是数字集足够组成 target)。

在这里插入图片描述
这样这道题目就可以转变成 : 恰好装 capacity,求方案数

二维dp数组写法

不熟练的可以先从 二维数组 出发。

  1. 确定 dp 数组以及下标的含义
    定义 dp 数组 dp[][] ,其中 dp[i][j] 表示从下标为[0-i]的物品里任意取,放进恰好容量为j的背包,方案数是多少
  2. 确定递推公式
    对于每个物品,无非是放和不放两种方式。
    不放对应着 f[i + 1][c] = f[i][c] (其实不是不放,是放不了)
    放对应着 f[i + 1][c] = f[i][c] + f[i][c - x] (f[i][c]是不放的方案数,f[i][c-x]是放i+1的方案数,需要加起来)
  3. dp 数组初始化
    dp[][0] = 1; // 也就是只有都不选,价值和才是 0 .
  4. 确定遍历顺序
    这里无论是先遍历物品还是先遍历背包都是可以的,唯一需要注意的是在遍历物品时的顺序需要从前往后。(因为递推公式中 dp[i][ ] 依赖 dp[i - 1][])
  5. 举例推导 dp 数组
class Solution {
    public int findTargetSumWays(int[] nums, int target) {
        int totalSum = Arrays.stream(nums).sum(), n = nums.length;
        if ((totalSum + target) % 2 != 0 || totalSum < Math.abs(target)) return 0;
        target = (totalSum + target) / 2;			
        int[][] dp = new int[n + 1][target + 1];		// dp[i]是组成价值i的方法数
        dp[0][0] = 1;   // 0个数字的时候,有1种可能得到0价值 | // 组成0的方法有1种(都不选)
        for (int i = 1; i <= n; ++i) {  // 遍历1个数字到n个数字
            int num = nums[i - 1];
            for (int j = 0; j <= target; ++j) {
                dp[i][j] = dp[i - 1][j];
                if (j >= num) dp[i][j] += dp[i - 1][j - num];
            }
        }
        return dp[n][target];
    }
}

一维dp数组写法

一维 dp 数组的写法和二维 dp 数组的写法很不一样,因为背包容量一定要倒序遍历!,所以必须先遍历物品嵌套遍历背包容量

其中背包容量一定要倒序遍历的原因是:本质上还是一个对二维数组的遍历,并且右下角的值依赖上一层左上角的值,因此需要保证左边的值仍然是上一层的,从右向左覆盖

(即必须先遍历物品嵌套遍历背包,且物品正序遍历,背包倒序遍历)

同样是
x + y = sum
y - x = target

那么 y = (sum + target) / 2
我们要求组成 y 有几种方案。

class Solution {
    public int findTargetSumWays(int[] nums, int target) {
        int totalSum = Arrays.stream(nums).sum(), n = nums.length;
        if ((totalSum + target) % 2 != 0 || totalSum < Math.abs(target)) return 0;
        target = (totalSum + target) / 2;
        int[] dp = new int[target + 1];	// dp[i]是组成价值i的方法数
        dp[0] = 1;						// 组成0的方法有1种(都不选)
        for (int i = 0; i < n; ++i) {	// 遍历物品
            for (int j = target; j >= nums[i]; --j) {	// 遍历背包
                dp[j] += dp[j - nums[i]];
            }
        }
        return dp[target];
    }
}

Q:为什么可以从二维数组变成一维数组?
A:因为 无后效性 。也就是这一行的二维数组的取值,只和紧挨着的上一层数组有关。

完全背包(例题:322. 零钱兑换)

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

322. 零钱兑换
在这里插入图片描述

class Solution {
    public int coinChange(int[] coins, int amount) {
        int n = coins.length;
        int[] dp = new int[amount + 1];
        Arrays.fill(dp, amount + 1);
        dp[0] = 0;
        for (int i = 0; i < n; ++i) {
            for (int j = coins[i]; j <= amount; ++j) {
                dp[j] = Math.min(dp[j], dp[j - coins[i]] + 1);
            }
        }
        return dp[amount] == amount + 1? -1: dp[amount];
    }
}

在完全背包中,对于一维dp数组来说,其实两个for循环嵌套顺序是无所谓的!

因为dp[j] 是根据 下标j之前所对应的dp[j]计算出来的。 只要保证下标j之前的dp[j]都是经过计算的就可以了。 (即物品和背包的遍历都是正序遍历即可)。(作者个人认为还应该加上:物品for循环嵌套背包容量for循环,解释详见最后的总结章节)

关于遍历顺序

在这里插入图片描述
如果 dp[i + 1][j] 的状态 与 (dp[i][j - c] 或 dp[i + 1][j + c])有关,就是倒序遍历。(来自左上右下)
如果 dp[i + 1][j] 的状态 与 (dp[i + 1][j - c] 或 dp[i][j + c])有关,就是正序遍历。(来自左下右上)

或者这样理解:我不能先覆盖 我之后还会使用到的地方


相关题目

416. 分割等和子集

416. 分割等和子集
在这里插入图片描述
实际上就是判断能否取一定数量的数字,使其总和也是全部数字总和的一半。(类似于 目标和 那道题目,只不过这道题目的目标和是 0)。

判断为 01 背包。

写法一:能否转移过来

class Solution {
    public boolean canPartition(int[] nums) {
        int n = nums.length, totalSum = Arrays.stream(nums).sum(), target = totalSum / 2;
        if (totalSum % 2 == 1) return false;
        boolean[] dp = new boolean[target + 1];
        dp[0] = true;
        for (int i = 0; i < n; ++i) {
            for (int j = target; j >= nums[i]; --j) {
                dp[j] |= dp[j - nums[i]];
            }
        }
        return dp[target];
    }
}

写法二:最大价值是否等于最大容量

class Solution {
    public boolean canPartition(int[] nums) {
        int n = nums.length, totalSum = Arrays.stream(nums).sum(), halfSum = totalSum / 2;
        if (totalSum % 2 == 1) return false;
        int[] dp = new int[halfSum + 1];
        for (int i = 0; i < n; ++i) {
            for (int j = halfSum; j >= nums[i]; --j) {
                dp[j] = Math.max(dp[j], dp[j - nums[i]] + nums[i]);
            }
        }
        return dp[halfSum] == halfSum;
    }
}

写法三:达到最大价值的方案数是否>=1

class Solution {
    public boolean canPartition(int[] nums) {
        int n = nums.length, totalSum = Arrays.stream(nums).sum(), halfSum = totalSum / 2;
        if (totalSum % 2 == 1) return false;
        int[] dp = new int[halfSum + 1];
        dp[0] = 1;
        for (int i = 0; i < n; ++i) {
            for (int j = halfSum; j >= nums[i]; --j) {
                dp[j] += dp[j - nums[i]];
            }
            if (dp[halfSum] >= 1) return true;
        }
        return false;
    }
}

279. 完全平方数(完全背包求装完容量所需的最少物品数量)

279. 完全平方数
在这里插入图片描述

每个平方数都可以重复选择,所以是完全背包问题。

class Solution {
    public int numSquares(int n) {
        int[] dp = new int[n + 1];
        Arrays.fill(dp, n + 1);     // 因为求最小值,所以都先设成最大值
        dp[0] = 0;                  // dp数组初始化
        for (int i = 1; i * i <= n; ++i) {      // 遍历不同的物品
            for (int j = i * i; j <= n; ++j) {  // 完全背包问题需要:正向遍历背包
                dp[j] = Math.min(dp[j], dp[j - i * i] + 1);
            }
        }
        return dp[n];
    }
}

518. 零钱兑换 II(完全背包 组合数)

518. 零钱兑换 II
在这里插入图片描述

和 零钱兑换 相同,都是 完全背包问题。

但这道题目求的是方案数量,所以状态转移方程不同,其他部分均大致相同。

class Solution {
    public int change(int amount, int[] coins) {
        int n = coins.length;
        int[] dp = new int[amount + 1];
        dp[0] = 1;      // 总金额为0的方案数是1
        for (int i = 0; i < n; ++i) {
            for (int j = coins[i]; j <= amount; ++j) {
                dp[j] += dp[j - coins[i]];  // 求方案数,所以是 dp[j] += dp[j - coins[i]];
            }
        }
        return dp[amount];
    }
}

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

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

在这里插入图片描述

把石头分成两组 x 和 y
x + y = total
假设 x <= y
那我们希望 x 和 y尽可能接近
所以 x 越大越好
但是 x 最大也只能是 total / 2,因为它比 y 小
所以就是看 total / 2 最多能装的价值是多少 即求 x
那么最后的答案是 y - x
也就是 total - x - x = total - 2 * x

class Solution {
    public int lastStoneWeightII(int[] stones) {
        int totalSum = Arrays.stream(stones).sum(), halfSum = totalSum / 2;
        int[] dp = new int[halfSum + 1];
        for (int i = 0; i < stones.length; ++i) {
            for (int j = halfSum; j >= stones[i]; --j) {
                dp[j] = Math.max(dp[j], dp[j - stones[i]] + stones[i]);
            }
        }
        return totalSum - 2 * dp[halfSum];
    }
}

377. 组合总和 Ⅳ(完全背包 排列数)

https://leetcode.cn/problems/combination-sum-iv/
在这里插入图片描述
提示:
1 <= nums.length <= 200
1 <= nums[i] <= 1000
nums 中的所有元素 互不相同
1 <= target <= 1000

class Solution {
    public int combinationSum4(int[] nums, int target) {
        int[] dp = new int[target + 1];
        dp[0] = 1;
        for (int j = 0; j <= target; ++j) {
            for (int num: nums) {
                if (j >= num) dp[j] += dp[j - num];
            }
        }
        return dp[target];
    }
}

总结⭐

其实 01背包 和 完全背包 问题,最重要的就是两个问题:

  1. 把原问题转换成背包问题。
  2. 背包问题的模板。

关于模板,作者推荐都只记住 一维 dp 数组的方法。

对于 01背包:必须先遍历物品嵌套遍历背包,且物品正序遍历,背包倒序遍历
对于 完全背包:必须先遍历物品嵌套遍历背包,且物品正序遍历,背包正序遍历

对于完全背包——
求组合数:物品在外
求排列数:容量在外

物品在外的话,必须考虑完 i 之后再考虑 i + 1,所以去掉了他俩的排列关系; 而容量在外的话,每个容量的循环中可以尝试所有的物品,就像那个排列数每个位置可以放任何物品一样,这就有顺序了。(笔者自己想的不确定对不对)


备注:
有一种说法是:
在这里插入图片描述
来自https://programmercarl.com/%E8%83%8C%E5%8C%85%E9%97%AE%E9%A2%98%E7%90%86%E8%AE%BA%E5%9F%BA%E7%A1%80%E5%AE%8C%E5%85%A8%E8%83%8C%E5%8C%85.html(代码随想录)
但实际上在 518. 零钱兑换 II 这道题目中,如果调换 两个 for 循环的嵌套顺序,结果会出现问题!

因此建议大家都 先遍历物品嵌套遍历背包


补充:多重背包

多重背包就是每个物品有 k 个。

可以将其拆成 0-1 背包。

补充:分组背包

分组背包即
「在所有物品中选择一件」变成了「从当前组中选择一件」,于是就对每一组进行一次 0-1 背包就可以了

这里要注意:一定不能搞错循环顺序,这样才能保证正确性。(组——容量——组内物品)

分组背包模板

for (int k = 1; k <= ts; k++)           // 循环每一组
  for (int i = m; i >= 0; i--) 			// 循环背包容量
    for (int j = 1; j <= cnt[k]; j++)   // 循环该组的每一个物品
      if (i >= w[t[k][j]])  // 背包容量充足
        dp[i] = max(dp[i], dp[i - w[t[k][j]]] + c[t[k][j]]);  // 像0-1背包一样状态转移

资料来源:https://oi-wiki.org/dp/knapsack/#%E5%88%86%E7%BB%84%E8%83%8C%E5%8C%85

题目列表

2218. 从栈中取出 K 个硬币的最大面值和

https://leetcode.cn/problems/maximum-value-of-k-coins-from-piles/

在这里插入图片描述
提示:

n == piles.length
1 <= n <= 1000
1 <= piles[i][j] <= 10^5
1 <= k <= sum(piles[i].length) <= 2000

将问题转化成分组背包,每一个栈为一组。
每个组只能取出一个元素块,一个元素块即为栈顶的若干个元素。

class Solution {
    public int maxValueOfCoins(List<List<Integer>> piles, int k) {
        int n = piles.size();   // 有n个组
        int[] dp = new int[k + 1];
        for (List<Integer> pile: piles) {
            for (int i = 1; i < pile.size(); ++i) {
                // 将元素的价值修改为前缀和
                pile.set(i, pile.get(i - 1) + pile.get(i));
            }
        }

        for (int x = 0; x < n; ++x) {       // 循环每一组
            for (int i = k; i >= 1; --i) {  // 循环背包容量
                for (int j = 1; j <= piles.get(x).size(); j++) {     // 循环该组的每一个物品
                    if (i >= j) {
                        dp[i] = Math.max(dp[i], dp[i - j] + piles.get(x).get(j - 1));
                    }
                }
            }
        }
        return dp[k];
    }
}

参考资料

【总结】用树形图和剪枝操作理解完全背包问题中组合数和排列数问题
https://oi-wiki.org/dp/knapsack/#

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

Wei *

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

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

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

打赏作者

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

抵扣说明:

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

余额充值