参考:分享丨【算法题单】动态规划(入门/背包/划分/状态机/区间/状压/数位/树形/优化)- 讨论 - 力扣(LeetCode)
一、入门 DP
二、网格图DP
对于一些二维 DP(例如背包、最长公共子序列),如果把 DP 矩阵画出来,其实状态转移可以视作在网格图上的移动。所以在学习相对更抽象的二维 DP 之前,做一些形象的网格图 DP 会让后续的学习更轻松(比如 0-1 背包的空间优化写法为什么要倒序遍历)。
2.1 基础
Leetcode 931. 下降路径最小和
思路:
f(row, col)表示前row行,最终停留在(row, col)位置处的最小路径值,这个值是后续行的子问题。同样的,f(row - 1, col - 1)、f(row - 1, col)、f(row - 1, col + 1)也是f(row, col)的子问题。即状态转移公式为
f(row, col) = min(f(row - 1, col - 1),f(row - 1, col),f(row - 1, col + 1)) + mat[row][col]。
在实现的时候注意细节,也就是边界的问题,col-1有可能会越界,所以我们统一对col列进行+1的映射。所以此时的状态转移公式为:
f(row, col+1)=min(f(row - 1, col),f(row - 1, col + 1),f(row - 1, col + 2))+mat[row][col]。
初始化的细节,f[0][c+1] 初始化为mat[0][c],其余位置都设置为正无穷。
Code:
class Solution { public: int minFallingPathSum(vector<vector<int>>& matrix) { int n = matrix.size(), f[n][n + 2]; memset(f, 0x3f, sizeof f); for (int i = 0; i < n; i ++) f[0][i + 1] = matrix[0][i]; for (int r = 1; r < n; r ++) for (int c = 0; c < n; c ++) f[r][c + 1] = min(min(f[r - 1][c], f[r - 1][c + 1]), f[r - 1][c + 2]) + matrix[r][c]; return *min_element(f[n - 1] + 1, f[n - 1] + 1 + n); // 最后一行的最小元素 } };
2.2 进阶
Leetcode 3363. 最多可收集的水果数目
思路:
首先根据每个人恰好移动n-1次,可以发现中间的人只能走对角线。
剩余两个人实际上是一个问题,可以通过翻转转化为同一个dp的解法,思路和Leetcode 931类似。只不过,需要注意,i和j的取值范围,简单来说就是下面的限制。
Code:
class Solution { public: int maxCollectedFruits(vector<vector<int>>& fruits) { int n = fruits.size(); auto dp = [&] () -> int { vector f(n - 1, vector<int> (n + 1)); f[0][n - 1] = fruits[0][n - 1]; for (int i = 1; i < n - 1; i ++) for (int j = max(n - 1 - i, i + 1); j < n; j ++) f[i][j] = max({f[i - 1][j - 1], f[i - 1][j], f[i - 1][j + 1]}) + fruits[i][j]; return f[n - 2][n - 1]; }; int res = 0; // 从(0,0)出发的人->只能走对角线才能满足使用n-1步到达目标地点。 for (int i = 0; i < n; i ++) res += fruits[i][i]; // 从(0,n-1)出发的人 res += dp(); // 从(n - 1, 0)的人(将下三角翻转到上三角中,可以复用dp函数) for (int i = 0; i < n; i ++) for (int j = 0; j < i; j ++) fruits[j][i] = fruits[i][j]; return res + dp(); } };
三、背包
3.1 0-1背包
任务限制:每个物品只能选一次。
0-1背包模板及推导:
const int N = 1010; int v[N], w[N]; int f[N]; int main() { int n, V; cin >> n >> V; for (int i = 1; i <= n ; i ++) cin >> v[i] >> w[i]; // 1 ~ n 读入 for(int i = 1; i <= n ; i ++) // 1 ~ n for(int j = V; j >= v[i]; j --) // V(总体积) ~ v[i] 均为闭区间 f[j]= max(f[j], f[j - v[i]] + w[i]); cout << f[V] << endl; return 0; }
Leetcode 416. 分割等和子集
示例 1:
输入:nums = [1,5,11,5] 输出: true 解释:数组可以分割成 [1, 5, 5] 和 [11]
数据范围:1 <= nums.length <= 200;1 <= nums[i] <= 100
思路:
设 nums 的元素和为 s,如果 s 是奇数, s/2不是整数,直接返回 false。
如果 s 是偶数,问题相当于:能否从 nums 中选出一个子序列,其元素和恰好等于 s/2?这可以用「恰好装满」型 0-1 背包解决。
状态表示:定义 dfs(i,j) 表示能否从 nums[0] 到 nums[i] 中选出一个和恰好等于s/2的子序列。
记忆化搜索写法:
class Solution { public: bool canPartition(vector<int>& nums) { // 恰好型0-1背包 int s = reduce(nums.begin(), nums.end()); if (s % 2) return false; int n = nums.size(); vector<vector<int>> memo(n, vector<int> (s / 2 + 1, -1)); // -1表示没用过 auto dfs = [&](this auto&& dfs, int i, int j)->bool{ if (i < 0) return j == 0; int& res = memo[i][j]; // 注意这里是引用 if (res != -1) return res; return res = (j >= nums[i] && dfs(i - 1, j - nums[i]) || dfs(i - 1, j)); }; return dfs(n - 1, s / 2); } };
空间优化递推写法:
关注初始值的含义,f[i][j]表示是否能用前i个数字表示出数字j。这里面省略了第一维,相当于f[0][0]不用数字表示出数字0,这个是成立的,故初始化f[0] = true;
class Solution { public: bool canPartition(vector<int>& nums) { // 空间优化dp写法 int s = reduce(nums.begin(), nums.end()); if (s % 2) return false; int V = s / 2; // 背包容量 vector<bool> f(V + 1, false); f[0] = true; for (int x: nums) for (int j = V; j >= x; j --) f[j] = f[j] || f[j - x]; return f[V]; } };
Leetcode 494. 目标和
思路:关键在于想到转化为背包问题(求方案数)
从统计的角度来看问题。
Code:(未优化空间版)
class Solution { public: int findTargetSumWays(vector<int>& nums, int target) { int s = reduce(nums.begin(), nums.end()) - abs(target); if (s < 0 || s % 2) { return 0; } int m = s / 2; // 背包容量 int n = nums.size(); vector f(n + 1, vector<int>(m + 1)); f[0][0] = 1; for (int i = 0; i < n; i++) { for (int c = 0; c <= m; c++) { if (c < nums[i]) { f[i + 1][c] = f[i][c]; // 只能不选 } else { f[i + 1][c] = f[i][c] + f[i][c - nums[i]]; // 不选 + 选 } } } return f[n][m]; } };
Code:(空间优化递推)
注意这边的初始化,f[0]表示f[0][0],即用空间=0的背包装前0个东西的方案数,就是一个!!!
class Solution { public: int findTargetSumWays(vector<int>& nums, int target) { int s = reduce(nums.begin(), nums.end()) - abs(target); if (s < 0 || s % 2) return 0; int m = s / 2; // 背包容量 vector<int> f(m + 1); f[0] = 1; // 表示方案数(啥都不选就是) for (int x: nums) for (int c = m; c >= x; c --) f[c] += f[c - x]; return f[m]; } };
Leetcode 2787. 将一个数字表示成幂的和的方案数
思路:
把 n 看成背包容量,1^x,2^x,3^x,… 看成物品,本题是恰好装满型 0-1 背包的方案数。
代码实现时,由于最坏情况 n=300,x=1 的答案没有超过 64 位整数的最大值,可以在计算结束后再取模。(求方案数-不选的话方案数是不会增加的,写法同494。)
Code: 同样注意这里的初始化-f[0]的含义!
class Solution { public: int numberOfWays(int n, int x) { vector<long long> f(n + 1); f[0] = 1; // 数据范围小,pow的计算结果是准确 for (int i = 1; pow(i, x) <= n; i ++) { int v = pow(i, x); for (int s = n; s >= v; s --) f[s] += f[s - v]; } return f[n] % int(1e9 + 7); } };
3.2 完全背包
3.3 多重背包(选做)
3.4 分组背包
四.经典线性DP
Leetcode 3177 求出最长好子序列 II
题型分析:
(1)记忆化搜索(能过小数据)时间复杂度为O(n²k)。母题来源于LIS
class Solution {
public:
// 状态数是n·k,状态专题的时间复杂度是n,所以总的时间复杂度是O(n²·k)
int maximumLength(vector<int>& nums, int k) {
int n = nums.size();
int f[n][k + 1];
memset(f, -1, sizeof f);
// dp(i, x)表示以nums[i]结尾的,有至多x对相邻元素不同 的 最长子序列的长度
function<int(int, int)> dp = [&](int i, int x)->int{
if(x < 0) return 0;
if(i == 0) return 1;
if(f[i][x] != -1) return f[i][x];
int res = INT_MIN;
for(int j = 0; j < i; j ++){
if(nums[j] == nums[i])
res = max(res, dp(j, x) + 1);
else
res = max(res, dp(j, x - 1) + 1);
}
return f[i][x] = res;
};
int ans = 0;
for(int i = 0; i < n; i ++)
ans = max(ans, dp(i, k));
return ans;
}
};
(2)优化:从值域角度考虑:时间复杂度为O(n·k)
class Solution {
public:
int maximumLength(vector<int>& nums, int k) {
// 按照出现的值进行优化, 而非下标(状态转移必然要O(n)),由于值域1e9,所以用hash
unordered_map<int, vector<int>> fs;
vector<int> mx(k + 1, 0);
// 为了实现O(1)的状态转移,需要用O(1)的时间知道f[y][j - 1] for y in set的最大值,用mx维护
for(int x: nums){
auto& f = fs[x];
f.resize(k + 1);
for(int j = k; j >= 0; j --){
// 这边j要倒序(思想类似于背包),防止污染上一轮的mx[j - 1]
f[j] += 1; // 必定会+1(把x放到x结尾的子序列的末尾)sit1:
if(j > 0) f[j] = max(f[j], mx[j - 1] + 1); // sit2: 加到y结尾的子序列末尾(y!=x)
// mx[j - 1]就表示了max(f[y][j] for y in set)这里y是可以等于x的,如果也是mx[j -1]的子序列就是与x相等的
//那么mx[j-1]就变成了f[x][j - 1],相较于f[x][j]甚至条件更严格,所以只会比上面的f[x][j]小,不影响答案。
mx[j] = max(mx[j], f[j]); // 及时比较更新
}
}
return mx[k];
}
};
Leetcode 198. 打家劫舍
法一:记忆化搜索(自顶向下)
时间与空间均为O(n)
class Solution {
public:
int rob(vector<int>& nums) {
int n = nums.size();
vector<int> f(n, -1);
function<int(int)> dp = [&](int i)-> int{
if(i < 0) return 0;
if(f[i] != -1) return f[i];
int res = 0;
res = max(dp(i - 1), dp(i - 2) + nums[i]);
f[i] = res;
return res;
};
return dp(n - 1);
}
};
法二:动态规划&&空间优化
dp: 时间与空间均为O(n)
class Solution {
public:
int rob(vector<int>& nums) {
int n = nums.size();
vector<int> f(n + 2, 0);
for(int i = 0; i < n; i ++)
f[i + 2] = max(f[i + 1], nums[i] + f[i]);
return f[n + 1];
}
};
空间优化(滚动数组)--空间复杂度优化到O(1)
class Solution {
public:
int rob(vector<int>& nums) {
int n = nums.size();
int f1 = 0, f2 = 0, f3 = 0;
for(int i = 0; i < n; i ++){
f3 = max(f2, nums[i] + f1);
int t = f2;
f2 = f3;
f1 = t;
}
return f2;
}
};
Leetcode 213. 打家劫舍 II
Leetcode 337. 打家劫舍 III
Leetcode 2560. 打家劫舍 IV
Leetcode 2140. 解决智力问题
这题区别于打家劫舍198,“记忆化搜索”从左向右去搜。
class Solution {
public:
long long mostPoints(vector<vector<int>>& questions) {
int n = questions.size();
vector<long long> f(n, -1);
auto dp = [&](this auto&& dp, int i)->long long{
if(i >= n) return 0;
if(f[i] != -1) return f[i];
return f[i] = max(dp(i + 1), dp(i + questions[i][1] + 1) + questions[i][0]);
}; // 注意:这里写function可能会被卡时间
return dp(0);
}
};
如果都需要写递归,如何确定自左向右还是自右向左?
Leetcode 121. 买卖股票的最佳时机
Leetcode 122. 买卖股票的最佳时机 II
Leetcode 123. 买卖股票的最佳时机 III
Leetcode 188. 买卖股票的最佳时机 IV
Leetcode 53. 最大子数组和
方法一:DP
class Solution {
public:
int maxSubArray(vector<int>& nums) {
int n = nums.size();
vector<int> f(n + 1, -2e9);
if(nums[0] > -2e9) f[0] = nums[0];
for(int i = 1; i < n; i ++){
f[i] = max(nums[i], f[i - 1] + nums[i]);
}
int res = -2e9;
for(int i = 0; i < n; i ++) res = max(res, f[i]);
return res;
}
};
方法二:前缀和
转换为Leetcode 121. 买卖股票的最佳时机。
class Solution {
public:
int maxSubArray(vector<int>& nums) {
int n = nums.size(), res = -2e9;
int min_pre_sum = 0;
int pre_sum = 0;
for(int i = 0; i < n; i ++){
pre_sum += nums[i]; // 维护出前缀和
res = max(res, pre_sum - min_pre_sum); // 减去前缀和的最小值
min_pre_sum = min(min_pre_sum, pre_sum); // 维护前缀和的最小值
}
return res;
}
};
Leetcode 3129. 找出所有稳定的二进制数组 I/II
方法一:记忆化搜索
class Solution {
public:
int f[1010][1010][2];
int numberOfStableArrays(int zero, int one, int limit) {
int MOD = 1e9 + 7;
memset(f, -1, sizeof f);
// dp(i,j,k)表示用i个0个和j个1构造合法方案数,其中第i+j的位置填k
function<int(int, int, int)> dp = [&](int i, int j, int k) -> int{
if(i < 0 || j < 0) return 0;
if(i == 0) return k == 1 && j <= limit;
if(j == 0) return k == 0 && i <= limit;
if(f[i][j][k] != -1) return f[i][j][k];
int res = 0; // 方案数
if(k == 0) // +MOD保证答案非负
res = ((long long)dp(i - 1, j, 0) + dp(i - 1, j, 1) - dp(i - limit - 1, j, 1) + MOD) % MOD;
else
res = ((long long)dp(i, j - 1, 0) + dp(i, j - 1, 1) - dp(i, j - limit - 1, 0) + MOD) % MOD;
f[i][j][k] = res;
return res;
};
return (dp(zero, one, 1) + dp(zero, one, 0)) % MOD;
}
};
8/7日每日一题,错因:当j>limit的时候显然是错误情况需要返回0。
在下面的dp方法中,体现在
方法二:DP
由记忆化搜索1:1转化来,主要注意:
①初始条件:分别进行初始化,zero和limit取min(不一定够用)
②i - limit - 1可能会越界,越界的部分都是0,这里用问号冒号表达式处理了
③内部计算可能会超出int范围,所有用longlong, +MOD )%MOD
class Solution {
public:
int MOD = 1e9 + 7;
int f[1010][1010][2] = {0};
int numberOfStableArrays(int zero, int one, int limit) {
// 初始化
for(int i = 1; i <= min(zero, limit); i ++)
f[i][0][0] = 1;
for(int i = 1; i <= min(one, limit); i ++)
f[0][i][1] = 1;
// 递推
for(int i = 1; i <= zero; i ++)
for(int j = 1; j <= one; j ++){
f[i][j][0] = ((long long)f[i - 1][j][0] + f[i - 1][j][1] - (i >= limit + 1? f[i - limit - 1][j][1]: 0) + MOD) % MOD;
f[i][j][1] = ((long long)f[i][j - 1][0] + f[i][j - 1][1] - (j >= limit + 1? f[i][j - limit - 1][0]: 0) + MOD) % MOD;
}
return ((long long)f[zero][one][0] + f[zero][one][1]) % MOD;
}
};
Leetcode 1035. 不相交的线
思考过程&题解:
class Solution {
public:
int f[510][510] = {0};
int maxUncrossedLines(vector<int>& nums1, vector<int>& nums2) {
int n = nums1.size(), m = nums2.size();
memset(f, -1, sizeof f);
function<int(int, int)> dp = [&](int i, int j) -> int{
if(i < 0 || j < 0) return 0; // 有一端没有数字就连接不了
if(f[i][j] != -1) return f[i][j];
int res = 0;
if(nums1[i] == nums2[j]) res = dp(i - 1, j - 1) + 1;
else res = max(dp(i - 1, j), dp(i, j - 1));
f[i][j] = res;
return res;
};
return dp(n - 1, m - 1);
}
};
Leetcode 552. 学生出勤记录 II
思路:
代码:
class Solution {
public:
const static int MOD = 1e9 + 7;
int checkRecord(int n) {
int f[n + 2][3][5];
memset(f, -1, sizeof f);
auto dp = [&](auto&& dp, int i, int j, int k) -> long long{
if(j > 1 || k >= 3) return 0; // 注意顺序, 有可能是i == 0且j和k非法,那应该算是非法状态返回0,如果与下面这行互换,则会返回1
if(i == 0) return 1; // 其他解决办法: 特判当j == 0的时候才加dp(i - 1, j + 1, 0),k同理
if(f[i][j][k] != -1) return f[i][j][k];
f[i][j][k] = ((long long)dp(dp, i - 1, j, 0) + dp(dp, i - 1, j + 1, 0) + dp(dp, i - 1, j, k + 1)) % MOD;
return f[i][j][k];
};
return dp(dp, n, 0, 0) % MOD;
}
};
Leetcode 3154. 到达第 K 级台阶的方案数
dp思路(核心在于寻找限制/子问题):
class Solution {
public:
int waysToReachStair(int k) {
unordered_map<long long, int> memo;
auto dfs = [&](auto&& dfs, int i, int j, bool pre_down) -> int {
if (i > k + 1) { // 无法到达终点 k
return 0;
}
// 把状态 (i, j, pre_down) 压缩成一个 long long
long long mask = (long long) i << 32 | j << 1 | pre_down;
if (memo.contains(mask)) { // 之前算过了
return memo[mask];
}
int res = i == k;
res += dfs(dfs, i + (1 << j), j + 1, false); // 操作二
if (!pre_down) {
res += dfs(dfs, i - 1, j, true); // 操作一
}
return memo[mask] = res; // 记忆化
};
return dfs(dfs, 1, 0, false);
}
};
组合数学方法见数学专题。
Leetcode 2708. 一个小组的最大实力值
class Solution {
public:
// maxx = max({maxx, nums[i], maxx * nums[i], minn * nums[i]});
// minn = min({minn, nums[i], minn * nums[i], maxx * nums[i]}); 两个式子要同时计算
long long maxStrength(vector<int>& nums) {
long long maxx = nums[0], minn = nums[0];
for(int i = 1; i < nums.size(); i ++){
long long tmp = maxx;
long long x = nums[i];
maxx = max({maxx, x, maxx * x, minn * x});
minn = min({minn, x, minn * x, tmp * x});
}
return maxx;
}
};
如果将子序列修改为子数组,那么变为下面152题。
Leetcode 152. 乘积最大子数组
思路分析:
关注这里面在维护当前最大值和当前最小值的时候,nums[i]是一定会在的(而非选or不选)。
class Solution {
public:
int maxProduct(vector<int>& nums) {
int res = INT_MIN, curmax = 1, curmin = 1;
for(int i = 0; i < nums.size(); i ++){
if(nums[i] < 0) swap(curmax, curmin);
curmax = max(nums[i], curmax * nums[i]);
curmin = min(nums[i], curmin * nums[i]);
res = max(res, curmax);
}
return res;
}
};
X.1 最长公共子序列(LCS)
X.2 最长递归子序列(LIS)
Leetcode 368. 最大整除子集
排个序,相邻元素间的限制关系。
class Solution { public: vector<int> largestDivisibleSubset(vector<int>& nums) { sort(nums.begin(), nums.end()); //两个条件,不好处理。可以把nums排序(从小到大) // 从(排序后的)nums 中选一个子序列,在子序列中,任意相邻的两个数,右边的数一定是左边的数的倍数。 int n = nums.size(); vector<int> memo(n), from_(n, -1); // from 用于生成具体的生成方案 auto dfs = [&](this auto&& dfs, int i) -> int{ int& res = memo[i]; // 注意是引用 if (res) return res; for (int j = 0; j < i; j ++){ if (nums[i] % nums[j]) continue; int f = dfs(j); if (f > res) res = f, from_[i] = j; // 记录最佳转移来源 } res ++; // 加上nums[i]自己 return res; }; int max_f = 0, max_i = 0; for (int i = 0; i < n; i ++) { int f = dfs(i); if (f > max_f) max_f = f, max_i = i; // 最长合法子序列的最后一个数的下标 } vector<int> path; for (int i = max_i; i >= 0; i = from_[i]) path.push_back(nums[i]); return path; // 任意顺序返回均可 } };
五.划分型DP
5.1 判定能否划分
5.2 计算划分最优值
5.3 约束划分个数
(固定右,枚举左)
Leetcode 3117. 划分数组得到最小的值之和
寻找子问题
class Solution { public: int minimumValueSum(vector<int>& nums, vector<int>& andValues) { const int N = INT_MAX / 2; // 防止下面+nums[i]溢出(func:让非法状态作废) unordered_map<long long, int> f; int n = nums.size(), m = andValues.size(); // 维度i:访问数组的第i个元素 维度j:andValues数组的第j个元素 维度and_当前组前面已经&的结果(单调不升) auto dp = [&](auto&& dp, int i, int j, int and_) -> int { if(n - i < m - j) return N; // 不够划分的 if(j == m) return i == n ? 0: N; int ad = and_ & nums[i]; // 先算新的and值,才有state!!! long long st = (long long) i << 36 | (long long) j << 32 | ad; if(f.count(st)) return f[st]; if(ad < andValues[j]) return N; // 剪枝, 往后再运算不可能得到andValues[j]了 int res = dp(dp, i + 1, j, ad); // 不选(即继续往当前子数组添加数,而非重新开一个子数组) if(ad == andValues[j]) // and值重置为-1为下一各子数组的情况做准备 res = min(res, dp(dp, i + 1, j + 1, -1) + nums[i]); // 选择nums[i]作为子数组的最后一个元素 return f[st] = res; // 记忆化 }; int ans = dp(dp, 0, 0, -1); return ans < N ? ans: -1; } };
5.4 不相交区间
六、状态机 DP
七、其他线性 DP
7.1 一维DP
Leetcode 2901. 最长相邻不相等子序列 II
思路:类似于LIS,只不过为了输出最后的路径,选用后缀的写法。
Code:
class Solution { bool ok(string&s , string& t) { if (s.size() != t.size()) return false; bool diff = false; for (int i = 0; i < s.size(); i ++) { if (s[i] != t[i]) { if (diff) return false; // 汉明距离>1 diff = true; } }return diff; } public: vector<string> getWordsInLongestSubsequence(vector<string>& words, vector<int>& groups) { int n = words.size(); vector<int> f(n), from(n); int max_i = n - 1; for (int i = n - 1; i >= 0; i --) { // 为什么定义成后缀?方便后面输出具体方案。 for (int j = i + 1; j < n; j ++) { if (f[j] > f[i] && groups[j] != groups[i] && ok(words[i], words[j])) f[i] = f[j], from[i] = j; }f[i] ++; // +1写在这 if (f[i] > f[max_i]) max_i = i; } int i = max_i, m = f[i]; vector<string> res(m); for (int k = 0; k < m; k ++) res[k] = words[i], i = from[i]; return res; } };
7.2 不相交区间
Leetcode 2008. 出租车的最大盈利
思路:
寻找子问题,递归拆解。假设答案n = 9,那么答案是dfs(9)。在地点i=9,可以选择由i-1的状态直接转移过来(不接人),或者说不能接。当然也可以从 i - len的地点刚好送过来,构成转移,然而刚好送到终点i=9可能有很多个ride区间,所以需要进行分组统计以end为key的start和报酬对。(这里类似于1353题,)
Code:
class Solution { public: long long maxTaxiEarnings(int n, vector<vector<int>>& rides) { vector<vector<pair<int, int>>> groups(n + 1); for (auto& r: rides) { int start = r[0], end = r[1], tip = r[2]; groups[end].push_back({start, end - start + tip}); } vector<long long> f(n + 1); // f[1] = 0 初始状态 for (int i = 2; i <= n; i ++) { f[i] = f[i - 1]; // 不选当前end=i的ride或者没有此ride for (auto& [s, t]: groups[i]) // 如果要选,从子问题曲线救国 f[i] = max(f[i], f[s] + t); } return f[n]; } };
Leetcode 1235. 规划兼职工作
思考:“动态规划 + 二分查找”
不同于2008题,这题最大的end时间在1e9,但开不了这么大的空间,所以引出2008题的第二种解法“动态规划+二分”。
还是从选还是不选的角度来考虑。f(i)表示使用前1-i个区间所得到的最大利益。如果不选择第i个区间,直接从f(i - 1)转移来;如果选择第i个区间,则转移前驱区间j的end_time需要小于第i个区间的start_time,此时f(i) = f(j) + profit(i)。
此处的区间 j 可以通过二分查找找到小于等于区间i的start_time的最大endtime区间。
Code:
class Solution { public: int jobScheduling(vector<int>& startTime, vector<int>& endTime, vector<int>& profit) { int n = startTime.size(); vector<array<int, 3>> jobs(n); for (int i = 0; i < n; i ++) jobs[i] = {endTime[i], startTime[i], profit[i]}; // 按照end时间从小到大排序。 sort(jobs.begin(), jobs.end(), [](auto& a, auto& b){return a[0] < b[0];}); vector<int> f(n + 1); for (int i = 0; i < n; i ++) { // 默认比较第一个元素,为显示声明的元素为0 int j = upper_bound(jobs.begin(), jobs.begin() + i, array<int, 3>{jobs[i][1], INT_MAX}) - jobs.begin(); f[i + 1] = max(f[i], f[j] + jobs[i][2]); // 状态转移中,为什么是 j 不是 j+1:上面算的是 > st,-1 后得到 <= st,但由于还要 +1,抵消了 // 即f(i) = max(f(i - 1), f(j - 1) + jobs[i - 1][2]); }return f[n]; } };
Leetcode 1751. 最多可以参加的会议数目 II
7.3 子数组DP
7.4 合法子序列DP
Leetcode 3202. 找出有效子序列的最大长度 II
思路:可以从k=2开始思考,乍一看有点像LIS(需要找到一个比前面的数更大的,去接上去)。这边实际上只要维护前面同余的两个数的值即可完成转移。具体分析如下:
①问题转化:
②本题转移公式:
Code:
class Solution { public: int maximumLength(vector<int>& nums, int k) { int res = 0; vector f(k, vector<int>(k)); // 注意二维数组的写法 for (int x: nums) { x %= k; for (int y = 0; y < k; y ++) { f[y][x] = f[x][y] + 1; res = max(res, f[y][x]); } }return res; } };
7.5 子矩阵DP
7.6 多维DP
Leetcode 1900. 最佳运动员的比拼回合
思考:
我们可以通过翻转等操作,保证a的左侧人数比b右侧人更少。(题目保证a的位置在b左侧)。
剩下只需要给b的位置分类:①b在中轴线左侧②右侧。
Code:
class Solution { public: vector<int> earliestAndLatest(int n, int firstPlayer, int secondPlayer) { vector memo(n + 1, vector(n + 1, vector<pair<int, int>>(n + 1))); auto dfs = [&] (this auto&& dfs, int n, int first, int second) -> pair<int, int> { // a和b相遇的时候 if (first + second == n + 1) return {1, 1}; // 保证a左边人数比b右边人数少,题目已经保证first < second if (first + second > n + 1) tie(first,second) = pair(n + 1 - second, n + 1 -first); // 翻转一下 auto& res = memo[n][first][second]; if (res.first) return res; int m = (n + 1) / 2; // 下一回合的人数 // ab之间保留 [mid_mid, max_mid]人 int min_mid = second <= m ? 0: second - n / 2 - 1; int max_mid = second <= m ? second - first: m - first; int earliest = INT_MAX; int latest = 0; for (int left = 0; left < first; left ++) { // 枚举A左侧保留left个人 for (int mid = min_mid; mid < max_mid; mid ++) { // 枚举ab之间保留mid个人 auto [e, l] = dfs(m, left + 1, left + mid + 2); earliest = min(earliest, e); latest = max(latest, l); } } return res = {earliest + 1, latest + 1}; // 加上当前的回合。 }; auto [earliest, latest] = dfs(n, firstPlayer, secondPlayer); return {earliest, latest}; } };
八、区间 DP
九、状态压缩 DP(状压 DP)
十.数位DP
下面的题目使用 数位DP V1.0 板子
只支持计算 [0, n]的数位dp的结果。若要计算[low, high]之间的数位dp的结果,那就要用类似前缀和的计算方式:f(high) - f(low - 1)。如果high和low超过int范围,还得用字符串表示
Leetcode 2376. 统计特殊整数(模板)
class Solution {
public:
int countSpecialNumbers(int n) {
string s = to_string(n);
int m = s.length();
int f[m][1 << 10]; // 备忘录
memset(f, -1, sizeof f); // 初始化为-1,-1即表示没有搜索
function<int(int, int, bool, bool)> dp = [&] (int i, int mask, bool is_lim, bool is_num)->int{
if(i == m) return is_num; // is_num为true则代表搜索到了合法的数字
if(!is_lim && is_num && f[i][mask] != -1) return f[i][mask]; // 一定注意!is_lim这个限制
int res = 0;
// sit 1: 前面都没有填数字,那么这一位也可以不填数字
if(!is_num)
res = dp(i + 1, mask, false, false);
// sit 2: 枚举每一种可能填写的位数
int upbound = is_lim ? s[i] - '0' : 9;
for(int j = 1 - is_num; j <= upbound; j ++)
if ((mask >> j & 1) == 0) // 要填的这个数字不在mask(集合)中
res += dp(i + 1, mask | (1 << j), is_lim && j == upbound, true);
// 将答案记忆化
if(!is_lim && is_num) // 最大值和最小值只会计算一次,无需记忆化
f[i][mask] = res;
return res;
};
return dp(0, 0, true, false);
}
};
Leetcode 902. 最大为 N 的数字组合
直接修改一下板子就可以。
class Solution {
public:
int atMostNGivenDigitSet(vector<string>& digits, int n) {
string s = to_string(n);
int m = s.length(), f[m];
memset(f, -1, sizeof f);
function<int(int, bool, bool)> dp = [&](int i, bool is_lim, bool is_num)->int{
if (i == m) return is_num;
if (!is_lim && is_num && f[i] != -1) return f[i];
int res = 0;
// sit 1:
if (!is_num) res = dp(i + 1, false, false);
// sit 2:
char up = is_lim ? s[i] : '9';
for (int j = 0; j < digits.size() && digits[j][0] <= up; j ++)
res += dp(i + 1, digits[j][0] == s[i] && is_lim, true);
if (!is_lim && is_num) f[i] = res;
return res;
};
return dp(0, true, false); // true表示得按照限制去执行
}
};
Leetcode 1012. 至少有 1 位重复的数字
“至少”这种问题-->正难则反,转换成求无重复数字的个数。答案等于 n 减去无重复数字的个数。(leetcode 2376)
class Solution {
public:
int numDupDigitsAtMostN(int n) {
string s = to_string(n);
int m = s.length(), f[m][1 << 10];
memset(f, -1, sizeof(f));
function<int(int, int, bool, bool)> dp = [&](int i, int mask, bool is_lim, bool is_num)->int{
if (i == m) return is_num;
if (!is_lim && is_num && f[i][mask] != -1) return f[i][mask];
int res = 0;
if (!is_num) res = dp(i + 1, mask, false, false); // 本位不选
int up = is_lim ? s[i] - '0' : 9;
for(int j = 1 - is_num; j <= up; j ++){
if (((mask >> j) & 1) == 0)
res += dp(i + 1, mask | (1 << j), j == up && is_lim, true);
}
if (!is_lim && is_num) f[i][mask] = res;
return res;
};
return n - dp(0, 0, true, false); // is_lim设置为true时表示第一个位需要受到s[0]的限制
}
};
Leetcode 600. 不含连续1的非负整数
前几题是直接在位置上填十进制数,这边实际上就是改成填写二进制数即可。
class Solution {
public:
// 多少个整数的二进制表示中不存在连续的1?
int findIntegers(int n) {
int m = __lg(n); // 二进制表示最高位的位数在哪(从0开始)
int f[m + 1][2]; // 第二维的状态只有2个, 第一维代表二进制空位上的下标(m有可能为0,所以要+1),第二维是该下标对应的数字是多少
memset(f, -1, sizeof f);
// 为什么不用 bool is_num:因为本题是连续的1,所以有前导0也无所谓
function<int(int, int, bool)> dp = [&](int i, int pre, bool is_lim)->int{
if (i < 0) return 1; // 能运行到这个条件的,一定是正确情况
if (!is_lim && f[i][pre] != -1) return f[i][pre];
int res = 0;
int up = is_lim ? n >> i & 1 : 1;
// sit 1: 本位填0无所谓,一定不会涉及到连续1的条件
res += dp(i - 1, 0, up == 0 && is_lim);
// sit 2: 如果本位要填写1,一定要检查
if(pre != 1 && up == 1) res += dp(i - 1, 1, is_lim);
if(!is_lim) f[i][pre] = res;
return res;
};
return dp(m, 0, true);
}
};
Leetcode 233. 数字 1 的个数
class Solution {
public:
int countDigitOne(int n) {
string s = to_string(n);
int m = s.length();
int f[m][m];
memset(f, -1, sizeof f);
function<int(int, int, bool, bool)> dp = [&](int i, int k, bool is_lim, bool is_num)->int{
if(i == m) return k;
if(!is_lim && is_num && f[i][k] != -1) return f[i][k];
int res = 0;
if(!is_num) res = dp(i + 1, 0, false, false);
int up = is_lim ? s[i] - '0' : 9;
for(int j = 1 - is_num; j <= up; j ++){
res += dp(i + 1, k + (j == 1), is_lim && j == up, true);
}
if(!is_lim && is_num) f[i][k] = res;
return res;
};
return dp(0, 0, true, false);
}
};
Leetcode 1399. 统计最大组的数目
如果 n=10^18,方法一就超时了,怎么办?
class Solution { public: int countLargestGroup(int n) { string s = to_string(n); int m = s.size(); vector memo(m, vector<int> (m * 9 + 1, -1)); auto dfs = [&](this auto&& dfs, int i, int left, bool limit_high) -> int{ if (i == m) return left == 0; if (!limit_high && memo[i][left] != -1) return memo[i][left]; int hi = limit_high? s[i] - '0': 9; int res = 0; for (int d = 0; d <= min(hi, left); d ++) // 枚举当前数位填d res += dfs(i + 1, left - d, limit_high && d == hi); if (!limit_high) memo[i][left] = res; return res; }; int max_cnt = 0, ans = 0; for (int target = 1; target <= m * 9; target ++) { // 枚举目标数位和 int cnt = dfs(0, target, true); if (cnt > max_cnt) max_cnt = cnt, ans = 1; else if (cnt == max_cnt) ans ++; }return ans; } };
着重关注状态使用的left(距离target的大小),而非已累计的数位和。
下面使用数位DP V2.0板子
支持[low, high]的数位DP求解。
Leetcode 2999. 统计强大整数的数目
思路:
代码(不考虑前导零)的写法:
class Solution { public: long long numberOfPowerfulInt(long long start, long long finish, int limit, string s) { string low = to_string(start); string high = to_string(finish); int n = high.size(); low = string(n - low.size(), '0') + low; // 补上前导零,和high对其 int diff = n - s.size(); vector<long long> memo(n, -1); auto dfs = [&](this auto&& dfs, int i, bool limit_low, bool limit_high) -> long long { if (i == high.size()) return 1; if(!limit_low && !limit_high && memo[i] != -1) return memo[i]; int lo = limit_low ? low[i] - '0': 0; int hi = limit_high ? high[i] - '0': 9; // 错误hi = min(hi, limit_high) -->反例:limithigh=5, 63566,会导致枚举错误5xxxx(第二位受限于limithigh标志,只能填0~3,但实际上是可以填上54xxxxx的) long long res = 0; if (i < diff){ // 枚举当前数位填什么 for (int d = lo; d <= min(hi, limit); d ++) res += dfs(i + 1, limit_low && d == lo, limit_high && d == hi); // 统计不同的方案之和 }else{ int x = s[i - diff] - '0'; // 这个数位只能填s[i-diff] if (lo <= x && x <= hi) // 其实题目已经保证 x < limit了 res = dfs(i + 1, limit_low && x == lo, limit_high && x == hi); // 成为固定的方案了,而非res += xxx了 } if(!limit_low && !limit_high) memo[i] = res; // 记忆化i, false, false return res; }; return dfs(0, true, true); } };
考虑前导零的板子:
十一、优化 DP
十二、树形 DP
十三、图 DP
十四、博弈 DP
十五、概率/期望 DP
Leetcode 808. 分汤
思考:
①从递归的角度开始思考:
模拟过程:定义目标事件-汤 A 先于 B 耗尽,或者两种汤在同一回合耗尽(此时只计一半的权重)。比如 n=200:
从汤 A 取 100 毫升,从汤 B 取 0 毫升。问题变成两种汤的剩余量分别为 100 毫升和 200 毫升时,目标事件发生的概率。
从汤 A 取 75 毫升,从汤 B 取 25 毫升。问题变成两种汤的剩余量分别为 125 毫升和 175 毫升时,目标事件发生的概率。
从汤 A 取 50 毫升,从汤 B 取 50 毫升。问题变成两种汤的剩余量分别为 150 毫升和 150 毫升时,目标事件发生的概率。
从汤 A 取 25 毫升,从汤 B 取 75 毫升。问题变成两种汤的剩余量分别为 175 毫升和 125 毫升时,目标事件发生的概率。
这些问题都是和原问题相似的、规模更小的子问题,可以用递归解决。②定义 dfs(a,b) 表示两种汤的剩余量分别为 a 毫升和 b 毫升时,目标事件发生的概率。
在当前回合,我们等概率地选择以下四种操作中的一种执行:
从汤 A 取 100 毫升,从汤 B 取 0 毫升。问题变成两种汤的剩余量分别为 a−100 毫升和 b 毫升时,目标事件发生的概率,即 dfs(a−100,b)。
从汤 A 取 75 毫升,从汤 B 取 25 毫升。问题变成两种汤的剩余量分别为 a−75 毫升和 b−25 毫升时,目标事件发生的概率,即 dfs(a−75,b−25)。
从汤 A 取 50 毫升,从汤 B 取 50 毫升。问题变成两种汤的剩余量分别为 a−50 毫升和 b−50 毫升时,目标事件发生的概率,即 dfs(a−50,b−50)。
从汤 A 取 25 毫升,从汤 B 取 75 毫升。问题变成两种汤的剩余量分别为 a−25 毫升和 b−75 毫升时,目标事件发生的概率,即 dfs(a−25,b−75)。
在当前回合,上述四种操作互斥且等概率地被选择,因此目标事件发生的概率为四个后续状态的平均值,dfs(a,b)= 0.25 * [dfs(a−100,b)+dfs(a−75,b−25)+dfs(a−50,b−50)+dfs(a−25,b−75)]
执行一次操作 2 和一次操作 4,会让 a 和 b 均减少 100;执行两次操作 3,也会让 a 和 b 均减少 100。这两种方式都会递归到 dfs(a−100,b−100),这会导致我们重复计算同一个状态。考虑到整个递归过程中有大量重复递归调用(递归入参相同)。由于递归函数没有副作用,同样的入参无论计算多少次,算出来的结果都是一样的,因此可以用记忆化搜索来优化
③边界:
- 如果 a≤0 且 b≤0,根据题目要求,返回 0.5。
- 否则如果 a≤0,返回 1。
- 否则如果 b≤0,返回 0。
递归入口:dfs(n,n),即答案。
④优化空间:
⑤进一步的剪枝:
题目说n的范围是1e9,但除了25之后也是不能开到这么大空间的数组,阅读题干,我们知道
且返回值在正确答案1e-5的范围内将被认为是正确的,所以如果正确答案>=1e-5,直接返回1就可以。不断的实验n的取值,发现n >=4451的时候正确答案>=1e-5,此时直接返回1。
Code:
class Solution { public: double soupServings(int n) { if (n >= 4451) return 1; vector memo(n + 1, vector<double>(n + 1)); n = (n + 24) / 25; auto dfs = [&](this auto&& dfs, int a, int b) -> double{ if (a <= 0 && b <= 0) return 0.5; // 同时被耗尽 if (a <= 0) return 1.0; // a 先被耗尽 if (b <= 0) return 0; // b 先被耗尽 double& res = memo[a][b]; // 注意这里是引用 if (res == 0) // res首先没被计算过(答案不可能取到0的,所以这里用0来判断) res = (dfs(a - 4, b) + dfs(a - 3, b - 1) + dfs(a - 2, b - 2) + dfs(a - 1, b - 3)) / 4; // 等概率发生 return res; }; return dfs(n, n); } };