背包问题的模板
背包问题的模板
常见的背包类型主要有以下几种:
1.0/1背包问题:每个元素最多选取一次
一维数组优化:外循环物品,内循环背包,内循环倒序。
2.全背包问题:每个元素可以重复选择
一维数组优化:外循环物品,内循环背包,内循环正序。
完全背包的组合和排列:
当计算组合的时候:外循环物品,内循环背包
当计算排列的时候:外循环背包,内循环物品。
而每个背包问题要求的也是不同的,按照所求问题分类,又可以分为以下几种:
1.最值问题:要求最大值/最小值 -》dp[j] = max(dp[j], dp[j - nums[i]] + 1)
或者dp[j] = max(dp[j], dp[j - nums[i]] + nums[i])
01背包 | 完全背包 | 题解 | leetcode做题 |
---|---|---|---|
√ | 1049.最后一块石头的重量Ⅱ | [1049. 最后一块石头的重量 II | |
√ | 474.一和零 | 474.一和零 | |
√ | 322.零钱兑换 | 322.零钱兑换 | |
√ | 279.完全平方数 | 279.完全平方数 |
2、存在问题:是否存在…………,满足…………-》dp[j] = dp[j] || dp[j - nums[i]]
01背包 | 完全背包 | 题解 | leetcode做题 |
---|---|---|---|
√ | 416.分割等和子集 | 416.分割等和子集 | |
√ | 139.单词拆分 | 139.单词拆分 |
3、组合总数问题:求所有满足……的排列组合-》dp[j] += dp[j - nums[i]]
01背包 | 完全背包 | 题解 | leetcode做题 |
---|---|---|---|
√ | 494.目标和 | 494.目标和 | |
√ | 518.零钱对话Ⅱ | 518.零钱对话Ⅱ | |
√ | 377.组合总和Ⅳ | 377.组合总和Ⅳ | |
√ | 70.爬楼梯 | 70.爬楼梯 |
背包问题
01背包
题目要求:有N件物品和一个最多能背W的背包,每一件物品的重量无w[i],价值为v[i]。每一件物品只能用一次,求解将哪些物品放入背包中可以使得背包中的物品价值总量最大。
第一种思路应该是暴力递归,只有N件物品,每一件物品只有一件,所以每一件物品只有两种状态:选或者不选。
(回溯)
#include <iostream>
#include <algorithm>
using namespace std;
const int N = 1010;
int n, W;
int w[N], v[N];// 物体的重量和价值
int dp[N][N];
int ans;
void dfs(int index, int val)
{
if (W < 0) return ;// 如果背包不能够剩余的空间就直接返回
if (index == n + 1) {
if (val > ans) {
ans = val;
}
return ;
}
//不选第index件物品
dfs(index + 1, val);
// 选择第index件物品
val += v[index];// 将加上相应的价值
W -= w[index]; // 减去相应的重量
dfs(index + 1, val);
W += w[index]; // 撤销
val -= v[index];
}
int main()
{
cin >> n >> W;
for (int i = 1; i <= n; i ++) cin >> w[i] >> v[i];
int val = 0;
dfs(1, val);
cout << ans << endl;
return 0;
}
第二种思路就是动态规划,因为背包容量为W的背包可以拆解成更小的背包,w1 , w2,w3 …所以根据大问题化成小问题的思想就可以使用动态规划。并且01背包问题,相较前面的问题而言多了一个约束条件,就是不仅要将物品放入背包中,背包也是有容量的,由于这个约束条件所以才需要挑选,否则每次只要选择价值最大的物品即可。正因为这个有容量的考虑,所以这次就不可以只用一个一维的数组来保存前面的计算的结果,还要在多加一维数组来记录背包的容量。
1.dp数组的含义
dp[i][j]
表示前i个物品中任意选取,放入容量为j的背包,可以得到的最大价值总和为多少。
2.递推公式
将前i个物品这个大问题拆解成小问题,即只讨论第i个物品,前i - 1个默认已经递推计算完成了。
对于第i个物品来说只有两种状态:
1.背包中不放第i个物品,那么前i个物品中的价值总和前i - 1个物品中的价值总和相等。即dp[i][j] = dp[i - 1][j]
。
2.背包中方如第i个物品,那么前i个物品中价值总和等于第i个物品的价值加上前i - 1个物品中选择任意物品放入背包容量为W - w[i](背包的容量因为放入第i个物品而缩小了)。
在上面两者中选择最大值,即dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - w[i]] + v[i])
。
小技巧:添加第一列空背包容量即没有背包已将满了这是必须要的,因为当背包容量为0的时候需要加dp[i][0] (等于0)
。但是如果加上上面的第一行(即空背包),可以简便计算。因为递推公式中:dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - w[i]] + v[i]);
都需要用到上一层数组中的数字,但是如果不加第一行的话,因为第0行没有上一层,所以就需要先给第一行初始化,在使用递推公式。如果加上第一行的话,默认空背包的时候没有价值,这样就可以不用初始化了,因为也可以使用递推公式推出来了。
3.初始化dp数组
3.1
ps:如果不加第一行需要初始化第0行的代码:
for (int i = W; i >= w[0]; i --) {// 逆序初始化
dp[0][i] = dp[0][i - w[0]] + v[0];
}
3.2
如果使用第一行的dp数组,第一行全部初始化为0即可。
4.遍历顺序
如果是最常规的遍历方法可以根据递推公式:dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - w[i]] + v[i]);
可知需要用到上一行的计算过的数组,所有从上到下,从左到右的遍历即可。
如果要优化到一维数组的话就必须要从右往左的遍历(下面在优化dp的时候会有详细配图解释)。
5.举例dp
w[i] = {1, 2, 3, 4}
v[i] = {2, 4, 4, 5}
(朴素动规)
#include <iostream>
#include <algorithm>
using namespace std;
const int N = 1010;
int n, W;
int w[N], v[N];
int dp[N][N];
int main()
{
cin >> n >> W;
for (int i = 1; i <= n; i ++) cin >> w[i] >> v[i];
for (int i = 1; i <= n; i ++) {
for (int j = 1; j <= W; j ++) {
if (j < w[i]) {
dp[i][j] = dp[i - 1][j];
} else {
dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - w[i]] + v[i]);
}
}
}
cout << dp[n][W] << endl;
return 0;
}
for (int i = 1; i <= n; i ++) {
for (int j = 1; j <= W; j ++) {
dp[i][j] = dp[i - 1][j];// 先默认不选第i个物品
if (j >= w[i]) {
dp[i][j] = max(dp[i][j], dp[i - 1][j - w[i]] + v[i]);
}
}
}
因为每一次使用递推公式的时候,dp[i][j]
都只是使用到了dp[i - 1][j]
或者是dp[i - 1][j - w[i]]
而这些都是这一层数组上面一层的数组,并没有使用到更早之前的数组中的数(类似dp[i - 2]或者dp[i - 5]
。。。)这样就可以就直接使用一个一维数组就可以了,一维数组保留上一层循环已经算过的数字,而本层要循环的数字直接在上一层的基础上改动即可。
但是有一个问题,虽然下一次的循环会用到上一次循环得到的数字,但是怎么在原来算好的数组的基础上计算本层的数组。说白了,使用一维数组这样的空间优化只不过是为了节省原来二维数组的空间而已,其余的思想应该是相同的,所以就再看一看原来二维数组的思想,在背包容量可以放下物品的时候dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - w[i]] + v[i]);
,每一次只用到上一层是因为dp[i][...] = max(dp[i - 1][...], dp[i - 1][...] + v[i]);(...表示先不看)
,但是如果注意后面第二位:用来限制背包的容量,dp[...][j] = max(dp[...][j], dp[...][j - w[i]] + v[i])
背包的容量都是 j 或者是 j - w[i],这些都是 < j的。所以对应到数组上也就是说本层的数组都只是会使用到上面一层数组的左上或者上方的数字
这就提醒我们需要在第一个for循环枚举容量的时候要倒序枚举,因为每一次因为数组都可能会用到自己本身的数字或者是左边的数字,而左边的数字是上一层计算的数字,所以如果正着枚举的话,因为是从左到右计算的,所以如果这个时候再使用左边的数字就不是上一层计算的数字了,而是刚刚这一层循环计算的数字,这不是我们预期的。所以综上可知应该倒序枚举for (int j = W; j >= w[i]; j --)
,这样当从右往左计算的时候就使用的左边的数字还是上一层计算的数字。
(一维数组优化空间)
#include <iostream>
#include <algorithm>
using namespace std;
const int N = 1010;
int n, W;
int w[N], v[N];
int dp[N];
int main()
{
cin >> n >> W;
for (int i = 1; i <= n; i ++) cin >> w[i] >> v[i];
for (int i = 1; i <= n; i ++) {
for (int j = W; j >= w[i]; j --) {
dp[j] = max(dp[j], dp[j - w[i]] + v[i]);
}
}
cout << dp[W] << endl;
return 0;
}
总结:01背包问题是最基本的背包问题,也是其他背包问题的基础,所以一定要掌握dp[i][j]
中的第二维[j]
是容量的限制的含义,并且要学会一维数组的优化方法思想。
分割等和子集
本题和473. 火柴拼正方形很相似,都是将集合进行均等分割,使得每一个分割出的子集中的所有的元素的总和都是相等的。所以本题的第一种思路就是用回溯法暴搜出所有的集合划分方式,最后检查是否可以两个划分出的集合中元素总和相等。但是由于时间复杂度是指数级别的,所以会超时。(代码还是贴出来了,可以作为参考)
class Solution {
public:
bool dfs(int target, vector<int>& nums, int index, int boxs[2]) {
if (index == nums.size()) {// 如果遍历完整个数组,检查是否可以有两个等分的集合
if (boxs[0] == boxs[1] && boxs[1] == target) {
return true;
}
return false;
}
// 将数组中的元素放入集合中
for (int i = 0; i < 2; i ++) {
if (boxs[i] + nums[index] > target) continue;
boxs[i] += nums[index];
if (dfs(target, nums, index + 1, boxs)) return true;
boxs[i] -= nums[index];
}
return false;
}
bool canPartition(vector<int>& nums) {
int n = nums.size();
int sum = 0;
for (int num : nums) sum += num;
if ((sum & 1) == 1) return false;
int target = sum / 2;
sort(nums.begin(), nums.end(), greater<int>());// 剪枝
int boxs[2] = {0};// 等分的两个集合
return dfs(target, nums, 0, boxs);
}
};
因为每一个元素都只能使用一次,所以就可以把数组中的元素当做物品体积同时也是物品的价值,总和的一半当做背包的容量。这就转换为了一个经典的01背包问题(这种转换一开始比较难接受,但是要慢慢去适应)。
1.dp数组的含义
dp[i][j]
表示在前i个元素中选,使得集合中的和不超过j的最大和为dp[i][j]
。
2.递推公式
如果背包容量不足nums[i]的话,就不选当前的元素,dp[i][j] = dp[i - 1][j]
。
如果背包容量可以的话,就选当前的元素,dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - nums[i]] + nums[i])
。
3.初始化
如果不初始化第一行的话(即二维数组多开一层,假设出有一个空的背包),需要初始化一行,当targer >= nums[0]
的时候,所有的背包容量大于nums[0]的背包都需要放入nums[0]。
如果初始化第一行就不用初始化了,因为第一行可以用递推公式赋值了,但是注意因为数组的下标是从0开始的,所以在使用数组的时候原来的nums[i]是现在的nums[i - 1]。
4.遍历顺序
背包问题从前往后,从上到下的枚举即可,但是如果是一维数组空间优化,前面也说过那样的话就必须在枚举背包容量的时候倒着遍历。
(朴素动规 01背包) 价值
class Solution {
public:
bool canPartition(vector<int>& nums) {
int n = nums.size();
int sum = 0;
for (int num : nums) sum += num;
if ((sum & 1) == 1) return false;
int target = sum / 2;
vector<vector<int>> dp(n, vector<int>(target + 1, 0));
for (int i = target; i >= nums[0]; i --) {// 从前0个物品中选,背包容量为[nums[0], target]的背包中可以放得下nums[0]
dp[0][i] = nums[0];
}
for (int i = 1; i < n; i ++) {
for (int j = 0; j <= target; j ++) {
dp[i][j] = dp[i - 1][j];
if (j >= nums[i]) {
dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - nums[i]] + nums[i]);
}
}
}
return dp[n - 1][target] == target;
}
};
初始化第一行的写法
vector<vector<int>> dp(n + 1, vector<int>(target + 1, 0));
for (int i = 1; i <= n; i ++) {
for (int j = 1; j <= target; j ++) {
dp[i][j] = dp[i - 1][j];
if (j >= nums[i - 1]) {
dp[i][j] = fmax(dp[i - 1][j], dp[i - 1][j - nums[i - 1]] + nums[i - 1]);
}
}
}
return dp[n][target] == target;
小总结:将既定的目标和问题(目标和就是数组中元素总和的一半)转换为背包问题,最后判断背包中的最大价值是否等于目标和。
动态规划还有第二种思路,但是这次不是算出集合中的和,而是针对性的判断是不是可以凑成目标和,如果可以凑成目标和就为true,如果凑不成目标和就为false。
1.dp数组的含义
dp[i][j]
表示在前i个物品中选取元素,能凑成j为true,否则为false。
2.递推公式
有三个分支:
如果选第i件物品
1.前i个物品中选和前i-1个物品中选,至少是一样的。
如果不选第i件物品
2.如果nums[i] == j的话,那一定可以凑数目标和。
3.如果nums[i] < j的话,就看dp[i - 1][j - nums[i]] || dp[i - 1][j]
d p [ i ] [ j ] = { d p [ i − 1 ] [ j ] 至少是上一个状态。如果前i-1个元素中可以凑出taret,那前i个元素也可以凑出,如果上一个状态凑不出就先保持 t r u e nums[i] == j d p [ i − 1 ] [ j − n u m s [ i ] ] nums[i] < j dp[i][j] = \begin{cases}dp[i-1][j]& \text{至少是上一个状态。如果前i-1个元素中可以凑出taret,那前i个元素也可以凑出,如果上一个状态凑不出就先保持}\\ true& \text{nums[i] == j} \\ dp[i - 1][j - nums[i]]& \text{nums[i] < j} &\end{cases} dp[i][j]=⎩⎪⎨⎪⎧dp[i−1][j]truedp[i−1][j−nums[i]]至少是上一个状态。如果前i-1个元素中可以凑出taret,那前i个元素也可以凑出,如果上一个状态凑不出就先保持nums[i] == jnums[i] < j
3.初始化
如果不初始化第一行的话,第一个背包只能被和背包容量相同的背包填满,即if (nums[0] == target) dp[0][target] = true;
如果初始化第一行的话,就不用初始化第一个背包了,但是dp[0][0] = true;
因为当背包容量为0的时候,可以装下容量为0的物品
4.遍历顺序
和01背包相同,从左往右,往上到下。
(朴素动规 01背包)bool
class Solution {
public:
bool canPartition(vector<int>& nums) {
int n = nums.size();
int sum = 0;
for (int num : nums) sum += num;
if ((sum & 1) == 1) return false;// 总和为奇数
int target = sum / 2;// 总和的一半
vector<vector<bool>> dp(n, vector<bool>(target + 1, false));
if (nums[0] == target) dp[0][target] = true;
for (int i = 1; i < n; i ++) {
for (int j = 1; j <= target; j ++) {
dp[i][j] = dp[i - 1][j];
if (nums[i] == j) {
dp[i][j] = true;
}
if (nums[i] < j) {
dp[i][j] = dp[i - 1][j] || dp[i - 1][j - nums[i]];
}
}
}
return dp[n - 1][target];
}
};
多添加一个第一行,这样第0行就可以不用初始化了
dp[0][0] = true;
for (int i = 1; i <= n; i ++) {
for (int j = 1; j <= target; j ++) {
dp[i][j] = dp[i - 1][j];
if (nums[i - 1] == j) {
dp[i][j] = true;
}
if (nums[i - 1] < j) {
dp[i][j] = dp[i - 1][j] || dp[i - 1][j - nums[i - 1]];
}
}
}
return dp[n][target];
(动规 一维数组优化)bool
class Solution {
public:
bool canPartition(vector<int>& nums) {
int n = 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, false);
dp[0] = true;// 当背包为空的时候,默认可以放下0件物品
if (target >= nums[0]) {
dp[nums[0]] = true;
}
for (int i = 1; i < n; i ++) {
for (int j = target; j >= nums[i]; j --) {
dp[j] = dp[j] || dp[j - nums[i]];
}
}
return dp[target];
}
};
设置并初始化第一行的数组
vector<bool> dp(target + 1, false);
dp[0] = true;// 当背包为空的时候,默认可以放下0件物品
for (int i = 1; i <= n; i ++) {
for (int j = target; j >= nums[i - 1]; j --) {
dp[j] = dp[j] || dp[j - nums[i - 1]];
}
}
return dp[target];
(动规 一维数组优化)价值
class Solution {
public:
bool canPartition(vector<int>& nums) {
int n = nums.size();
int sum = 0;
for (int num : nums) sum += num;
if (sum & 1) return false;
int target = sum / 2;
vector<int>dp (target + 1, 0);
for (int i = 0; i < n; i ++) {
for (int j = target; j >= nums[i]; j --) {
dp[j] = max(dp[j], dp[j - nums[i]] + nums[i]);
}
}
return dp[target] == target;
}
};
总结:二维数组朴素动规需要注意加一个问题抽象为另一个问题的能力。将目标和问题转化为01背包是否可以被填满的问题。一维数组需要注意的是遍历背包容量的时候需要倒序遍历。
最后一块石头的重量Ⅱ
题目要求:在stones数组中选出任意两个石头,如果x==y
那两块石头全部销毁,如果x != y
那就留下abs(y - x)
。最后会剩下一个石头,返回这个石头的最小重量(如果没有石头剩下就返回0)。
看完题目其实一点想法都没有,这题难就难在如何将题目要求转化为学过的某一个算法中的某一个小的模型。先看最终的目标是要求出最小的石头的重量,而每一个如果两个石头不相等,就会留下两个石头重量的绝对值。所以每一次挑选出来的两个石头的重量应该是差不多的(最好的情况就是相等,这样绝对值就是0),这样就可以使得最后留下的石头的重量是最小的。然后下面一步就是最关键的就是不要单独看一个石头的个体,而是统筹全局看一堆石头,如果两堆石头的重量是差不多的,那么最终两堆石头中的每一个石头可以两两抵消掉。
所以最终得到的结论可以将题目要求换一个说法:尽量将stone分成两堆重量相同的两堆,这样每堆中的石头可以相互抵消掉。(注意是尽量将石头分成重量相同,因为当重量相同的时候,就正好将所有的石头全部抵消掉,而不一定有这种情况,所有要尽量的将对量平均分配)。
综上所述:可以将这本转换为一个01背包问题。
1.dp数组的含义
dp[i][j]
表示从前i个数字中选,重量不超过j的最大重量为dp[i][j]
。
2.递推公式
同01背包dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - stones[i]] + stones[i])
。
3.初始化
vector<vector<int>> dp(n + 1, vector<int>(target + 1, 0))
将所有的数字初始化为0(设置第一行)。
4.遍历顺序
从左往右,从上到下。
本题只是转换01背包问题较难,但是01背包问题的解法和上面的解法都是一样的。
最终一堆石头重量为dp[n - 1][target]
另一堆石头的重量为sum - dp[n - 1][target]
,所以最后的石头的重量为两者相减。又因为dp[n - 1][target]
的含义是从前n - 1个数字中选,重量不超过target的最大价值,所以 dp[n - 1][target] <= sum - dp[n - 1][target]
,所以最后石头的重量为sum - 2 * dp[n - 1][target]
。
(朴素动规)
class Solution {
public:
int lastStoneWeightII(vector<int>& stones) {
int n = stones.size();
int sum = 0;
for (int s : stones) sum += s;
int target = sum / 2;
vector<vector<int>> dp(n, vector<int>(target + 1, 0));
// 初始化第一行,将所有背包容量>=stones[0]的背包初始化为stones[0]
for (int i = stones[0]; i <= target; i ++) {
dp[0][i] = stones[0];
}
for (int i = 1; i < n; i ++) {
for (int j = 1; j <= target; j ++) {
dp[i][j] = dp[i - 1][j];
if (j >= stones[i]) {
dp[i][j] = max(dp[i][j], dp[i - 1][j - stones[i]] + stones[i]);
}
}
}
return sum - dp[n - 1][target] * 2;
}
};
(动规 一维数组空间优化)
class Solution {
public:
int lastStoneWeightII(vector<int>& stones) {
int n = stones.size();
int sum = 0;
for (int s : stones) sum += s;
int target = sum / 2;
vector<int> dp(target + 1, 0);
for (int i = 0; i < n; i ++) {
for (int j = target; j >= stones[i]; j --) {
dp[j] = max(dp[j], dp[j - stones[i]] + stones[i]);
}
}
return sum - dp[target] - dp[target];
}
};
目标和
暴搜思路:数组中的每一个数都有两种状态,一种是加上该数字,一种是减去该数字,所以只要利用递归暴搜这两种方式就可以了。
(回溯)
class Solution {
public:
int ans = 0;
void dfs(vector<int>& nums, int target, int sum, int index) {
if (index >= nums.size()) {
if (sum == target) ans ++;
return ;
}
dfs(nums, target, sum + nums[index], index + 1);// +当前数
dfs(nums, target, sum - nums[index], index + 1);// -当前数
}
int findTargetSumWays(vector<int>& nums, int target) {
dfs(nums, target, 0, 0);
return ans;
}
};
1.dp数组的含义
dp[i][j]
表示从前i个物品中选,总和等于j的方案数
2.递推公式
dp[i][j] = dp[i - 1][j]
总前i个物品中选的方案数一定包含从前i - 1个物品中选的方案数。if (j >= nums[i]) dp[i][j] += dp[i - 1][j - nums[i]]
如果可以选第i个物品nums[i],则dp[i][j]
再加上从前i - 1个物品中选的等于j - nums[i]的方案数。
3.初始化
在背包容量为0的时候,一定有1中方案。即dp[0][0] = 1
。当在第1个物品中选(即nums[0])如果target >= nums[0]的话,总和等于target的方案又可以加1,即dp[0][nums[0]] += 1
。
4.遍历顺序
从左往右,从上往下,如果是一维数组优化空间的话,需要在遍历容量的内循环的时候倒序遍历。
5.dp举例
(朴素动规)
01背包 ->目标和的思维过程
目标和 | 01背包 |
---|---|
每个数必须选,分为正数和负数 | 每一个数选与不选 |
约束条件:所有数的和恰好为(sum + target) / 2 | 约束条件:所有物品的重量不能超过背包的最大承重 |
目标求出所有的方案数,每一次状态转移的时候需要累加 | 目标求出所有物品的价值总和最大 |
背包问题的提问方式:试问在数组arr中,是否可以找到总和为target的数。(求的东西可以是方案数或者是否存在或者是最值)
class Solution {
public:
int findTargetSumWays(vector<int>& nums, int target) {
int n = nums.size();
int sum = 0;
for (int num : nums) sum += num;
if ((sum + target) % 2 != 0) return 0;
target = (sum + target) / 2;
vector<vector<int>> dp(n, vector<int>(target + 1, 0));
dp[0][0] = 1;// 前0个物品中选,组合成0的方法数(即什么都不选)
if (target >= nums[0]) dp[0][nums[0]] += 1;// 初始化第一行
for (int i = 1; i < n; i ++) {
for (int j = 0; j <= target; j ++) {
dp[i][j] += dp[i - 1][j];
if (j >= nums[i]) {
dp[i][j] += dp[i - 1][j - nums[i]];
}
}
}
return dp[n - 1][target];
}
};
(动规 一维数组空间优化)
class Solution {
public:
int findTargetSumWays(vector<int>& nums, int target) {
int n = nums.size();
int sum = 0;
for (int num : nums) sum += num;
if ((sum + target) % 2 != 0) return 0;
target = (sum + target) / 2;
vector<int> dp(target + 1, 0);
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];
}
};
一和零
这道题目最暴力的解法就是将所有的符合要求的组合全部都枚举一遍,最后算出集合数量最多的一个答案。
(回溯)
class Solution {
public:
int ans = 0;
vector<string> path;
void dfs(vector<string>& strs, int m, int n, int start) {
if (n < 0 || m < 0) return ;// 如果已经将1或0消耗完了就返回
if (ans < path.size()) {// 更新ans
ans = path.size();
}
for (int i = start; i < strs.size(); i ++) {
int t0 = 0, t1 = 0;
for (int j = 0; j < strs[i].size(); j ++) {// 计算0和1的个数
if (strs[i][j] == '0') t0 ++;
else t1 ++;
}
path.push_back(strs[i]);
dfs(strs, m - t0, n - t1, i + 1);
path.pop_back();
}
}
int findMaxForm(vector<string>& strs, int m, int n) {
dfs(strs, m, n, 0);
return ans;
}
};
背包问题的特点:当用背包问题求最值的时候就是在:用代价换取价值,代价可能是物品的重量也可能是数组中的数字等等,价值可能是物品的价值也可能是数组中数字也可能是数字的个数等等。
本题和01背包的区别:
一和零 | 01背包 |
---|---|
每一个字符串选或者不选 | 每一个数选与不选 |
约束条件:所有的字符串中0和1的个数不得超过最大的限制 | 约束条件:所有物品的重量不能超过背包的最大承重 |
目标:求出所有的字符串集合中字符串数量最多 | 目标:求出所有物品的价值总和最大 |
一和零这道题目是一个二维费用的背包问题,但是本质上还是一个01背包问题,因为每一个字符串还是只能选一次,唯一的区别就在于01背包的约束条件只有一个不能超过最大重量,但是本题是两个限制条件即不能超过0的最大数量和1的最大数量。这就可以想象成一个还是一个01背包的问题,只是这次这个背包不仅在不能超过背包的最大重量也不能超过这个背包的最大体积,因为多加了一个限制条件,所以是一个二维费用的背包问题。但是做法还是和01背包问题是一样的。
另外还有一点因为要求出符合要求的字符串的最大个数,所以01背包中的每一个物品的价值换成了字符串的个数+1。
1.dp数组的含义
dp[k][i][j]
表示在前k个字符串中选,0和1的个数最大数量不超过i和j的最大集合数为dp[k][i][j]
。
2.递推公式
d p [ i ] [ j ] = { d p [ k − 1 ] [ i ] [ j ] 不选strs[k]字符串 m a x ( d p [ k − 1 ] [ i ] [ j ] , d p [ i − 1 ] [ i − c n t 0 ] [ j − c n t 1 ] + 1 ) 选择strs[k]字符串 dp[i][j] = \begin{cases} dp[k - 1][i][j] & \text{不选strs[k]字符串}\\ max(dp[k - 1][i][j], dp[i - 1][i - cnt0][j - cnt1] + 1)& \text{选择strs[k]字符串} &\end{cases} dp[i][j]={dp[k−1][i][j]max(dp[k−1][i][j],dp[i−1][i−cnt0][j−cnt1]+1)不选strs[k]字符串选择strs[k]字符串
3.初始化
如果设置第一行不需要初始化,如果没有设置第一行,需要将第一个字符串中0和1的个数计算出来cnt0和cnt1,当i >= cnt0 && j >= cnt1
的时候,dp[0][i][j] = 1
4.遍历顺序
同01背包其中0和1的个数两个循环不分内外。
5举例dp数组
(朴素动规1)
class Solution {
public:
int findMaxForm(vector<string>& strs, int m, int n) {
int len = strs.size();
vector<vector<vector<int>>> dp(len,
vector<vector<int>>(m + 1, vector<int>(n + 1, 0)));
int t0 = 0, t1 = 0;// 计算第一行的0和1的个数
for (char& ch : strs[0]) {
if (ch == '0') t0 ++;
else t1 ++;
}
for (int i = 0; i <= m; i ++) {// 初始化第一行
for (int j = 0; j <= n; j ++) {
if (i >= t0 && j >= t1) dp[0][i][j] = 1;// 注意
}
}
for (int k = 1; k < strs.size(); k ++) {// 从第二行开始开始算0和1的个数
int cnt0 = 0, cnt1 = 0;
string str = strs[k];
for (char ch : str) {
if (ch == '0') cnt0 ++;
else cnt1 ++;
}
for (int i = 0; i <= m; i ++) {
for (int j = 0; j <= n; j ++) {
dp[k][i][j] = dp[k - 1][i][j];
if (i >= cnt0 && j >= cnt1)
dp[k][i][j] = max(dp[k - 1][i][j], dp[k - 1][i - cnt0][j - cnt1] + 1);
}
}
}
return dp[len - 1][m][n];
}
};
(朴素动规2)
class Solution {
public:
int findMaxForm(vector<string>& strs, int m, int n) {
int size = strs.size();
vector<vector<vector<int>>> dp(size + 1,
vector<vector<int>>(m + 1, vector<int>(n + 1, 0)));
for (int k = 1; k <= strs.size(); k ++) {
int cnt0 = 0, cnt1 = 0;
string str = strs[k - 1];
for (char ch : str) {
if (ch == '0') cnt0 ++;
else cnt1 ++;
}
for (int i = 0; i <= m; i ++) {
for (int j = 0; j <= n; j ++) {
dp[k][i][j] = dp[k - 1][i][j];
if (i >= cnt0 && j >= cnt1)
dp[k][i][j] = max(dp[k - 1][i][j], dp[k - 1][i - cnt0][j - cnt1] + 1);
}
}
}
return dp[size][m][n];
}
};
(动规 一维数组空间优化)
class Solution {
public:
int findMaxForm(vector<string>& strs, int m, int n) {
int size = strs.size();
vector<vector<int>> dp(m + 1, vector<int>(n + 1, 0));
for (string str : strs) {
int cnt0 = 0, cnt1 = 0;
for (char ch : str) {
if (ch == '0') cnt0 ++;
else cnt1 ++;
}
for (int i = m; i >= cnt0; i --) {
for (int j = n; j >= cnt1; j --) {
dp[i][j] = max(dp[i][j], dp[i - cnt0][j - cnt1] + 1);
}
}
}
return dp[m][n];
}
};
完全背包
题目描述:在N中重量和价值分别为wi和vi的物品,且每种物品都有无数件,从这些物品中挑选出总重量不超过W的物品的前提下,求出背包能够放得下的最大价值。
前后联系:在01背包的基础上,去掉了每一件物品只有一件的限制,即在每一件物品有无数多件,问在不超过背包重量的的情况下,背包的容量能装下的最大价值为多少?
一开始还是最暴力的解法,利用递归回溯,暴搜每一种符合要求的组合, 然后不断的更新答案。
(回溯)TLE
#include <iostream>
#include <algorithm>
using namespace std;
const int N = 1010;
int n, W;
int w[N], v[N];
int ans;
void dfs(int W, int val)
{
if (W < 0) return ;// W<0就返回
if (W >= 0) {// 更新答案
if (ans < val) ans = val;
}
for (int i = 0; i < n; i ++) {// 一直选择第i件物品,减去相应的重量,加上相应的价值
dfs(W - w[i], val + v[i]);
}
}
int main()
{
cin >> n >> W;
for (int i = 0; i < n; i ++) cin >> w[i] >> v[i];
dfs(W, 0);
cout << ans << endl;
return 0;
}
其实完全背包只是物品的数量上没有了限制,但是这一点可以用循环遍历所有的物品数量来达到目的。即for (int k = 0; k * w[i] <= j; j ++) {dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - k * w[i]] + k * v[i])}
这样就可以让每一个物品都能够使用尽量多的物品(包括自己本身)。
而如果这么看来,原来01背包问题其实就是完全背包的一个特例,完全背包是01背包的推广,当==k == 0
的时候对应着不选择当前的物品,即dp[i][j] = dp[i - 1][j]
==,当 (j >= w[i]) && k == 1
的时候,dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - w[i]] + v[i])
。
(素朴动规)TLE
#include <iostream>
#include <algorithm>
using namespace std;
const int N = 1010;
int n, W;
int w[N], v[N];
int dp[N][N];
int main()
{
cin >> n >> W;
for (int i = 0; i < n; i ++) cin >> w[i] >> v[i];
for (int k = 0; k * w[0] <= W; k ++) {// 初始化第一行
dp[0][k * w[0]] = k * v[0];
}
for (int i = 1; i < n; i ++) {
for (int j = 0; j <= W; j ++) {
for (int k = 0; k * w[i] <= j; k ++) {
dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - k * w[i]] + k * v[i]);
}
}
}
cout << dp[n - 1][W] << endl;
return 0;
}
但是由于有三层循环,所以时间复杂度还是太高了,最终还是因为超时而不能通过。
这是还是要看是否可以将状态转移公式通过一个等价变形使时间复杂度降下来。通过下面的公式推导发现dp[i][j - w[i]]
(不用管为什么是dp[i][j - w[i]]
这是总结下的规律)带入原推导公式可以是O(N)的时间复杂度直接可以通过O(1)来实现。
公式推导:
d p [ i ] [ j ] = m a x ( d p [ i − 1 ] [ j ] , d p [ i − 1 ] [ j − w [ i ] ] + v [ i ] , d p [ i − 1 ] [ j − 2 ∗ w [ i ] ] + 2 ∗ v [ i ] . . . d p [ i − 1 ] [ j − j ] + j ÷ w [ i ] ∗ v [ i ] ) d p [ i ] [ j − w [ i ] ] = m a x ( d p [ i − 1 ] [ j − w [ i ] ] , d p [ i − 1 ] [ j − 2 ∗ w [ i ] ] + 2 ∗ v [ i ] . . . d p [ i − 1 ] [ j − j ] + j ÷ w [ i ] ∗ v [ i ] ) dp[i][j] = max(dp[i-1][j], dp[i-1][j-w[i]]+v[i], dp[i-1][j-2*w[i]]+2*v[i] \qquad ... \qquad dp[i-1][j-j]+ j\div w[i]*v[i]) \\ dp[i][j-w[i]]=max(\qquad \qquad \ \ \ \ dp[i-1][j-w[i]], \qquad \ \ \ dp[i-1][j-2*w[i]]+2*v[i] \qquad ... \qquad dp[i-1][j-j]+j\div w[i]*v[i]) dp[i][j]=max(dp[i−1][j],dp[i−1][j−w[i]]+v[i],dp[i−1][j−2∗w[i]]+2∗v[i]...dp[i−1][j−j]+j÷w[i]∗v[i])dp[i][j−w[i]]=max( dp[i−1][j−w[i]], dp[i−1][j−2∗w[i]]+2∗v[i]...dp[i−1][j−j]+j÷w[i]∗v[i])
将dp[i][j - w[i]]
带入dp[i][j]
中得出
d p [ i ] [ j ] = m a x ( d [ i − 1 ] [ j ] , d p [ i ] [ j − w [ i ] ] + v [ i ] ) dp[i][j] = max(d[i-1][j], dp[i][j-w[i]]+v[i]) dp[i][j]=max(d[i−1][j],dp[i][j−w[i]]+v[i])
(素朴动规)
#include <iostream>
#include <algorithm>
using namespace std;
const int N = 1010;
int n, W;
int w[N], v[N];
int dp[N][N];
int main()
{
cin >> n >> W;
for (int i = 0; i < n; i ++) cin >> w[i] >> v[i];
for (int k = 0; k * w[0] <= W; k ++) {
dp[0][k * w[0]] = k * v[0];
}
for (int i = 1; i < n; i ++) {
for (int j = 0; j <= W; j ++) {
dp[i][j] = dp[i - 1][j];
if (j >= w[i]) {
dp[i][j] = max(dp[i - 1][j], dp[i][j - w[i]] + v[i]);
}
}
}
cout << dp[n - 1][W] << endl;
return 0;
}
当然完全背包也是可以用一维数组来空间优化的,但是要注意的是在内循环遍历物品的价值的时候和01背包不同,在前文已经解释过01背包为什么要逆序遍历,同样的方法因为原本的状态转移方程式是dp[i][j] = max(dp[i - 1][j], dp[i][j - w[i]] + v[i])
其中第二个是dp[i][j-w[i]]+v[i]
不是dp[i - 1][j - w[i]] + v[i]
所以要计算的本层的计算过的dp[i][j-w[i]]+v[i]
所以这里需要正序的遍历。
(动规 一维空间优化)
#include <iostream>
#include <algorithm>
using namespace std;
const int N = 1010;
int n, W;
int w[N], v[N];
int dp[N];
int main()
{
cin >> n >> W;
for (int i = 0; i < n; i ++) cin >> w[i] >> v[i];
for (int i = 0; i < n; i ++) {
for (int j = w[i]; j <= W; j ++) {
dp[j] = max(dp[j], dp[j - w[i]] + v[i]);
}
}
cout << dp[W] << endl;
return 0;
}
零钱兑换Ⅱ
每一个硬币都可以选或者不选,如果选择当前的硬币那么下一次还可以选相同的硬币。
(递归暴搜)TLE
class Solution {
public:
int dfs(vector<int>& coins, int amount, int index, int sum) {
if (sum == amount) return 1;
if (sum > amount || index >= coins.size()) return 0;
int ans = 0;
// 选择当前数,并且下一个继续可以选择当前数
ans += dfs(coins, amount, index, sum + coins[index]);
// 不选当前数,并且跳过当前数
ans += dfs(coins, amount, index + 1, sum);
return ans;
}
int change(int amount, vector<int>& coins) {
return dfs(coins, amount, 0, 0);
}
};
组合问题,不同硬币的组合,并且组合中可以出现重复的相同元素。
(回溯)TLE
class Solution {
public:
int ans = 0;
void dfs(vector<int>& coins, int amount, int start, int sum) {
if (sum > amount || start >= coins.size()) return ;
if (sum == amount) {
ans ++;
return ;
}
for (int i = start; i < coins.size(); i ++) {
dfs(coins, amount, i, sum + coins[i]);
}
}
int change(int amount, vector<int>& coins) {
dfs(coins, amount, 0, 0);
return ans;
}
};
可以用01背包的解法再加一层循环来枚举同一个硬币的个数。
(朴素动规)TLE
class Solution {
public:
int change(int amount, vector<int>& coins) {
int n = coins.size();
vector<vector<int>> dp(n + 1, vector<int>(amount + 1, 0));
dp[0][0] = 1;// 用0个硬币可以兑换处0元
for (int i = 1; i <= n; i ++) {
for (int j = 0; j <= amount; j ++) {
for (int k = 0; k * coins[i - 1] <= j; k ++) {// 枚举0~j/coins[i - 1]个数
dp[i][j] += dp[i - 1][j - k * coins[i - 1]];
}
}
}
return dp[n][amount];
}
};
因为硬币是无限数量的,所以这道题目是一个完全背包的问题,既然是完全背包的问题就可以利用完全背包的一维数组的空间优化和公式推导来优化空间和时间。
1.dp数组的含义
dp[j]
表示凑成j元的组合数有dp[j]种
2.递推公式
dp[j] += dp[j - coins[i]]
,即凑成j
元的方案数加上凑成j - coins[i]
的方案数。这是求方案数的常用递推公式。
3.初始化
dp[0] = 1
因为如果不选任何的硬币,就可以凑出0元。其他的情况还未知,所以都初始化为0即可。
4.遍历顺序(重点)
在01背包和完全背包的求解时,将物品放在外循环,物品价值放在内循环,和将物品放在内循环,物品价值放在外循环得到的答案都是一样的,因为纯正的完全背包求的是背包中最后的最大价值,这个和物品的先后组合的顺序是没有关系的,如[1, 1, 2]
和[2, 1, 1]
最后的价值都是4.
但是因为本题是硬币的组合,这就要求硬币的组合中没有重复元素的组合,如[1, 1, 2]
和[2, 1, 1]
就是不一样的组合,但是如果循环的顺序不同就会有不同的答案。
第一种:外循环coins,内循环amount。这种方式是一个物品一个物品枚举的,所以所有的组合中不会排列的情况。
第二种:外循环amount,内循环coins。背包中的每一个容量都是经过相同的元素计算过的,所有会包含相同元素排列的情况。这样求出的是排列数。
(动规 一维数组空间优化)
class Solution {
public:
int change(int amount, vector<int>& coins) {
int n = coins.size();
vector<int> dp(amount + 1, 0);
dp[0] = 1;
for (int i = 0; i < n; i ++) {// 先循环物品
for (int j = coins[i]; j <= amount; j ++){// 再循环背包
dp[j] += dp[j - coins[i]];
}
}
return dp[amount];
}
};
总结:当在求组合数的时候,使用物品外循环,容量内循环。当在求排列数的时候,使用容量内循环,物品外循环。
组合总和Ⅳ
(回溯)TLE
class Solution {
public:
int ans = 0;
void dfs(vector<int>& nums, int target, int sum) {
if (sum >= target) {
if (sum == target) ans ++;
return ;
}
for (int i = 0; i < nums.size(); i ++) {
dfs(nums, target, sum + nums[i]);
}
}
int combinationSum4(vector<int>& nums, int target) {
dfs(nums, target, 0);
return ans;
}
};
(记忆化搜索)
class Solution {
public:
int combinationSum4(vector<int>& nums, int target) {
unordered_map<int,int> hash;
return dfs(target,nums, hash);
}
int dfs(int target,vector<int>& nums, unordered_map<int, int>& hash){
if(target == 0){
return 1;
}
if(target < 0){
return 0;
}
// 记忆化搜索的精髓,将已经计算过的递归保存下来,后面可以直接使用接口
if(hash.find(target) != hash.end()){
return hash[target];
}
int ans = 0;
for(int i = 0; i < nums.size(); i ++) {
if (target - nums[i] >= 0)// 剪枝
ans += dfs(target - nums[i], nums, hash);
}
hash[target] = ans;// 记录下来递归过的结果
return ans;
}
};
注意这里只能用target
倒着减,然后减到等于0或者小于0退出递归,不可以再设置一个变量sum
来和target
比较来结束递归,因为记忆化搜索需要将计算过的递归结果记录下来,如果是用sum
来比较的话,target
没有变,所以hash[target] = ans
就会被一直覆盖掉,但是如果是用target
倒着减的话,target
就会一直在变,这样就可以保存来计算过的递归的结果了。
(动规)
用回溯递归因为时间复杂度太高了,所以一定会超时,但是因为本题只是要求求出所有的组合的个数数量,而不是将所有的组合都枚举出来,所以可以用动态规划之完全背包解决。
硬币是无限的,然后求得是组合的数量。这不就和刚刚的零钱兑换Ⅱ是一道题目嘛!只是零钱兑换Ⅱ不可以出现排列数,即组合中出现重复的数字排列。打好也就对应着那道题的递归顺序的问题,如果方案数组合数的话,就外循环物品,内循环容量。但是这道题目是求排列数的方案数,所以就外循环容量,内循环物品即可。
class Solution {
public:
int combinationSum4(vector<int>& nums, int target) {
int n = nums.size();
vector<int> dp(target + 1, 0);
dp[0] = 1;
for (int i = 0; i <= target; i ++) {// 先循环背包
for (int j = 0; j < n; j ++) {// 再循环物品
if (i >= nums[j] && dp[i] < INT_MAX - dp[i - nums[j]]) {// 有数据会超出int的范围,所以超过的数据就直接跳过
dp[i] += dp[i - nums[j]];
}
}
}
return dp[target];
}
};
上面的第9~10行也可以改成if (i >= nums[j]) dp[i] = ((long long)0 + dp[i] + dp[i - nums[j]]) % INT_MAX;
爬楼梯(进阶版)
爬楼梯以前是根据推导发现就是斐波那契数列,所以用总结出的规律来完成动态规划。但是学完了完全背包,发现爬楼梯和完全背包的解法一模一样。如果把每一次可以爬的楼梯数看成是物品的价值,而楼梯数看成背包的容量。这不就是一个背包问题吗?求出爬完n阶楼梯的方案数是多少,而且每次爬楼梯的阶数是不可以重复的,这不就是完全背包可以任意无限去物品吗!!!
1.dp数组的含义
dp[i]
表示爬i层台阶有dp[i]
种方法
2.递推公式
求方案数dp[i] += dp[i - j]
3.dp数组初始化
dp[0] = 1
即爬0层台阶有1中方法
4.遍历顺序
因为先爬一层台阶再爬两层台阶和先爬两层台阶再爬一层台阶,即{1, 2}
和{2, 1}
是不一样的,所以这里需要求出排列数,所以需要外循环台阶数(背包的容量),内循环每次爬的台阶数(物品)。
(动规 完全背包解法)
class Solution {
public:
int climbStairs(int n) {
vector<int> dp(n + 1, 0);
dp[0] = 1;// 爬0阶台阶有1中方法,即不用爬
for (int i = 1; i <= n; i ++) {// 先循环背包
for (int j = 1; j <= 2; j ++) {// 再循环物品
if (i >= j) dp[i] += dp[i - j];
}
}
return dp[n];
}
};
进阶版:如果每一次都可以爬m阶楼梯,需要爬n阶楼梯,问有多少种方法?
class Solution {
public:
int climbStairs(int n) {
vector<int> dp(n + 1, 0);
dp[0] = 1;// 爬0阶台阶有1中方法,即不用爬
for (int i = 1; i <= n; i ++) {
for (int j = 1; j <= m; j ++) {// 将2改成mj
if (i >= j) dp[i] += dp[i - j];
}
}
return dp[n];
}
};
零钱兑换
(深搜回溯)TLE
class Solution {
public:
int ans = INT_MAX;
void dfs(vector<int>& coins, int amount, int index) {
if (amount < 0) return ;
if (amount == 0) {
if (index < ans) ans = index;
return ;
}
for (int i = 0; i < coins.size(); i ++) {
dfs(coins, amount - coins[i], index + 1);
}
}
int coinChange(vector<int>& coins, int amount) {
dfs(coins, amount, 0);
return ans == INT_MAX ? -1 : ans;
}
};
将所有的递归过的结果都记录下来,这样就可以省去大量的时间。
(记忆化搜索)
class Solution {
public:
int dfs(vector<int>& coins, int amount, int index, unordered_map<int, int>& hash) {
if (amount < 0) return -1;
if (amount == 0) return 0;
if (hash.find(amount) != hash.end()) return hash[amount];
int ans = INT_MAX;
for (int i = 0; i < coins.size(); i ++) {
int t = dfs(coins, amount - coins[i], index + 1, hash);
if (t >= 0 && t < ans) {
ans = t + 1;
}
}
hash[amount] = (ans == INT_MAX ? -1 : ans);
return hash[amount];
}
int coinChange(vector<int>& coins, int amount) {
unordered_map<int, int> hash;
return dfs(coins, amount, 0, hash);
}
};
因为试求出最短的数量,所以就可以想到用广搜,因为广搜是一层一层搜,如果一旦搜到答案就会停下来,如果在配合上先排序之后,大数会先减,小数会被后减,这样的贪心思想,所以就可以在短时间内求出用最少的数组成目标和。
(广搜)
class Solution {
public:
int coinChange(vector<int>& coins, int amount) {
sort(coins.begin(), coins.end(), greater<int>());// 剪枝,首先减去大数再减去小数
queue<int> q;
q.push(amount);
unordered_set<int> vis;
vis.insert(amount);
int ans = 0;
while (!q.empty()) {
int size = q.size();
while (size --) {
int top = q.front();
q.pop();
if (top == 0) return ans;// 当amount已经减到0就返回广搜的层数
for (int i = 0; i < coins.size(); i ++) {
int num = top - coins[i];
if (!vis.count(num) && num >= 0) {
vis.insert(num);
q.push(num);
}
}
}
ans ++;
}
return -1;
}
};
无限数量的硬币,要求达到一个目标的总金额的最少步数,这就是一个完全背包的最值问题。所以就可以直接用完全背包问题的方法思考。
1.dp数组的含义
dp[i][j]
表示用前i个硬币可以组成目标金额j,所需的最少硬币数为dp[i][j]
。
或者是一维数组dp[i]
表示达到目标金额i所需的最少硬币数为dp[i]
。
2.递推公式
第一种情况:
当不用coins[i]
这个硬币的时候,所需最少的硬币数为前i - 1个硬币的组合,即dp[i][j] = dp[i - 1][j]
。
如果是一维数组的话就是dp[j] = dp[j]
。
第二种情况:
当恰好可以使用coins[i]
的时候,所需的硬币数是用前i - 1应硬币组合成j - coins[i]
的总金额数所需的硬币数加上coins[i]
这个硬币,即dp[i][j] = dp[i - 1][j - coins[i]] + 1
。
如果是一维数组的话就是dp[j] = dp[j - coins[i]] + 1
。
两者取一个最小值即为递推公式:dp[i][j] = min(dp[i - 1][j], dp[i - 1][j - coins[i]] + 1)
。dp[j] = dp[j - coins[i]] + 1
。
3.初始化
当组成的金额为0的时候,可以使用0个硬币,即dp[0][0] = 0``dp[0] = 1
,而其他的位置都应该初始化为正无穷,因为要求的是最小值,所以要初始为正无穷(INT_MAX
)
4.遍历顺序
因为是完全背所以在使用一维数组空间优化的时候,需要正序遍历总金额数。因为本题不涉及元素的顺序关系,而是最值问题,所以内外循环遍历硬币或者总金额都是可以的。
注意的是需要在递推公式前面加上一个特判dp[j - coins[i]) != INT_MAX
因为在C++中INT_MAX + 1
是会报错的,所以需要特判一下,或者可以将dp数组中的值都初始化为INT_MAX - 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, INT_MAX));
dp[0][0] = 0;
for (int i = 1; i <= n; i ++) {
for (int j = 0; j <= amount; j ++) {
dp[i][j] = dp[i - 1][j];
if (j >= coins[i - 1] && dp[i][j - coins[i - 1]] != INT_MAX) {
dp[i][j] = min(dp[i - 1][j], dp[i][j - coins[i - 1]] + 1);
}
}
}
return dp[n][amount] == INT_MAX ? -1 : dp[n][amount];
}
};
(动规 一维数组空间优化)
class Solution {
public:
int coinChange(vector<int>& coins, int amount) {
int n = coins.size();
vector<int> dp(amount + 1, INT_MAX);
dp[0] = 0;
for (int i = 0; i < n; i ++) {
for (int j = coins[i]; j <= amount; j ++) {
if (dp[j - coins[i]] != INT_MAX) dp[j] = min(dp[j], dp[j - coins[i]] + 1);
}
}
return dp[amount] == INT_MAX ? -1 : dp[amount];
}
};
完全平方数
(广搜)
class Solution {
public:
int numSquares(int n) {
queue<int> q;
unordered_set<int> vis;
q.push(0);
vis.insert(0);
int ans = 0;
while(!q.empty()) {
int size = q.size();// 一层的结点个数
while (size --) {
int top = q.front();
q.pop();
if (top == n) return ans;// 如果找到目标和就返回当前的层数
for (int i = 1; i <= n; i ++) {// 遍历多叉树
int val = top + i * i;
if (val > n) break;// 剪枝,val已经超出目标
if (vis.find(val) == vis.end()) {// 剪枝,将重复的元素跳过
q.push(val);
vis.insert(val);
}
}
}
ans ++;
}
return 0;
}
};
刚刚讲完零钱兑换,即用最少的硬币组成目标的金额,而且硬币的无限取用的。本题其实也大差不差,即用最少的完全平方和醉成目标数,而且完全平方数可以无限取用。所以第一种思路就出来了,如果将104之内的所有完全平方数全部保存起来当做硬币使用,就和零钱兑换这题一样了。
(动规 完全背包解法)
class Solution {
public:
int numSquares(int n) {
vector<int> dp(n + 1, INT_MAX);
int nums[100] = {0};
for (int i = 1; i <= 100; i ++) {// 用nums数组保存完全平方数
nums[i - 1] = i * i;
}
dp[0] = 0;// 凑成0需要0个完全平方数
for (int i = 1; i <= n; i ++) {// 先循环背包
int index = upper_bound(nums, nums + 100, i) - nums;// 找到完全平方数的范围
for (int j = 0; j < index; j ++) {// 再循环物品,根据背包的容量来限定物品的范围
if (i >= nums[j] && dp[i - nums[j]] != INT_MAX) {
dp[i] = min(dp[i], dp[i - nums[j]] + 1);
}
}
}
return dp[n];
}
};
当然也可以不用将所有的完全平方数全部保存,可以用到哪些平方数就算出哪些平方数。下面是两种遍历顺序,来补充零钱兑换那题遍历顺序不影响本题的求解。
(动规1)
class Solution {
public:
int numSquares(int n) {
vector<int> dp(n + 1, INT_MAX);
dp[0] = 0;// 凑成0需要0个完全平方数
for (int i = 1; i <= n; i ++) {// 先循环背包
for (int j = 1; j * j <= i; j ++) {// 再循环物品
// 因为dp[i-j*j]已经被算过了,所以就不可能是INT_MAX,所以就没有必要特判了
dp[i] = min(dp[i], dp[i - j * j] + 1);
}
}
return dp[n];
}
};
(动规2)
class Solution {
public:
int numSquares(int n) {
vector<int> dp(n + 1, INT_MAX);
dp[0] = 0;// 凑成0需要0个完全平方数
for (int i = 1; i * i <= n; i ++) {// 先循环物品
for (int j = i * i; j <= n; j ++) {// 再循环背包
dp[j] = min(dp[j], dp[j - i * i] + 1);
}
}
return dp[n];
}
};
单词拆分
(回溯)TLE
class Solution {
public:
bool dfs(string& s, vector<string>& wordDict, string& path) {
if (path.size() >= s.size()) {
if (path == s) return true;
return false;
}
for (int i = 0; i < wordDict.size(); i ++) {
path += wordDict[i];
if (dfs(s, wordDict, path)) return true;
path = path.substr(0, path.rfind(wordDict[i]));
}
return false;
}
bool wordBreak(string s, vector<string>& wordDict) {
string path;
return dfs(s, wordDict, path);
}
};
(动规 一维数组优化)
因为是用字典中的单词组成一个目标字符串,所以还是一个背包问题,而且因为可以无限制的取用字典中的单词,所以这还是一个完全背包问题。但是因为是字符串的选取,所以比较特殊,因为不可以像组成目标和那样直接用一个维度dp[]..[]
这样来表示,因为字符串的表示比较特殊,所以这题比较难想。
但是因为都是背包问题的存在性问题,所以肯定还是有共性的。组成目标和的递推公式是dp[j] = dp[j] || dp[j - nums[i]]
其实这个递推公式中隐含了很多的信息。其实dp[j]
是否可以组成目标和取决于 dp[j - nums[i]]
的原因是j - nums[i]
如果可以被组成,然后再加上nums[i]
就可以知道dp[i]
是可以被组成的,其中很重要的一点是nums[i]
默认已经是存在的了,所以可以省略掉,dp[j]
也同样是的,只不过是j
自己本身和0
可以组成目标和。
综上可知,如果想要在字典中找单词组成字符串,就要说明前j
个字符可以组成字符串的前半段,而后[j, i]
中的字符也是在字典中的就可以了。因为字符串不能相减,所以dp[j - string]
这个过程就必须被分成两步来实现,即dp[j] == true
并且string
是在wordDict
中的。
其中第二个循环是循环物品,也就是字符串的前i
个字符。在前i
个字符中找是否出现单词出现在wordDict
中。
class Solution {
public:
bool wordBreak(string s, vector<string>& wordDict) {
unordered_set<string> wordSet(wordDict.begin(), wordDict.end());
vector<bool> dp(s.size() + 1, false);
dp[0] = true;
for (int i = 1; i <= s.size(); i ++) {// 先循环背包
for (int j = 0; j < i; j ++) {// 再循环物品
string word = s.substr(j, i - j);
// 如果前j个字符可以匹配,并且[j, i]也可以匹配上,则前i个字符也可以匹配上
if (dp[j] && wordSet.find(word) != wordSet.end()) {
dp[i] = true;
}
}
}
return dp[s.size()];
}
};
总结背包问题
背包问题分类:
常见的背包类型主要有以下几种:
1、0/1背包问题:每个元素最多选取一次
2、完全背包问题:每个元素可以重复选择
而每个背包问题要求的也是不同的,按照所求问题分类,又可以分为以下几种:
1、最值问题:要求最大值/最小值 -》dp[j] = max(dp[j], dp[j - nums[i]] + 1)
或者dp[j] = max(dp[j], dp[j - nums[i]] + nums[i])
2、存在问题:是否存在…………,满足…………-》dp[j] = dp[j] || dp[j - nums[i]]
3、组合问题:求所有满足……的排列组合-》dp[j] += dp[j - nums[i]]
分类解题模板
背包问题大体的解题模板是两层循环,分别遍历物品nums和背包容量target,然后写转移方程,
根据背包的分类我们确定物品和容量遍历的先后顺序,根据问题的分类我们确定状态转移方程的写法
首先是背包分类的模板:
1、0/1背包:外循环nums,内循环target,target倒序且target>=nums[i];
2、完全背包:外循环nums,内循环target,target正序且target>=nums[i];
然后是问题分类的模板:
1、最值问题: dp[i] = max/min(dp[i], dp[i-nums]+1)或dp[i] = max/min(dp[i], dp[i-num]+nums);
2、存在问题(bool):dp[i]=dp[i]||dp[i-num];
3、组合问题:dp[i]+=dp[i-num];