动态规划 - 二维费用的背包问题、似包非包、卡特兰数
相关动态规划问题
在算法题中,很多问题不会直接标注 “这是背包问题”,但只要出现以下几类关键 “字眼”,就大概率能和背包模型挂钩,帮我们快速锁定解题方向。结合之前提到的二维费用背包、“似包非包” 问题,我们可以按场景拆解这些核心特征:
一、基础背包问题的核心 “信号词”(通用识别点)
无论哪种背包,核心逻辑都是 “有限资源下的选择优化”,因此出现以下表述时,先往背包方向联想:
- 资源约束类:“容量”“重量”“体积”“时间”“次数”“数量上限”—— 这些是背包的 “约束维度”,比如 “背包容量为 V”“每件物品重量不超过 W”,直接对应经典背包的 “背包容量”“物品重量” 核心参数。
- 选择目标类:“最大价值”“最小消耗”“能否装满”“恰好达到某状态”—— 这是背包问题的 “优化目标”,比如 “选择物品使总价值最大”“能否用物品装满容量为 V 的背包”,完美匹配 0-1 背包、完全背包的经典目标。
- 物品属性类:“每件物品只能选一次”(0-1 背包)、“每件物品可选无限次”(完全背包)、“每件物品最多选 k 次”(多重背包)—— 这类 “选择次数限制” 的描述,是区分背包类型的关键,直接决定状态转移方程的写法。
二、二维费用背包的专属 “提示词”
当问题中出现两个独立的约束维度时,基本可判定为二维费用背包,常见组合如下:
- 双重资源约束:“重量不超过 W,体积不超过 V”“花费不超过 C,时间不超过 T”—— 比如 “每件物品有重量和体积,背包同时限制最大重量和最大体积,求最大价值”,这里 “重量 + 体积”“花费 + 时间” 就是典型的二维约束。
- 目标与约束结合:“在满足 A 条件(如总数量≤K)的前提下,最大化 B 目标(如总价值),同时满足 C 约束(如总重量≤W)”—— 当约束条件不止一个,且彼此独立时,优先考虑二维(或多维)费用背包模型。
三、“似包非包” 问题的隐藏 “线索词”
这类问题最隐蔽,但只要抓住 “选择的累积效应” 和 “状态依赖前序选择”,就能关联背包思维,常见表述有:
- 拆分 / 组合类:“将一个数拆成若干个数的和,求方案数”“从数组中选元素,使和为某值,求最小 / 最大代价”—— 比如 “拆分 n 为若干个不重复的正整数,求拆分方案数”,本质是 “物品为 1~n,背包容量为 n,每件物品选一次” 的 0-1 背包计数问题。
- 限制条件类:“选 k 个元素,使总和不超过 S,求最大价值”“完成任务需要满足两个条件(如 A≥x,B≥y),求最小成本”—— 这类 “多条件限制 + 选择优化” 的问题,往往可转化为 “背包容量为 k+S”“二维约束 A 和 B” 的背包模型,只是 “物品” 和 “容量” 的定义更灵活。
简单来说,只要题目中出现 “选择”“约束”“优化目标” 这三大核心要素,且约束是 “有限的资源”,目标是 “最大化 / 最小化某值” 或 “计数方案数”,就可以优先尝试用背包的思维去拆解 —— 先定义 “背包容量”(约束条件)、“物品”(可选择的元素 / 操作)、“价值”(优化目标),再推导状态转移方程,多数时候能迎刃而解。
题目练习
474. 一和零 - 力扣(LeetCode)
解法(动态规划):
算法思路:
先将问题转化成我们熟悉的题型。
-
ⅰ. 在一些物品中 「挑选」一些出来,然后在满足某个「限定条件」 下,解决一些问题,大概率是背包模型;
-
ⅱ. 由于每一个物品都只有
1
个,因此是一个 「01 背包问题」。
但是,我们发现这一道题里面有 「两个限制条件」。因此是一个「二维费用的 01 背包问题」。那么我们定义状态表示的时候,来一个三维 dp
表,把第二个限制条件加上即可。
1. 状态表示:
dp[i][j][k]
表示:从前 i
个字符串中挑选,字符 0
的个数不超过 j
,字符 1
的个数不超过 k
,所有的选法中,最大的长度。
2. 状态转移方程:
线性 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][j][k], dp[i - 1][j - a][k - b] + 1)
。
3. 初始化:
当没有字符串的时候,没有长度,因此初始化为 0
即可。
4. 填表顺序:
保证第一维的循环 **「从小到大」** 即可。
5. 返回值:
根据 「状态表示」,我们返回 dp[len][m][n]
。
其中 len
表示字符串数组的长度。
class Solution {
public:int findMaxForm(vector<string>& strs, int m, int n) {int len = strs.size();vector<vector<vector<int>>> dp(len + 1, vector<vector<int>>(m + 1, vector<int>(n + 1)));for(int i = 1; i <= len; ++i){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);}}return dp[len][m][n];}
};
6. 空间优化:
所有的 **「背包问题」**,都可以进行空间上的优化。
对于 **「二维费用的 01 背包」** 类型的,我们的优化策略是:
- ⅰ. 删掉第一维;
- ⅱ. 修改第二层以及第三层循环的遍历顺序即可。
class Solution {
public:int findMaxForm(vector<string>& strs, int m, int n) {int len = strs.size();vector<vector<int>> dp(m + 1, vector<int>(n + 1));for(int i = 0; i < len; ++i){int a = 0, b = 0;for(auto ch : strs[i]) 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);}}return dp[m][n];}
};
879. 盈利计划 - 力扣(LeetCode)
解法(动态规划):
算法思路:
这道题目非常难读懂,但是如果结合例子多读几遍,你就会发现是一个经典的 「二维费用的背包问题」。因此我们可以仿照「二维费用的背包」 来定义状态表示。
1. 状态表示:
dp[i][j][k]
表示:从前 i
个计划中挑选,总人数不超过 j
,总利润至少为 k
,一共有多少种选法。
注意注意注意,这道题里面出现了一个 「至少」,和我们之前做过的背包问题不一样。因此,我们在分析「状态转移方程」 的时候要结合实际情况考虑一下。
2. 状态转移方程:
老规矩,根据 「最后一个位置」的元素,结合题目的要求,我们有「选择」最后一个元素或者「不选择」 最后一个元素两种策略:
-
ⅰ. 不选
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])]
。
3. 初始化:
当没有任务的时候,我们的利润为 0
,此时无论人数限制为多少,我们都能找到一个 「空集」 的方案。
因此初始化 dp[0][j][0]
的位置为 1
,其中 0 <= j <= n。
4. 填表顺序:
根据 「状态转移方程」,我们保证 i
从小到大即可。
5. 返回值:
根据 「状态表示」,我们返回 dp[len][m][n]
。
其中 len
表示字符串数组的长度。
class Solution {
public:const int MOD = 1e9 + 7;int profitableSchemes(int n, int m, vector<int>& g, vector<int>& p) {int len = g.size();vector<vector<vector<int>>> dp(len + 1, vector<vector<int>>(n + 1, vector<int>(m + 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 <= m; ++k){dp[i][j][k] = dp[i - 1][j][k];if(j >= g[i - 1]) dp[i][j][k] += dp[i - 1][j - g[i - 1]][max(0, k - p[i - 1])];dp[i][j][k] %= MOD;}return dp[len][n][m];}
};
6. 空间优化:
所有的 「背包问题」,都可以进行空间上的优化。
对于 「二维费用的 01 背包」 类型的,我们的优化策略是:
- ⅰ. 删掉第一维;
- ⅱ. 修改第二层以及第三层循环的遍历顺序即可。
class Solution {
public:const int MOD = 1e9 + 7;int profitableSchemes(int n, int m, vector<int>& g, vector<int>& p) {int len = g.size();vector<vector<int>> dp(n + 1, vector<int>(m + 1));for(int j = 0; j <= n; ++j) dp[j][0] = 1;for(int i = 1; i <= len; ++i)for(int j = n; j >= g[i - 1]; --j)for(int k = m; k >= 0; --k){dp[j][k] += dp[j - g[i - 1]][max(0, k - p[i - 1])];dp[j][k] %= MOD;}return dp[n][m];}
};
377. 组合总和 Ⅳ - 力扣(LeetCode)
解法(动态规划):
算法思路:
一定要注意,我们的背包问题本质上求的是 「组合」数问题,而这一道题求的是「排列数」 问题。因此我们不能被这道题给迷惑,还是用常规的 dp
思想来解决这道题。
1. 状态表示:
这道题的状态表示就是根据 「拆分出相同子问题」 的方式,抽象出来一个状态表示:
当我们在求 target
这个数一共有几种排列方式的时候,对于最后一个位置,如果我们拿出数组中的一个数 x
,接下来就是去找 target - x
一共有多少种排列方式。
因此我们可以抽象出来一个状态表示:
dp[i]
表示:总和为 i
的时候,一共有多少种排列方案。
2. 状态转移方程:
对于 dp[i]
,我们根据 「最后一个位置」 划分,我们可以选择数组中的任意一个数 nums[j]
,其中 0 <= j <= n - 1。
当 nums[j] <= target
的时候,此时的排列数等于我们先找到 target - nums[j]
的方案数,然后在每一个方案后面加上一个数字 nums[j]
即可。
因为有很多个 j
符合情况,因此我们的状态转移方程为:dp[i] += dp[target - nums[j]]
,其中 0 <= j <= n - 1。
3. 初始化:
当和为 0
的时候,我们可以什么都不选,「空集」 一种方案,因此 dp[0] = 1
。
4. 填表顺序:
根据 「状态转移方程」易得「从左往右」。
5. 返回值:
根据 「状态表示」,我们要返回的是 dp[target]
的值。
class Solution {
public:int combinationSum4(vector<int>& nums, int target) {vector<double> dp(target + 1);dp[0] = 1;for(int i = 1; i <= target; ++i)for(auto x : nums)if(i >= x) dp[i] += dp[i - x];return dp[target];}
};
96. 不同的二叉搜索树 - 力扣(LeetCode)(卡特兰数 --- 不是背包问题)
解法(动态规划):
算法思路:
这道题属于 「卡特兰数」的一个应用,同样能解决的问题还有「合法的进出栈序列」、「括号匹配的括号序列」、「电影购票」等等。如果感兴趣的同学可以「百度」 搜索卡特兰数,会有很多详细的介绍。
1. 状态表示:
这道题的状态表示就是根据 「拆分出相同子问题」 的方式,抽象出来一个状态表示:
当我们在求个数为 n
的 BST
的个数的时候,当确定一个根节点之后,左右子树的结点 「个数」 也确定了。此时左右子树就会变成相同的子问题,因此我们可以这样定义状态表示:
dp[i]
表示:当结点的数量为 i
个的时候,一共有多少颗 BST
。
难的是如何推导状态转移方程,因为它跟我们之前常见的状态转移方程不是很像。
2. 状态转移方程:
对于 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
」。
3. 初始化:
我们注意到,每一个状态转移里面的 j - 1
和 i - j
都是小于 i
的,并且可能会用到前一个的状态(当 i = 1,j = 1
的时候,要用到 dp[0]
的数据)。因此要先把第一个元素初始化。
当 i = 0
的时候,表示一颗空树,「空树也是一颗二叉搜索树」,因此 dp[0] = 1
。
4. 填表顺序:
根据 「状态转移方程」,易得「从左往右」。
5. 返回值:
根据 「状态表示」,我们要返回的是 dp[n]
的值。
class Solution {
public:int numTrees(int n) {vector<int> dp(n + 1);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];}
};