1.动态规划模板
1.确定dp数组(dp table)以及下标的含义
2.确定递推公式
3.dp数组如何初始化
4.确定遍历顺序
5.举例推导dp数组
1.1 动态规划和回溯的区别
什么时候用动态规划,什么时候用回溯:
如果一个题的答案是一个值,E.g. 3,true or false ,则用动态规划
如果题目中有让列出所有结果的可能性:回溯
1.2 动态规划和谈心的区别
DP 和贪心的区别:
DP : y i = f(yi-1) 根据上一个状态推断
贪心:yi = f(yi) 通用的式子
2.LeetCode 相关题目
2.1_509斐波那契数
2.1.1 算法描述
1.确定 dp 数组以及其下标含义
dp[i] 的定义为:第 i 个数的斐波那契数为 dp[i]
2.确定推导公式
根据题目可知推导公式:
dp[i] = dp[i-1]+dp[i-2]
3.dp 数组如何初始化
题目已给出
dp[1] = 0 ; dp[1] = 1
4.确定遍历顺序
必须要先知道前面的值才能得到后面的值,所以是从前向后
5.举例推导 dp 数组
N为10的时候,dp数组应该是如下的数列:
0 1 1 2 3 5 8 13 21 34 55
2.1.2 C++ 代码实现
1.完整代码
class Solution {
public:
int fib(int n) {
if(n==0) return 0;
if(n==1) return 1;
vector<int>dp(n+1); // 因为要保存 0 ,所以最后个数要加上 1
dp[0] = 0;
dp[1] = 1;
for(int i = 2;i<=n;i++){
dp[i] = dp[i-1]+dp[i-2];
}
return dp[n];
}
};
2.代码简化
因为最后取的只是一个值,cur 状态的判断只需要它前面的那个值,所以使用两个变量就可以得到cur 的值
class Solution {
public:
int fib(int n) {
if(n==0) return 0;
if(n==1) return 1;
vector<int>dp(2); // 只保存两个值就可以实现
dp[0] = 0;
dp[1] = 1;
for(int i = 2;i<=n;i++){
int cur = dp[0]+dp[1]; // 0 保存 cur-2 , 1 保存 cur-1
dp[0] = dp[1];
dp[1] = cur;
}
return dp[1];
}
};
2.1.3 时空复杂度
时间复杂度:O(N)
空间复杂度:O(N)
但是在递归情况下时空复杂度:
时间复杂度:O(n^2)
空间复杂度:O(n) 栈所需要空间
2.2_70爬楼梯
2.2.1 算法描述
0阶:0种
1阶:1种
1;
2阶:2种
1+1;2
3阶:3种
1+1+1;2+1;1+2
4阶:5种
1+1+1+1;2+1+1;1+2+1;1+1+2;2+2
其中 2+1+1 , 1+2+1,1+1+2 时由上一步 2+1 再添了 1 后排列组合生成的
5阶:8种
1+1+1+1+1;2+1+1+1;1+2+1+1+;1+1+2+1+1;1+1+1+2;1+2+2;2+1+2;2+2+1
。。。。。。
1.确定dp数组及其下标的含义
dp[i]的定义为:爬到第 i层有多少种方法
2.确定推导公式
对于 i 来说,i-1 的方法个数再跳一节就是 i ; i-2 的方法个数再跳两节就是 i 阶的方法个数。所以最后总的方法个数为为两种情况相加
dp[i] = dp[i-1]+dp[i-2]
3.dp 数组如何初始化
dp[1] = 1
dp[2] = 2
4.确定遍历顺序
从前向后遍历
5.举例推导 dp 数组
上面已经有了举例
2.2.2 C++ 代码实现
1.普通代码
class Solution {
public:
int climbStairs(int n) {
if(n==1) return 1;
if(n==2) return 2;
vector<int> dp(n+1);
dp[1] = 1;
dp[2] = 2;
for(int i=3;i<=n;i++){
dp[i] = dp[i-1]+dp[i-2];
}
return dp[n];
}
};
2.优化代码
class Solution {
public:
int climbStairs(int n) {
if(n==1) return 1;
if(n==2) return 2;
int dp[3];
dp[1] = 1;
dp[2] = 2;
for(int i=3;i<=n;i++){
int cur = dp[1]+dp[2];
dp[1] = dp[2];
dp[2] = cur;
}
return dp[2];
}
};
2.2.3 时空复杂度
非优化方法:
时间复杂度:O(N)
空间复杂度:O(N)
2.3_746使用最小花费爬楼梯
2.3.1 算法描述
1.确定 dp 数组每个值的含义
dp[i] 代表:到达第 i 阶花费的最小体力
2.确定递推顺序
dp[i] 的值可以由两个值得到 ,是 dp[i-1] 和 dp[i-2] ,判断哪个更小就累加哪个
关键:选择出更小的那个出发点
min(dp[i-1],dp[i-2])
3.dp 数组如何初始化
dp[0] = 0;
dp[1] = 1;
4.确定遍历顺序
从前向后
5.举例推导数组
省略。。。
2.3.2 C++ 代码实现
1.普通方法
class Solution {
public:
int minCostClimbingStairs(vector<int>& cost) {
vector<int> dp(cost.size());
dp[0] = cost[0];
dp[1] = cost[1];
for (int i = 2; i < cost.size(); i++) {
dp[i] = min(dp[i - 1], dp[i - 2]) + cost[i];
}
// 注意最后一步可以理解为不用花费,所以取倒数第一步,第二步的最少值
return min(dp[cost.size() - 1], dp[cost.size() - 2]);
}
};
2.优化方法
class Solution {
public:
int minCostClimbingStairs(vector<int>& cost) {
int dp[2];
dp[0] = cost[0];
dp[1] = cost[1];
for(int i =2;i<cost.size();i++){
int cur = min(dp[0],dp[1])+cost[i];
dp[0] = dp[1];
dp[1] = cur;
}
return min(dp[0],dp[1]);
}
};
2.3.3 时空复杂度
时间复杂度:O(N)
空间复杂度:O(1)
2.4_62不同路径
2.4.1 算法描述
绿色的框 = 橙框1向下走+橙框3向右走
1.确定 dp 每个元素代表什么
dp[i] [j] 代表从 (0,0) 走到 (i,j) 这个位置有几种方法
2.dp 的推导公式
除了最上面一排和最左边一排
dp [i] [j] = dp [i-1] [j]+dp [i] [j-1]
从上面逼近的所有情况再往下走+从左边逼近的所有情况再往右走
3.dp 的初始化
对最上面一排和最左边一排进行初始化为 1
4.遍历方式
从前向后
5.举例推导数组
如上图所示
2.4.2 C++ 代码实现
class Solution {
public:
int uniquePaths(int m, int n) {
vector<vector<int>> dp(m,vector<int>(n,0));
for(int i =0; i<m;i++){
for(int j =0;j<n;j++){
if(i==0 || j==0) dp[i][j] = 1;
else dp[i][j] = dp[i-1][j]+dp[i][j-1];
}
}
return dp[m-1][n-1];
}
};
2.4.3时空复杂度
时间复杂度:O(M*N)
空间复杂度:O(M*N)
2.4.4 知识扩展
1.如何初始化一个一维数组
vector<int> res(n,0);
2.如何初始化二维数组
vector<vector<int>> res(n,vector<int>(n,0));
为什么可以这么初始化,看一下 vector 的源码就可以知道
2.5_63不同路径2
2.5.1 算法描述
这个题和 63 题的方法一样,有两个地方不一样:
1.对于第一行和第一列的判断
**易错点:**这里不能根据当前坐标是否有障碍而初始化 0,1 ,因为他们属于边上的格子,如果他的前一个没法走通后面的所有格子即使没有障碍物也走不通
所以如果前面的格子走不通就 break;
2.对中间部分每个格进行判断
这个判断和上一个题一样,只不过在赋值之前要判断当前各自没有遮挡,如果遮挡了就不进行任何处理。不遮挡才用公式取值
2.5.2 C++ 代码实现
class Solution {
public:
int uniquePathsWithObstacles(vector<vector<int>>& obstacleGrid) {
int m = obstacleGrid.size();
int n = obstacleGrid[0].size();
vector<vector<int>> dp(m, vector<int>(n, 0));
// 使用 for 循环单独赋值
for (int i = 0; i < m && obstacleGrid[i][0] == 0; i++) dp[i][0] = 1;
for (int j = 0; j < n && obstacleGrid[0][j] == 0; j++) dp[0][j] = 1;
for (int i = 1; i < m; i++) {
for (int j = 1; j < n; j++) {
if (obstacleGrid[i][j] == 1) continue;
dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
}
}
return dp[m - 1][n - 1];
}
};
2.5.3时空复杂度
时间复杂度:O(M*N)
空间复杂度:O(M*N)
2.6_343整数拆分
2.6.1 算法描述
1.dp 中的每个元素代表什么
dp[i] 代表第 i 个元素的最大拆分乘积
2.dp[i] 中的每个值如何得到
当 i>2 时,对于正整数 i 拆分出的第一个正整数为 j ,则有以下两种方案:
将 i 拆分成 j 和 (i-j) 的和,然后不再进行切分了,那么当前值的乘积结果就为 j*(i-j)
将 i 拆分成 j 和 (i-j) 的和,但是 (i-j) 再继续拆分,此时的乘积是 j*dp[i-j]
i之前的每一个数都有可能成为i 的最后一个乘积,所以要一个个列举
所以就要使用双重 for 循环找到这个 j
所以当 j 固定时,有 dp[i] = max(j * (i-j),j * dp[i-j])
由于 j 的取值范围是 1~i-1,每次不断的更新 dp[i] 中的值,直到把这个 j 找到,
转换成公式就为:
3.dp 的初始化
i = 0 的时候就不用初试化了,0 乘任何数都为 0 ,所以只初始化 dp[1] 和 dp[2] 即可
dp[2] =1
4.确定遍历顺序
从前往后遍历
5.举例推导 dp 数组
举例 i=4 时:
j | j-i | j*(i-j) | j*dp[i-j] dp[3]=2,dp[1]=1 |
---|---|---|---|
1 | 3 | 3 | 2 |
2 | 2 | 4 | 4(取最大值) |
3 | 1 | 3 | 2 |
2.6.2 C++ 代码实现
class Solution {
public:
int integerBreak(int n) {
// 1. 定义 dp 数组
vector<int> dp(n+1);
// 2. dp 数组初始化
dp[1] = 1;
dp[2] = 1;
for(int i =2;i<=n;i++){
for(int j=1;j<i;j++){ // 从 [1~i-1] 逐个判断
dp[i] = max(dp[i],max(j*(i-j),j*dp[i-j]));
}
}
return dp[n];
}
};
易错:
1在第二层 for 循环时判断的范围是 1~i-1 这样就需要给 dp[1] 进行初始化
2.7_96不同的二叉搜索树
2.7.1 算法描述
基础是 node1 和 node2 的两个子树的结构,node3 就可以推导出来
将搜索树的所有情况都列出,每个节点都可以当头结点
①当node1 做为头结点时,剩下的所有节点只能放在 node 1 的右子树。node2 和 node3 的布局个数就是 node 1 和 node2 的布局个数
②当 node2 作为头节点时,其右边只有 node3 这一个节点,左边是 node1 的布局个数。
②当node3作为头结点时,剩余的所有节点只能放在 node3 的左侧,同理布局个数就是 node2 的布局个数
dp[3],就是 元素1为头结点搜索树的数量 + 元素2为头结点搜索树的数量 + 元素3为头结点搜索树的数量
他们都属于同一个树,所以是相乘,不是相加。最后求总个数时将多种结果相加
元素1为头结点搜索树的数量 = 右子树有2个元素的搜索树数量 * 左子树有0个元素的搜索树数量
元素2为头结点搜索树的数量 = 右子树有1个元素的搜索树数量 * 左子树有1个元素的搜索树数量
元素3为头结点搜索树的数量 = 右子树有0个元素的搜索树数量 * 左子树有2个元素的搜索树数量
根据搜索二叉树的性质,每个子节点都可以如下划分,下面是 4 个节点的布局:
从上面的公式可以推导出,整体是一个双重 for 循环,要判断每个节点的同时每个节点都要从 node1~noden 分别做头结点去判断
dp[i] += dp[j-1]*dp[i-j] // i 之前*i之后
动态规划5步曲:
1.dp[i] 表示什么
dp[i] 表示当 n=i 时二叉搜索树的结构(布局)个数
2.如何得到 dp[i]
由上面的推导
dp[i] += dp[j-1]*dp[i-j]
3.dp 数组如何初始化
dp[0] = 1 ; dp[1] = 1; dp[2]=2
易错点:
这里的 dp[0] 初始化的值是 1,不是 0,node 0 也算是一种结构。而且如果初始化是 0 那后面很多值点乘后都是 0 了
4.遍历的顺序
从前向后遍历
5.举例推导 dp 数组
上面又对 n=3 举例,当 n=4 时比较难画
2.7.2 C++ 代码实现
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];
}
};
易错点:
题目中给出的范围是 1~xx 所以在进行初始化的时候 >1 的值就不要进行初始化
2.7.3 时空复杂度
时间复杂度:O(n^2)
空间复杂度:O(n)
2.8_01背包
背包分类:
只看 01 背包和完全背包就好
2.8.1_二维数组算法描述
前提:背包中每个编号的物体只能放一件
1所在行:
包里只能放 0,1 这两个物品,包的容量不断变大,但是也只能放 01 两个物品,并且两个物品最多放一件
Eg:在坐标 (1,4) :
包里可以放编号为 01 的两个物品,但是包的容量只有 4 这么大。
经过判断是可以放下 1 的。这时候有两种选择,放 1 还是不放 1 。
不放 1 :只放 0 ,对应的表是 (0,4) 这个坐标,这个坐标的价值是 0
放1 :首先包的容量要 -2,价值+3 ;然后还能放 0,所以对应的是 (0,2) 这个坐标的价值是0;总价值是 3
对比放 1 和不放 1 两种情况,最大值是 放 1 ,价值是 3,写在坐标 (1,4) 中
Eg2:在坐标(3,7)
包里可以放编号 0,1,2,3 这三种物品,包的容量是 7
经过判断 3 是可以放下的。这个时候有两种选择放 3 还是不放 3
不放3:就相当于只放 0,1,2 这种情况,对应的坐标是 (2,7) ,价值为 7
放3:首先包的容量 -4,容量变为 3,价值累加 5。但是可以放(2,3) 的价值,也就是 4。那么这 4 最后累加 5 的总值是 9
Eg 通式:
Step1:首先判断坐标 j 是否可以放这个物品
Step2:求放与不放哪个更大
dp[i] [j] = max(dp[i - 1] [j], dp[i - 1] [j - weight[i]] + value[i])
图下图所示,要判断红框的值,则分别判断绿框的值谁大
暴力解如何实现:
这里有三个物品,每个物品的重量都是 {1,3,4} ;如果使用暴力解就是不断枚举可以组合的方案
{1} ; {1,3};{1,3,4};{3};{3,4};{4} ,每个物品都有 N 种结果,那么所有物品就有 N*N 种方案。这很明显是一个 O(N^2) 的方案;当然这其中我们也可以对 {1,3,4} , {3,4} 等不满足背包容量的放按进行剪枝处理
但是如果这里使用 DP 就可以将其转换成 O(N*M) 的方案,与此同时也可以进行剪枝操作
动态规划5步曲:
1.dp[i] [j] 表示什么
dp[i] [j] 表示从下标为 0-i的物品里任意取,放进容量为j的背包,价值总和最大是多少
(这里是背包容量,不是背包个数!!)
2.如何得到 dp[i]
dp[i] [j] = max(dp[i - 1] [j], dp[i - 1] [j - weight[i]] + value[i])
3.dp 数组如何初始化
首先从dp[i] [j]的定义出发,如果背包容量j为0的话,即dp[i] [0],无论是选取哪些物品,背包价值总和一定为0
状态转移方程 dp[i] [j] = max(dp[i - 1] [j], dp[i - 1] [j - weight[i]] + value[i]); 可以看出i 是由 i-1 推导出来,那么i为0的时候就一定要初始化。初始化方程如下所示:
4.遍历顺序
(1)先遍历背包再遍历物品
(2)先遍历物品再遍历背包
5.举例推导 dp 数组
上图灰色表格
2.8.2 二维数组C++ 代码实现
using namespace std;
#include<vector>
#include<iostream>
void test_2_wei_bag_problem1() {
vector<int> weight = {1, 3, 4};
vector<int> value = {15, 20, 30};
int bagWeight = 4; // 背包里面最多可以放重量为 4 的物品
// 二维数组
vector<vector<int>> dp(weight.size(), vector<int>(bagWeight + 1, 0));
// 初始化
for (int j = weight[0]; j <= bagWeight; j++) {
dp[0][j] = value[0];
}
// weight数组的大小 就是物品个数
for(int i = 1; i < weight.size(); i++) { // 遍历物品
for(int j = 0; j <= bagWeight; j++) { // 遍历背包容量
if (j < weight[i]) dp[i][j] = dp[i - 1][j];
else dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
}
}
cout << dp[weight.size() - 1][bagWeight] << endl;
}
int main() {
test_2_wei_bag_problem1();
}
2.8.3 时空复杂度
假设有 N 个数据,背包容量为 M
时间复杂度:O(N*M)
空间复杂度:O(N*M)
2.8.4 滚动数组算法描述
上面使用了一个二维数组存放数据,代表编号 [0,i-1] 任意存放在不同背包重量时最大价值是多少。
但是往往题目只要求我们求出在某个编号下背包的容量,就没有必要将前面所有的数据都进行存储。
其实可以发现如果把dp[i - 1]那一层拷贝到dp[i]上,表达式完全可以是:dp[i] [j] = max(dp[i] [j], dp[i] [j - weight[i]] + value[i]);
vector 的 index 值就是背包的重量
所以这个时候用到了 一维滚动数组。
1.确定 dp[j] 的含义
d[j] 的含义:当前编号下,包的容量为 j 时所存放的最大价值。
2.递推公式
这样就会存在一个覆盖过程:
当物体放入背包:d[j] = d[j-weight[i]]+value[i]
当物品不放入背包:d[j] = d[j] 也就是原先的 d[i-1] [j]
max(d[j],d[j-weight[i]]+value[i])
3.dp 数组如何初始化
从上面的递推公式可以看出只需要将 dp[j] = 0 就可以了,也就是说一开始一位数组所有的数都设为 0
举例:
物品 | 重量 | 价值 |
---|---|---|
物品0 | 1 | 15 |
物品1 | 3 | 20 |
物品2 | 4 | 30 |
下面就以画图的方式展现 “如何进行覆盖”
最外层循环遍历背包,内层循环遍历物品,内层循环从后向前。下图中横向是背包重量,纵向是可以向背包内放的编号
Step1:将背包初始化
一维数组每个值都初始化 0
Step2:遍历 0 号物品
从后向前进行遍历
dp[4] = max(dp[4],dp[4-weight[0]]+value[0])
公式解读:
物品 0 有放入与放入两种情况:
不放入则继续保持原样即 dp[j]
放入:因为包内容量是固定的,现在想再加一个物品,所以要先将包内 **“一般等重物” **拿出,拿出的这个物品只是和物品 0 等重,但不是物品 0 。,所以放入的公式为: dp[j-weight[0]] + value[0] 其中 weight[0] 就是物品 0 的一般等重物
所以推导出来上面那个公式
。。。。。。按照上面相同的方法将物品0 放入背包 。。。。。。
Step3:将物品 2 放入背包
这里遍历背包重量时还是从后向前遍历,所以先遍历重量为 4 的情况
根据公式 dp[j] = max(dp[j],dp[j-weight[i]]+value[i]) 得出
dp[4] = max(dp[4],dp[4-weight[2]]+value[2])
物品1 在重量为 4 时可以选择放入,可以不放入
放入:这时候包里面是满的,必须要将一部分一般等重物拿出再放入,公式如下:
dp[4] = dp[4-weight[1]]+value[1] dp[4] = 30
不放入:背包里面的原价值不变
dp[j] = dp[4]=35
这时候发现拿出的一般等重物竟然都是贵的东西,所以还不如不拿
面试常考题1: 为什么遍历背包的时候(内层 for 循环)是从后往前
下面是滚动数组的核心代码:
for(int i=0;i<weight.size();i++){
for(int j=weight[i];j>=bagweight;j++){ // 遍历到 weight[i] 是因为再往前数 i 被判断过了
dp[j] = max(dp[j],dp[j-weight[i]]+value[i]);
}
}
就拿物品0的放入来说,在这个阶段只能放编号 0 ,并且 0 只能放入一次:
将 i =0 ,j = 1,2 代入代码中正向遍历,下面是编号 0 物品放入的情况:
dp[1] = dp[1-weight[0]]+value[0] = 0+15
dp[2] = dp[2-weight[0]]+value[0] = 15+15 = 30
所以 dp[2] 很奇怪,这时候只能放 0 号物品,-weight[0] 操作已经将等重物拿出来了,而且这个等重物就是 0 ,但是包的重量是 15 ,说明包里还有编号 0 ,与包中只能存在一个 0 冲突
反向 for 循环:
dp[2] = dp[2-weight[1]]+value[0] = 0+15
dp[1] = dp[1-weight[1]]+value[0] = 0+15
反向循环后将编号 0 的物品拿出就不会出现包里有多余的编号0存在
通过上图的红绿箭头就可以看出依赖关系
解释:
从后向前循环,每次取得的状态不会和之前取得的状态重合,保证每个物品只放一次
对于二维数组:因为对于二维dp,dp[i] [j]都是通过上一层即dp[i - 1] [j]计算而来,所以就不存在上图中 b 物品已经放入的情况
面试常考题2: 遍历物品顺序和背包顺序是否可以颠倒
答案:不能
解释:
因为一维dp的写法,背包容量一定是要倒序遍历(原因上面已经讲了),如果遍历背包容量放在上一层,那么每个dp[j]就只会放入一个物品,即:背包里只放入了一个物品
不会覆盖
2.8.5 一维数组 C++ 代码实现
using namespace std;
#include<vector>
#include<iostream>
void test_beibao(){
vector<int>weight = {1,3,4};
vector<int>value = {15,20,30};
int bagWeight = 4;
// 背包
vector<int>dp(bagWeight+1,0);
for(int i=0;i<weight.size();i++){
for(int j=bagWeight;j>=weight[i];j--){ // 遍历到 weight[i] 是因为再往前数 i 被判断过了
dp[j] = max(dp[j],dp[j-weight[i]]+value[i]);
}
}
cout<<dp[bagWeight]<<endl;
}
int main(){
test_beibao();
}
2.8.6 时空复杂度
时间复杂度:O(N*M)
空间复杂度:O(N)
2.7_416分割等和子集
2.7.1 算法描述
1.dp[j] 表示什么
dp[j] 表示,数组当前能容纳 j 的总和情况下,背包的真实重量是多少
2.dp[j] 的推导公式
dp[j] = max(dp[j],dp[j-nums[i]]+nums[i])
3.dp 数组如何初始化
dp[0] = 0
4.确定遍历顺序
滚动数组使用逆序遍历
5.举例推导 dp 数组
【1,5,5,11】
dp 的大小为 11 +1
dp[0] = 0
dp[1] = 1
dp[2] = 1
dp[3] = 1 出现背包大小大于元素和的情况
…
dp[6] = 6
…
dp[11] = 11 最后判断数组中元素的和是否可以达到 sums/2 ,如果没有达到也说明不能整除
2.7.2 C++ 代码实现
class Solution {
public:
bool canPartition(vector<int>& nums) {
int sums = 0;
// 首先判断能否将数组进行均分
for(int i =0;i<nums.size();i++) sums+=nums[i];
if(sums%2==1) return false;
int bagWeight=sums/2;
// 因为元素个数和元素值决定 sums 的结果不会大于 20000,所以定义一个 10001 的背包
vector<int> dp(bagWeight+1,0);
// 将元素一个个的放入背包
for(int i = 0;i<nums.size();i++){
for(int j = bagWeight;j>=nums[i];j--)
dp[j] = max(dp[j],dp[j-nums[i]]+nums[i]); // 选择放这个数还是不放
}
// 判断集合中的元素是否可以凑成 target
if(dp[bagWeight]==bagWeight) return true;
return false;
}
};
最后还需要添加判断,判断数组中的值是否可以达到要求
易错点:
求出中间值后对于背包来说最后的结果还要再 +1 才是背包的重量
2.7.3 时空复杂度
时间复杂度:O(N^2)
空间复杂度:O(N)
2.8_1049最后一块石头的重量2
2.8.1 算法描述
1.如何粉碎使得粉碎到最后石头的剩余的最小
找两个重量接近的石头进行粉碎
2.化解为背包问题
现在石头的总和为 sums 。我们可以将一堆石头划分成重量非常接近的两小堆。让每一个小堆的 target 接近于 sums/2
也就是求怎么放石头可以让背包内的物品重量最大接近于 sums/2
3.动规五部曲
①dp[j] 代表什么
dp[i] 代表背包容量为 j 时,包内放得重量
②dp[j] 的推导公式
dp[j] = max(dp[j],dp[j-stone[i]]+stone[i]);
③dp[i] 的初始化
dp[0] = 0
④遍历顺序
遵循滚动数组逆向遍历
⑤举例推导dp数组
【2,7,4,1,8,1】
dp[0] = 0
dp[1] = 0
dp[2] = 2
dp 数组的赋值顺序是如下图所示
6.为什么我们只需要算一堆的重量
bagWeight 是向下取整 ,所以 bagWeight *2 <sums
sums-bagWeight *2 会得到一个大于 0 的值,这个值就是相撞的损耗
2.8.2 C++ 代码实现
lass Solution {
public:
int lastStoneWeightII(vector<int>& stones) {
// 计算背包中放入的总重量
int sums = 0;
for(int i =0 ;i<stones.size();i++) sums+=stones[i];
int bagWeight = sums/2;
vector<int> dp(bagWeight+1,0);
for(int i = 0;i<stones.size();i++){
for(int j = bagWeight;j>=stones[i];j--) dp[j]=max(dp[j],dp[j-stones[i]]+stones[i]);
}
return sums-dp[bagWeight]-dp[bagWeight];
}
};
2.8.3 时空复杂度
时间复杂度:O(M*N) 双重 for 循环
空间复杂度:O(N) 石头个数
2.9_494目标和
2.9.1 算法描述
1.如何转换成 01 背包问题
在一个公式中有两堆数字,left 和 right ,这两堆数字都是无符号的数字,在下面的式子中,left :1,1,1,1 right(绝对值) :1
推导公式1:
left-right=target;right = target -left
推导公式2:
其中 target 是固定的,数组中所有元素的和 sums 也是固定的,下面将 right 替换出来
left-(sums-left) = target --> left = (target+sums)/2
总结:
本问题化简成在集合中 nums 找出和为 left 的组合,转换为背包问题就是:
有几种生成 left 目标值的方式
2.两种无解的情况
sums = left+right
target = left-right
sums+target = 2left
if((sums+target)%2==1) return 0;
target= < sums
if(abs(target>sums)) return 0;
3.动规五部曲
①确定 dp 数组以及下标的含义
dp[j]:生成值为 j 的 left ,有几种生成方式
这里的 left 最大是等于 target 的
②确定递推公式
所以 dp[j] 中存放的就是在 [0,i-1] 这几个编号的数中凑成了和为 j 有几种方法
dp[j] = dp[j]+dp[j-nums[i]] 分别对应不将 i 放入 left 和将 i 放入 left
③dp 数组初始化
dp[0] = 1:装满容量为0的背包,有1种方法,就是装0件物品
如果 dp[0] =0 则后面的值全部是 0
④确定遍历顺序
内循环颠倒
⑤举例推导 dp 数组
4.遇到求种类个数的问题
如果下次遇到相同求个数的问题还是用公式:
dp[j]+=dp[j-nums[i]];
这里还是是否将 nums [i] 放入公式中的情况。分为放和不放
放入 nums[i] :dp[j-nums[i]] left 公式中就要相应减少一定的数值才能保证 target 不变。因为求得是个数所以后面不用再加 nums[i]
假设 nums[i] 等于 2 ,dp[3] = 4 ,有四种方法生成等式 3 ,这时候想要生成等式 5 ,只需要将 2 加在公式中,这只是一种方法,所以还是 dp[j-nums[i]] 方法个数没变
不放入 nums[i] :left 式子中不放入 i ,则组成的公式个数为 dp[j]
最后求得是情况的个数:将放和不放的情况个数进行相加
2.9.2C++ 代码实现
public:
int findTargetSumWays(vector<int>& nums, int target) {
int sums = 0;
// 判断是否由解决方案
for(int i=0;i<nums.size();i++) sums+=nums[i];
// 没有解决方案
if(abs(target)>sums) return 0;
if((target+sums)%2==1) return 0;
int bagWeight = (target+sums)/2;
vector<int> dp(bagWeight+1,0);
dp[0] = 1;
for(int i=0;i<nums.size();i++){
for(int j =bagWeight;j>=nums[i];j--){
dp[j]+=dp[j-nums[i]];
}
}
return dp[bagWeight];
}
};
2.9.3 时空复杂度
时间复杂度:O(N*M) M 为 left 的和,即背包的容量
空间复杂度:O(M)
2.10_474一和零
2.10.1算法描述
首先这是一个 01 背包的问题。因为每个数组中的每个物品只能拿一次。但是对包中放的物品是有要求的
因为对于背包内的物品是有要求的,所以可以定义二维数组用来确定向背包中放什么
感觉第二维就像是背包的另一个夹层,夹层背包要设置一个二位 dp
1.确定dp[i]是什么
dp[i] [j]:最多有i个0和j个1的strs的最大子集的大小为dp[i] [j]。
这里的 j 就是代表的 01 的个数
dp[i] [j]就是题目
2.确定递推公式
dp[i] [j] 就可以是 dp[i - zeroNum] [j - oneNum] + 1
如果减法是在 【】 内部减的就代表我们要将这个元素放进背包了,这里是在内部减的,所以就是将这个元素放入背包了
所以公式就是:
dp[i] [j] = max(dp[i] [j], dp[i - zeroNum] [j - oneNum] + 1);
因为是求个数所以最后是+1不是加数量
3.dp 数组如何初始化
dp[0] [0] = 0
一般在 0 的位置上都是 0
4.遍历顺序
外部从前往后,内部从后向前
2.10.2 C++ 代码实现
class Solution {
public:
int findMaxForm(vector<string>& strs, int m, int n) {
vector<vector<int>> dp(m+1,vector<int>(n+1,0));
for(string str:strs){ // 遍历每个物品
int oneNum=0,zeroNum=0; // 求得当前这个物品 0,1 的个数
for(char c:str){
if(c=='0') zeroNum++;
else oneNum++;
}
// 向 dp 中放入该物品
for(int i=m;i>=zeroNum;i--){
for(int j =n;j>=oneNum;j--){
dp[i][j] = max(dp[i][j],dp[i-zeroNum][j-oneNum]+1);
}
}
}
return dp[m][n];
}
};
易错点:
在动态的推导公式中 ,如果写的是 ++ 会报错
dp[i-zeroNum][j-oneNum]+1
2.10.3 时空复杂度
时间复杂度:O(M * N * S)
空间复杂度:O(M*N)
2.11_完全背包
2.11.1 算法描述
完全背包和 01 背包的不同点在于完全背包中每个物品都可以放入无限次,01背包只能放一次
完全背包需要正向遍历第二个循环
// 先遍历物品,再遍历背包
for(int i = 0; i < weight.size(); i++) { // 遍历物品
for(int j = weight[i]; j <=bagWeight ; j++) { // 遍历背包容量
dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
}
}
2.11.2 代码实现
1.先遍历物品
using namespace std;
#include<vector>
#include<iostream>
void wanquanbeibao(){
vector<int>weight = {1,3,4};
vector<int>value = {15,20,30};
int bagWeight = 4;
vector<int>dp(bagWeight+1,0);
for(int i=0;i<weight.size();i++){
for(int j=weight[i];j<=bagWeight;j++){ // 从 weight[i] 开始的目的就是保证可以将物品放下
dp[j] = max(dp[j],dp[j-weight[i]]+value[i]);
}
}
cout<<dp[bagWeight]<<endl;
}
int main(){
wanquanbeibao();
return 0;
}
2.先遍历背包
using namespace std;
#include<vector>
#include<iostream>
void wanquanbeibao(){
vector<int>weight = {1,3,4};
vector<int>value = {15,20,30};
int bagWeight = 4;
vector<int>dp(bagWeight+1,0);
for(int j =0;j<=bagWeight;j++){
for(int i=0;i<weight.size();i++){
// 判断物品是否可以放入
if(j-weight[i]>=0) dp[j] = max(dp[j],dp[j-weight[i]]+value[i]);
}
}
cout<<dp[bagWeight]<<endl;
}
int main(){
wanquanbeibao();
return 0;
}
先遍历背包时 i,j 代表的寓意没有变,在放入物品之前先判断是否可以放入
2.11.3 面试中常问问题
最后,又可以出一道面试题了,就是纯完全背包,要求先用二维dp数组实现,然后再用一维dp数组实现,最后在问,两个for循环的先后是否可以颠倒?为什么? 这个简单的完全背包问题,估计就可以难住不少候选人了。
2.12_518 零钱兑换2
2.12.1 算法描述
1.dp[j] 代表什么
dp[j] :在当前 index 情况下,零钱值为 j ,有几种换零钱的方式
2.dp[j] 的推导公式
这个题很像 494 目标和,dp[j] = dp[j] + dp[j-nums[i]]
即不加这个硬币和加上这个硬币换零钱的方式个数
3.dp[j] 的初始化
dp[0] = 1;因为零钱为 0 时有 1 种方式,就是啥也不放
4.遍历方式
外部硬币,内部背包。而且是完全背包
5.举例 dp 数组
2.12.2 C++ 代码实现
class Solution {
public:
int change(int amount, vector<int>& coins) {
vector<int>dp(amount+1,0);
dp[0] = 1;
for(int i =0;i<coins.size();i++){
for(int j=coins[i];j<=amount;j++){
dp[j]+=dp[j-coins[i]]; // 不放+放
}
}
if(dp[amount]==0) return 0; // 判断是否可以凑出金额
return dp[amount];
}
};
易错点:
这里只是将 dp[0] = 1 ,千万别把所有 dp 的值都设置为 1
2.12.3 时空复杂度
时间复杂度:O(M*N)
空间复杂度:O(M ) M 为 amount 的值
2.12.4 知识扩展–为什么不能先背包再物品
如果先物品,那么在不同的背包中出现的都是相同的物品,因为都属于一个大的 for 循环,所以是组合问题。
如果是先背包,那么第二层 for 循环每次都从0开始,假设说放2 ,那么每次放的2都被认作是不同的2。所以就变成了排列问题
2.判断是否可以凑足零钱
这里存在没有办法凑够零钱的情况,如果没有办法凑够零钱那么 dp[amount] 的值一定是没有被赋值过的初始化的值,所以只需要在遍历的最后判断一下即可
if(dp[amount]==0) return 0;
2.14_377 组合总和 4
2.14.1 算法描述
这个题和上面的题不一样,这个题的答案顺序是重复的,所以需要将背包放在外面将物品放在里面
2.14.2 C++ 代码实现
class Solution {
public:
int combinationSum4(vector<int>& nums, int target) {
vector<int> dp(target+1,0);
dp[0]=1;
for(int j = 1;j<=target;j++){
for(int i =0;i<nums.size();i++){
if(j>=nums[i]&&dp[j]<INT_MAX-dp[j-nums[i]]) dp[j]=dp[j]+dp[j-nums[i]]; // 防止 dp[j]+dp[j-nums[i]] 过大
else dp[j] = dp[j];
}
}
return dp[target];
}
};
易错:
需要注意的是 dp[j]+dp[j-nums[i]] 有可能会是一个很大的数,所以这里要先判断一下,如果这个数很大就不进行操作了
dp[j]<INT_MAX-dp[j-nums[i]]
2.2_70爬楼梯(完全背包)
2.2.1 算法描述
这个题有点类似于将零钱放入钱包的问题。假设现在有一个可以放 n 个硬币的钱包,硬币分为 1块,2块两种。从题目中可以看出这个钱是一种排列,在钱包能放 3 块时,有 (1,2) 和 (2,1) 都算对,所以是一个 排列问题
当一次可以爬 1~m 个楼梯时可以转换为向背包中放入 1~m 个零钱问题
2.2.2 C++ 代码实现
class Solution {
public:
int climbStairs(int n) {
vector<int>dp(n+1,0);
dp[1] = 1;
if(n==1) return 1; // 只有一个数的时候要单独处理,否则会有数组越界移异常
dp[2] = 1;
for(int j = 0;j<=n;j++){
for(int i =1;i<=2;i++){
if(j>=i) dp[j]+=dp[j-i];
}
}
return dp[n];
}
};
易错点:
1.因为初始化的时候
2.2.3 时空复杂度
时间复杂度:O(M*N) M:一共爬几阶,N:一次可以迈几阶
空间复杂度:O(M)
2.15_322零钱兑换
2.15.1 算法描述
1.dp[j] 代表什么
dp[j] :凑足总额为 j 所需钱币的个数最少为 dp[j] 个
2.确定地推公式
有两种可能将背包凑够重量,就是这个硬币是放还是不放,我们需要在放和不放当中找处硬币个数最少的情况
放:因为求得是个数所以如果将 cosin[i] 放入的话需要将 dp[j-coins[i]] 结果的个数 +1
不放:直接是 dp[j]
最后公式为: min(dp[j],dp[j-cosin[i]]+1)
求最大个数时要分为 放和不放两种情况的相加和最后的结果一定情况最多
最少情况那就选 放或者不放其中一个最小值就好
要想将 coins[i] 放入背包需要添加的判断:
如果背包的重量能放下 cosin[i] ,并且 cosin[i] 所依附的结果是可以成功的(否则求 dp[j-conins[i]] 是没有结果的)
if(i-cosin[j]>=0&&dp[j-consin[i]]!=INT_MAX)
3.dp 数组初始化
因为要求最小个数,所以初始化时每个元素一定要是最大
vector<int>dp(amount+1,INT_MAX);
dp[0]=0; // 这个值是真实值
4.确定遍历顺序
这个题求得是个数,所以不在乎能有几种组合或者排列的方式。所以这个题 for 循环的顺序没有影响
5.举例 dp 数组:完全背包中的排列组合问题
2.15.2C++ 代码实现
class Solution {
public:
int coinChange(vector<int>& coins, int amount) {
// 初始化 dp 数组
vector<int>dp(amount+1,INT_MAX);
dp[0] = 0;
for(int j = 0;j<=amount;j++){
for(int i = 0;i<coins.size();i++){
if(j-coins[i]>=0&&dp[j-coins[i]]!=INT_MAX) // 当前背包还能放下这个硬币&&j-coins[i]是可以放下的
dp[j] = min(dp[j-coins[i]]+1,dp[j]);
}
}
if(dp[amount]==INT_MAX) return -1;
return dp[amount];
}
};
2.15.3 时空复杂度
时间复杂度:O(M*N)
空间复杂度:O(M)
2.16_279完全平方数
2.16.1 算法描述
1.dp[j] 代表什么
dp[j] :当物品恰好将背包塞满的情况下需要多少个物品
2.dp 的遍历公式
这个题同上面的题一样,分为将 i*i 放入背包与不放入
放入:dp[j-i*i]+1
不放入:dp[j]
需要注意这里的拿一般等重物时拿出的值为 i*i
3.dp的初始化
dp[0] = 0,因为上面给的数 i 是从 1 开始的
dp[1] =1
4.dp 的遍历顺序
因为是数个数所以无关乎遍历顺序
5.举例 dp 数组
2.16.2 C++ 代码实现
1.先遍历物品
class Solution {
public:
int numSquares(int n) {
vector<int> dp(n+1,INT_MAX);
dp[0] = 0;
dp[1] = 1;
for(int j =0;j<=n;j++){
for(int i =1;i*i<=j;i++){ // 这里循环的条件需要注意
dp[j] = min(dp[j],dp[j-i*i]+1); // 从包中拿出的物品质量
}
}
return dp[n];
}
};
看这里的条件,在 for 循环中已经判断是否可以将 i*i 放入 j 中了
2.先遍历背包
class Solution {
public:
int numSquares(int n) {
vector<int>dp(n+1,INT_MAX);
dp[0] = 0;
dp[1] = 1;
for(int i=1;i<n;i++){
for(int j = i*i;j<=n;j++){
dp[j] = min(dp[j],dp[j-i*i]+1);
}
}
return dp[n];
}
};
2.16.3 时空复杂度
时间复杂度:O(M*N)
空间复杂度:O(M)
2.17_139单词拆分
2.17.1 算法描述
如何转换成动态规划题:
字符串 s 是背包,单词就是物品,问物品是否可以将背包装满,并且单词可以重复使用,所以是完全背包
背包的重量就是 s 不断变化的长度
1.dp[j] 代表什么
dp[i] 为下标指向字符串中的 i 时,[i,j] 是否在 set 中,是一个 bool 类型的值
2.确定递推公式
这个是需要不断的往前判断
当往背包中放 lee 时,先判断 lee 是否在 set 中,如果不在,则判断 ee 是否在,然后判断 e 是否在
什么时候 dp[j] 为 true
①包内的字母属于 Set 子集&&②从包内开始地方 i 之前的那一堆字母组成的单词也在 Set 中
3.dp 的初始化
dp[0] = true
**从遍历公式看出,这个子串的值要想是 true 其前面的那个值也得是 true **
当 s 表示 Null 时 {} 中也是 Null 所以从这个角度解释也是 true
4.数组的遍历方式
只是求是否出现过,所以排列还是组合不用在意
5.举例推导 dp 数组
在放入背包单词时一个一个放单词,但是在判断子串会先剔除前面的字符
i=0 | i=1 | i=2 | i=3 | i=4 | i=5 | i=6 | i=7 | |
---|---|---|---|---|---|---|---|---|
j=1 | l | |||||||
j=2 | le(这时候背包只能放两个物品) | e | ||||||
j=3 | lee | ee | e | |||||
j=4 | leet(true) break; | |||||||
j=5 | leetc | eetc | etc | tc | c | |||
j=6 | leetco | eetco | etco | tco | co | o | ||
j=7 | leetcod | eetcod | etcod | tcod | cod | od | d | |
j=8 | leetcode | eetcode | etcode | tcode | code | ode | de | e |
2.17.2 C++ 代码实现
class Solution {
public:
bool wordBreak(string s, vector<string>& wordDict) {
unordered_set<string> wordSet(wordDict.begin(), wordDict.end());
vector<bool> dp(s.size() + 1, false);
dp[0] = true;
for (int j = 1; j <= s.size(); j++) { // 遍历背包
for (int i = 0; i < j; i++) { // 遍历物品
string word = s.substr(i, j - i); //substr(起始位置,截取的个数)
if (wordSet.find(word) != wordSet.end() && dp[i]) {
dp[j] = true;
break;
}
}
}
return dp[s.size()];
}
};
2.18_多重背包
2.18.1 算法描述
多重背包和 0,1 背包非常相似,只不过多重背包中每个物品的个数是可以大于 1 的,但是 01 背包中物品的个数只能是 1 个
如果将每个物品都展开转换成只有 1 个则是下面的情况:
所以对于多重背包来说第一步是先展成 01 背包
2.18.2 C++ 代码实现
using namespace std;
#include<vector>
#include<iostream>
int test(){
vector<int>weight = {1,3,4};
vector<int>value={15,20,30};
vector<int>nums = {2,3,2};
int bagWeight = 10;
// 将所有的物品都展开
for(int i = 0;i<nums.size();i++){
while(nums[i]>1){
weight.push_back(weight[i]);
value.push_back(value[i]);
nums[i]--;
}
}
vector<int>dp(bagWeight+1,0);
dp[0]=0;
for(int i=0;i<weight.size();i++){
for(int j=bagWeight;j>=weight[i];j--){
dp[j] = max(dp[j],dp[j-weight[i]]+value[i]);
}
}
return dp[bagWeight];
}
int main(){
int res = test();
cout<<res<<endl;
return 0;
}
2.18.3 时空复杂度
时间复杂度:O(M * (N * K)) K 是 每个物品的数量
空间复杂度:O(M)
2.19_198 打家劫舍
打家劫舍的题会对每次更改哪个 dp[j] 有所讲究
2.19.1 算法描述
1.dp[i] 代表什么
在只有 i 间房屋的情况下,最后能偷到总金额 dp[i]
2.dp 的推导公式
分为偷与不偷两种情况
dp[j] = max(dp[j-1],dp[j-2]+nums[i])
这里一定要注意在偷与不偷的情况下应该选哪个下标
3.dp 初始化
根据推导公式所需进行初始化
在这里需要的是 dp[0] 和 dp[1] 分别代表偷第 0 个房子和偷第一个房子得到的最大利润
易错点:偷房子 1 时的最大利润还要进行判断
dp[0] = nums[0] 偷 0 的时候一定是偷 0 最大
dp[1] = max(nums[0],nums[1]) 偷 1 的时候需要判断哪个更大
4.确定遍历顺序
后面的结果是由前面结果推导出来的,所以从前向后遍历
5.举例 dp 数组
2.20.2 C++ 代码实现
class Solution {
public:
int rob(vector<int>& nums) {
if(nums.size()==0) return 0;
if(nums.size()==1) return nums[0];
vector<int> dp(nums.size(),0);
dp[0] = nums[0];
dp[1] = max(nums[0],nums[1]);
for(int i = 2;i<nums.size();i++){
dp[i] = max(dp[i-1],dp[i-2]+nums[i]); // 不偷,偷
}
return dp[nums.size()-1];
}
};
2.20.3 时空复杂度
时间复杂度:O(N)
空间复杂度:O(N)
2.21_213打家劫舍2
2.21.1 算法描述
这个题被当做模板是因为我们可以从任意的一个位置判断,判断任意的长度,所以这里设了一个 start 一个 end
本题就是去掉了开头第一个元素和结尾的元素分别判断两个子序列的结果,最后求最大值,如果将该模板套到上个题中 start 就变成了 0 ,end 就是 size-1:
去掉尾元素:
去掉首元素:
2.21.2 C++ 代码实现
class Solution {
public:
int base(vector<int>nums,int start,int end){ // 左闭右闭
if(end==start) return nums[start];
vector<int> dp(nums.size()); // 依旧保持原先的长度
dp[start] = nums[start];
dp[start+1] = max(nums[start],nums[start+1]);
for(int i= start+2;i<=end;i++){
// 偷 or 不偷
dp[i] = max(dp[i-2]+nums[i],dp[i-1]);
}
return dp[end];
}
int rob(vector<int>& nums) {
if (nums.size() == 0) return 0;
if (nums.size() == 1) return nums[0];
int a = base(nums,0,nums.size()-2);
int b = base(nums,1,nums.size()-1);
return max(a,b);
}
};
易错点:
①在 Base 方法中传入的为 start 和 end ,所以所有的一切都按照 start 和 end 来。下标之类的也要和 start 和 end 有关
②在最后 dp return 的时候
2.337_打家劫舍3
树+DP
2.22.1 算法描述
本题需要树的遍历。
1.dp [i] 代表什么
因为使用的树结构,树结构是链式存储的所以很难预判需要多大的存储空间,所以使用两个值进行记录偷与不偷获取的最大现金,然后再向上返回。
[0]:当前节点不偷的情况下资金的最大值;
[1]:当前节点偷的情况下资金的最大值;
2.dp 如何推导
这个节点分为不偷和偷两种情况,我们不管偷不偷,只管记录他的值,选择相应的下标就好
不偷:左右两边的子树就都可以偷
易错点:左右两边的孩子怎么偷还要再判断
int val1 = max(left[0],left[1])+max(right[0],right[1])
偷:左右子树就不能偷
int val2 = cur->val+left[0]+right[0];
3.dp 的初始化
如果这个树中一个节点都没有就将其设置为 0
dp[0]=0;dp[1]=0
4.确定遍历顺序
应该选择:后序遍历
后序遍历先遍历根节点,而根节点上的点比较多,不容易引发报警
5.举例 dp 数组
2.22.2 C++ 代码实现
class Solution {
public:
// 长度为2的数组,0:不偷,1:偷
vector<int> base(TreeNode* cur) {
if (cur == NULL) return vector<int>{0, 0};
vector<int> left = base(cur->left);
vector<int> right = base(cur->right);
// 偷cur : 左右两个孩子都不偷
int val1 = cur->val + left[0] + right[0];
// 不偷cur : 左孩子怎么偷还要进一步判断
int val2 = max(left[0], left[1]) + max(right[0], right[1]);
return {val2, val1};
}
int rob(TreeNode* root) {
vector<int> res = base(root);
return max(res[0], res[1]);
}
};
2.22.3 时空复杂度
时间复杂度:O(N)
空间复杂度:O(logN)
2.23_121 买卖股票的最佳时机
2.23.1算法描述
1.dp[i] [j] 代表什么
这里一共有三种状态
dp[i] [0] 在这一天以及之前的所有天不进行任何操作所得的最多现金
dp[i] [1]:第 i 天持有股票所得的最多现金
dp[i] [2]:第 i 天不持有股票所得的最多现金
2.确定递推公式
因为是有限次数的买入卖出
持有股票分为两种情况:
昨天持有股票 or 今天买入
max(dp[i] [0]-prices[i],dp[i] [1])
不持有股票分为两种情况:
昨天是第一次持有股票阶段,今天卖出去+继续不持有股票
max(dp[i-1] [1]+prices[i],dp[i-1] [2])
dp[i] [0] 这里省略了,要不不进行任何操作本来是:
很显然下面的公式总是 dp[i-1] [2] 大。
dp[i][0] = max(dp[i-1][0],dp[i-1][2]);
继续向下推 dp[i] [1] 的式子也可以化简
dp[i][1] = max(dp[i-1][2]-prices[i],dp[i-1][1])
3.dp 初始化
根据上面的推导重视初始化第 0 天
dp[i] [0] :0 因为在这一天之前什么操作都没有进行所以也没赚也没赔,所以第 i 天的 [0] 全部为 0
dp[0] [1] = dp[0] [0]-prices[i]
dp[0] [2] = dp[0] [1]+prices[i]
4.遍历顺序
从前向后遍历
5.举例遍历 dp 数组
2.23.2 C++ 代码实现
class Solution {
public:
int maxProfit(vector<int>& prices) {
vector<vector<int>>dp(prices.size(),vector<int>(3,0));
dp[0][1] = -prices[0];
dp[0][2] = 0;
for(int i =1;i<prices.size();i++){
dp[i][1] = max(dp[i-1][0]-prices[i],dp[i-1][1]);
dp[i][2] = max(dp[i-1][1]+prices[i],dp[i-1][2]);
}
return dp[prices.size()-1][2];
}
};
2.23.3 时空复杂度
时间复杂度:O(N)
空间复杂度:O(N)
2.25_123买卖股票的最佳时机3
2.25.1 算法描述
1.dp[i] [j] 代表什么
dp[i] [0] :没有操作
dp[i] [1] :第一次持有:
①延续前一天持有的操作
dp[i-1] [1]
②第 i 天第一次买入股票
dp[i-1] [0]-price[i]
dp[i] [2] :第一次不持有:
①延续前一天操作
dp[i-1] [2]
②第 i 天卖出股票,是在第一天买入股票的基础上卖出
dp[i-1] [1]+price[i]
max(dp[i-1] [2],dp[i-1] [1]+price[i])
dp[i] [3] :第二次持有
同上
dp[i] [4] :第二次不持有
同上
3.dp 数组初始化
看一下在递推公式中最初的依赖:
第 0 天什么也不操作:dp[0] [0] = 0 在这之前没有进行任何操作
第 0 天进行第一次买入操作: dp[0] [1] = -prices[i]
第 0 天进行第一次卖出:dp[0] [2] = 0 当天买当天卖的利润是 0
第 0 天进行第二次买入:dp[0] [3] = -prices[0]
第 0 天进行第二次卖出:dp[0] [4] = 0
4.举例 dp 数组
2.25.2 C++ 代码实现
class Solution {
public:
int maxProfit(vector<int>& prices) {
// 1. dp 定义
vector<vector<int>>dp(prices.size(),(vector<int>(5,0)));
// 2. dp 初始化
dp[0][0] = 0;
dp[0][1] = -prices[0];
dp[0][2] = 0;
dp[0][3] = -prices[0];
dp[0][4] = 0;
for(int i =1;i<prices.size();i++){
dp[i][0] = dp[i-1][0]; // 不进行任何操作
dp[i][1] = max(dp[i-1][0]-prices[i],dp[i-1][1]);
dp[i][2] = max(dp[i-1][1]+prices[i],dp[i-1][2]);
dp[i][3] = max(dp[i-1][2]-prices[i],dp[i-1][3]);
dp[i][4] = max(dp[i-1][3]+prices[i],dp[i-1][4]);
}
return dp[prices.size()-1][4];
}
};
2.25.3 时空复杂度
时间复杂度:O(N)
空间复杂度:O(N * 5)
2.26_Base:188买卖股票的最佳时机4
2.26.1 算法描述
0:躺平,不进行任何操作
1k :持有–>持续前一天持有 or 第 k 天买入
2k:不持有–>持续前一天不持有 or 第 k 天卖出
为什么不对 dp[i] [0] 进行单独的赋值操作:
在第一次卖出股票之后(以操作两次为例):
dp[i] [0] = dp[i] [2] 就是那一天刚刚卖出股票,因为这一天任何操作都不能做,所以这个值就是一直不变的。在后面求 dp[i] [1] 时 ,持有状态可能从躺平状态进入也可能从不持有状态进入,也可能从前一次的持有状态进入。但是 dp[i] [0] 已经和不持有相等了,所以这里只用判断不持有就好了
这个题的递推公式可以借鉴进行两次操作的公式
2.26.2 C++ 代码实现
class Solution {
public:
int maxBase(vector<int>& prices,int k){
vector<vector<int>>dp(prices.size(),vector<int>(2*k+1,0));
for(int i =1;i<2*k;i+=2){
if(i%2!=0) dp[0][i] = -prices[0];
}
for(int i =1;i<prices.size();i++){
for(int j = 1;j<2*k+1;j = j+2){
// 持有
dp[i][j] = max(dp[i-1][j-1]-prices[i],dp[i-1][j]);
// 不持有
dp[i][j+1] = max(dp[i-1][j]+prices[i],dp[i-1][j+1]);
}
}
return dp[prices.size()-1][2*k];
}
int maxProfit(int k, vector<int>& prices) {
if(prices.size()==0) return 0;
int maxVal = maxBase(prices,k);
return maxVal;
}
};
2.26.3 时空复杂度
时间复杂度:O(k*N)
空间复杂度:O(k*N)
2.27_309买卖股票的最佳时机含冷冻期
2.27.1 算法描述
因为包含冷冻期。冷冻期后的一天不能进行任何操作,又细分为以下几种状态
1.dp[i] [j] 代表什么
冷却期很像单次买入时的 dp[0] [0] 状态,是无关乎任何持有或者不持有的状态,需要单独列出
冷却期的值一定是从 不持有且当天卖出的情况中分离出来的,所以不持有且当天卖出这种情况要单独列出
持有的话可以从冷冻期持有也可以从持续持有中持有也可以当天买入持有,那么就要从这三种状态中求最大值
2.递推公式
- 持有股票状态
这里要将买入和持有(但不买入)状态分开
1.冷冻期买入 1
2.从不持有买入 2
3:持有但是不买入 3
**状态合并 为 1:**1,2,3 可以进行合并 ,因为冷冻期并没有用到持有的状态
- 不持有股票
1:当天卖出 2
2.持续前一天的持有状态或者冷冻期 3
状态合并:状态不能合并,因为 “当天卖出”和冷冻期有关
- 冷冻期 4
3.dp 数组初始化
dp[0] [0] = 0 // 躺平
dp[0] [1] = -prices[i] // 持有股票:持续持有状态 or 当天买入
dp[0] [2] = 0 // 不持有股票(不包含当天买入)
dp[0] [3] = 0 // 当天卖出股票
dp[0] [4] = 0 // 冷冻期
最后结果是 :不持有(两种情况)+冷却期都有可能出现最大值
2.27.2 C++ 代码实现
class Solution {
public:
int maxProfit(vector<int>& prices) {
vector <vector<int>>dp(prices.size(),vector<int>(5,0));
// 持有 or 不持有
dp[0][0] = 0; // 躺平
dp[0][1] = -prices[0];
dp[0][2] = 0; // 卖出
dp[0][3] = 0; // 不持有且不是当天卖出
dp[0][4] = 0; // 冷冻期
for(int i =1;i<prices.size();i++){
// 持有
dp[i][1] = max(dp[i-1][1],max(dp[i-1][3]-prices[i],dp[i-1][3]-prices[i]));
// 当天卖出
dp[i][2] = dp[i-1][1]+prices[i];
// 仅不持有
dp[i][3] = max(dp[i-1][2],dp[i-1][3]); // 不持有可以从两种状态过来
// 冷冻期
dp[i][4] = dp[i][2];
}
return max(dp[prices.size()-1][2],max(dp[prices.size()-1][3],dp[prices.size()-1][4]));
}
};
2.27.3 时空复杂度
时间复杂度:O(N)
空间复杂度:O(N)
2.24_122买卖股票的最佳时机2(调整顺序)
2.24.1 算法描述
这个题和上面题的区别在于:
因为可以多次买卖,所以“持有”的状态可以从 “不进行任何操作 [i-1] [0]”,“之前就持有 [i-1] [1]”,“不持有[i-1] [2]” 三个地方来。
而不持有状态从 “之前就不持有 [i-1] [2]”和 “持有并卖出 [i-1] [1]+prices[i]” 两个状态来
本题的推导公式:
dp[i][1] = max(max(dp[i-1][2]-prices[i],dp[i-1][0]-prices[i]),dp[i-1][1]); // 持有状态从三个方面来
dp[i][2] = max(dp[i-1][2],dp[i-1][1]+prices[i]); // 不持有状态从两个方向来
上个题的推导公式:
dp[i][1] = max(dp[i-1][0]-prices[i],dp[i-1][1]);
dp[i][2] = max(dp[i-1][1]+prices[i],dp[i-1][2]);
2.24.2 C++ 代码实现
class Solution {
public:
int maxProfit(vector<int>& prices) {
vector<vector<int>> dp(prices.size(),vector<int>(3,0));
dp[0][0] = 0; // 0 不进行任何操作
dp[0][1] = -prices[0]; // 1 持有
dp[0][2] = 0; // 2 不持有
for(int i = 1;i<prices.size();i++){
dp[i][1] = max(max(dp[i-1][2]-prices[i],dp[i-1][0]-prices[i]),dp[i-1][1]); // 持有状态从三个方面来
dp[i][2] = max(dp[i-1][2],dp[i-1][1]+prices[i]); // 不持有状态从两个方向来
}
return dp[prices.size()-1][2];
}
};
2.24.3 时空复杂度
时间复杂度:O(N)
空间复杂度:O(N)
2.28_714买卖股票的最佳时机含手续费
2.28.1 算法描述
一共分为三种状态:
躺平0,持有1,不持有2
1.持有股票:
max(dp[i-1] [1],dp[i-1] [2]-prices[i])
前一天就持有股票;当天买入
2.不持有股票
这里因为在成交之后要交费用问题,所以要将不持有分为:延续前一天不持有和当天卖出
延续前一天不持有股票:
dp[i] [2] = dp[i-1] [2]
当天卖出:
dp[i] [3] = dp[i-1] [1]+prices[i]-fee
因为上面两者可以算作不持有,所以即其合并
dp[i] [2] = max(dp[dp[i-1] [2],dp[i-1] [1]+prices[i]-fee])
3.初始化 dp 数组
因为第 0 天没有前一天,所以这一天只能买入或者不进行任何操作
dp[0] [0] = -prices[0];
dp[0] [1] = 0;
2.28.2 C++ 代码实现
class Solution {
public:
int maxProfit(vector<int>& prices, int fee) {
vector<vector<int>> dp(prices.size(),vector<int>(3,0));
dp[0][0] = 0; // 躺平
dp[0][1] = -prices[0]; // 持有
dp[0][2] = 0;// 当天卖出
for(int i =1;i<prices.size();i++){
// 持续前一天持有
dp[i][1] = max(dp[i-1][1],dp[i-1][2]-prices[i]);
// 什么时候卖出
dp[i][2] = max(dp[i-1][2],dp[i-1][1]+prices[i]-fee); // 这里的状态可以进行合并
}
return dp[prices.size()-1][2];
}
};
2.28.3 时空复杂度
时间复杂度:O(N)
空间复杂度 :O(N)
2.29_300最长上升递增子序列
2.29.1 算法描述
1.暴力解
S1:6 和 7 比,7 选,然后进入一层 for 循环;与此同时 7 不选也进入一个 for 循环。0,1 不选,9 选与不选都又分别进入两个 for 循环。就是相当于后面的每个数选或者不选都会进入两个不同的 for 循环。就像回溯一样,有所少个数就会有多少个 for 循环
2.DP 解
如:[6,7,0,1,9,3,5,8,4],这里从 7 开始
S1:7 和 6 比较,7>6 ,所以 dp[1] = dp[0]+1
S2:0 和 6 比较 ,0<6 ,所以不能选 6 作为 0 的最长递增子序列。0 和 7 比较,0<7 同理不能选为 0 的最长递增子序列;最后 0 的最长递增子序列还是 1
S3:1 和 6,7 比较,最长递增子序列都不变。1 和 0 比较,最长递增子序列 +1
。。。。。
S4:9 和 6 比较,9>6 dp[4] = dp[j]+1
(1)什么时候公式取 dp[i] :
就拿 9 举例,当其判断 0 时发现 ,0 的下标是 1 ,但是这时因为前面有 6,7 所以 9 的下标就已经是 3 了,所以 9 还是要去取 dp[i]
(2)为什么使用两个 for 循环:
因为是不连续的递增序列,有点类似于暴力搜索。以 7 为例,需要判断前面所有的递增数哪种组合可以达到最大值。
(3)DP 五部曲
1dp[i] 代表什么
dp[i] 表示,当总序列长度为 i 时最长子序列的长度
2.dp[i] 的递推公式
if(nums[i]>nums[j]) dp[i] = max(dp[i],dp[j]+1);
注意这里不是要 dp[i] 与 dp[j]+1 比较,而是取 dp[j]+1 的最大值
3.dp[i] 的初始化
dp[0] = 0
第 0 个数的最长上升子序列是他自己
4.dp[i] 的遍历顺序
从前向后遍历
5.举例 dp 数组
2.29.2 C++ 代码实现
class Solution {
public:
int lengthOfLIS(vector<int>& nums) {
vector<int>dp(nums.size(),1);
int result=1; // 最长子序列不一定发生在最后一个元素上,所以要对这个值进行保存
for(int i =1;i<nums.size();i++){ // 初始值从 1 开始
for(int j=0;j<i;j++){ // 和 i 之前的元素比较
if(nums[i]>nums[j]) dp[i] = max(dp[i],dp[j]+1);
}
if(dp[i]>result) result = dp[i];
}
return result;
}
};
2.29.3 时空复杂度
时间复杂度:O(N^2)
空间复杂度:O(N)
2.30_674最长连续递增序列
2.31.1 算法描述
这一题和上一题的区别在于这里不可以对原序列进行删减,所以对于 dp[i] 的判断是通过 i-1 得到的。而上一题的 dp[i] 是通过 之前所有的值得到的,这里只用一个 for 循环即可
1.dp[i] 代表什么
dp[i] 代表以下标 i 为结尾的数组的连续递增子序列的长度为 dp[i]
2.dp[i] 的递推公式
关键:当前 i 的状态由谁决定
关键:当前 i 的状态由他的前一个数决定
关键:是否需要连续
3.dp[i] 的初始化
同理,这里对所有的值都设为 1
4.dp 的遍历顺序
从前向后遍历
5.举例dp 数组
2.31.2 C++ 代码实现
class Solution {
public:
int findLengthOfLCIS(vector<int>& nums) {
vector<int>dp(nums.size(),1); // 最小的递增序列是他自己
dp[0] = 1; // 只有一个元素时递增序列的个数为 1
int maxVal = 1;
for(int i =1;i<nums.size();i++){
if(nums[i]>nums[i-1]) dp[i] = dp[i-1]+1; // 在前面的结果 +1
else dp[i] = 1; // 从头开始计算
maxVal = max(dp[i],maxVal); // 更新最大值
}
return maxVal;
}
};
2.31.3 时空复杂度
时间复杂度:O(N)
空间复杂度:O(N)
2.32_718最长重复子数组
2.32.1 算法描述
1.dp[i] [j] 代表什么
**因为这里有两个数组,所以需要定义二维 dp **
dp[i] [j] 代表以下标 i-1 为结尾的 A ,和以下标 j-1 为结尾的 B ,最长重复子数组的长度为 dp[i] [j](但是在 i-1,j-1 不相等时他们的值是 0 )
2.确定递推公式
当 A[i-1] 和 B[j-1] 相等的时候,因为需要连续所以要看前面的那个字符是否一样,如果一样 +1 。
如果 A[i-1] B[j-1] 不相等,则直接设为 0
dp[i] [j] = dp[i-1] [j-1]+1
很关键:当前值依赖于左斜上方的值,因为斜上方的值才是两个重复的值进行比较
3.dp 数组如何初始化
当 i,j 从 1 开始时,dp[i] [0] 和 dp[0] [j] 没有意义,这里初始化为 0
在后面 A[0] 和 B[0] 相等了, dp[1] [1] =dp[0] [0]+1 = 1
为什么 dp 的长度定义为 size+1 ,因为 A[0] B[0] 两个元素的值还是需要单独判断的,就相当于加了一个 dummy 。让这两个值在 for 循环中判断了
4.确定遍历顺序
最外层遍历 A 内层遍历 B ,也可以反过来
5.举例 dp 数组
从这个图中可以看如果 A[i-1]==B[j-1] 那个这个值依赖于其左斜上方的值。这样就可以组成一个子序列
2.32.2 C++ 代码实现
class Solution {
public:
int findLength(vector<int>& nums1, vector<int>& nums2) {
vector<vector<int>> dp(nums1.size()+1,vector<int>(nums2.size()+1,0));
int result=0;
for(int i=1;i<=nums1.size();i++){
for(int j = 1;j<=nums2.size();j++){
if(nums1[i-1]==nums2[j-1]){
dp[i][j] = dp[i-1][j-1]+1;
}
if(result<dp[i][j]) result=dp[i][j];
}
}
return result;
}
};
2.32.3 时空复杂度
时间复杂度:O(N*M) 分别代表 A,B 的长度
空间复杂度:O(N*M)
2.33_1143最长公共子序列
2.33.1 算法描述
1.dp[i] [j] 代表什么
dp[i] [j] 代表:字符串 text1 和 字符串 text2 对应的下标为 [i-1] 和 [j-1] 时最长公共子序列为 dp[i] [j]
2.dp 递推公式
因为 A 是可以删减的,所以判断 [i-1] [j-1] 时这个值可以从
i-1与j-1 相同(上个题):
if(text1[i-1]==text[j-1]) dp[i][j] = dp[i-1][j-1]+1
i-1与j-1 不同:
就可以对 A 进行删除
max(dp[i-1][j],dp[i][j-1])
**这个题和上一个题最大的不同就是在于 [i-1] 和 [j-1] 不相同的时候: **
因为 A 可以删减,所以 B[j-2] 的判断结果也可以引用到 B[j-1] 中,但是上一个题是不能删减的
所以这个题 [i] [j] 的结果都可以连续上个值的结果
3.dp 的初始化
dp[0] [0] 是没有意义的位置,所以设为0
4.dp 的遍历顺序
从前向后遍历
5.举例推导 dp 数组
这里我以为原字符串是可以删的,匹配字符串是不可以删的。但是通过测试用例
[a,b,c,d,e] & [a,c,f] 发现两个都是可以删的,因为我们只需要求匹配的长度。如果匹配字符 [a,c,f] 不能删,那么最后的结果就是 0 ,画出来的 DP 数组就如下图所示
如果短(匹配字符串可以删)
2.33.2 C++ 代码实现
class Solution {
public:
int longestCommonSubsequence(string text1, string text2) {
vector<vector<int>> dp(text1.size()+1,vector<int>(text2.size()+1,0));
dp[0][0] = 0;
for(int i = 1;i<=text1.size();i++){
for(int j=1;j<=text2.size();j++){
if(text1[i-1]==text2[j-1]) dp[i][j] = dp[i-1][j-1]+1; // 相同
else dp[i][j] = max(dp[i][j-1],dp[i-1][j]); // 不相同
}
}
return dp[text1.size()][text2.size()];
}
};
2.33.3 时空复杂度
时间复杂度:O(N*M)
空间复杂度:O(N*M)
2.34_1035不相交的线
2.34.1 算法描述
以 nums1 和 nums2 两个数组为例:
nums1 = [2,5,1,2,5],
nums2 = [10,5,2,1,5,2]
2 和第一个出现的 2 相连,5 和 2 后面的 5 相连,不可以和前面的 5 连。。。。。
如果想让两个数组生成的结果不想交,则必须保证: 子序列有序
这个题就是寻找相同子序列
2.34.2 C++ 代码实现
class Solution {
public:
int maxUncrossedLines(vector<int>& nums1, vector<int>& nums2) {
vector<vector<int>> dp(nums1.size()+1,vector<int>(nums2.size()+1,0));
dp[0][0] = 0;
for(int i = 1;i<=nums1.size();i++){
for(int j=1;j<=nums2.size();j++){
if(nums1[i-1]==nums2[j-1]) dp[i][j] = dp[i-1][j-1]+1; // 相同
else dp[i][j] = max(dp[i][j-1],dp[i-1][j]); // 不相同
}
}
return dp[nums1.size()][nums2.size()];
}
};
2.34.3 时空复杂度
时间复杂度:O(M*N)
空间复杂度:O(M*N)
2.35_53最大子序和
2.35.2 算法描述
1.dp[i] 是什么
dp[i] 保存的是当前元素所组成的最大子序的最大子序和。maxValue 保存的是整个序列的最大子序和
2.dp[i] 的递推公式
当前值是依赖于前面值的,递推公式分两种情况:
①将这个值加入最大子序列和
②以这个子序列为起点
max(dp[i-1]+nums[i],nums[i])
以上的两个操作都能保证最后的结果是连续的子序列
2.35.2 C++ 代码实现
class Solution {
public:
int maxSubArray(vector<int>& nums) {
vector<int>dp(nums.size(),0);
dp[0] = nums[0];
int maxVal = nums[0];
for(int i =1;i<nums.size();i++){
dp[i] = max(dp[i-1]+nums[i],nums[i]);
maxVal = max(maxVal,dp[i]);
}
return maxVal;
}
};
易错点:
如果不把这个值设置为最大子和中的一个数,那么 nums[i] 就是起点。因为这是一个连续子和,所以不能连续之前的那个值,即 dp[i] = max(dp[i-1],dp[i-1]+nums[i]) 这样写就会断开
2.35.i3 时空复杂度
时间复杂度:O(N)
空间复杂度:O(N)
2.36_392判断子序列
2.36.1 算法描述
这个题和 1143 其实是异曲同工,两个题一个要求返回 bool 类型,一个返回 int 类型。AB 都可以删除。因为题目中已经说明了 s 是子序列,所以最后只要判断最长子序列的个数是不是 s 就好
匹配题目看似是匹配返回的值是 bool 类型,但是 bool 类型的值如果放在 dp 中信息量太少,所以 dp 中一般还是放数字
本题 A 序列的值要全部满足 B 序列
A: a b c
B : a h b g d c
a-a 相等,dp 对应的下标 +1
b-b 相等,是在 a-a 相等的基础上 +1
a-h 不相等,dp 的值为 a-a
b-a 不相等,a-a 的值是不能给 b-a 的,因为这里要 a b c 全部满足才行,b-a 不满足
2.36.2 C++ 代码实现
class Solution {
public:
bool isSubsequence(string s, string t) {
vector<vector<int>>dp(s.size()+1,vector<int>(t.size()+1,0));
for(int i =1;i<=s.size();i++){
for(int j = 1;j<=t.size();j++){
if(s[i-1]==t[j-1]) dp[i][j] = dp[i-1][j-1]+1;
else dp[i][j] = dp[i][j-1];
}
}
if(dp[s.size()][t.size()]==s.size()) return true;
return false;
}
};
3.36.3 时空复杂度
时间复杂度:O()
总结:
A:a b c d e
B:a c e
第一行 a-a 相等+1
在第二行 b-a 不相等,但是 a-a 可以传给 b-a 所以他的值可以来自上方
与此同时第二行 b-a 不相等但是 b-a 的值还是可以算的,所以他的值可以来自左侧,并选择其中一个大的值
392
A: a b c
B : a h b g d c
因为这个题只有 A 全部满足 B 时才能算进行计数。也就是说在第一行 a-a 虽然满足了,但是在第二行 b-a 不满足,也不能算满足,所以 a-a 的值不能传给 b-a
718
A:1 2 3 2 1
B:3 2 1 4 7
必须要连续满足,所以只能对连续对角线的值进行相加
要分别看:
A B 子序列中的字母 α ,β
① α 和 β 相等时这个他俩对应的下标依赖谁
②α 和 β 不相等时他俩对应的下标依赖谁
2.37_115不同的子序列
2.37.1 算法描述
1.dp[i] [j] 代表什么
代表题目就完事
代表长度为 [i-1] 的 t 和 长度为 [j-1] 的s 所匹配的种类个数
这个子序列问题求的是 “匹配”不是 “长度”
2.dp 的递推公式
同样分成 t[i-1] 等于或者不等于 s[j-1] 两种情况
(1)t[i-1] 与 j[j-1] 相等分为两个部分:
为什么还分为不用来匹配的情况:
s:bagg
t:bag
这里一共有两个 g ,所以用哪个 g 去匹配 bag 都是可以的
所以相等时的最终结果是两个值相加
①s[j-1] 用来匹配 dp[i-1] [j-1]
②s[j-1] 不用来匹配 dp[i-1] [j] ,也就是这俩值不相等
(2)t[i-1] 和 s[j-1] 不相等
因为 s 是可以删除的,所以当 s = bae 的结果是 s=baeg 的结果的子集。也就是说 baeg 的结果可以借鉴 bae 的结果。那么在不相等的时候 baeg 的结果来自 bag
不相等就没有办法匹配,所以直接返回 dp[i] [j-1]
3.dp 数组初始化
首先说 dp[i] [0]
这里的 s 是可以进行删除的,以 bageg 匹配 bag 为例,为什么最后能够匹配到两个,是因为将 bageg 第一个 g 和第一个 e 删除变为了 bag 以及将 bageg 的最后两个 eg 删除变为了 bag ,这时候 s 和 t 就能有两次匹配。
这里我们的 dp 是以 0 开始的,0 就代表没有字符。
当 s=baged 时,s 也可以充当 0 ,因为 s 是可以随时删除字符的。所以 dp[i] [0] 一定为 1
其次是 dp[0] [j]
因为 t 不是子序列是要进行完全匹配的字符串,所以 t 是不能删除,所以 dp[0] [j] 全为 0
4.确定遍历顺序
从上到下,从左到右
5.举例 dp 数组
2.37.2 代码实现
class Solution {
public:
int numDistinct(string s, string t) {
vector<vector<uint64_t>>dp(t.size()+1,vector<uint64_t>(s.size()+1,0)); // 必须定义为 unit_64
// 初始化
for(int i = 0;i<s.size()+1;i++){
dp[0][i] = 1;
}
for(int i=1;i<=t.size();i++){ // 因为 t 是子串,所以 t 要放在 i 的位置
for(int j= 1;j<=s.size();j++){
if(t[i-1]==s[j-1]) dp[i][j] = dp[i-1][j-1]+dp[i][j-1]; // 不用 j 匹配和用 j 匹配
else dp[i][j] = dp[i][j-1]; // 匹配问题子串不能删减
}
}
return dp[t.size()][s.size()];
}
};
2.37.3 时空复杂度
2.38_583两个字符串的删除操作
2.38.1 算法描述
1.dp[i] [j] 代表什么
dp[i] [j]:以i-1为结尾的字符串word1,和以j-1位结尾的字符串word2,想要达到相等,所需要删除元素的最少次数
2.递推公式
当word1[i - 1] 与 word2[j - 1]相同:
dp[i] [j] = dp[i - 1] [j - 1]:不用删除任何元素,直接等于斜对角线
当word1[i - 1] 与 word2[j - 1]不相同:
删除 word1 中的元素,也就是行向上退一个 dp[i - 1] [j] + 1;
删除 word2 中的元素,也就是列向左退一个 dp[i] [j-1] + 1;
同时删除:dp[i - 1] [j - 1] + 2
因为问的是最小值:
dp[i] [j] = min({dp[i - 1] [j - 1] + 2, dp[i - 1] [j] + 1, dp[i] [j - 1] + 1});
3.dp 初始化
这里在进行初始化时需要注意,通过画表可知 ,这里的 x 必须当成空字符看。所以在进行初始化时第一行和第一列是顺序进行初始化的
4.dp 遍历顺序
从左到右,从上到下
5.举例 dp 数组
2.38.2 C++ 代码实现
class Solution {
public:
int minDistance(string word1, string word2) {
vector<vector<int>> dp(word1.size()+1,vector<int>(word2.size()+1,0));
// 初始化
for(int i=0;i<=word1.size();i++) dp[i][0]=i;
for(int j=0;j<=word2.size();j++) dp[0][j]=j;
for(int i=1;i<=word1.size();i++){
for(int j=1;j<=word2.size();j++){
if(word1[i-1]==word2[j-1]) dp[i][j] = dp[i-1][j-1]; // 两个字符串都不用删除元素
else dp[i][j] = min({dp[i-1][j]+1,dp[i][j-1]+1,dp[i-1][j-1]+2}); // 需要某一个字符串删除一个元素或者二者都删
}
}
return dp[word1.size()][word2.size()];
}
};
2.38.3 C++ 时空复杂度
时间复杂度:O(M*N)
空间复杂度:O(M*N)
2.39_72编辑距离
2.39.1 算法描述
1.dp[i] [j] 代表什么
以下标 i-1 为结尾的字符串 word1 和以下标 j-1 为结尾的的字符串 word2 ,最近编辑距离为 dp[i] [j]
2.递推公式
一共分为两大种四小种情况:
word1[i-1] == word2[j-1] 相等
不进行任何操作,因为只有在不进行任何操作的情况下才能保证操作数最小
我们不需要判断要进行什么操作可以让结果最小,我们只需要将这些操作列出来让程序判断
删除元素:
- 操作一:word1删除一个元素,那么就是以下标i - 2为结尾的word1 与 j-1为结尾的word2的最近编辑距离 再加上一个操作。
即:dp[i] [j] = dp[i - 1] [j] + 1;
- 操作二:word2删除一个元素,那么就是以下标i - 1为结尾的word1 与 j-2为结尾的word2的最近编辑距离 再加上一个操作。
即 :dp[i] [j] = dp[i] [j-1] + 1;
添加元素:
假设说目前 word1 长度或者对应的位置还不对,要给 word1 添加一个元素使得 word1[i-1] 和 word2[j-1] 相同 。那还不如说给 word2 减掉一个元素
即 : dp[i] [j] = dp[i] [j-1] +1
替换元素:
替换的意思是说将 word1 的 word1[i-1] 进行替换让其与 word2[j-1] 相同。因为这两个值相同,所以看得是对角线元素。
那么只需要在对应 dp[i-1] [j-1] 的位置上 +1 即可。这一步的替换操作和上一题的删除有所不同,上一步的删除必须要两个元素都删才能保证两个串相等。但是这里的替换当两个字符都不相等时,只需要将其中一个进行更改字符就行,所以最后是 +1 的操作
即:dp[i] [j] = dp[i-1] [j-1]+1
最终结果:
最后只需要求上面三种操作那种操作可以使得操作数最少:
dp[i] [j] = min({dp[i-1] [j],dp[i] [j-1],dp[i-1] [j-1]})+1
3.dp 数组如何初始化
其实初始化通过画图和对题目的理解就可以得到结果
4.确定遍历顺序
从左到右,从上到下
5.举例dp数组
2.39.2 C++ 代码实现
class Solution {
public:
int minDistance(string word1, string word2) {
vector<vector<int>> dp(word1.size()+1,vector<int>(word2.size()+1,0));
// 初始化
for(int i=0;i<word1.size()+1;i++) dp[i][0]=i;
for(int j=0;j<word2.size()+1;j++) dp[0][j]=j;
for(int i = 1;i<=word1.size();i++){
for(int j=1;j<word2.size()+1;j++){
if(word1[i-1]==word2[j-1]) dp[i][j] = dp[i-1][j-1];
else
dp[i][j] = min({dp[i-1][j],dp[i][j-1],dp[i-1][j-1]})+1;
}
}
return dp[word1.size()][word2.size()];
}
};
2.40_647 回文子串
2.40.1 算法描述
1.dp[i] [j]代表什么
表示在范围内 [i,j] 的子串是否是回文子串,bool 类型变量
2.dp 递推公式
在确定递推公式时,就要分析如下几种情况。
整体上是两种,就是s[i]与s[j]相等,s[i]与s[j]不相等这两种。
当s[i]与s[j]不相等,dp[i] [j]一定是false。
当s[i]与s[j]相等时,这就复杂一些了,有如下三种情况:
-
情况一:下标i 与 j相同,同一个字符例如a,当然是回文子串
-
情况二:下标i 与 j相差为1,因为一共就两个元素,也是文子串
-
情况三:下标:i 与 j相差大于1的时候,例如cabac,此时s[i]与s[j]已经相同了,我们看i到j区间是不是回文子串就看aba是不是回文就可以了,那么aba的区间就是 i+1 与 j-1区间
3.dp 数组初始化
这个题 dp[i] [j] 就是 index 值,不需要 0 进行占位,所以每个值都要进行独立的判断,所以初始化可以先全部设置为 false
4.确定遍历顺序
这里要从后向前判断。比如 abc ,每次将 i 固定改变 j 的值,j 是往后走的。从后向前判断。
当 i 指向 c 的时候 j 为 c 后面的元素。
当 i 指向 b ,j 就要判断 bb,bc
假设说要判断 a-c 是否是回文串,首先要知道 b 是否是回文串,所以这里的动归要从后向判断
i 从后向前,j 从前向后
5.举例 dp 数组
2.40.2 C++ 代码实现
class Solution {
public:
int countSubstrings(string s) {
vector<vector<bool>> dp(s.size(),vector<bool>(s.size(),false));
int res = 0; // 记录 true 的个数
for(int i=s.size()-1;i>=0;i--){
for(int j = i;j<s.size();j++){ // i 固定不动 j 一直向后,判断 [i,j] 是否是回文串
if(s[i]!=s[j]) dp[i][j] = false;
else{
if(j-i<=1){ // 情况1,2
dp[i][j] = true;
res++;
}else if(dp[i+1][j-1]){ // 这里直接判断中间子串是否是回文
dp[i][j] = true;
res++;
}
}
}
}
return res;
}
};
2.40.3 时空复杂度
时间复杂度:O(N2)
空间复杂度:O(N2)
2.41_516最长回文子序列
2.41.1 算法描述
这个题和上一个题的不同在于这里可以删减,上个题不能删减
1.dp[i] [j] 代表什么
字符串 s 在 [i,j] 范围内最长的回文子序列的长度为 dp[i] [j]
2.确定递推公式
关键在于 : s[i] 和 s[j] 是否相相等
如果s[i]与s[j]相同,那么dp[i] [j] = dp[i + 1] [j - 1] + 2;
如果不相同:
如果s[i]与s[j]不相同,说明s[i]和s[j]的同时加入 并不能增加[i,j]区间回文子串的长度,那么分别加入s[i]、s[j]看看哪一个可以组成最长的回文子序列。
加入s[j]的回文子序列长度为dp[i + 1] [j]。
加入s[i]的回文子序列长度为dp[i] [j - 1]。
那么dp[i] [j]一定是取最大的,即:dp[i] [j] = max(dp[i + 1] [j], dp[i] [j - 1]);
3.dp 数组如何初始化
这个题和上面的题不一样,这里的 i 是从后向前判断,j 是在 i 的下一个位置判断,从前向后判断,也就是 s[i] 和 s[j] 是两个不一样的元素,所以必须要手动初始化
在 dp[i] [i] 时指向的元素是同一个,这时候下标为 1
4.确定遍历顺序
从递推公式可以看出:
dp[i] [j] = dp[i + 1] [j - 1] + 2;
dp[i] [j] = max(dp[i + 1] [j], dp[i] [j - 1]);
所以 i 是从后向前推, j 是从前向后推的
测试用例:bbbab
ab;ba;b(a)b;bb;b(b)a;b(ba)b
5.举例 dp 数组
2.41.2 C++ 代码实现
class Solution {
public:
int longestPalindromeSubseq(string s) {
vector<vector<int>> dp(s.size(),vector<int>(s.size(),0));
// 初始化
for(int i=0;i<s.size();i++) dp[i][i] = 1;
// 赋值
for(int i = s.size()-1;i>=0;i--){
for(int j =i+1;j<s.size();j++){
if(s[i]==s[j]) dp[i][j] = dp[i+1][j-1]+2;
else dp[i][j] = max(dp[i+1][j],dp[i][j-1]);
}
}
return dp[0][s.size()-1];
}
};
易错点:
①这里没有对 i=j 的时候进行单独判断
② j 是从 i+1 开始判断的。如果 j 从 i 开始判断则 [j-1] 就会越界
3.其他题目
3.1_91解码方法
3.1.1 算法描述
DP 有几种方法组成
这道题需要一个个的字符进行判断所以考虑回溯或者动态规划。但是这个题只需要求个数,不需要将每一个答案列举出来所以使用 DP
本道题一共有三种情况:
以 226 组成的结果为例,求 i = 6 能组成的个数
1.dp[i] 代表什么
dp[i] 代表当字符串中包含 0~i 个字符时字符串有几种不同的结果组成
2.dp[i] 的递推公式
下面以 i=6 为例
组成 dp[i] 一共有两种情况,6 单独看,26 一起看。以及这个位置能单独看也能一起看。转换成 i 后的描述如下
① i 位置单独看
dp[i] = dp[i-1]
22 有两种方法。226 后如果 6 单独看还是有 2 中方法那就是 BB6 和 V6
②i 和 其前面的数组合成一起看
dp[i] = dp[i-2]
那么数组就被分成 2,26 就是 2 的结果,BZ 是一种
③ i 既能单独看也能和前面合并
dp[i] = dp[i-1]+dp[i-2]
1+2 = 3
对于上面的描述其实还要多判断一步 i 是否可以单独看,是否需要和并看,那么加上字母只能在 1~26 的这个范围条件后 dp 递推公式就变成了
3.dp 初始化
根据 dp 递推公式可以看出要算出 i-2 也就是需要对 0,1 进行初始化。因为本题需要对 i 的范围分成多种情况,所以 dp[1] 不是那么好初始化,所以需要添加一个哨兵节点。
前导 0 现象:与此同时为了让 s 的 index 和 dp 一一对应这里对 s 前面也添加一个空字符
dp[0] = 0
3.1.2 C++ 代码实现
class Solution {
public:
int numDecodings(string s) {
int n = s.size();
s = " " + s;
vector<int> dp(n + 1,0);
dp[0] = 1;
for(int i = 1; i < n + 1; i++) {
int a = s[i] - '0'; // 单独值
int b = (s[i - 1] - '0') * 10 + s[i] - '0'; // 合并值
if(1 <= a && a <= 9) dp[i] = dp[i - 1]; // 单独看的个数
if(10 <= b && b <= 26) dp[i] += dp[i - 2]; // 合并看的个数
}
return dp[n];
}
};
3.1.3 时空复杂度
时间复杂度:O(n)
空间复杂度:O(n)
3.2_10正则表达式匹配
3.2.1 算法描述
1.dp[i] [j] 的含义
dp[i] [j] 含义是:p 的前 [j-1] 个字符能否匹配s的前 [i-1] 个字符
3.2
3.3_42接雨水
3.3.1 算法描述
1.暴力法
如果想求一个格子中的雨水公式为,这就是短板效应:
min(max(h[0i]),max(h[in-1]))-h[i]
可以理解为将 cur 两边的柱子移动到 cur 的位置,中间夹着的就是水柱的高度
2.DP
对于上面的结果还有一个可以优化的地方。每次在判断 i 的最大左边界和 i 的最大由边界时都要从 i=0 或者 i = size-1 时进行逐个判断,判断 0~i 谁更大,但是这样会造成时间复杂度为 O(N²),所以这里使用 DP
1.dp[i] 代表什么
LeftMax[i]:从 0~i dp i 的最大值
rightMax[i] :从 size-1~i dp i 的最大值
2.dp 的递推公式
dp[i] = max(dp[i-1],hight[i]);
3.dp i 的初始化
从 0 开始初始化,从 size -1 开始初始化
4.迭代顺序
一个从前往后,一个从后往前
3.3.2 代码实现
class Solution {
public:
int trap(vector<int>& height) {
int res = 0;
int size = height.size();
for(int i =0;i<size;i++){
int maxLeft = 0,maxRight=0; // 找到左边的最大值和右边的最大值
for(int j = i;j>=0;j--){
maxLeft = max(maxLeft,height[j]); // 0~i 的最大值
}
for(int j = i;j<size;j++){
maxRight = max(maxRight,height[j]);
}
// 求面积
res+=min(maxLeft,maxRight)-height[i];
}
return res;
}
};
2.DP
class Solution {
public:
int trap(vector<int>& height) {
int res = 0;
int size = height.size();
if(size==0) return res;
vector<int> leftMax(size),rightMax(size);
leftMax[0] = height[0];
rightMax[size-1] = height[size-1];
// 初始化两个 dp dp i 代表指向 i 时左边的最大值和右边的最大值分别是多少
for(int i = 1;i<size;i++){ // 从 1 开始
leftMax[i] = max(height[i],leftMax[i-1]);
}
for(int i = size-2;i>=0;i--){
rightMax[i] = max(height[i],rightMax[i+1]);
}
// 遍历 dp 求面积
for(int i = 0;i<size;i++){
res+=min(leftMax[i],rightMax[i])-height[i];
}
return res;
}
};
3.3.3 时空复杂度
1.暴力:
时间:O(N²) ; 空间:O(1)
2.DP
时间:O(N) ; 空间:O(N)