目录
①力扣474. 一和零(二维费用背包)
难度 中等
给你一个二进制字符串数组 strs
和两个整数 m
和 n
。
请你找出并返回 strs
的最大子集的长度,该子集中 最多 有 m
个 0
和 n
个 1
。
如果 x
的所有元素也是 y
的元素,集合 x
是集合 y
的 子集 。
示例 1:
输入:strs = ["10", "0001", "111001", "1", "0"], m = 5, n = 3 输出:4 解释:最多有 5 个 0 和 3 个 1 的最大子集是 {"10","0001","1","0"} ,因此答案是 4 。 其他满足题意但较小的子集包括 {"0001","1"} 和 {"10","1","0"} 。{"111001"} 不满足题意,因为它含 4 个 1 ,大于 n 的值 3 。
示例 2:
输入:strs = ["10", "0", "1"], m = 1, n = 1 输出:2 解释:最大的子集是 {"0", "1"} ,所以答案是 2 。
提示:
1 <= strs.length <= 600
1 <= strs[i].length <= 100
strs[i]
仅由'0'
和'1'
组成1 <= m, n <= 100
class Solution {
public:
int findMaxForm(vector<string>& strs, int m, int n) {
}
};
解析代码
先看能不能将问题转化成熟悉的题型。
在一些物品中挑选一些出来,然后在满足某个限定条件下,解决一些问题,大概率是背包模型, 由于每一个物品都只有 1 个,因此是一个01 背包问题。 但是发现这一道题里面有两个限制条件。因此是一个二维费用的 01 背包问题。那么定义状态表示的时候,创建一个三维 dp 表,把第二个限制条件加上即可。
二维费用的背包问题是指:对于每件物品,具有两种不同的费用,选择这件物品必须同时付出这两种代价,对于每种代价都有一个可付出的最大值(例:背包容量,问怎样选择物品可以得到最大的价值。设这两种代价分别为代价1和代价2,第i件物品所需的两种代价分别为a[i]和b[i]。两种代价可付出的最大值(两种背包容量)分别为V和U。物品的价值为w[i]。)
故:对于01背包问题、完全背包问题和多重背包问题的方法都完全可以使用,只不过增加一个代价。
状态表示:dp[i][j][k] 表示:从前 i 个字符串中挑选,字符 0 的个数不超过 j ,字符 1 的个数不超过 k ,所有的选法中,最大的长度。
状态转移方程:
线性 dp 状态转移方程分析方式,一般都是根据最后一步的状况,来分情况讨论。为了方便叙述,记第 i 个字符中,字符 0 的个数为 a ,字符 1 的个数为 b :
- 不选第 i 个字符串:相当于就是去前 i - 1 个字符串中挑选,并且字符 0 的个数不超过 j ,字符 1 的个数不超过 k 。此时的最大长度为 dp[i][j][k] = dp[i - 1] [j][k] 。
- 选择第 i 个字符串:那么接下来仅需在前 i - 1 个字符串里面,挑选出来字符 0 的 个数不超过 j - a ,字符 1 的个数不超过 k - b 的最长长度,然后在这个长度后面加上字符串 i 即可。此时 dp[i][j][k] = dp[i - 1][j - a][k - b] + 1 。 但是这种状态不一定存在,因此需要特判一下。
综上,状态转移方程为:dp[i][j][k] = max(dp[i - 1][j][k], dp[i - 1][j - a] [k - b] + 1) ;
初始化: 每一维多开一个空间方便初始化,当第一维 i 为0。即没有字符串的时候,没有长度,因此初始化为 0 即可。
填表顺序: 保证第一维 i 从小到大即可。
返回值: 根据状态表示,返回 dp[len][m][n] ,其中 len 表示字符串数组的长度。
class Solution {
public:
int findMaxForm(vector<string>& strs, int m, int n) {
// dp[i][j][k] 表示:从前 i 个字符串中挑选,字符 0 的个数不超过 j ,
// 字符 1 的个数不超过 k ,所有的选法中,最大的长度
int len = strs.size();
vector<vector<vector<int>>> dp(len + 1, vector<vector<int>>(m + 1, vector<int>(n + 1, 0)));
for(int i = 1; i <= len; ++i)
{
int a = 0, b = 0;
for(auto& e : strs[i - 1]) // 统计0和1的个数
{
if(e == '0')
++a;
else
++b;
}
for(int j = 0; j <= m; ++j)
{
for(int k = 0; k <= n; ++k)
{
if(j >= a && k >= b)
dp[i][j][k] = max(dp[i - 1][j][k], dp[i - 1][j - a][k - b] + 1) ;
else
dp[i][j][k] = dp[i - 1][j][k];
}
}
}
return dp[len][m][n];
}
};
代码优化
所有的背包问题,都可以进行空间上的优化。 对于二维费用的 01 背包问题,优化还是和之前的01背包类似,删掉第一维,然后修改其他维度的遍历顺序:
class Solution {
public:
int findMaxForm(vector<string>& strs, int m, int n) {
// dp[i][j][k] 表示:从前 i 个字符串中挑选,字符 0 的个数不超过 j ,
// 字符 1 的个数不超过 k ,所有的选法中,最大的长度
int len = strs.size();
vector<vector<int>> dp(m + 1, vector<int>(n + 1, 0));
for(int i = 1; i <= len; ++i)
{
int a = 0, b = 0;
for(auto& e : strs[i - 1]) // 统计0和1的个数
{
if(e == '0')
++a;
else
++b;
}
for(int j = m; j >= 0; --j)
{
for(int k = n; k >= 0; --k)
{
if(j >= a && k >= b)
dp[j][k] = max(dp[j][k], dp[j - a][k - b] + 1) ;
else
dp[j][k] = dp[j][k];
}
}
}
return dp[m][n];
}
};
②力扣879. 盈利计划(二维费用背包)
难度 困难
集团里有 n
名员工,他们可以完成各种各样的工作创造利润。
第 i
种工作会产生 profit[i]
的利润,它要求 group[i]
名成员共同参与。如果成员参与了其中一项工作,就不能参与另一项工作。
工作的任何至少产生 minProfit
利润的子集称为 盈利计划 。并且工作的成员总数最多为 n
。
有多少种计划可以选择?因为答案很大,所以 返回结果模 10^9 + 7
的值。
示例 1:
输入:n = 5, minProfit = 3, group = [2,2], profit = [2,3] 输出:2 解释:至少产生 3 的利润,该集团可以完成工作 0 和工作 1 ,或仅完成工作 1 。 总的来说,有两种计划。
示例 2:
输入:n = 10, minProfit = 5, group = [2,3,5], profit = [6,7,8] 输出:7 解释:至少产生 5 的利润,只要完成其中一种工作就行,所以该集团可以完成任何工作。 有 7 种可能的计划:(0),(1),(2),(0,1),(0,2),(1,2),以及 (0,1,2) 。
提示:
1 <= n <= 100
0 <= minProfit <= 100
1 <= group.length <= 100
1 <= group[i] <= 100
profit.length == group.length
0 <= profit[i] <= 100
class Solution {
public:
int profitableSchemes(int n, int minProfit, vector<int>& group, vector<int>& profit) {
}
};
解析代码
题目结合例子多读几遍,就会发现是一个经典二维费用的背包问题。因此可以仿照二维费用的背包问题来定义状态表示。
状态表示:dp[i][j][k] 表示:从前 i 个计划中挑选,总人数不超过 j ,总利润至少为 k ,一共有多少种选法。
这道题里面出现了一个至少,和之前做过的背包问题不一样。因此在分析状态转移程的时候要结合实际情况考虑一下。
状态转移方程:
线性 dp 状态转移方程分析方式,一般都是根据最后一步的状况,来分情况讨论。结合题目的要求,有选择最后一个元素或者不选择最后一个元素两种策略:
- 不选 i 位置的计划:只能去前 i - 1 个计划中挑选,总人数不超过 j ,总利润至少为 k 。此时一共有 dp[i - 1][j][k] 种选法 。
- 选择 i 位置的计划:在前 i - 1 个计划中挑选的时候,限制就变成了,总人数不超过 j - g[i] ,总利润至少为 k - p[i] 。此时一共有 dp[i - 1][j - g[i]] [k - p[i]] 。
第二种情况下有两个细节需要注意:
- j - g[i] < 0 :此时说明 g[i] 过大,也就是人数过多。因为状态表示要求人数是不能超过 j 的,因此这个状态是不合法的,需要舍去。
- k - p[i] < 0 :此时说明 p[i] 过大,也就是利润太高。但是利润高正是我们想要的。所以这个状态不能舍去。但是问题来了,dp 表是没有负数的下标的,这就意味着这些状态无法表示。其实根本不需要负的下标,根据实际情况来看,如果这个任务的利润已经能够达标了,仅需在之前的任务中,挑选出来的利润至少为 0 就可以了。因为实际情况不允许是负利润,那么负利润就等价于利润至少为 0 的情况。所以说这种情况就等价于 dp[i][j][0] ,可以对 k - p[i] 的结果与 0 取一个 max 。
综上,状态转移方程为:dp[i][j][k] = dp[i - 1][j][k] + dp[i - 1][j - g[i - 1]][max(0, k - p[i - 1])] ;
初始化: 当没有任务的时候,利润为 0 ,此时无论人数限制为多少,都能找到一个空集的方案。 因此初始化 dp[0][j][0] 的位置为 1 ,其中 0 <= j <= n
填表顺序: 保证第一维i 从小到大即可。
返回值: 根据状态表示,返回 dp[len][n][minProfit] ,其中 len 表示字符串数组的长度。
class Solution {
public:
int profitableSchemes(int n, int minProfit, vector<int>& group, vector<int>& profit) {
// dp[i][j][k] 表示:从前i个计划中挑选,总人数不超过j,总利润至少为k,一共有多少种选法
const int MOD = 1e9 + 7; // 注意结果取模
int len = group.size();
vector<vector<vector<int>>> dp(len + 1, vector<vector<int>>(n + 1, vector<int>(minProfit + 1)));
for(int j = 0; j <= n; j++)
{
dp[0][j][0] = 1; // 初始化
}
for(int i = 1; i <= len; ++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; // 注意结果取模
}
}
}
return dp[len][n][minProfit];
}
};
代码优化
所有的背包问题,都可以进行空间上的优化。 对于二维费用的 01 背包问题,优化还是和之前的01背包类似,删掉第一维,然后修改其他维度的遍历顺序:
class Solution {
public:
int profitableSchemes(int n, int minProfit, vector<int>& group, vector<int>& profit) {
// dp[i][j][k] 表示:从前i个计划中挑选,总人数不超过j,总利润至少为k,一共有多少种选法
const int MOD = 1e9 + 7; // 注意结果取模
int len = group.size();
vector<vector<int>> dp(n + 1, vector<int>(minProfit + 1));
for(int j = 0; j <= n; j++)
{
dp[j][0] = 1; // 初始化
}
for(int i = 1; i <= len; ++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; // 注意结果取模
}
}
}
return dp[n][minProfit];
}
};
③力扣377. 组合总和 Ⅳ(似包非包)
难度 中等
给你一个由 不同 整数组成的数组 nums
,和一个目标整数 target
。请你从 nums
中找出并返回总和为 target
的元素组合的个数。
题目数据保证答案符合 32 位整数范围。
示例 1:
输入:nums = [1,2,3], target = 4 输出:7 解释: 所有可能的组合为: (1, 1, 1, 1) (1, 1, 2) (1, 2, 1) (1, 3) (2, 1, 1) (2, 2) (3, 1) 请注意,顺序不同的序列被视作不同的组合。
示例 2:
输入:nums = [9], target = 3 输出:0
提示:
1 <= nums.length <= 200
1 <= nums[i] <= 1000
nums
中的所有元素 互不相同1 <= target <= 1000
进阶:如果给定的数组中含有负数会发生什么?问题会产生何种变化?如果允许负数出现,需要向题目中添加哪些限制条件?
class Solution {
public:
int combinationSum4(vector<int>& nums, int target) {
}
};
解析代码
需要知道的是背包问题本质上求的是组合数问题,而这一道题虽然题目是组合总数,但求的是排列数问题(排列是无序的,组合是有序的),题目感觉不太严谨。(虽然不能用背包问题解决,但代码类似背包问题,所以称为似包非包)用常规的 dp 思想来解决这道题。
这道题的状态表示就是根据拆分出相同子问题的方式,抽象出来一个状态表示: 当在求 target 这个数一共有几种排列方式的时候,对于最后一个位置,如果拿出数组中的一个数 x ,接下来就是去找 target - x 一共有多少种排列方式。 因此可以抽象出来一个状态表示:
dp[i] 表示:总和为 i 的时候,一共有多少种排列方案
状态转移方程:
线性 dp 状态转移方程分析方式,一般都是根据最后一步的状况,可以选择数组中的任意一个数 nums[j] ,其中 0 <= j <= n - 1 。
当 nums[j] <= target 的时候,此时的排列数等于先找到 target - nums[j] 的方案数,然后在每t一个方案后面加上一个数字 nums[j] 即可。 因为有很多个 j 符合情况,因此状态转移方程为: dp[i] += dp[target - nums[j]] ,其中 0 <= j <= n - 1 。
初始化: 和为 0 的时候,可以什么都不选,空集一种方案,因此 dp[0] = 1 。
填表顺序: 根据状态转移方程易得从左往右。
返回值: 根据状态表示,返回 dp[target]。
class Solution {
public:
int combinationSum4(vector<int>& nums, int target) {
// dp[i] 表示:总和为 i 的时候,一共有多少种排列方案
vector<double> dp(target + 1, 0); // double防溢出
dp[0] = 1;
int sz = nums.size();
for(int i = 1; i <= target; ++i)
{
for(int j = 0; j < sz; ++j)
{
if(i >= nums[j])
dp[i] += dp[i - nums[j]];
}
}
return dp[target];
}
};
④力扣96. 不同的二叉搜索树(卡特兰数)
难度 中等
给你一个整数 n
,求恰由 n
个节点组成且节点值从 1
到 n
互不相同的 二叉搜索树 有多少种?返回满足题意的二叉搜索树的种数。
示例 1:
输入:n = 3 输出:5
示例 2:
输入:n = 1 输出:1
提示:
1 <= n <= 19
class Solution {
public:
int numTrees(int n) {
}
};
解析代码
这道题属于卡特兰数的一个应用,同样能解决的问题还有合法的进出栈序列、括号匹配的括号序列、电影购票等等。如果感兴趣的可以继续百度搜索卡特兰数,会有很多详细的介绍。
这道题的状态表示就是根据拆分出相同子问题的方式,抽象出来一个状态表示: 当在求个数为 n 的 BST(二叉搜索树) 的个数的时候,当确定一个根节点之后,左右子树的结点个数也确定了。此时左右子树就会变成相同的子问题,因此可以这样定义状态表示: dp[i] 表示:当结点的数量为 i 个的时候,一共有多少颗 BST 。 难的是如何推导状态转移方程,因为它跟之前常见的状态转移方程不是很像。
状态转移方程:
对于 dp[i] ,此时已经有 i 个结点了,为了方便叙述,将这 i 个结点排好序,并且编上 1、2、3、4、5.....i 的编号。那么对于所有不同的 BST ,可以按照下面的划分规则,分成不同的 i 类:按照不同的头结点来分类。分类结果就是:
- 头结点为 1 号结点的所有 BST
- 头结点为 2 号结点的所有 BST
- ......
如果能求出每一类中的 BST 的数量,将所有类的 BST 数量累加在一起,就是最后结果。
接下来选择头结点为 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 号结点作为头结点的 BST 的种类一共有 dp[j - 1] *dp[i - j] 种。因此只要把不同头结点的 BST 数量累加在⼀起,就能得到 dp[i] 的值: dp[i] += dp[j - 1] * dp[i - j] ( 1 <= j <= i) (这个公式就是卡特兰数的递推公式)。注意用的是 += ,并且 j 从 1 变化到 i 。
初始化: 注意到每一个状态转移里面的 j - 1 和 i - j 都是小于 i 的,并且可能会用到前一个的状态(当 i = 1,j = 1 的时候,要用到 dp[0] 的数据)。因此要先把第一个元素初始化。 当 i = 0 的时候,表示一颗空树,空树也是一颗二叉搜索树,因此 dp[0] = 1 。
填表顺序: 根据状态转移方程易得从左往右。
返回值: 根据状态表示,返回 dp[n]。
class Solution {
public:
int numTrees(int n) {
vector<int> dp(n + 1, 0);
// dp[i] 表示:当结点的数量为 i 个的时候,一共有多少颗 BST
dp[0] = 1; // 空树也是二叉搜索树
for(int i = 1; i <= n; ++i) // 枚举结点个数
{
for(int j = 1; j <= i; ++j) // 枚举每⼀个根节点
{
dp[i] += dp[j - 1] * dp[i - j];
}
}
return dp[n];
}
};
本篇完。
下一篇:BFS解决拓扑排序类型的OJ。
下下篇是递归中偏难的递归搜索与回溯类型的OJ。