动态规划(DP)作为算法领域的核心思想之一,其魅力在于能够将复杂问题分解为可管理的子问题。在掌握了基础DP模型后,我们需要挑战更高维度的思考方式。本文将深入探讨三种高阶DP模型:多维费用背包、排列数问题和数学DP(以卡特兰数为代表),这些技术不仅是算法竞赛的常客,更是面试中的高频考点。
传统背包问题通常只考虑单一限制条件(如背包容量)。但在现实场景中,决策往往受到多重约束:
这些场景催生了多维DP模型的发展。理解这些高阶技术,你将能够:
给定二进制字符串数组strs,整数m和n。要求选择尽可能多的字符串,使得所有选中字符串中:
示例:
输入:strs = ["10","0001","1","0"], m = 5, n = 3
输出:4(全选时:0共4个≤5,1共3个≤3)
这是一个典型的二维费用01背包问题:
与传统01背包的区别在于,现在有两个"背包容量"需要同时满足。
三维状态定义:
dp[k][i][j] = 前k个字符串中,'0'不超过i个,'1'不超过j个的最大选择数量
状态转移方程:
对于第k个字符串(zeros个0,ones个1):
初始三维实现:
cpp复制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, 0)));
for(int k=1; k<=len; k++){
int zeros = count(strs[k-1].begin(), strs[k-1].end(), '0');
int ones = strs[k-1].size() - zeros;
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 >= zeros && j >= ones){
dp[k][i][j] = max(dp[k][i][j],
dp[k-1][i-zeros][j-ones] + 1);
}
}
}
}
return dp[len][m][n];
}
};
空间优化技巧:
观察到状态转移只依赖前一行,可以压缩为二维数组。关键点:
cpp复制class Solution {
public:
int findMaxForm(vector<string>& strs, int m, int n) {
vector<vector<int>> dp(m+1, vector<int>(n+1, 0));
for(const auto& s : strs){
int zeros = count(s.begin(), s.end(), '0');
int ones = s.size() - zeros;
for(int i=m; i>=zeros; i--){
for(int j=n; j>=ones; j--){
dp[i][j] = max(dp[i][j], dp[i-zeros][j-ones]+1);
}
}
}
return dp[m][n];
}
};
关键提示:二维费用背包的空间优化必须同时逆序遍历两个费用维度,这是区别于传统背包的重要细节。
有n个员工和一组工作。第i个工作:
这是二维费用背包的变种:
关键难点:利润是下限约束,直接表示会导致数组越界(利润可以无限大)
解决方案:
将所有利润≥minProfit的状态压缩到dp[][minProfit]中
状态定义:
dp[k][i][j] = 前k个工作,使用i人,利润至少为j的方案数
状态转移:
对于工作k(需要g人,产生p利润):
初始版本:
cpp复制class Solution {
public:
int profitableSchemes(int n, int minProfit,
vector<int>& group, vector<int>& profit) {
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, 0)));
// 初始化:0个工作,0人,利润≥0的方案数为1(空集)
for(int i=0; i<=n; i++) dp[0][i][0] = 1;
for(int k=1; k<=len; k++){
int g = group[k-1], p = profit[k-1];
for(int i=0; i<=n; i++){
for(int j=0; j<=minProfit; j++){
dp[k][i][j] = dp[k-1][i][j];
if(i >= g){
dp[k][i][j] = (dp[k][i][j] +
dp[k-1][i-g][max(0, j-p)]) % MOD;
}
}
}
}
return dp[len][n][minProfit];
}
};
空间优化版:
cpp复制class Solution {
public:
int profitableSchemes(int n, int minProfit,
vector<int>& group, vector<int>& profit) {
const int MOD = 1e9+7;
vector<vector<int>> dp(n+1, vector<int>(minProfit+1, 0));
for(int i=0; i<=n; i++) dp[i][0] = 1;
for(int k=0; k<group.size(); k++){
int g = group[k], p = profit[k];
for(int i=n; i>=g; i--){
for(int j=minProfit; j>=0; j--){
dp[i][j] = (dp[i][j] + dp[i-g][max(0, j-p)]) % MOD;
}
}
}
return dp[n][minProfit];
}
};
给定不同整数数组nums和目标target,计算和为target的组合数。注意:(1,2)和(2,1)视为不同组合。
在传统完全背包问题(如零钱兑换II)中:
而本题要求排列数,关键在于:
dp[j] = 和为j的排列数
转移方程:
dp[j] += dp[j - nums[i]] 对所有nums[i] ≤ j
遍历顺序的重要性:
cpp复制class Solution {
public:
int combinationSum4(vector<int>& nums, int target) {
vector<unsigned long long> dp(target+1, 0);
dp[0] = 1;
for(int j=1; j<=target; j++){
for(int num : nums){
if(j >= num){
dp[j] += dp[j - num];
}
}
}
return dp[target];
}
};
注意:使用unsigned long long防止中间结果溢出,尽管题目保证最终结果在int范围内。
给定整数n,求由1...n构成的互不相同的BST数量。
对于n个节点的BST:
这正好是卡特兰数的递推公式:
C(n) = Σ C(i-1)*C(n-i) for i=1..n
cpp复制class Solution {
public:
int numTrees(int n) {
vector<int> dp(n+1, 0);
dp[0] = 1; // 空树
dp[1] = 1;
for(int i=2; i<=n; i++){
for(int j=1; j<=i; j++){
dp[i] += dp[j-1] * dp[i-j];
}
}
return dp[n];
}
};
经过这些例题,我们可以总结高阶DP的解题模式:
维度扩展:
约束转化:
顺序敏感:
数学模型:
| 技巧 | 适用场景 | 实现要点 |
|---|---|---|
| 双重逆序 | 二维费用背包优化 | 两个费用维度都需逆序遍历 |
| 下限处理 | 利润/收益下限约束 | 使用max(0, j-p)避免负索引 |
| 排列遍历 | 顺序敏感问题 | 外层循环背包容量 |
| 数学递推 | 卡特兰数类问题 | 分解为子问题乘积和 |
理解这些高阶DP模型后,建议通过以下步骤巩固:
记住,DP能力的提升在于:
当你能够自如地处理这些多维、多约束的DP问题时,就已经站在了算法能力的上层梯队。保持练习,这些技术终将成为你解决复杂问题的利器。