总言
主要内容:编程题举例,熟悉理解动态规划类题型(背包问题、似包非包、卡特兰数)。
文章目录
9、01背包问题
9.0、概述
背包问题(Knapsack Problem)是一种经典的组合 优化NP完全问题,它考验着我们在给定约束条件下 做出最优选择的能力。
背包问题可以描述为: 给定一组物品,每种物品都有自己的重量和价格(或价值)。在限定的总重量(容量)内,我们如何选择物品,才能使得所选物品的总价格最高。
根据物品的数量和特性,背包问题可以分为以下几类:
- 01背包问题:每个物品只有一个,即每个物品要么被选择,要么不被选择。
- 完全背包问题:每个物品有无限多个,即每种物品可以被选择任意多次。
- 多重背包问题:每件物品最多有si个,即每种物品有一个固定的数量限制。
- 混合背包问题:物品可能同时具有上述三种情况中的任意一种或多种。
- 分组背包问题:物品被分为n组,每组物品里有若干个,但每组里最多只能选择一个物品。
- ......
根据背包是否必须装满,背包问题还可以分为两类:
- 不一定装满背包:背包的容量不一定需要被完全利用。
- 背包一定装满:背包的容量必须被完全利用,不能留下任何空闲空间。
根据限定条件的个数,背包问题还可以进一步分类:
- 限定条件只有一个:如只有体积(容量)限制,这是最常见的背包问题。
- 限定条件有两个:如同时考虑体积和重量(或其他两个维度)的限制,这类问题通常被称为二维费用背包问题。
为了求解背包问题,我们可以采用多种优化方案:
1)、空间优化(滚动数组):可以利用滚动数组来减少空间复杂度。
2)、单调队列优化:在某些情况下,我们可以利用单调队列来优化动态规划的状态转移过程。
3)、贪心优化:虽然背包问题通常是NP完全问题,但在某些特殊情况下,贪心策略可以得到最优解。
根据不同的求解目标,背包问题还可以分为以下几类:
1)、输出方案:要求输出具体的选择方案,即哪些物品被选择了。
2)、求方案总数:要求计算满足条件的选择方案有多少种。
3)、最优方案:要求找到总价值最高的选择方案。
4)、方案可行性:判断是否存在满足条件的选择方案。
9.1、01背包(medium)(模板!重点!)
题源:链接。
9.1.1、题解
9.1.1.1、思路分析(第一问:背包不一定装满)
1)、思路分析
1、确定状态表示:
根据题目,背包问题本质上还是一个线性dp (是否选择某个物品),因此,容易想到的状态表示是:用dp[i]
来表示从前i
个物品中挑选,在所有选法中,能挑选出来的最大价值。然而,由于背包容量限制,这种表示方法忽略了挑选出的物品的总体积,无法准确地进行状态转移。
因此,我们需要引入此时背包的容量信息。修改状态表示为dp[i][j]
,其中i
表示前i个物品 ,j
表示当前背包的总体积 不超过j
。这样,dp[i][j]
就表示 ,从前i
个物品中挑选,总体积不超过j
时,所有选法中,能挑选出来的最大价值。
注意理解这里的"不超过"的含义 ,第一问中背包不一定装满,因此选出的物品的体积可以≤j
。
2、推导状态转移方程: 线性dp状态转移方程的分析,一般都是根据"最后一步"的状况分情况讨论。
本题中,根据是否选择第 i
个物品,来分情况讨论:
1)、如果我们不选择第 i
个物品 。此时需要在前 i-1
个物品中挑选,保证总体积不超过 j
,获得此时的最大价值。这种情况下,最大价值就是 dp[i-1][j]
。
2)、如果我们选择第 i
个物品 。由于第 i
个物品的体积为 v[i]
,在前 i-1
个物品中进行挑选时,要保证总体积不超过 j - v[i]
,获得这种情况下的最大价值,然后再加上当前第 i
个物品的价值w[i]
即可。因此有 dp[i-1][j - v[i]] + w[i]
。
需要注意:这里j - v[i]
可能为负数 ,也就是说,第2)种情况是否存在,有条件限制。
既然有两种选法,根据dp[i][j]的定义,需要选其中价值最大的情况,则需要对两种情况求一个max。得到背包问题的状态转移方程如下:
cpp
dp[i][j] = dp[i-1][j];// 不选择 i 处物品
if(j >= v[i])// 选择 i 处物品,有前提条件
dp[i][j] = max(dp[i][j], dp[i-1][j- v[i]] + w[i]);
3、初始化: 为了方便初始化,引入虚拟行列 。这里,由于是ACM模式,输入值需要我们自个创建变量存储,那么直接一步到位,让物品从 i = 1
下标开始存储。 如此便省去了下标映射问题,只需要注意虚拟行列的填值(填表从 i = 1,j =1开始)。
PS:这里,也可以选择只引入虚拟行。那么填表时,从 i =1, j =0开始填表(两种写法,思路一致,细节有些差异而已)。
4、填表顺序: 根据状态方程,填表顺序仅需满足从上往下即可(这里左右不做要求)。
5、返回值: 根据状态表示,我们应该返回dp[n][V]
的值,表示从 n 个物品中挑选,总体积不超过V时的最大价值。
9.1.1.2、思路分析(第二问:背包一定装满)
对于背包恰好装满的情况,可以根据第一问中状态表示和状态转移方程进行微调。需要注意,存在凑不出体积为 j 的情况,因此,这里我们把不合法的状态设置为-1
。
1、确定状态表示: dp[i][j]
,表示从前i
个物品中挑选,总体积恰好为j
时,所能获得的最大价值。
2、推导状态转移方程: 同样根据是否选择 i 位置的物品,分情况讨论:
1)、不选择第i
个物品: 此时,dp[i][j] = dp[i-1][j]
,即从前i-1
个物品中挑选,且总体积恰好为j
时的最大价值。
①、注意这里要求总体积恰好为 j ,存在选不出的情况 。按理来说应该做一下判断:if( dp[i-1][j] != -1)
,但直接赋值含义也是正确的。在选不出来是,会把dp[i][j]
赋值为 -1
,这是可以的。因为前 i-1个物品选不出总体积恰好为 j 的情况 。在当前这种条件下(前 i 个物品,但不选择 i 处物品),自然还是保持选不出来的情况 ,那么dp[i][j]=-1
是合理的。
2)、选择第i
个物品: 此时,dp[i][j] = dp[i-1][j - v[i]] + w[i]
。但这种情况有前提条件:
①、首先 j - v[i] >= 0
(也就是 j >= v[i]
)。如果 j < v[i]
,说明不能选择第i
个物品,因为背包无法容纳它。
②、与1)①同理,即使j - v[i] >= 0
坐标合法,仍旧存在dp[i-1][j - v[i]] = -1
选不出来的情况。但相比于1)①,这里一定要显示地判断一下:dp[i-1][j - v[i]] != -1
。这是因为状态转移方程的要求,dp[i][j] = max(dp[i-1][j], dp[i-1][j - v[i]] + w[i])
,如果此处不做判断,在求 max 时,有可能因为dp[i-1][j - v[i]]
加上了 w[i])
后,导致结果为正数被选中,从而获得一个错误的结果。
cpp
dp[i][j] = dp[i-1][j];// 不选择 i 处物品
if(j >= v[i] && dp[i-1][j-v[i]] != -1)// 选择 i 处物品,需要注意条件
dp[i][j] = max(dp[i][j], dp[i-1][j-v[i]] + w[i]);
理解这里的状态方程,才能理解下面第二问中的初始化,为什么填入-1,就能和第一问区分开。
3、初始化:
4、填表顺序: 根据状态方程,填表顺序仅需满足从上往下即可(这里左右不做要求)。
5、返回值: 根据状态表示,我们应该返回dp[n][V]
的值,表示从 n 个物品中挑选,总体积正好为 V 时的最大价值。注意,存在凑不出体积为 V的情况,最后返回时需要判断一下。
9.1.1.3、题解(未优化时)
根据上述分析,代码如下:
cpp
#include <iostream>
#include <cstring>
using namespace std;
// 为什么使用全局变量?全局变量默认初始化为0,方便。
// 不会浪费空间吗?编程题,主要是解题,一星半点的空间可忽略。(如果是工程项目中,需要考虑)
const int N = 1010;// 题目给定数值也不大,直接开辟了。
int n;// 物品数列(i)
int V;// 背包体积(j)
int v[N],w[N];// 每行的物品体积、价值
int dp[N][N];// dp表
// 这些都可以写一行解决,这里是为了标注注释。
int main()
{
// 1、根据题目,获取输入信息
cin >> n >> V;//第一行输入:两个整数n和V,表示物品个数和背包体积。
for(int i = 1; i <= n; ++i)// 接下来n行,每行两个数,表示第i个物品的体积和价值。
cin >> v[i] >> w[i];
// 2、第一问:背包不一定装满
// 2.1、建表并初始化(已完成)
// 2.2、填表:从上往下
for(int i = 1; i <= n; ++i)
{
for(int j = 1; j <= V; ++j)
{
dp[i][j] = dp[i-1][j];// 不选择 i 处物品
if(j >= v[i])// 选择 i 处物品,需要注意条件
dp[i][j] = max(dp[i][j], dp[i-1][j- v[i]] + w[i]);
}
}
// 2.3、输出
cout << dp[n][V] << endl;
// 3、第二问:背包一定装满
// 3.1、建表并初始化
memset(dp,0,sizeof dp);// 清空一下dp表,进行复用
for(int j = 1; j <= V; ++j)
dp[0][j] = -1;
// 3.2、填表:
for(int i = 1; i <= n; ++i)
{
for(int j = 1; j <= V; ++j)
{
dp[i][j] = dp[i-1][j];// 不选择 i 处物品
if(j >= v[i] && dp[i-1][j-v[i]] != -1)// 选择 i 处物品,需要注意条件
dp[i][j] = max(dp[i][j], dp[i-1][j-v[i]] + w[i]);
}
}
// 3.3、输出:存在无解情况,需要判断
cout << (dp[n][V] == -1 ? 0 : dp[n][V]) << endl;
return 0;
}
9.1.2、引入优化(滚动数组)
利用滚动数组做空间优化,一维dp表中的滚动方式之前章节介绍过,这里来看二维dp表如何滚动。
dp[i][j]
表示考虑前i
个物品且背包容量为j时的最大价值。注意到在更新dp[i][j]
时,我们仅依赖于dp[i-1][...]
的值,即前一行(或前一个阶段)的状态。这意味着我们只需要在更新当前行的每个元素时,能够访问到前一行的对应元素即可 。
1)、原始优化策略
以下是最原始的滚动数组使用策略:
2)、改进版
实际上,我们可以直接只用一个数组做到滚动。
总结一下:
1、利用滚动数组做空间上的优化
2、直接在原始的代码上稍加修改:
①、删除所有的横坐标
②、修改纵坐标 j 的遍历顺序
代码如下:
cpp
#include <iostream>
#include <cstring>
using namespace std;
const int N = 1010;// 题目给定数值也不大,直接开辟了。
int n;// 物品数列(i)
int V;// 背包体积(j)
int v[N], w[N]; // 每行的物品体积、价值
int dp[N];// dp表:优化后,只需要一维dp表即可
int main() {
// 1、根据题目,获取输入信息
cin >> n >> V;//第一行输入:两个整数n和V,表示物品个数和背包体积。
for (int i = 1; i <= n; ++i) // 接下来n行,每行两个数,表示第i个物品的体积和价值。
cin >> v[i] >> w[i];
// 2、第一问:背包不一定装满
// 2.1、建表并初始化(已完成)
// 2.2、填表:
for (int i = 1; i <= n; ++i) //从上往下填每一行
{
for (int j = V; j >= v[i]; --j) //加入优化后,每行需要从右往左
{
dp[j] = max(dp[j], dp[j - v[i]] + w[i]);
}
}
// 2.3、输出
cout << dp[V] << endl;
// 3、第二问:背包一定装满
// 3.1、建表并初始化
memset(dp, 0, sizeof dp); //清空一下dp表,进行复用
for(int j = 1; j <= V; j++) dp[j] = -1;
// 3.2、填表:
for (int i = 1; i <= n; ++i) {
for (int j = V; j >= v[i]; --j) {
if (dp[j - v[i]] != -1)
dp[j] = max(dp[j], dp[j - v[i]] + w[i]);
}
}
// 3.3、输出:存在无解情况,需要判断
cout << (dp[V] == -1 ? 0 : dp[V]) << endl;
return 0;
}
注意事项:
1、模板题里面的分析思路 ,可以用到很多题里面(我们要学的是这种思路过程 ,并非指模板题可以原模原样照搬不动)。
2、不要去强行去记忆解释优化后的状态表示以及状态转移方程。
9.2、分割等和子集(medium)
题源:链接。
9.2.1、题解
1)、思路分析
分析此题, 如果数组能够被分成两个相同元素之和相同的子集,那么原数组有以下几个特征:
a. 总和为偶数: 数组 nums 的所有元素之和必须是一个偶数,因为两个子集的和要相等,所以总和必须是偶数才能被平均分配 。
b. 最大元素限制: 数组中的最大元素应该小于或等于所有元素总和的一半(这个条件不是严格必需的,但可以帮助我们快速排除一些不可能的情况,如果最大元素已经超过了总和的一半,那么显然无法分割成两个和相等的子集)。
因此,问题就转化为了在数组 nums 中选择一些元素 (每个元素只有一次选择),使得这些元素的和恰好等于数组总和的一半。
往我们熟悉的题型转换,这里很容易就想到01背包问题。其中,数组内的元素就是物品重量,总和就是背包容量。
cpp
a. 数组中的元素只能选择一次;
b. 每个元素⾯临被选择或者不被选择的处境;
c. 选出来的元素总和要等于所有元素总和的一半。(我们要判断的就是,这种背包装满的情况是否存在)
1、确定状态表示: 根据上述分析,dp[i][j]
表示,在前 i
个元素中选择一些数,是否存在一种选法,使得这些数的和恰好为 j
。
换句话说,这是一个bool类型的dp表,dp表里只需要判断true
或false
即可,而非像9.1中背包问题一样需要存储判断最大价值。
2、推导状态转移方程: 老规矩,根据最后一个元素,分情况讨论。(本题中,指是否选择最后一个元素)
1)、如果不选择最后一个元素 nums[i]
。我们就需要在前 i-1
个元素中选数,看看是否能凑出元素和 j
的情况。此时,状态转移方程为 dp[i][j] = dp[i-1][j]
。
2)、如果选择了最后一个元素 nums[i]
。此时需要在前 i-1
个元素中选数,看看是否能凑出元素和为 j - nums[i]
的情况。注意,这种选法要成立,则要保证 j >= nums[i]
,因为背包的容量(即目标和)不能小于我们要放入的物品的重量(即 nums[i])。此时,状态转移方程为 dp[i][j] = dp[i-1][j - nums[i]]
。
综合上述两种情况,只要满足其中一种情况,dp[i][j]
的状态就是 true
,表示存在一种选法使得前 i
个元素的和恰好为 j
。如果两种情况都不满足,则 dp[i][j]
的状态为 false
。
cpp
dp[i][j] = dp[i - 1][j];
if(nums[i - 1] <= j)
dp[i][j] = dp[i][j] || dp[i - 1][j - nums[i]];
3、初始化: 这里,根据状态方程,为了防止填表时下标越界,可以引入虚拟行列/虚拟节点。需要注意两个事项:
①、下标映射关系(填表时注意)
②、虚拟节点填值要保证后续状态转移方程正确。
4、填表顺序: 根据状态方程,填表满足从上往下即可(左右不做要求)
5、返回值: 根据题目,返回dp[n][sum/2]
的值。
2)、题解
未优化版本:
cpp
class Solution {
public:
bool canPartition(vector<int>& nums) {
int sum = 0;
for(auto n : nums) sum += n;
if(sum % 2) return false;// 奇数,无法平分成两个集合
// 1、建表
int m = nums.size();// dp表的i值
int aim = sum /2;// dp表的j值
vector<vector<bool>> dp(m+1,vector<bool>(aim+1, false));
// 2、初始化:dp[i][0]第一列
for(int i = 0; i <= m; ++i)
dp[i][0] = true;
// 3、填表:从上往下
for(int i = 1; i <= m; ++i)
{
for(int j = 1; j <= aim; ++j)
{
dp[i][j] = dp[i-1][j];// 不选 i 值
if(j >= nums[i-1]) // 选 i 值:注意这里的下标映射
dp[i][j] = dp[i][j] || dp[i-1][j - nums[i-1]];// 满足其中一种情况即可(因此使用或等)
}
}
// 4、返回
return dp[m][aim];
}
};
滚动数组优化版:
cpp
class Solution {
public:
bool canPartition(vector<int>& nums) {
int sum = 0;
for(auto n : nums) sum += n;
if(sum % 2) return false;// 奇数,无法平分成两个集合
// 1、建表
int m = nums.size();// i 值
int aim = sum /2;// j值
vector<bool> dp(aim+1, false);
// 2、初始化
dp[0] = true;
// 3、填表:从上往下,从右往左
for(int i = 1; i <= m; ++i)
{
for(int j = aim; j >= nums[i-1]; --j)//注意这里的下标映射
dp[j] = dp[j] || dp[j - nums[i-1]];// 满足其中一种情况即可(因此使用或等)
}
// 4、返回
return dp[aim];
}
};
9.3、目标和(medium)
题源:链接。
9.2.1、递归(DFS回溯剪枝)
相关链接。
9.2.2、动态规划
9.2.2.1、解法一
1)、思路分析
分析此题,无非是将非负整数数组 nums
分成两个集合(设这两个集合元素和分别为a
、b
),使得两个集合元素和的差为 target
。
如此,有:
cpp
a - b = target (1)
a + b = sum (2)
将(1)、(2)式合并得, 2a = (target + sum)
a = (target + sum) / 2
设 aim = (target + sum) / 2
,那么,问题就转换成了,从非负整数数组 nums 中选数,使得这些元素和为 aim ,一共有多少种选法。有没有觉得很熟悉?这与 "9.2、分割等和子集" 的分析思路一致。
这里有几个关键的边界条件需要考虑: 其中,①②是使用这种动态规划必须考虑的边界情况,③是基于整个问题分析后的特殊情况。
①、aim
必须非负: 因为 aim
,也就是上述的集合a,代表的是我们想要找到的和(即子集的和)。根据题目给定的元素值,它显然不能是负数,如果我们计算得负数,说明是 target为负数且其绝对值小于sum和。
②、sum + target
必须为偶数: 这是因为我们需要找到一个整数 a, 使得 2a = sum + target。如果 sum + target 是奇数,那么就不存在这样的整数 a。也就不存在这样的选法。
③、target
的绝对值不能超过 sum
: ①中只考虑了这里的其中一种情况,实则我们可以对两种情况都做处理。因为 |target| > sum
,那么不可能存在两个不相交的子集,它们的差正好是 target(除非允许子集为空集,但在本题中,sum.length
至少为1)。
1、确定状态表示: 根据上述,dp[i][j]
表示,在数组的前 i
个元素中选数,使得元素和正好为 j
时,一共存在多少种选法。
2、推导状态转移方程: 根据最后一个位置的元素,分情况讨论。(本题指,是否选择最后一个元素)
1)、如果不选择 nums[i]
,那么就需要从前 i-1
个元素元素中选数,使得选出的元素和恰好为 j
。因此,dp[i][j]
至少应该等于 dp[i-1][j]
。
2)、如果选择了 nums[i]
,那么在前 i-1
个元素中选数时,只需要使得选出的元素和恰好为 j - nums[i-1]
即可。此使有 dp[i][j] =dp[i-1][j - nums[i]]
。(这里需要注意两点 :①这种选法要成立,需要保证 j >= nums[i]
。②总的方法种类,不会因为选了nums[i]
就发生变化,需要理解这一点。
综上所述,两种情况如果存在的话,应该要累加在一起。因此,状态转移方程为:
cpp
dp[i][j] = dp[i - 1][j]
if(nums[i - 1] <= j)
dp[i][j] = dp[i][j] += dp[i - 1][j - nums[i - 1]]
3、初始化: 引入虚拟行里,需要注意这里初始化的细节,注意学习理解为什么列可以不做处理。
4、填表顺序: 根据状态方程,这种不做优化处理时,满足从上往下填表即可。
5、返回值: 根据状态表示,返回dp[m][aim]。这里,m 指nums数组的元素个数,aim 指最初我们推导的那一个式子,(target + sum) / 2,也就是我们要凑出的目标和。
2)、题解
不做优化时的写法如下:
cpp
class Solution {
public:
int findTargetSumWays(vector<int>& nums, int target) {
int m = nums.size();// i下标
int sum = 0;
for(auto n :nums) sum +=n;
if(sum < abs(target)) return 0;// 处理边界条件1:target目标值过大或过小
int aim = (sum + target) / 2;// j 下标
if(aim < 0 || (sum + target) % 2 ) return 0;// 处理边界条件2
// 1、创建dp表并初始化
vector<vector<int>> dp(m+1, vector<int>(aim+1, 0));
dp[0][0] = 1;// 首列可以不单独拎出初始化,用状态转移方程获得
// 2、填表
for(int i = 1; i <= m; ++i)
{
for(int j = 0;j <= aim; ++j)// 因为我们没有对j=0的情况初始化,因此j=0也需要使用状态转移方程填表获得
{
dp[i][j] += dp[i-1][j];
if(j >= nums[i-1])// 注意下标映射关系
dp[i][j] += dp[i-1][j - nums[i-1]];
}
}
// 3、返回
return dp[m][aim];
}
};
使用滚动数组做优化的写法如下: 在原先的代码基础上做修改即可得到。
①、删掉第一维;
②、修改第二层循环的遍历顺序:从右往左。
cpp
class Solution {
public:
int findTargetSumWays(vector<int>& nums, int target) {
int m = nums.size();
int sum = 0;
for(auto n :nums) sum += n;
if(sum < abs(target)) return 0;// 处理边界条件1:target目标值过大或过小
int aim = (sum + target) / 2;
if(aim < 0 || (sum + target) % 2 ) return 0;// 处理边界条件2
// 1、创建dp表并初始化
vector<int> dp(aim+1, 0);
dp[0] = 1;
// 2、填表
for(int i = 1; i <= m; ++i)
{
for(int j = aim;j >= nums[i-1]; --j)// 优化后,填表需要满足从右往左。
dp[j] += dp[j - nums[i-1]];// 注意下标映射关系
}
// 3、返回
return dp[aim];
}
};
9.2.2.2、解法二
1)、思路分析
上述解法是通过数学公式转化,使得我们只用考虑其中一个子集a即可,以下是另一种思考方法,但相对来说注意细节较多。
1、确定状态表示: 定义一个二维数组 dp,其中 dp[i][j]
表示在前 i
个元素中选数,经过系列操作(每个元素前可以加 +
或 -
),使得它们的和等于 j
时,存在的选法 。
需要注意,dp[i][j]
数组下标是从0
开始的。给定的nums
数组为非负数,其在进行+
或-
操作时,总和可以在{-maxSum, +maxSum}
之间。这就表明,代表元素和下标 j
有可能为负数,但负数是不能作为dp表数组下标的,这需要我们进行下标映射 。将所有可能的和映射到一个非负整数范围内,通常是通过加上一个足够大的数(比如数组元素绝对值之和 maxSum)来实现的。
也就是说,dp[i][j]
的大小为(n+1) x (2*maxSum + 1)
cpp
n+1 :给定的nums数组一共有 n 个元素。
2*maxSum + 1 : 需要处理从 -maxSum 到 maxSum 的所有和,并映射到非负整数索引上。-maxSum、0、maxSum,注意这里的列数。
2、推导状态转移方程: 仍旧以最后一个元素分情况讨论。对 i
位置的元素 nums[i]
,可以选择 +nums[i]
或 -nums[i]
。
dp[i][j]是用整数来表示有多少种方式可以达到这个和。因此,状态方程应该写成:
cpp
dp[i][j + maxSum] += dp[i-1][j+ maxSum - nums[i-1] ] // 选择+nums[i-1]
dp[i][j + maxSum] += dp[i-1][j+ maxSum + nums[i-1] ] // 选择-nums[i-1]
// PS:可以用一个变量表示 j 映射后的下标,如此就方便了上述简写:
int j_idx = j + maxSum
3、初始化: 需要初始化dp[0][0] = 1,表示没有元素,元素和为 0,有且仅有一种方式(即不选择任何元素)。经过下标映射后为,dp[0][maxSum] = 1。
对其它dp[0][j]:不存在这种选法。
对列:无需引入虚拟列,因为状态转移方程做了越界判断。
4、填表顺序: 从上往下
5、返回值: 返回值是 dp[n][target + maxSum]
,其中 n 是数组的长度,target 是目标值,maxSum 是数组中所有元素绝对值之和(用于偏移和)。
2)、题解
cpp
class Solution {
public:
int findTargetSumWays(vector<int>& nums, int target) {
int n = nums.size(); // 数组 nums 的长度
// 计算数组中所有元素的绝对值之和,这将是我们偏移的基础
int maxSum = 0;
for (int num : nums) {
maxSum += std::abs(num);
}
if(maxSum < abs(target)) return 0;// 边界情况:直接返回
// 1、创建一个二维动态规划数组 dp,大小为 (n+1) x (2*maxSum + 1)
// n+1 是因为我们考虑从 0 到 n-1 ,共 n 个元素的情况
// 2*maxSum + 1 是因为我们需要处理从 -maxSum 到 maxSum 的所有和,并映射到非负整数索引上
std::vector<std::vector<int>> dp(n + 1,vector<int>(2 * maxSum + 1, 0));
// 2、初始化: dp[0][0] = 1,表示没有选择任何元素时,和为 0有且仅有一种方式(j = 0映射后变成了 maxSum)
dp[0][0 + maxSum] = 1;
// 3、填表:从上往下
for (int i = 1; i <= n; ++i)
{
int num = nums[i - 1]; // 注意dp表与原表的下标隐射关系
for (int j = -maxSum; j <= maxSum; ++j) // 遍历所有可能的元素和,j 可以在{-maxSum,+maxSum}之间
{
int j_idx = j + maxSum; // 因为 j 可能是负数 ,填dp表时需要进行下标偏移,使用 j + maxSum 作为 dp 数组的索引
// 选择 +num 或 -num 来更新 dp[i][j]
if(j_idx - num >=0 ) dp[i][j_idx] += dp[i-1][j_idx - num];// 选择 +num[i]
if(j_idx + num <= 2*maxSum) dp[i][j_idx] += dp[i-1][j_idx + num];// 选择 -num[i]
}
}
// 返回 dp[n][target+maxSum],即考虑所有 n 个元素时,和为 target(映射后变成了 target+maxSum)的方式数
return dp[n][target + maxSum];
}
};
9.4、最后一块石头的重量II
题源:链接。
附:最后一块石头的重量(easy),使用优先级队列,题解链接。
9.4.1、题解
1)、思路分析
先来分析题目:
由此,我们将石头(数组中的元素)分为两个集合,设集合的元素和分别为 a、b,且 a ≤ b。那么,我们所要求的就是两堆石头的重量差(b - a)最小。
这其实就是一道数学问题,a+b=sum,要是的b - a差值尽可能小,则a、b两数的值要尽可能接近,极端情况下,有 a = b = sum / 2。于是问题就转换成了:在数组中选择一些数,让这些数的和尽量接近sum / 2 。
如果把数看成物品,每个数的值看成体积和价值,这就变成了01背包问题: 设定背包的容量为sum / 2,在不超过背包容量的情况下,选择哪些石头(物品)能使得背包的总重量(即所选石头的总重量)最大。
1、确定状态表示: 根据上述分析,dp[i][j]
表示从前 i
个数中选数,总和不超过 j
时的元素最大和。
2、推导状态转移方程: 通常根据"最后一个位置"的元素分情况讨论。本题为,是否选择最后一个元素 stones[[i]
时,总和不超过 j 时的元素最大和。
1)、不选择 stones[[i]
,此时,需要在前 i-1 个元素中选数,求使得总和不超过 j 时的最大和。因此有 dp[i][j] = dp[i-1][j]
2)、选择 stones[[i]
,此时,只能从前 i - 1 个数中选数,看是否能凑成总和不超过 j - stones[[i]。因此有 dp[i][j] = dp[i - 1][j - stones[i]] + stones[i]
(需要注意,这里有条件,那就是 stones[i] ≤ j
)
综上,我们需要的是最大和:
cpp
dp[i][j] = dp[i - 1][j];
if(j >= stones[i])
dp[i][j] = dp[i][j] + dp[i - 1][j - stones[i]] + stones[i];
3、初始化: 引入了虚拟行列,在9.2.2.1中我们曾讲过,列是无需初始化的,因为状态转移方程中已经做出了判断,保证填表不会发生越界。对于虚拟行,dp[0][j]
,i = 0
时表示没有元素时选数,使得元素和不超过 j,那么此时怎么选, 最大和都是0。
4、填表顺序: 根据状态转移方程,从上往下填表。
5、返回值: 根据状态表示,先找到最接近sum / 2的最大和dp[n][sum / 2]
(这个值是不会超过sum/2 的)。由于我们要的是两堆石子的差,因此返回sum - 2*dp[n][sum / 2]
。
2)、题解
不做优化的写法:
cpp
class Solution {
public:
int lastStoneWeightII(vector<int>& stones) {
int n = stones.size();
int sum = 0;
for(auto num : stones) sum += num;
int aim = sum / 2;
// 1、创建dp表并初始化
vector<vector<int>> dp(n+1,vector<int>(aim+1,0));
// 2、填表:从上往下
for(int i = 1; i <= n; ++i)
{
for(int j = 0; j <= aim; ++j)//j = 0我们没有特殊处理,因此需要从j = 0开始
{
dp[i][j] = dp[i-1][j];
if(j >= stones[i-1])// 注意下标映射
dp[i][j] = max(dp[i][j], dp[i-1][j - stones[i-1]] + stones[i-1]);
}
}
// 3、返回
return sum - 2*dp[n][aim];
}
};
滚动数组优化版本:
cpp
class Solution {
public:
int lastStoneWeightII(vector<int>& stones) {
int n = stones.size();
int sum = 0;
for(auto num : stones) sum += num;
int aim = sum / 2;
// 1、创建dp表并初始化
vector<int> dp(aim+1,0);
// 2、填表:从上往下
for(int i = 1; i <= n; ++i)
{
for(int j = aim; j >= stones[i-1]; --j)// 注意下标映射
{
dp[j] = max(dp[j], dp[j - stones[i-1]] + stones[i-1]);
}
}
// 3、返回
return sum - 2*dp[aim];
}
};
10、完全背包问题
10.1、完全背包(medium)
题源:链接。
10.1.1、题解
完全背包等各种背包问题,都是建立在01背包的基础上 的,它们的思考分析方式大同小异。
与之区别的是物品数量限制:
cpp
01背包:每种物品只有一个。要么选,要么不选。
完全背包:每种物品有无限个。理论上可以选0、1、2、......、任意次数(实际会受到背包容量限制)
10.1.1.1、思路分析(第一问:背包不一定装满)
1)、思路分析
1、确定状态表示: 与01背包同,dp[i][j]
表示 ,从前i
个物品中挑选,总体积不超过j
时,所有选法中,能挑选出来的最大价值。
2、推导状态转移方程: 线性dp的状态转移方程,一般都是根据最后一步的状况,来分情况讨论。根据本题是完全背包问题, 每种物品可以选任意次。对最后一个物品同理,可以有很多种选法,因此我们的需要分很多情况:
对最后一处位置的物品 i
,其价值为 w[i]
,体积为 v[i]
:
1)、选 0
个。此时需要在前 i - 1
个物品中挑选,获得总体积不超过 j
时的最大价值。即:dp[i-1][j]
2)、选 1
个。此时需要在前 i - 1
个物品中挑选,获得总体积不超过 j - v[i]
时的最大价值,然后再加上当前挑选出的1
个 i
物品的价值总量。即:dp[i-1][j - v[i]] + w[i]
3)、选 2
个。此时需要在前 i - 1
个物品中挑选,获得总体积不超过 j - 2*v[i]
时的最大价值,然后再加上当前挑选出的2
个 i
物品的价值总量。即:dp[i-1][j - 2*v[i]] + 2*w[i]
4)、依此类推。选 k
个。此时需要在前 i - 1
个物品中挑选,获得总体积不超过 j - k*v[i]
时的最大价值,然后再加上当前挑选出的k
个 i
物品的价值总量。即:dp[i-1][j - k*v[i]] + k*w[i]
由此可得:
cpp
(1)式:
dp[i][j] = max(dp[i-1][j], dp[i-1][j - v[i]]+ w[i], dp[i-1][j - 2*v[i]] + 2*w[i] , ...... , dp[i-1][j - k*v[i]] + k*w[i])
其中,k*v[i] <= j,不得超过背包容量。
可以发现,计算某一个状态时(dp[i][j]
),需要一个循环才能搞定。自然,我们会想到能否优化掉这个循环,用一个或者两个状态来表示这一堆的状态。
根据先前的经验(通配符匹配、正则表达式匹配),通常就是用数学的方式做一下等价替换 。 观察发现,第二维是有规律的变化的,因此让 j = j - v[i]
,代入原公式,看看dp[i][j - v[i]]
这个状态(PS:注意理解这里,如何选择是公式替换的基础)。
cpp
(2)式:
dp[i][j-v[i]] = max(dp[i-1][j-v[i]], dp[i-1][j - 2*v[i]] + w[i], dp[i-1][j - 3*v[i]] + 2*w[i], ......, dp[i-1][j - (k+1)*v[i]] + k*w[i])
其中,(k+1)*v[i]<= j,不得超过背包容量。
观察比较会发现,把(2)式中的dp[i][j-v[i]]
左右两边同时加上w[i]
,正好和(2)式dp[i][j]
中,除了第一项以外的全部一致:
cpp
dp[i][j-v[i]] + w[i] = max(dp[i-1][j-v[i]] + w[i], dp[i-1][j - 2*v[i]] + 2*w[i], dp[i-1][j - 3*v[i]] + 3*w[i], ...... , dp[i-1][j - (k+1)*v[i]] + (k+1)*w[i])
因此我们可以修改原先的状态转移方程为:
cpp
dp[i][j] = max(dp[i-1][j], dp[i][j-v[i]] + w[i])
//引入条件限制后:j-v[i] >= 0
dp[i][j] = dp[i-1][j]
if(j >= v[i])
dp[i][j] = max(dp[i][j], dp[i][j-v[i]] + w[i])
3、初始化: 初始化是为了防止填表时下标越界。与先前一样,引入虚拟行列。由于我们做了判断,虚拟列dp[i][0]无需特意拎出初始化,可以在填表时直接使用状态方程。对虚拟行dp[0][j],表示不选择任何物品时,体积不超过 j 时的最大价值,当然就是 0。
4、填表顺序: 根据状态转移方程,从上到下填每一行,每一行从左到右。
5、返回值: 根据我们的状态表示,需要返回dp[n][V]
,表示从 n 个物品中挑选,总体积不超过V时的最大价值。
10.1.1.2、思路分析(第二问:背包一定装满)
1)、思路分析
思路与背包不一定装满时完全一致,只是在细节需要注意。
1、确定状态表示: dp[i][j]
,表示从前i
个物品中挑选,总体积恰好为 j
时,所能获得的最大价值。
注意事项:
①、强调:要求背包的总体积必须严格等于j
,不允许有多余的空间。
②、在实际操作中,可能会遇到无法用前i
个物品组合出总体积为j
的情况 。为了应对这种情况,我们特别规定:当无法凑出体积为j的组合时,将对应的状态dp[i][j]
设置为-1
。(需要注意,这里的规定是我们自己设置的,其值不一定要是-1
,你也可以用INT_MIN
、-0x3f3f3f3f
等来表示。这些细节规定,会影响到状态方程的细节表示 。)
③既然可以自己规定,不存在的情况也可以设置为0
吗? 回答:不可以 。因为 0
在背包问题中具有特定的含义 。在背包问题中,0
通常代表不选择任何物品时的价值,即当背包为空或没有物品可选时,所能获得的最大价值自然为0
。因此,如果我们将不可行状态也设置为0,就会与这种"不选物品"的合法状态产生混淆 ,从而导致问题的复杂化。
2、推导状态转移方程:
①、与"背包不一定装满"时的情况一致。但因为我们规定了不存在的状况为-1
,这里dp[i][j-v[i]] + w[i]
运算后有可能为正数,导致max求值选择了非法情况。因此需要做判断:dp[i][j-v[i]] != -1
cpp
dp[i][j] = dp[i-1][j]
if(j >= v[i] && dp[i][j-v[i]] != -1)
dp[i][j] = max(dp[i][j], dp[i][j-v[i]] + w[i])
②、问题:不做判断可以吗?
回答:当然可以,只要保证在max求值时,不会选中非负情况即可。那么我们可以规定凑不出体积的状态为INT_MIN
、-0x3f3f3f3f
等,这些值远超于题目给定的数值。这种设置下,就不用特别做判断处理。
cpp
dp[i][j] = dp[i-1][j]
if(j >= v[i])
dp[i][j] = max(dp[i][j], dp[i][j-v[i]] + w[i])
3、初始化: 列不用初始化,对于行dp[0][0] = 0
,dp[0][j] = -1
。
4、填表顺序: 根据状态转移方程,从上到下填每一行,每一行从左到右。
5、返回值: 因为存在凑不出的情况,所以需要对dp[n][V]
判断一下。
10.1.1.3、题解
第二问中,不存在的情况设为 -1
的写法:注意条件判断与返回值。
cpp
#include <iostream>
#include <cstring>
using namespace std;
// 使用全局变量:默认初始化为0
const int N = 1010;//数值上限
int n,V;// 物品个数、背包体积
int v[N],w[N];// 物品的体积、价值
int dp[N][N];
int main()
{
// 1、将数据输入
cin >> n >> V;
for(int i = 1; i <= n; ++i)
cin >> v[i] >> w[i];
// 2、解决第一问
// 2.1、创建dp表并初始化(已完成)
// 2.2、填表:从上到下,从左到右
for(int i = 1; i <= n; ++i)
{
for(int j = 0; j <= V; ++j)
{
dp[i][j] = dp[i-1][j];
if(j >= v[i])
dp[i][j] = max(dp[i][j],dp[i][j - v[i]]+w[i]);
}
}
// 2.3、返回(输出结果)
cout << dp[n][V] << endl;
// 3、解决第二问
// 3.1、创建dp表并初始化:这里设不存在的情况为-1
memset(dp,0,sizeof dp);
for(int j = 1; j <= V; ++j)
dp[0][j] = -1;
// 3.2、填表:从上往下,从左到右
for(int i = 1; i <= n; ++i)
{
for(int j = 0; j <= V; ++j)
{
dp[i][j] = dp[i-1][j];
if(j >= v[i] && dp[i][j- v[i]]!= -1)
dp[i][j] = max(dp[i][j],dp[i][j- v[i]] + w[i]);
}
}
// 3.3、返回
cout << ((dp[n][V] == -1) ? 0: dp[n][V]) << endl;
return 0;
}
第二问中,不存在的情况设为-0x3f3f3f
的写法:注意条件判断与返回值。
cpp
// 3、解决第二问
// 3.1、创建dp表并初始化:这里设不存在的情况为-0x3f3f3f3f
memset(dp,0,sizeof dp);
for(int j = 1; j <= V; ++j)
dp[0][j] = -0x3f3f3f3f;
// 3.2、填表:从上往下,从左到右
for(int i = 1; i <= n; ++i)
{
for(int j = 0; j <= V; ++j)
{
dp[i][j] = dp[i-1][j];
if(j >= v[i])
dp[i][j] = max(dp[i][j],dp[i][j- v[i]] + w[i]);
}
}
// 3.3、返回
cout << ((dp[n][V] < 0) ? 0: dp[n][V]) << endl;
10.1.2、优化(滚动数组)
注意区别:使用滚动数组做优化,在01背包中,填表要求从右到左。在完全背包中,填表仍旧保持从左到右的顺序不变。
cpp
#include <iostream>
#include <cstring>
using namespace std;
// 使用全局变量:默认初始化为0
const int N = 1010;//数值上限
int n,V;// 物品个数、背包体积
int v[N],w[N];// 物品的体积、价值
int dp[N];
int main()
{
// 1、将数据输入
cin >> n >> V;
for(int i = 1; i <= n; ++i)
cin >> v[i] >> w[i];
// 2、解决第一问
// 2.1、创建dp表并初始化(已完成)
// 2.2、填表:从上到下,从左到右
for(int i = 1; i <= n; ++i)
{
for(int j = v[i]; j <= V; ++j)
dp[j] = max(dp[j],dp[j - v[i]]+w[i]);
}
// 2.3、返回(输出结果)
cout << dp[V] << endl;
// 3、解决第二问
// 3.1、创建dp表并初始化:这里设不存在的情况为-1
memset(dp,0,sizeof dp);
for(int j = 1; j <= V; ++j)
dp[j] = -1;
// 3.2、填表:从上往下,从左到右
for(int i = 1; i <= n; ++i)
{
for(int j = v[i]; j <= V; ++j)
{
if(dp[j- v[i]]!= -1)
dp[j] = max(dp[j],dp[j- v[i]] + w[i]);
}
}
// 3.3、返回
cout << ((dp[V] == -1) ? 0: dp[V]) << endl;
return 0;
}
10.2、零钱兑换(medium)
题源:链接。
10.2.1、题解
1)、思路分析
分析此题,可以发现它能转换为完全背包问题:
cpp
物品(硬币):coins 数组中的每个元素。
背包容量(总金额):amount。
目标:找到"最少的物品(硬币)数量",使得总金额"恰好为" amount。
那么,就是完全背包问题中,背包一定装满的情况。我们可以借助10.1中的分析思路。
1、确定状态表示: dp[i][j]
,表示在前 i
枚硬币(元素)中挑选,使其总金额恰好为 j
时,所需要的最小硬币数量。
需要注意,存在凑不出总金额恰为 j
的情况 ,因为这里要求的是最小值,我们规定 dp[i][j]
不存在时,设为0x3f3f3f
。
2、推导状态转移方程: 线性dp,一般根据"最后一步"的状况,来分情况讨论。在本题中,由于最后一处位置的硬币不限选择次数,因此存在多种情况:
1)、对最后一枚硬币,选 0
个。此时需要在前 i - 1
枚硬币中挑选,获得总金额恰好为 j
时的最小硬币数量。即:dp[i-1][j]
2)、对最后一枚硬币,选 1
个,金额为coins[i]
。此时需要在前 i - 1
枚硬币中挑选,获得总金额恰好为 j - coins[i]
时的最小硬币数量,然后再加上当前 i
位置挑选出的硬币总数。则有:dp[i-1][j - coins[i]] + 1
3)、依此类推。对最后一枚硬币,选 k
个,金额为k*coins[i]
。此时需要在前 i - 1
枚硬币中挑选,获得总金额恰好为 j - k*coins[i]
时的最小硬币数量,然后再加上当前 i
位置挑选出的硬币总数。即:dp[i-1][j - k*coins[i]] + k
由于dp[i][j]
要选金额最小的情况,则有:
cpp
dp[i][j] = min(dp[i-1][j], dp[i-1][j - coins[i]] + 1, dp[i-1][j - 2*coins[i]] + 2, dp[i-1][j - k*coins[i]] + k)
让 j = j - coins[i]
,代入上式得:
cpp
dp[i][j- coins[i]] = min(dp[i-1][j- coins[i]], dp[i-1][j - 2*coins[i]] + 1, dp[i-1][j - 3*coins[i]] + 2, dp[i-1][j - (k+1)*coins[i]] + k)
在等式左右两边+1
,得:
cpp
dp[i][j- coins[i]] + 1 = min(dp[i-1][j- coins[i]] + 1, dp[i-1][j - 2*coins[i]] + 2, dp[i-1][j - 3*coins[i]] + 3, dp[i-1][j - (k+1)*coins[i]] + (k+1))
由此,整理可得:
cpp
dp[i][j] = min(dp[i-1][j],dp[i][j- coins[i]] + 1)
//加入有条件限制:
dp[i][j] = dp[i-1][j];
if(j >= coins[i])
dp[i][j] = min(dp[i][j], dp[i][j - coins[i]] + 1);
3、初始化: 根据状态方程,引入虚拟行列初始化,需要注意两点。
1)、下标映射关系(原coins表,dp表)
2)、虚拟行列的初始化要保证使用状态转移方程时,填表正确。①根据之前的经验,虚拟列不用初始化。②虚拟行dp[0][j]
,表示没有硬币时,要是的总金额为 j 时的最少硬币数目。只有 dp[0][0] = 0
成立,i = 0
时,其它 j > 0
的情况,均不存在这种选法,可设为正无穷(0x3f3f3f
)。
4、填表顺序: 根据状态转移方程,从上往下,从左往右。
5、返回值: 返回dp[n][amount]
。
2)、题解
不做优化的写法:
cpp
class Solution {
const int default_max = 0x3f3f3f;
public:
int coinChange(vector<int>& coins, int amount) {
int n = coins.size();
// 1、创建dp表:i物品, j总金额
vector<vector<int>> dp(n + 1, vector<int>(amount + 1, 0));
// 2、初始化
for (int j = 1; j <= amount; ++j)
dp[0][j] = default_max;
// 3、填表:从上往下,从左往右
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] = min(dp[i][j], dp[i][j - coins[i-1]] + 1);
}
}
// 4、返回
return (dp[n][amount] >= default_max) ? -1 : dp[n][amount];
}
};
使用滚动数组优化的写法:
cpp
class Solution {
const int default_max = 0x3f3f3f;
public:
int coinChange(vector<int>& coins, int amount) {
int n = coins.size();
// 1、创建dp表并初始化:优化为一维数组
vector<int> dp(vector<int>(amount + 1, default_max));
dp[0] = 0;//少循环一次
// 2、填表:从上往下,从左往右
for (int i = 1; i <= n; ++i) {
for (int j = coins[i - 1]; j <= amount; ++j)
{
dp[j] = min(dp[j], dp[j - coins[i-1]] + 1);//注意映射关系
}
}
// 3、返回
return (dp[amount] >= default_max) ? -1 : dp[amount];
}
};
10.3、零钱兑换II(medium)
题源:链接。
10.3.1、题解
1)、思路分析
此题和10.2思路一样,同属于完全背包问题。只不过10.2中我们求的是 "凑出目标金额时的最小硬币数",这里求的是 "凑出目标金额时,存在多少种选法/凑法"。
1、确定状态表示: dp[i][j]
,表示在前 i
枚硬币(元素)中挑选,使其总金额恰好为 j
时,一共存在多少种选法。
需要注意,存在凑不出总金额恰为 j
的情况 ,那么干脆把 选法设为0
即可。
2、推导状态转移方程: 线性dp,一般根据"最后一步"的状况,来分情况讨论。在本题中,由于最后一处位置的硬币不限选择次数,因此存在多种情况:
1)、对最后一枚硬币,选 0
个。此时需要在前 i - 1
枚硬币中挑选,看看总金额恰好为 j
时,一共存在多少种选法。则有:dp[i-1][j]
2)、对最后一枚硬币,选 1
个。此时需要在前 i - 1
枚硬币中挑选,看看总金额恰好为 j - coins[i]
时,一共存在多少种选法。则有:dp[i-1][j - coins[i]]
(注意理解,选法总数不变)
3)、依此类推。对最后一枚硬币,选 k
个。此时需要在前 i - 1
枚硬币中挑选,看看总金额恰好为 j - k*coins[i]
时,一共存在多少种选法。则有:dp[i-1][j - k*coins[i]]
dp[i][j]
处存在的选法,等于上述所有选法累加:
cpp
dp[i][j] = dp[i-1][j] + dp[i-1][j-coins[i]] + dp[i-1][j-2*coins[i]] + ......
让 j = j - coins[i]
,代入上式得:
cpp
dp[i][j-coins[i]] = dp[i-1][j-coins[i]]+ dp[i-1][j - 2*coins[i]] + dp[i-1][j-3*coins[i]]......
由此,整理可得:
cpp
dp[i][j] = dp[i-1][j]+ dp[i][j- coins[i]]
//加入有条件限制:
dp[i][j] = dp[i-1][j];
if(j >= coins[i])
dp[i][j] += dp[i][j - coins[i]];
3、初始化: 根据状态方程,引入虚拟行列初始化,需要注意两点。
1)、下标映射关系(原coins表,dp表)
2)、虚拟行列的初始化要保证使用状态转移方程时,填表正确。
4、填表顺序: 根据状态转移方程,从上往下,从左往右。
5、返回值: 返回dp[n][amount]
。但是要特判⼀下,因为有可能凑不到目标金额。
2)、题解
不做优化的写法:
cpp
class Solution {
public:
int change(int amount, vector<int>& coins) {
int n = coins.size();
// 1、创建dp表并初始化
vector<vector<unsigned long long>> dp(n+1,vector<unsigned long long>(amount+1,0));
dp[0][0] = 1;// 没有硬币,总金额恰好为 0 时,存在1种选法(就是什么都不选)
// 2、填表:从上往下,从左往右
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] += dp[i][j - coins[i-1]];
}
}
// 3、返回
return dp[n][amount];
}
};
使用滚动数组优化的写法:
cpp
class Solution {
public:
int change(int amount, vector<int>& coins) {
int n = coins.size();
// 1、创建dp表并初始化
vector<unsigned long long> dp(amount+1,0);
dp[0] = 1;
// 2、填表:从上往下,从左往右
for(int i = 1; i <= n; ++i)
{
for(int j = coins[i-1]; j <= amount; ++j)
dp[j] += dp[j - coins[i-1]];
}
// 3、返回
return dp[amount];
}
};
优化后也可以直接使用范围for:
cpp
class Solution {
public:
int change(int amount, vector<int>& coins) {
// 1、创建dp表并初始化
vector<unsigned long long> dp(amount+1,0);
dp[0] = 1;
// 2、填表:
for(auto x : coins)
{
for(int j = x; j <= amount; ++j)
dp[j] += dp[j - x];
}
// 3、返回
return dp[amount];
}
};
10.4、完全平方数(medium)
题源:链接。
10.4.1、题解
1)、思路分析
分析题目,题目要求我们从一堆完全平方数:{ 1 2 1^2 12、 2 2 2^2 22、 3 2 3^2 32、 ... ... ...... ......} 中选数,每个数不限选择次数,求选数的数和恰好为 n 时,所需要的最小的元素个数。
这就是完全背包问题,且背包一定要装满的情况。
细节分析:
1)、由于有 1 2 1^2 12的存在,在允许同一个数被选择多次的情况下,题目保证有解。
2)、实际上,我们只需要考虑那些 i 2 ≤ n i^2 ≤ n i2≤n 的整数即可。这些整数对应的平方数就是我们的物品集合。
1、确定状态表示: dp[i][j]
,表示从前 i
个完全平方数中挑选,使得总和恰好为 j
时,所有选法中,"元素个数最小"的情况。
2、推导状态转移方程: 一般根据"最后一步"的状况,来分情况讨论。在本题中,根据选择 i 2 i^2 i2不限选择次数,因此存在多种情况:
1)、选 0
个 i 2 i^2 i2。此时需要在前 i - 1
个数中挑选,获得总和恰好为 j
时的最小元素数量。即:dp[i-1][j]
2)、选 1
个 i 2 i^2 i2。此时需要在前 i - 1
个数中挑选,获得总和恰好为 j - i*i
时的最小元素数量,然后再加上当前 i
位置选择的 i 2 i^2 i2 的总数。则有:dp[i-1][j - i*i] + 1
3)、依此类推。选 k
个 i 2 i^2 i2。此时需要在前 i - 1
个数中挑选,获得总和恰好为 j - k* i*i
时的最小元素数量,然后再加上当前 i
位置选择的 i 2 i^2 i2 的总数。即:dp[i-1][j - k*i*i]] + k
在多种情况中,选择其中数量最小的情况,由此得:
cpp
dp[i][j] = min(dp[i-1][j],dp[i][j - i*i] + 1)//注意条件
PS:实际上这里仅需将选 1
个 的情况,dp[i-1][j - i*i] + 1
中横坐标i-1
改为i
即可,简单记一下可以不用一步步推导,但仅适用于完全背包的问题。如果不能确定,那就用公式推导验证。
3、初始化: 列无需单独拎出初始化,对行,dp[0][j]
,仅当dp[0][0] = 0
成立,表示不存在元素时,总和 j 为0,此时有一种选法,那就是不选任何数,因此最小数量为0
。其它 j > 0
的情况,不存在,为了防止min
求值选择错误情况,可将不存在的情况初始化为0x3f3f3f
。
4、填表顺序: 根据状态方程,从上往下,从左往右。
5、返回值: 根据之前分析,返回 d p [ n ] [ n ] dp[\sqrt{n}][n] dp[n ][n]
2)、题解
不做优化的写法:
cpp
class Solution {
int defmax = 0x3f3f3f;
public:
int numSquares(int n) {
int m = sqrt(n);
// 1、建表并初始化
vector<vector<int>> dp(m+1,vector<int>(n+1,0));
for(int j = 1; j <= n; ++j)
dp[0][j] = defmax;
// 2、填表
for(int i = 1; i <= m; ++i)
{
for(int j = 0; j <= n; ++j)
{
dp[i][j] = dp[i-1][j];
if(j >= i*i)
dp[i][j] = min(dp[i][j],dp[i][j - i*i]+1);
}
}
// 3、返回
return dp[m][n];
}
};
使用滚动数组优化:
cpp
class Solution {
int defmax = 0x3f3f3f;
public:
int numSquares(int n) {
int m = sqrt(n);
// 1、建表并初始化
vector<int> dp(n+1,defmax);
dp[0] = 0;
// 2、填表
for(int i = 1; i <= m; ++i)
{
for(int j = i*i; j <= n; ++j)
dp[j] = min(dp[j],dp[j - i*i]+1);
}
// 3、返回
return dp[n];
}
};
11、二维费用的背包问题
11.0、概述
二维费用的背包问题是01背包问题的一种变体 ,也是一类经典的组合优化问题。在这个问题中,每件物品具有两个不同的属性,通常被称为"费用"或"资源限制",以及一个价值 。问题的目标是在给定的两种资源限制下,选择一组物品,使得它们的总价值最大。
一、问题描述
设有N
件物品,每件物品有两种不同的费用(或资源限制),分别记为a[i]
和b[i]
(或v[i]
和u[i]
),以及一个价值w[i]
。同时,有两种费用(或资源)可付出的最大值(背包容量),分别记为V和U。要求确定选择哪些物品放入背包,使得在不超过这两种费用限制的情况下,背包中物品的总价值最大。
二、解题思路
状态定义: 设dp[i][j][k]
表示前i件物品在两种费用分别为j
和k
的情况下可获得的最大价值。
初始状态为dp[0][0][0]=0
,表示没有物品时,背包的价值为0
。
状态转移方程: 对于第i
件物品,有两种选择:放入背包或不放入背包。
1)、如果放入背包,则dp[i][j][k] = dp[i-1][j-a[i]][k-b[i]] + w[i]
(当j >= a[i]
且k >= b[i]
时)。
2)、如果不放入背包,则dp[i][j][k] = dp[i-1][j][k]
。
因此,状态转移方程为:
cpp
dp[i][j][k] = max(dp[i-1][j][k], dp[i-1][j-a[i]][k-b[i]] + w[i]) //后者有限定条件:j >= a[i]且k >= b[i]
边界条件: 当i=0
时,dp[0][j][k] = 0
,表示没有物品时的背包价值。
优化: 由于dp[i][j][k]
只与dp[i-1][...]
有关,因此可以使用滚动数组优化空间复杂度,将三维数组降为二维数组。
11.1、一和零(medium)
题源:链接。
11.1.1、题解
1)、思路分析
分析题目,将问题转化成我们熟悉的题型。
1)、在一些物品中"挑选"一些出来,然后在满足某个"限定条件" 下,解决一些问题,大概率是背包模型;
题目要求在给定的二进制字符串数组 strs 中,找出一个最大子集,该子集中最多有 m 个 0 和 n 个 1。这个问题可以转化为在有限制条件(即最多 m 个 0 和 n 个 1)下,从一系列物品(即字符串)中选择一些物品,使得选择的物品数量最大化。
2)、由于每个字符串(或物品)只能被选择一次,因此这是一个01背包问题。
但是,与传统的01背包问题不同,本题有两个限制条件:最多 m 个 0 和 n 个 1。因此,它是一个二维费用的01背包问题。那么我们定义状态表示的时候,来一个三维dp表,把第二个限制条件加上即可。
1、确定状态表示: dp[i][j][k]
,表示从前 i
个字符串中挑选,字符 0
的个数不超过j
,字符1
的个数不超过k
,所有的选法中,最大的长度。
注意理解题目中的"子集长度",指的是选出的子集的个数,也就是字符串的个数,而非字符长度。
cpp
输入:strs = ["10", "0001", "111001", "1", "0"], m = 5, n = 3
输出:4
解释:最大子集是 {"10","0001","1","0"} ,因此最大子集的长度就是 4 。
2、推导状态转移方程: 线性dp的状态转移方程分析方式,一般都是根据"最后一步"的状况,分情况讨论。这里,我们记第 i
个字符中,字符0
的个数为a
,字符1
的个数为b
:
1)、不选第 i
个字符串。此时,需要去前 i-1
个字符串中挑选,保证字符 0
的个数不超过 j
,字符 1
的个数不超过k
,获得此时的最大长度。因此有:dp[i][j][k] = dp[i -1][j][k]
;
2)、选择第 i
个字符串。先前我们设过第 i
个字符串的0
、1
字符个数分别为 a
、b
,因此在选择第 i
个字符串的情况下,仅需在前 i-1
个字符串里面,挑选出来字符 0
的个数不超过j-a
,字符 1
的个数不超过k-b
的最长长度,然后在这个长度后面加上字符串i
的即可。此时dp[i][j][k]=dp[i-1][j-a][k-b]+1
。
注意,这种状态不一定存在,因此需要特判一下:j >= a && k >= b
。
题目求的是最大子集长度,因此我们需要选两种情况下的最大值,状态转移方程为:
cpp
dp[i][j][k] = max(dp[i - 1][j][k], dp[i - 1][j - a][k - b] + 1);
3、初始化: i = 0时,表示没有字符串时,要挑选出 "字符 0
的个数不超过 j
,字符1
的个数不超过 k
"的子集,自然只有空集,子集长度当然为0。
4、填表顺序: 根据状态转移方程,填写 i 面的值时,仅需用到 i-1 面的值,因此保证第一维的从小到大即可。
5、返回值: 根据状态表示,返回dp[len][m][n]
。其中,len表示字符串数组的长度。
2)、题解
不做优化的写法:
cpp
class Solution {
public:
int findMaxForm(vector<string>& strs, int m, int n) {
int len = strs.size();
// 1、建表并初始化
vector<vector<vector<int>>> dp(len+1,vector<vector<int>>(m+1,vector<int>(n+1,0)));
// 2、填表
for(int i = 1; i <= len; ++i)
{
// 统计当前 strs[i-1](映射)字符串中的0和1的个数
int a = 0,b = 0;
for(auto ch : strs[i-1])
{
if(ch == '0') a++;
else b++;
}
for(int j = 0; j <= m; ++j)
{
for(int k = 0; k <= n; ++k)
{
dp[i][j][k] = dp[i-1][j][k];
if(j >= a && k >= b)
dp[i][j][k] = max(dp[i][j][k],dp[i-1][j-a][k-b]+1);
}
}
}
// 3、返回
return dp[len][m][n];
}
};
优化为二维的写法:与01背包一致,①删掉第⼀维;②修改第⼆层以及第三层循环的遍历顺序即可。(原先不做要求,优化后要求从大到小遍历)
cpp
class Solution {
public:
int findMaxForm(vector<string>& strs, int m, int n) {
int len = strs.size();
// 1、建表并初始化
vector<vector<int>> dp(m+1,vector<int>(n+1,0));
// 2、填表
for(int i = 1; i <= len; ++i)
{
// 统计当前 strs[i-1](映射)字符串中的0和1的个数
int a = 0,b = 0;
for(auto ch : strs[i-1])
{
if(ch == '0') a++;
else b++;
}
for(int j = m; j >= a; --j)
{
for(int k = n; k >= b; --k)
{
dp[j][k] = max(dp[j][k],dp[j-a][k-b]+1);
}
}
}
// 3、返回
return dp[m][n];
}
};
11.2、盈利计划(hard)
题源:链接。
11.2.1、题解
1)、思路分析
分析题意,题目要求我们从给定的工作中选择若干项,每项工作有对应的需求人数(group[i]
)和产生的利润(profit[i]
)。我们的目标是找出所有满足以下条件的计划的总数:
cpp
成员总数限制:参与工作的员工总数不超过 n。
利润下限:所有选定的工作产生的总利润不少于 minProfit。
由于每个工作只能被选择一次(即员工一旦参与了某项工作,就不能再参与其他工作),这个问题实际上是01背包问题的一个变种,具体来说是二维费用的背包问题,因为我们需要同时考虑两个限制条件:员工人数和利润。
1、确定状态表示: 根据上述分析,dp[i][j][k]
表示,从前 i
份工作中挑选, 找出员工人数不超过 j
,利润不少于 k
时,一共存在多少种选法。
注意,本题中出现了一个"不少于",和我们之前做过的背包问题不一样,我们在分析状态转移方程的时候要结合实际情况考虑一 下 。
2、推导状态转移方程: 根据"最后一个位置"的元素(即第 i 个工作)的情况,分情况讨论:
1)、不选择第 i 个工作: 在这种情况下,我们只能从前 i-1
个工作中挑选计划,使得总人数不超过 j
,总利润至少为 k
。此时一共有 dp[i-1][j][k]
种选法。
2)、选择第 i 个工作: 在这种情况下,从前 i-1 个工作中挑选计划时,总人数和总利润会受到第 i 个工作的影响。实际需要保证总人数不超过 j - group[i]
,总利润至少为 k - profit[i]
。
来理解一下这里:dp[i-1][j - group[i]][k - profit[i]]
①、j - group[i] >= 0
要进行条件判断吗? 如果 j - group[i] < 0
,则说明第 i
个工作所需的人数过多,无法在当前状态下选择它,因此这个状态是不合法的,需要判断。
②、k - profit[i] >= 0
要进行条件判断吗? 如果k - profit[i] < 0
,则说明第 i
个工作的利润已经足够大,足够满足要求"利润至少 为 k
"的要求了。但是,由于dp表是数组形式,其下标不能是负数 。虽然k - profit[i] < 0
是成立的,但为了使得数组下标不为负数,我们要做处理,将 k - profit[i]
与 0
取一个 max
,即 max(0, k -profit[i] )
,这表示,我们只需要在前 i-1
个工作中找到一种方案,使得总利润至少为 0(但实际上可能会更多,因为我们要加上第 i 个工作的利润),然后再加上第 i 个工作的利润,就能满足至少 k
的利润需求。
因此有:
cpp
dp[i][j][k] = dp[i - 1][j][k] + dp[i - 1][j - g[i - 1][max(0, k - p[i - 1])]
3、初始化: 根据状态表示,当i = 0
没有任务时,无论人数限制 j
是多少,此时仅有一种方案,即选择一个"空集",总利润自然是 0
。因此,我们应该将 dp[0][j][0]
初始化为 1
(0 <= j <= n ),表示在没有任何任务的情况下,存在一种方案(即空集)使得总利润为 0,且这个方案对于任何人数限制 j 都是有效的。
4、填表顺序: 根据"状态转移方程", 我们保证 i
从小到大即可。
5、返回值: 根据"状态表示",返回dp[m][n][minProfit]
。其中:
cpp
m:表示group.size(),等同于profit.size()
n:题目给定的员工限制总人数
minProfit:题目给定的最小利润
2)、题解
未优化的写法:
cpp
class Solution {
const int MOD = 1e9+7;// 题目说明
public:
int profitableSchemes(int n, int minProfit, vector<int>& group, vector<int>& profit) {
int m = group.size();
// 1、建表, i 表示工作,j 表示员工人数,k 表示利润
vector<vector<vector<int>>> dp(m+1,vector<vector<int>>(n+1,vector<int>(minProfit+1,0)));
// 2、初始化:dp[0][j][0]
for(int j = 0; j <= n; ++j)
dp[0][j][0] = 1;
// 3、填表
for(int i = 1; i <= m; ++i)
{
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 >= group[i-1])//注意下标映射
dp[i][j][k] += dp[i-1][j-group[i-1]][max(0,k - profit[i-1])];
dp[i][j][k] %= MOD;//数值很大,不要忘记取模
}
}
}
// 4、返回
return dp[m][n][minProfit];
}
};
使用滚动数组优化的写法:
cpp
class Solution {
const int MOD = 1e9+7;// 题目说明
public:
int profitableSchemes(int n, int minProfit, vector<int>& group, vector<int>& profit) {
int m = group.size();
// 1、建表
vector<vector<int>> dp(n+1,vector<int>(minProfit+1,0));
// 2、初始化:
for(int j = 0; j <= n; ++j)
dp[j][0] = 1;
// 3、填表
for(int i = 1; i <= m; ++i)
{
for(int j = n; j >= group[i-1]; --j)// 记得修改遍历顺序
{
for(int k = minProfit; k >=0 ; --k)
{
dp[j][k] += dp[j-group[i-1]][max(0,k - profit[i-1])];
dp [j][k] %= MOD;// 数值很大,不要忘记取模
}
}
}
// 4、返回
return dp[n][minProfit];
}
};
12、似包非包
12.1、组合总数IV(medium)
题源:链接。
12.1.1、题解
1)、思路分析
需要注意:背包问题是用于解决限制条件下的组合问题 ,而本题,示例1表明,"顺序不同的序列"也是一种有效解。我们都知道数学上的"排列、组合",这种元素顺序也考虑进入的情况,实则求的是"排列数",因此,将其视为背包问题进行求解,而需要采用普通的动态规划的思想解题即 可。
1、确定状态表示: 对于常规的动态规划,根据分析问题的过程中,发现重复子问题,抽象出来一个状态表示 。
当我们想要计算总和为 target
的数的排列方式有多少种时,我们可以考虑这样一个思路:对于构成这个总和的最后一个数字,如果我们从给定的数组 nums 中选择一个数字 x
,那么剩下的数字之和就应该是 target - x
。接下来,我们就需要去找出总和为 target - x
的数字有多少种排列方式。
基于这个思路,状态表示 dp[i]
表示的是:当总和为 i
时,所有可能的排列方式的数量。
2、推导状态转移方程: 对于 dp[i]
,根据最后一个位置划分,可以选择数组中的任意一个元素nums[j]
(其中 0 <= j <= n-1)。
当 nums[j] <= i
(即 nums[j] 不大于当前的总和 i)时,我们可以将 nums[j]
加到某个总和为 i - nums[j]
的排列的末尾,从而得到一个总和为 i
的新排列。
因此,状态转移方程可以表示为:
cpp
if(i >= nums[j])
dp[i] += dp[i-nums[i]] // 0 <= j <= n - 1
3、初始化: dp[0]
应该被初始化为 1
,因为总和为 0
的排列只有一种,即空排列(不包含任何数字)。
4、填表顺序: 根据状态表示,我们需要从小到大遍历 i,并且在每个 i 上遍历 nums的所有元素。
在实际编程中,这个状态转移方程可以通过两层循环来实现:外层循环遍历总和 i,内层循环遍历数组 nums 中的每个数字 nums[j]。在每次内层循环中,我们根据状态转移方程更新 dp[i] 的值。
5、返回值: 根据题意,返回dp[target]
处的值。
2)、题解
cpp
class Solution {
public:
int combinationSum4(vector<int>& nums, int target) {
int n = nums.size();
// 1、创建dp表并初始化
vector<double> dp(target+1);//double是防止数值越界
dp[0] = 1;
// 2、填表:从左到右
for(int i = 1; i <= target; ++i)
{
for(auto x : nums)
if(i >= x) dp[i] += dp[i - x];
}
// 3、返回
return dp[target];
}
};
13、卡特兰数
13.1、不同的二叉搜索树(medium)
题源:链接。
扩展: 这道题属于"卡特兰数"的一个应用,同样能解决的问题还有"合法的进出栈序列"、"括号匹配的括号序列"、"电影购票"等等。
13.1.1、题解
1)、思路分析
1、确定状态表示: 对于常规的动态规划,根据分析问题的过程中,发现重复子问题,抽象出来一个状态表示 。
对二叉搜索树,其左子树节点< 根节点 < 右子树节点。且左右子树也是二叉搜索树。
当我们在求个数为 n 的二叉搜索树的个数时,如果确定了一个根节点,则左右子树的结点个数也就确定了。此时左右子树就会变成相同的子问题:
因此我们可以这样定义状态表示:dp[i]
,表示当结点的数量为i
个的时候,⼀共有多少颗二叉搜索树。
2、推导状态转移方程: 对dp[i]
,为了便于讨论,我们将给定的 i
个结点从 1 到 i 进行依次编号。接下来,选择一个特定的结点 j
作为头结点,以此为基础来分析构建 i
个结点时的所有可能二叉搜索树(BST)的数量。
当我们选择 j
号结点作为头结点时,根据二叉搜索树(BST)的定义:
左子树:j
号结点的左子树包含的结点编号应在 [1, j-1]
之间,一共有 j-1
个结点。因此,对于以 j
号结点作为头结点的情况,其左子树可能的种类数为 dp[j-1]
(根据 dp 数组的定义)。
右子树:j
号结点的右子树包含的结点编号应在 [j+1, i]
之间,一共有 i-j
个结点。同样地,对于 j
号结点作为头结点的情况,其右子树可能的种类数为 dp[i-j]
。
由此,以 j 作为根节点的左子树存在 dp[j-1]
种,右子树存在 dp[i-j]
种,我们可以任选一种左子树,与右子树进行组合,所形成的都是一颗以 j 作为根节点的二叉搜索树。
也就是说,根据排列组合的原理,当 j 号结点作为头结点时,能够形成的 BST 的种类数为 dp[j-1] * dp[i-j]
。
3、初始化: 根据状态转移方程,可以发现 j-1
和 i-j
都是小于 i
的数值。这意味着,在求解 dp[i]
时,我们可能会依赖到前一个或更前面的状态值(特别是,当 i = 1 且 j = 1 时,我们需要用到 dp[0]
的数据)。
鉴于此,我们首先需要确保已经对 dp 数组的第一个元素进行了初始化: 当 i = 0
时,它表示的是一颗空树。在二叉搜索树的定义中,空树同样被视为有效的二叉搜索树 。因此 dp[0] = 1
,这代表了存在且仅存在一种空树的情况。
4、填表顺序:从左往右
5、返回值:根据状态表示,返回dp[n]
的值。
2)、题解
cpp
class Solution {
public:
int numTrees(int n) {
// 1、创建dp表并初始化
vector<int> dp(n+1);
dp[0] = 1;// 空树也是⼀颗⼆叉搜索树
// 2、填表:从左到右
for(int i = 1; i <= n; ++i)// 枚举结点的总数
{
for(int j = 1; j <= i; ++j) // 选择每⼀个根节点
{
dp[i] += dp[j-1]*dp[i-j];// 将满足要求的⼆叉树总量累加在⼀起
}
}
// 3、返回
return dp[n];
}
};