动态规划
概念
动态规划解决的问题通常满足三个特征:
- 重叠子问题:你算出结果的过程,需要反复用到前面算过的结果。
- 最优子结构:大问题的最优解,包含了小问题的最优解。
- 无后效性:前面的状态一旦确定,就不受后面决策的影响。
通俗理解:想象你在爬楼梯。你想知道爬到第 10 层有多少种方法。你需要知道爬到第 9 层的方法数(再跨 1 步就到了)和爬到第 8 层的方法数(再跨 2 步就到了)。公式就是: f ( 10 ) = f ( 9 ) + f ( 8 ) f(10) = f(9) + f(8) f(10)=f(9)+f(8)。为了不重复计算 f ( 9 ) f(9) f(9) 和 f ( 8 ) f(8) f(8) 里的重叠部分,我们用一个数组 dp[] 把以前算过的楼层结果存起来,这就是动态规划。
DP解题模版
-
定义状态
我们要解决什么问题?
dp[i] 代表什么含义?(比如:凑满金额 i 所需的最少硬币数)。
-
推导状态转移方程
这是最难的一步。思考:dp[i] 是怎么由之前的状态(比如 dp[i-1], dp[i-k])推导出来的?
通常是 Math.min, Math.max 或者求和。
-
初始化
最开始的情况是什么?dp[0] 是多少?
数组其他位置默认填什么值(0?还是无穷大?)
-
确定遍历顺序
是从小到大遍历,还是从大到小?
如果是二维 DP,先遍历行还是先遍历列?
零钱兑换
cpp
#include <vector>
#include <algorithm>
#include <iostream>
using namespace std;
class Solution {
public:
int coinChange(vector<int>& coins, int amount) {
// 1. 定义 dp 数组
// 大小为 amount + 1,因为要算到 dp[amount]
// 初始值设为 amount + 1,这相当于无穷大。
// 为什么是 amount + 1?因为只要有解,硬币数一定 <= amount(全是1块钱的情况)
vector<int> dp(amount + 1, amount + 1);
// 2. Base Case 初始化
dp[0] = 0;
// 3. 遍历(外层遍历背包容量,内层遍历物品,或者反过来都可以,这里用外层遍历金额)
for (int i = 1; i <= amount; ++i) {
// 尝试每一枚硬币
for (int coin : coins) {
// 如果当前金额 i 能容下这枚硬币
if (i - coin >= 0) {
// 状态转移方程:取当前值 和 (凑足剩余金额所需个数 + 1) 的较小值
dp[i] = min(dp[i], dp[i - coin] + 1);
}
}
}
// 4. 返回结果
// 如果 dp[amount] 还是初始值,说明没有硬币组合能凑出这个金额
if (dp[amount] > amount) {
return -1;
}
return dp[amount];
}
};
最长递增子序列
最长递增子序列(序列类 DP)
关注的是"位置"。
dp[i] 的 i 代表数组下标。
逻辑:数值大小不是状态索引,数值只是判断条件。我在乎的是**"我是排在第几个的人"以及"我能不能接在别人后面"**。
回头看:回头看 0 到 i-1 的所有下标(位置回溯)。
cpp
class Solution {
public:
int lengthOfLIS(vector<int>& nums) {
int n = nums.size();
if (n == 0) return 0;
// 1. 定义状态
// dp[i]:以 nums[i] 结尾的最长递增子序列长度
// 既然是求长度,初始化为 1 (因为每个数字本身就是长度 1 的序列)
vector<int> dp(n, 1);
int maxAns = 1; // 用于记录全局最大值
// 2. 外层循环:i 从 1 到 n-1
// 这一步是在确定:我们现在要解决哪一个数字的"归属"问题
for (int i = 1; i < n; ++i) {
// 3. 内层循环:j 从 0 到 i-1 (回顾历史)
// 这一步是在做:"选秀"。i 看着前面的 j,挑选能接在谁后面
for (int j = 0; j < i; ++j) {
// 4. 核心判断
// 只有当 nums[i] 比 nums[j] 大,才能排在 j 后面
if (nums[i] > nums[j]) {
// 5. 状态转移
// 我有两种选择:
// A. 保持现在的 dp[i] (可能我之前已经跟了一个很长的大哥了)
// B. 跟随 nums[j],长度变成 dp[j] + 1
// 我要选更长的那个
dp[i] = max(dp[i], dp[j] + 1);
}
}
// 每算完一个人的 dp[i],都跟全局最大值比一下
maxAns = max(maxAns, dp[i]);
}
return maxAns;
}
};
俄罗斯套娃
先排序,在dp
由于时间复杂度是O(N2)导致超时
需要用贪心加二分查找,后续学习
cpp
class Solution {
public:
int maxEnvelopes(vector<vector<int>>& envelopes) {
int n = envelopes.size();
if(n == 0) return 0;
sort(envelopes.begin(),envelopes.end(),[](const vector<int>& a,const vector<int>& b){
if(a[0] != b[0]){
return a[0]<b[0];
}
return a[1]<b[1];
});
vector<int> dp(n,1);
int max_res = 1;
for(int i = 1;i<n;i++){
for(int j = 0;j<i;j++){
if(envelopes[i][0]>envelopes[j][0]&&
envelopes[i][1]>envelopes[j][1]){
dp[i] = max(dp[i],dp[j]+1);
}
}
max_res = max(max_res,dp[i]);
}
return max_res;
}
};
目标和
01背包问题,和前文的零钱问题(无限背包,零钱无限用)和最长递增子序列(序列dp,关注下标)不同
问题拆解:
sum ( P ) − sum ( N ) = target \text{sum}(P) - \text{sum}(N) = \text{target} sum(P)−sum(N)=target我们可以利用总和 sum ( nums ) = sum ( P ) + sum ( N ) \text{sum}(\text{nums}) = \text{sum}(P) + \text{sum}(N) sum(nums)=sum(P)+sum(N) 来变形: sum ( P ) − sum ( N ) = target sum ( P ) − sum ( N ) + sum ( nums ) = target + sum ( nums ) sum ( P ) − sum ( N ) + ( sum ( P ) + sum ( N ) ) = target + sum ( nums ) 2 × sum ( P ) = target + sum ( nums ) sum ( P ) = target + sum ( nums ) 2 \begin{aligned} \text{sum}(P) - \text{sum}(N) &= \text{target} \\ \text{sum}(P) - \text{sum}(N) + \text{sum}(\text{nums}) &= \text{target} + \text{sum}(\text{nums}) \\ \text{sum}(P) - \text{sum}(N) + (\text{sum}(P) + \text{sum}(N)) &= \text{target} + \text{sum}(\text{nums}) \\ 2 \times \text{sum}(P) &= \text{target} + \text{sum}(\text{nums}) \\ \text{sum}(P) &= \frac{\text{target} + \text{sum}(\text{nums})}{2} \end{aligned} sum(P)−sum(N)sum(P)−sum(N)+sum(nums)sum(P)−sum(N)+(sum(P)+sum(N))2×sum(P)sum(P)=target=target+sum(nums)=target+sum(nums)=target+sum(nums)=2target+sum(nums)
转换之后的问题就是:请在 nums 中挑出一些数字,让它们的和恰好等于 (target + sum) / 2。问有多少种挑法?
值得注意的一点就是必须要第二个for循环要倒序排列,因为01背包问题一个数字只能用一次
如果顺序排列的话就会导致元素的重复使用(我之前用这个数填过一次了,后来我继续使用这个数字去填)
cpp
class Solution {
public:
int findTargetSumWays(vector<int>& nums, int target) {
int sum = 0;
for(int n : nums){
sum += n;
}
if(abs(target)>sum) return 0;
if((target+sum)%2 == 1) return 0;
int bagsize = (target+sum) / 2;
vector<int> dp(bagsize+1,0);
dp[0] =1;
for(int num :nums){
for(int j = bagsize;j>=num;j--){
dp[j] = dp[j] + dp[j-num];
}
}
return dp[bagsize];
}
};
最大子数组和
cpp
class Solution {
public:
int maxSubArray(vector<int>& nums) {
int cur = nums[0];
int max_res = nums[0];
for(int i = 1;i<nums.size();i++){
cur = max(nums[i],cur + nums[i]);
if(cur>max_res){
max_res = cur;
}
}
return max_res;
}
};