一套框架解决「背包问题」
动态规划
背包问题
背包问题是一类经典的动态规划问题,它非常灵活,需要仔细琢磨体会,本文先对背包问题的几种常见类型作一个总结,期望可以用一套框架解决背包问题。 常见背包问题可分为:
-
01 背包问题:
最基本的背包问题就是 01 背包问题:一共有 N 件物品,第 i(i 从 1 开始)件物品的重量为 w[i],价值为 v[i]。在总重量不超过背包承载上限 W 的情况下,能够装入背包的最大价值是多少? -
完全背包问题:
完全背包与 01 背包不同就是每种物品可以有无限多个:一共有 N 种物品,每种物品有无限多个,第 i(i 从 1 开始)种物品的重量为 w[i],价值为 v[i]。在总重量不超过背包承载上限 W 的情况下,能够装入背包的最大价值是多少? 可见 01 背包问题与完全背包问题主要区别就是物品是否可以重复选取。
1、背包问题具备的特征:
是否可以根据一个 target(直接给出或间接求出),target 可以是数字也可以是字符串,再给定一个数组 arrs,问:能否使用 arrs 中的元素做各种排列组合得到 target。
2、背包问题解法:
-
01 背包问题:
如果是 01 背包,即数组中的元素不可重复使用,外循环遍历 arrs,内循环遍历 target,且内循环倒序: -
完全背包问题:
(1)如果是完全背包,即数组中的元素可重复使用并且不考虑元素之间顺序,arrs 放在外循环(保证 arrs 按顺序),target在内循环。且内循环正序。 (2)如果组合问题需考虑元素之间的顺序,需将 target 放在外循环,将 arrs 放在内循环,且内循环正序。
3、背包问题解法推导过程(来源)
以leecode 322.零钱兑换题 为例:
给你一个整数数组 coins ,表示不同面额的硬币;以及一个整数 amount ,表示总金额。
计算并返回可以凑成总金额所需的 最少的硬币个数 。如果没有任何一种硬币组合能组成总金额,返回 -1 。
你可以认为每种硬币的数量是无限的。
示例 1:
输入:coins = [1, 2, 5], amount = 11
输出:3
解释:11 = 5 + 5 + 1
示例 2:
输入:coins = [2], amount = 3
输出:-1
示例 3:
输入:coins = [1], amount = 0
输出:0
提示:
1 <= coins.length <= 12
1 <= coins[i] <= 231 - 1
0 <= amount <= 104
一、递归搜索+保存计算结果=记忆化搜索
class Solution {
public:
int coinChange(vector<int>& coins, int amount) {
int n = coins.size();
vector<vector<int>> cache(n, vector<int>(amount+1));
function<int(int, int)> dfs = [&](int i, int c) -> int {
if ( i < 0) {
return c == 0 ? 0 : INT_MAX / 2;
}
int &res = cache[i][c];
if (res != -1) {
return res;
}
if (c < coins[i]) {
return res = dfs(i-1, c);
}
return res = min(dfs(i - 1, c), dfs(i, c - coins[i]) + 1);
};
int ans = dfs(n - 1, amount);
return ans < INT_MAX/2 ? ans : -1;
}
};
二、将记忆化搜索转为动态规划即递推
class Solution {
public:
int coinChange(vector<int> &coins, int amount) {
int n = coins.size();
vector<vector<int>> dp(n + 1, vector<int>(amount + 1, amount + 1));
dp[0][0] = 0;
for (int i = 0; i < n; ++i)
for (int c = 0; c <= amount; ++c)
if (c < coins[i]) {
dp[i+1][c] = dp[i][c];
} else {
dp[i+1][c] = min(dp[i][c], dp[i+1][c - coins[i]] + 1);
}
int ans = dp[n][amount];
return ans > amount ? -1 : ans;
}
};
三、空间优化为两个数组
原理为每次只使用当前行和上一行数据,因此可以进行优化
class Solution {
public:
int coinChange(vector<int> &coins, int amount) {
int n = coins.size();
vector<vector<int>> dp(2, vector<int>(amount + 1, amount + 1));
dp[0][0] = 0;
for (int i = 0; i < n; ++i)
for (int c = 0; c <= amount; ++c)
if (c < coins[i]) {
dp[(i+1)%2][c] = dp[i%2][c];
} else {
dp[(i+1)%2][c] = min(dp[i%2][c], dp[(i+1)%2][c - coins[i]] + 1);
}
int ans = dp[n%2][amount];
return ans > amount ? -1 : ans;
}
};
四、空间优化为一个数组
class Solution {
public:
int coinChange(vector<int> &coins, int amount) {
vector<int> dp(amount + 1, amount + 1);
dp[0] = 0;
for (int x : coins) {
for (int c = x; c <= amount; ++c) {
dp[c] = min(dp[c], dp[c - x] + 1);
}
}
int ans = dp[amount];
return ans > amount ? -1 : ans;
}
};
可以看出在第四步的时候的答案即为0-1背包问题的通用解。
4、例题:
一、 01 背包问题
例题一:416. 分割等和子集
本题要求把数组分成两个等和的子集,相当于找到一个子集,其和为 sum / 2,这个 sum / 2 就是 target(target 间接给出)。 于是转化为是否可以用 nums 中的数组合和成 target,01 背包问题,外层循环为选择池 num: nums,内层循环为 target。
dp[i] 表示是否存在和为 i 的 num 组合。
外层遍历 nums 每个 num;
内层遍历 target(由大到小)。
对于元素之和等于 i - num 的每一种组合,在最后添加 num 之后即可得到一个元素之和等于 i 的组合,因此dp[i] 依赖于 dp[i - num],并且在计算 dp[i - num] 时,要保证索引较小的元素值不被覆盖,需要后向更新 dp[i],并且当 i - num < i 时, dp[i] 已经更新过,于是: dp[i] = dp[i] || dp[i - num] 对于特例:如果 sum 为奇数,那一定找不到符合要求的子集,返回 False。 对于边界条件,我们定义 dp[0] = true 表示当 i - num = 0,存在一个 num 和为 i。 最后返回 dp[target]。
class Solution {
public:
bool canPartition(vector<int>& nums) {
int len = nums.size();
int sum = 0;
for (int num: nums) {
sum += num;
}
if ((sum & 1) == 1) {
return false;
}
int target = sum / 2;
vector<bool> dp(target + 1);
dp[0] = true;
for(int num: nums){
for(int i = target; i >= num; i--){
dp[i] = dp[i] || dp[i - num];
}
}
return dp[target];
}
};
复杂度分析:
时间复杂度:O(target × n),其中 n 是数组 nums 的长度。
空间复杂度:O(target)。
例题二:494. 目标和
我们想要的 S = 正数和 - 负数和 = x - y 而已知 x 与 y 的和是数组总和:x + y = sum 可以求出 x = (S + sum) / 2 = target 也就是我们要从 nums 数组里选出几个数,令其和为 target(target 间接给出)。 于是转化为是否可以用 nums 中的数组合和成 target,01 背包问题,外层循环为选择池 nums,内层循环为 target。 dp[i] 表示和为 i 的 num 组合有 dp[i] 种。
外层遍历 nums 每个 num;
内层遍历 target(由大到小)。
对于元素之和等于 i - num 的每一种排列,在最后添加 num 之后即可得到一个元素之和等于 i 的排列,因此在计算 dp[i] 时,应该计算所有的 dp[i − num] 之和。 dp[i] = dp[i] + dp[i - num] 对于边界条件,我们定义 dp[0] = 1 表示只有当不选取任何元素时,元素之和才为 0,因此只有 1 种方案。 最后返回 dp[target]
class Solution {
public:
int findTargetSumWays(vector<int>& nums, int S) {
int sum = 0;
for(int num : nums) sum += num;
if(S > sum || (S + sum) % 2 == 1) return 0;
int target = (S + sum) / 2;
vector<int> dp(target + 1);
dp[0] = 1;
for(int num : nums){
for(int i = target; i >= num; i--){
dp[i] = dp[i] + dp[i - num];
}
}
return dp[target];
}
};
复杂度分析:
时间复杂度:O(target × n),其中 n 是数组 nums 的长度。
空间复杂度:O(target)。
二、完全背包问题
例题三:139. 单词拆分
转化为是否可以用 wordDict 中的词组合成 s,完全背包问题,并且为“考虑排列顺序的完全背包问题”,外层循环为 target ,内层循环为选择池 wordDict。 dp[i] 表示以 i 结尾的字符串是否可以被 wordDict 中组合而成。
外层遍历 s 中每一个与 word 同长度的字串 s.substr(i - sz, sz) ;
内层遍历 wordDict 每个 word。
判断 s.substr(i - sz, sz) == word: (1)若不相等,说明与该 word 不匹配,继续遍历; (2)若相等,说明从 [i - sz] 到 i 的字符与 word 匹配。 dp[i] = dp[i] || d[[i - sz]] 对于边界条件,我们定义 dp[0] = true 表示空串且合法。 最后返回 dp[s.size()]
class Solution {
public:
bool wordBreak(string s, vector<string>& wordDict) {
vector<bool> dp(s.size() + 1);
dp[0] = true;
for(int i = 1; i <= s.size(); i++){
for(auto& word: wordDict){
int sz = word.size();
if (i - sz >= 0 && s.substr(i - sz, sz) == word)
dp[i] = dp[i] || dp[i - sz];
}
}
return dp[s.size()];
}
};
复杂度分析
时间复杂度:O(target × n),其中 n 是数组 nums 的长度。
空间复杂度:O(target)。
例题四:279. 完全平方数
我们想要的 S = 若干个完全平方数的和 完全平方数最小为 1,最大为 sqrt(n) 也就是我们要从 nums = [1, 2, ..., sqrt(n)] 数组里选出几个数,令其平方和为 target = n。 于是转化为是否可以用 nums 中的数组合和成 target,完全背包问题,外层循环为选择池 nums,内层循环为 target。 dp[i] 表示和为 i 的 nums 组合中完全平方数最少有 dp[i] 个。
外层遍历 nums 每个 num;
内层遍历 n。
class Solution {
public:
int numSquares(int n) {
vector<int> dp(n+1, INT_MAX);;
dp[0]=0;
for(int num = 1; num <= sqrt(n); num++){
for(int i = 0; i <= n; i++){
if(i >= num * num)
dp[i] = min(dp[i], dp[i - num * num] + 1);
}
}
return dp[n];
}
};
对于元素之和等于 i - num * num 的每一种组合,在最后添加 num 之后即可得到一个元素平方和等于 i 的组合,因此在计算 dp[i] 时,应该计算所有的 dp[i − num * num] + 1 中的最小值。 dp[i] = min(dp[i], dp[i - num * num] + 1) 对于边界条件,我们定义 dp[0] = 0。 最后返回 dp[n]
复杂度分析
时间复杂度:O(n x sqrt{n}),在主步骤中,我们有一个嵌套循环,其中外部循环是 n 次迭代,而内部循环最多需要 sqrt{n} 迭代。
空间复杂度:O(n),使用了一个一维数组 dp。
例题五:322. 零钱兑换
转化为是否可以用 coins 中的数组合和成 amount,完全背包问题,并且为“不考虑排列顺序的完全背包问题”,外层循环为选择池 coins,内层循环为 amount。 dp[i] 表示和为 i 的 coin 组合中硬币最少有 dp[i] 个。
外层遍历 coins 每个 coin;
内层遍历 amount。
对于元素之和等于 i - coin 的每一种组合,在最后添加 coin 之后即可得到一个元素之和等于 i 的组合,因此在计算 dp[i] 时,应该计算所有的 dp[i − coin] + 1 中的最小值。 dp[i] = min(dp[i], dp[i - coin] + 1) 对于边界条件,我们定义 dp[0] = 0。 最后返回 dp[amount]
class Solution {
public:
int coinChange(vector<int>& coins, int amount) {
vector<long long> dp(amount+1, INT_MAX);
dp[0] = 0;
for(int& coin: coins){
for(int i = 0; i <= amount; i++){
if(coin <= i)
dp[i] = min(dp[i], dp[i-coin] + 1);
}
}
return dp[amount] == INT_MAX ? -1 : dp[amount];
}
};
复杂度分析:
时间复杂度:O(amount x n),其中 n 为 coins 大小
空间复杂度:O(amount)
例题六:377. 组合总和 Ⅳ
转化为是否可以用 nums 中的数组合和成 target,完全背包问题,并且为“考虑排列顺序的完全背包问题”,外层循环为 target ,内层循环为选择池 nums。 dp[i] 表示和为 i 的 num 组合有 dp[i] 种。
外层遍历 target;
内层遍历 nums 每个 num。
对于元素之和等于 i - num 的每一种排列,在最后添加 num 之后即可得到一个元素之和等于 i 的排列,因此在计算 dp[i] 时,应该计算所有的 dp[i − num] 之和。 dp[i] = dp[i] + dp[i - num] 对于边界条件,我们定义 dp[0] = 1 表示只有当不选取任何元素时,元素之和才为 0,因此只有 1 种方案。 最后返回 dp[target]
class Solution {
public:
int combinationSum4(vector<int>& nums, int target) {
vector<int> dp(target + 1);
dp[0] = 1;
for(int i = 1; i <= target; i++){
for(int& num: nums){
if(num <= i && dp[i - num] < INT_MAX - dp[i])
dp[i] += dp[i - num];
}
}
return dp[target];
}
};
复杂度分析:
时间复杂度:O(target x n),其中 n 为 wordDict 大小
空间复杂度:O(target)
例题七:518. 零钱兑换 II 转化为是否可以用 coins 中的数组合和成 amount,完全背包问题,并且为“不考虑排列顺序的完全背包问题”,外层循环为选择池 coins,内层循环为 amount。 dp[i] 表示和为 i 的 coin 组合有 dp[i] 种。
外层遍历 coins 每个 coin;
内层遍历 amount。
对于元素之和等于 i - coin 的每一种组合,在最后添加 coin 之后即可得到一个元素之和等于 i 的组合,因此在计算 dp[i] 时,应该计算所有的 dp[i − coin] 之和。 dp[i] = dp[i] + dp[i - coin] 对于边界条件,我们定义 dp[0] = 1,表示只有当不选取任何元素时,元素之和才为 0,因此只有 1 种方案。 最后返回 dp[amount]。
class Solution {
public:
int change(int amount, vector<int>& coins) {
vector<int> dp(amount + 1);
dp[0] = 1;
for(int& coin: coins){
for(int i = 0; i <= amount; i++){
if(coin <= i)
dp[i] += dp[i - coin];
}
}
return dp[amount];
}
};
复杂度分析:
时间复杂度:O(amount x n),其中 n 为 coins 大小
空间复杂度:O(amount)
转载:
https://leetcode.cn/problems/coin-change-ii/solutions/783992/yi-pian-wen-zhang-chi-tou-bei-bao-wen-ti-2xkk/?envType=study-plan-v2&envId=dynamic-programming
https://leetcode.cn/problems/coin-change-ii/solutions/744156/yi-tao-kuang-jia-jie-jue-bei-bao-wen-ti-6kaze/?envType=study-plan-v2&envId=dynamic-programming
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· Manus爆火,是硬核还是营销?
· 终于写完轮子一部分:tcp代理 了,记录一下
· 震惊!C++程序真的从main开始吗?99%的程序员都答错了
· 别再用vector<bool>了!Google高级工程师:这可能是STL最大的设计失误
· 单元测试从入门到精通