DP简介
动态规划,英文:Dynamic Programming,简称DP,如果某一问题有很多重叠子问题,使用动态规划是最有效的。
动规一定是由前一个状态推导出来的,而贪心是局部直接选最优的。
动态规划五步曲:
- 数组定义:确定dp数组(dp table)以及下标的含义
- 递推公式:确定递推公式
- 初始化:dp数组如何初始化
- 遍历顺序:确定遍历顺序
- 举例推导:举例推导dp数组
DP问题如何DEBUG:
写动规题目,代码出问题很正常!
找问题的最好方式就是把dp数组打印出来,看看究竟是不是按照自己思路推导的!
写代码之前一定要把状态转移在dp数组上的具体情况模拟一遍,心中有数,确定最后推出的是想要的结果。
然后再写代码,如果代码没通过就打印dp数组,看看是不是和自己预先推导的哪里不一样。
如果打印出来和自己预先模拟推导是一样的,那么就是自己的递归公式、初始化或者遍历顺序有问题了。
如果和自己预先模拟推导的不一样,那么就是代码实现细节有问题。
可以自己先思考这三个问题:
这道题目我举例推导状态转移公式了么?
我打印dp数组的日志了么?
打印出来了dp数组和我想的一样么?
509.斐波那契数列
动规解法:
时间复杂度:O(n)
空间复杂度:O(1)
class Solution {
public:
int fib(int N) {
if (N <= 1) return N;
int dp[2];
dp[0] = 0;
dp[1] = 1;
int sum =0;
for (int i = 2; i <= N; i++) {
sum = dp[0] + dp[1];
dp[0] = dp[1];
dp[1] = sum;
}
return sum;// dp[1];
}
};
递归解法:
时间复杂度:O(2^n) (画一下树形图就知道了)
空间复杂度:O(n)(算上了编程语言中实现递归的系统栈所占空间)
另外知识点:
leetcode 转 acm 模式
string 与 int 互转
# include <iostream>
# include <string>
using namespace std;
class Solution {
public:
int fib(int n) {
if(n == 0 ) return 0;
else if (n==1) return 1;
else return fib(n-1)+fib(n-2);
}
};
int main() {
string line;
while (getline(cin, line)) {//输入字符串"23",不按回车不停止本轮
// string -> int : stoi( s )
int n = stoi(line); //n=23;
//注意调用solution中的函数的形式:Solution定义的时候没有(),调用的时候却要加上
int ret = Solution().fib(n);
// int -> string : to_string( a )
string out = to_string(ret);
cout << out << endl;
}
return 0;
}
70.爬楼梯(同斐波那契)
基本DP解法:
时间复杂度:
O
(
n
)
O(n)
O(n)
空间复杂度:
O
(
n
)
O(n)
O(n)
class Solution {
public:
int climbStairs(int n) {
if (n <= 1) return n; // 因为下面直接对dp[2]操作了,防止空指针
vector<int> dp(n + 1);
dp[1] = 1;
dp[2] = 2;
for (int i = 3; i <= n; i++) { // 注意i是从3开始的
dp[i] = dp[i - 1] + dp[i - 2];
}
return dp[n];
}
};
优化一下空间复杂度,代码如下,失去了DP思想的精髓:
// 版本二
class Solution {
public:
int climbStairs(int n) {
if (n <= 1) return n;
int dp[3];
dp[1] = 1;
dp[2] = 2;
for (int i = 3; i <= n; i++) {
int sum = dp[1] + dp[2];
dp[1] = dp[2];
dp[2] = sum;
}
return dp[2];
}
};
时间复杂度:
O
(
n
)
O(n)
O(n)
空间复杂度:
O
(
1
)
O(1)
O(1)
746.使用最小花费爬楼梯
LeetCode模式:
class Solution {
public:
int minCostClimbingStairs(vector<int>& cost) {
vector<int> dp(cost.size(), 0);
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];
}///dp[2] = min(dp[ 1 ], dp[ 0 ])+cost[2];//=dp[0](10)+dp[2](20)=30
// 注意最后一步(对应dp[cost.size()],在cost数组以外那步)可以理解为不用花费,所以取倒数第一步,第二步的最少值
return min(dp[cost.size()-1], dp[cost.size()-2]);
}
};
ACM模式:本解法没有solution
# include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
int minCostClimbingStairs(vector<int>& cost) {
///dp只能是 vector<int>,不能是int数组,与cost类型一致
///大概率是因为力扣系统方便调用(参见这道题的playground
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];
//实际的体力消耗数组可以理解为[1,100,1,1,1,100,1,1,【100】,【1】]【[ cost.size() ]】
///最后必须到这一步,所以是前2级+2步与前1级+1步, +这步的cost
}
return min(dp[cost.size() - 1], dp[cost.size() - 2]);
}
int main(){
vector<int> cost;
int k;
int m = 10;
while( cin>>k){//m-- &&
cost.push_back(k);
}
int res = minCostClimbingStairs(cost);
cout<<res<<endl;
// system("pause");
return 0;
}
亲测LeetCode的playground和牛客在线编程都可以通过空格分隔。
若想输入任意数量的整数,用 while( cin>>k) 时,如何判断输入结束:
如果不确定输入数字个数,可以输入任意字母结束; 输入CTRL+z后回车
不能单纯通过 “输入 1 100 1 1 1 100 1 1 100 1(一行,用空格间隔)后回车” 实现!
不同路径2道题
62. 不同路径
(0,0到m-1,n-1,只能向下向右,问总共有多少条不同的路径)
# include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
class Solution {
public:
int uniquePaths(int m, int n) {
//int dp[m][n] ;//= {0};
vector<vector<int>> dp(m, vector<int>(n,0));
for(int i = 0; i<m; i++) dp[i][0]= 1;
for(int i = 0; i<n; i++)dp[0][i] = 1;
for(int i = 1; i <m; i++){
for(int j = 1; j<n; j++){
dp[i][j] = dp[i-1][j] + dp[i][j-1];
}
}
return dp[m-1][n-1];
}
};
int main(){
int m, n;
cin>>m>>n;
int res = Solution().uniquePaths(m, n);
cout<<res<<endl;
system("pause");
return 0;
}
63.不同路径II
(有0~n个障碍物,obstacleGrid对应网格值为1)
思路:如果obstacleGrid[ i ][ j ]==1,就把dp[ i ][ j ](如果obstacleGrid[ i ][ j ]在左、上边缘,其右边、下边也需要)赋为0
代码实现方式:赋初值为全0,然后在循环中遇到需要保持为0的,不更新,continue即可。
class Solution {
public:
int uniquePathsWithObstacles(vector<vector<int>>& obstacleGrid) {
int m = obstacleGrid.size();
int n = obstacleGrid[0].size();
if (obstacleGrid[m - 1][n - 1] == 1 || obstacleGrid[0][0] == 1) //(没有也行)如果在起点或终点出现了障碍,直接返回0
return 0;
vector<vector<int>> dp(m, vector<int>(n, 0));
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]为0不变,而不是赋值0
dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
}
}
return dp[m - 1][n - 1];
}
};
343. 整数拆分
这道题目的递推公式并不好想,而且初始化的地方也很有讲究(翻译:背会,遇到就套,不可以用回溯)
-
确定dp数组(dp table)以及下标的含义
dp[i]:分拆数字i,可以得到的最大乘积为dp[i]。 -
确定递推公式
从1遍历j,然后有两种渠道得到dp[i]。并不断更新其最大值
一个是 j * (i - j) 直接相乘。(相当于拆成 j 和0+(i-j))
一个是 j * dp[i - j],相当于是拆分(i - j)。
也可以这么理解,j * (i - j) 是单纯的把整数拆分为两个数相乘,而 j * dp[i - j]是拆分成两个以及两个以上的个数相乘。
如果定义dp[i - j] * dp[j] 也是默认将一个数强制拆成4份以及4份以上了。(其实也可以加上,也能通过,就是这种总是比较小不会被选中,从数学上(没有证明)没必要加而已)
所以递推公式:dp[i] = max({dp[i], (i - j) * j, dp[i - j] * j}); -
dp的初始化
dp[0] dp[1] 就不应该初始化,也就是没有意义的数值。这里我只初始化dp[2] = 1,遍历从3开始 -
确定遍历顺序:两层for嵌套顺序
-
举例推导dp数组
class Solution {
public:
int integerBreak(int n) {
vector<int> dp(n+1);//vector<int>初始化用圆括号dp(n);是0~n-1所以dp[n]报错
dp[2] = 1;
for(int i = 3; i <=n; i++){//3
//cout<<"i="<<i;
for(int j = 1; j<i; j++ ){//因为题目要求拆分结果是正整数,那么需要j和i-j都是从1开始,不能取0
//WRONG dp[i] = max((i - j) * j, dp[i - j] * j);
dp[i] = max(dp[i], max((i - j) * j, dp[i - j] * j));
//dp[i]需要加入进去!!!例如已知dp[3]=2,求dp[4]:
//max(dp[4],max(3*1, dp[3]*1))=>3
//max(dp[4],max(2*2, dp[2]*2))=>4
//cout<<", dp[i]="<<dp[i];
}
cout<<endl;
}
return dp[n];
}
};
96.不同的二叉搜索树
实际与树结构无关,难点在
建模:看谁在头结点;
dp定义:总数有几个节点的搜索树的数量。
dp[3],就是 元素1为头结点搜索树的数量 + 元素2为头结点搜索树的数量 + 元素3为头结点搜索树的数量
元素1为头结点搜索树的数量 = 右子树有2个元素的搜索树数量 * 左子树有0个元素的搜索树数量
元素2为头结点搜索树的数量 = 右子树有1个元素的搜索树数量 * 左子树有1个元素的搜索树数量
元素3为头结点搜索树的数量 = 右子树有0个元素的搜索树数量 * 左子树有2个元素的搜索树数量
dp[i] : 1到i为节点组成的二叉搜索树的个数,或者 i 个元素的二叉搜索树的数量
//WRONG dp[1] = 1;直接赋值会报错溢出(因为n可以从1~19任取,如果n为1,在dp[2]就溢出了)
//WRONG dp[2] = 2;
for(int i = 1; i <=n; i++){//WRONG 3//<=
class Solution {
public:
int numTrees(int n) {
vector<int> dp(n+1,0);//);
dp[0] = 1;
for(int i = 1; i <=n; i++){
for(int j = 0;j<i;j++){
dp[i] = dp[i]+ dp[j]*dp[i-1-j];
//==> dp[0]*dp[i-1] + ... + dp[i-1]*dp[0]
//0 + d0*d(3-1-0=2) + d1*d1 + d2*d0
}
}
return dp[n];
}
};
0-1背包理论基础
对于面试的话,其实掌握01背包,和完全背包,就够用了,最多可以再来一个多重背包。
背包容量v,物品价值w,体积v:
01背包:每个物品只有一个,不选/选1个
完全背包:每个物品有无数个,不选/选几个
多重背包:不同物品数量不同
0-1背包:
每一件物品其实只有两个状态,取或者不取,所以可以使用回溯法搜索出所有的情况,那么时间复杂度就是
o
(
2
n
)
o(2^n)
o(2n),这里的n表示物品数量。所以暴力的解法是指数级别的时间复杂度。进而才需要动态规划的解法来进行优化!
1、dp含义
使用二维数组时,dp[i][j] 表示从下标为[0-i]的物品里任意取,放进容量为j的背包,价值总和最大是多少。
2、递归公式:
dp[i][j] = max( dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
可以看出dp[i][j] 是由左+上方数值推导出来的,那么其他下标初始是什么数值都可以,因为都会被覆盖。
3. 初始化
4. 遍历顺序
先遍历物品,然后遍历背包重量
// 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]);
}
}
5.完整c++测试代码
void test_2_wei_bag_problem1() {
vector<int> weight = {1, 3, 4};
vector<int> value = {15, 20, 30};
int bagweight = 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();
}
- 滚动数组
——通过01背包,来彻底讲一讲滚动数组:就是把二维dp降为一维dp
(1)定义:
i是物品,j是背包容量。
二维dp[ i ][ j ] 表示:从下标为[0-i]的物品里任意取,放进容量为j的背包,所背的物品价值可以最大为dp[ i ][ j ]。
(2)递推公式:
一维dp[j]表示:从下标为[0-i]的物品里任意取,放进容量为j的背包,所背的物品价值可以最大为dp[j]。
此时dp[j]有两个选择:
一个是取自己dp[j]的旧值,相当于二维dp数组中的dp[i-1][j],即不放物品i;
一个是取dp[j - weight[i]] + value[i],即放物品i,指定是取最大的,毕竟是求最大价值。
所以递归公式为:
dp[j] = max( dp[j], dp[j - weight[i]] + value[i] );
(3)初始化:
dp[0]就应该是0,因为背包容量为0所背的物品的最大价值就是0;
非0下标也都初始化为0(如果题目给的价值都是正整数),这样才能让dp数组在递归公式的过程中取的最大的价值,而不是被初始值覆盖了。
那么我假设物品价值都是大于0的,所以dp数组初始化的时候,都初始为0就可以了。(???)(类似之前的一道题)(还是不理解)(这样才能保证更新dp[i]是从最小的自然数开始啊,哪里不理解)
(4)遍历顺序
先遍历【物品】,再嵌套遍历【背包容量】,不可以先遍历背包容量嵌套遍历物品!
因为一维dp的写法,背包容量一定是要倒序遍历,这样可以保证物品只放入一次。
举一个例子:物品0的重量weight[0] = 1,价值value[0] = 15
倒序就是先算dp[2]
dp[2] = dp[2 - weight[0]] + value[0] = 15 (dp数组已经都初始化为0)
dp[1] = dp[1 - weight[0]] + value[0] = 15
所以从后往前循环,每次取得状态不会和之前取得状态重合,这样每种物品就只取一次了。
(5)一维dp01背包完整C++测试代码
void test_1_wei_bag_problem() {
vector<int> weight = {1, 3, 4};
vector<int> value = {15, 20, 30};
int bagWeight = 4;
vector<int> dp(bagWeight + 1, 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]);
}
}
cout << dp[bagWeight] << endl;
}
int main() {
test_1_wei_bag_problem();
}
416. 分隔等和子集
主要就是转换成0-1背包,不是用回溯
class Solution{
public:
bool canPartition(vector<int>& nums){
int sum=0;
for(int i = 0; i<nums.size();i++){
sum+=nums[i];
}
if(sum%2==1)return false;
int target = sum/2;//为什么不能前面加else?(二+三刷还是不知道)
int dp[10001]={0};//也可以vector<int> dp(10001, 0);//也可以把10001换成sum+1,注意必须+1
for(int i = 0; i<nums.size();i++){
for(int j = target; j>=nums[i];j--){
dp[j]= max(dp[j], dp[j-nums[i]]+nums[i]);
}
}
return dp[target]==target;
}
};
二刷:建议这种图不要这样画。最好还是按代随给的表格来搞,比较能体现题意,而不是直接按代码的i和j。
/* 理解不太充分,还需要看代码随想录的讲解。
本题是01背包,因为元素我们只能用一次。
题目中物品是nums[i],重量是nums[i],价值也是nums[i],背包体积是sum/2,本题要求集合里能否出现总和为 sum / 2 的子集。
dp[i]中的i表示背包内已有元素的总和
题目中说:每个数组中的元素不会超过 100,数组的大小不会超过 200
总和不会大于20000,背包容量最大只需要其中一半,所以10001大小就可以了
j= 11 , nums[ i ]== 1 , dp[j]== 0 , then dp[j]== 1
j= 10 , nums[ i ]== 1 , dp[j]== 0 , then dp[j]== 1
j= 9 , nums[ i ]== 1 , dp[j]== 0 , then dp[j]== 1
j= 8 , nums[ i ]== 1 , dp[j]== 0 , then dp[j]== 1
j= 7 , nums[ i ]== 1 , dp[j]== 0 , then dp[j]== 1
j= 6 , nums[ i ]== 1 , dp[j]== 0 , then dp[j]== 1
j= 5 , nums[ i ]== 1 , dp[j]== 0 , then dp[j]== 1
j= 4 , nums[ i ]== 1 , dp[j]== 0 , then dp[j]== 1
j= 3 , nums[ i ]== 1 , dp[j]== 0 , then dp[j]== 1
j= 2 , nums[ i ]== 1 , dp[j]== 0 , then dp[j]== 1
j= 1 , nums[ i ]== 1 , dp[j]== 0 , then dp[j]== 1
j= 11 , nums[ i ]== 5 , dp[j]== 1 , then dp[j]== 6
j= 10 , nums[ i ]== 5 , dp[j]== 1 , then dp[j]== 6
j= 9 , nums[ i ]== 5 , dp[j]== 1 , then dp[j]== 6
j= 8 , nums[ i ]== 5 , dp[j]== 1 , then dp[j]== 6
j= 7 , nums[ i ]== 5 , dp[j]== 1 , then dp[j]== 6
j= 6 , nums[ i ]== 5 , dp[j]== 1 , then dp[j]== 6
j= 5 , nums[ i ]== 5 , dp[j]== 1 , then dp[j]== 5
j= 11 , nums[ i ]== 11 , dp[j]== 6 , then dp[j]== 11
j= 11 , nums[ i ]== 5 , dp[j]== 11 , then dp[j]== 11
j= 10 , nums[ i ]== 5 , dp[j]== 6 , then dp[j]== 10
j= 9 , nums[ i ]== 5 , dp[j]== 6 , then dp[j]== 6
j= 8 , nums[ i ]== 5 , dp[j]== 6 , then dp[j]== 6
j= 7 , nums[ i ]== 5 , dp[j]== 6 , then dp[j]== 6
j= 6 , nums[ i ]== 5 , dp[j]== 6 , then dp[j]== 6
j= 5 , nums[ i ]== 5 , dp[j]== 5 , then dp[j]== 5
*/
1049. 最后一块石头的重量 II
如果能够知道背后的数学原理,则代码几乎与上一题一模一样
//上一题是求能否组合成两个正好是总数一半的数
//本题是消消乐返回剩下的最小可能重量,相当于求能否组合成最大的两个一样的数,求总数减他俩剩下的;而01背包的运算结果就是能装的最大容量。
class Solution {
public:
int lastStoneWeightII(vector<int>& stones) {
int sum = 0;
for(int i = 0; i < stones.size(); i++) sum+=stones[i];
int target = sum/2;
int dp[30*100/2+1]={0};///+1
for(int i =0; i<stones.size();i++){// 遍历物品
for(int j = target;j>=stones[i];j--){// 遍历背包
dp[j]=max(dp[j],dp[j-stones[i]]+stones[i]);
}
}
return sum-dp[target]-dp[target];
}
};
494.目标和*
-
问题转换成0-1背包:
已知总和sum和目标值S,求加法总和x, 对应减法总和y满足:x+y=sum和s-y=S,从而x=(S+sum)/2
限制条件:当(S+sum)%2==1时(因为2x必为偶数=S+sum)和abs(S)>sum(x和y>0-> -sum<S<sum)时无解。
有解时转换为求背包容量 == x有多少种方法(不是小于等于了) -
dp[j] 表示:
填满j(包括j)这么大容积的包,有dp[j]种方法
(经典背包应该是,尽量填满 / 能填的最大值) -
确定递推公式
有哪些来源可以推出dp[j]呢?
不考虑nums[i]的情况下,填满容量为j的背包,有dp[j]种方法。
那么只要搞到nums[i]的话,凑成dp[j]就有dp[j - nums[i]] 种方法。
例如:dp[j],j 为5,
已经有一个1(nums[i]) 的话,有 dp[4]种方法 凑成 dp[5]。
已经有一个2(nums[i]) 的话,有 dp[3]种方法 凑成 dp[5]。
已经有一个3(nums[i]) 的话,有 dp[2]中方法 凑成 dp[5]
已经有一个4(nums[i]) 的话,有 dp[1]中方法 凑成 dp[5]
已经有一个5 (nums[i])的话,有 dp[0]中方法 凑成 dp[5]
那么凑整dp[5]有多少方法呢,也就是把 所有的 dp[j - nums[i]] 累加起来。
求组合类问题的公式,都是类似这种:dp[j] += dp[j - nums[i]]
这个公式在后面在讲解背包解决排列组合问题的时候还会用到!
- 初始化
dp[0]=1是指背包取数使得和为0的方式只有1种就是谁都不取,不是说[11111]加正负号得到0有一种方法。此时0只是到达left的一个中间过程用来铺路的。
class Solution {
public:
int findTargetSumWays(vector<int>& nums, int S) {
int sum = 0;
for (int i = 0; i < nums.size(); i++) sum += nums[i];
if (abs(S) > sum || (S + sum) % 2 == 1) return 0;
int bagSize = (S + sum) / 2;
vector<int> dp((bagSize + 1, 0);
dp[0] = 1;
for (int i = 0; i < nums.size(); i++) {
for (int j = bagSize; j >= nums[i]; j--) {
dp[j] += dp[j - nums[i]];
}
}
return dp[bagSize];
}
};
474.一和零(需要再看)
输入:strs = [“10”, “0001”, “111001”, “1”, “0”], m = 5, n = 3 输出:4
解释:最多有 5 个 0 和 3 个 1 的最大子集是 {“10”,“0001”,“1”,“0”} ,因此答案是 4 。 其他满足题意但较小的子集包括 {“0001”,“1”} 和 {“10”,“1”,“0”} 。{“111001”} 不满足题意,因为它含 4 个 1 ,大于 n 的值 3 。
分析:
还是0-1背包,但m 和 n相当于是一个两个维度的背包。
必须用二维dp了,本题定义:dp[i][j]:最多有i个0和j个1的strs的最大子集的大小为dp[i][j]。
(自己理解为:m和n是重量,子集大小(包含str元素的个数多少)(二刷:都是1)是价值:
字符串的zeroNum和oneNum相当于物品的重量(weight[i]),字符串本身的个数(不是长度!)相当于物品的价值(value[i])。
所以需要的for是1+(1+1)形式,外面一个大for是“物品”,里面两个小的for是并列的“背包”;另外,0和1数量是同步变化的,所以不存在“dp[i-1][j]”这种情况。
统计每个字符串拥有的0和1数量,可以不用另外写个函数把0和1数量写成两个1维数组,而是直接在dp外面套一个大for,遇到一个string,用两个 int 统计一个其中的0数量1数量!
class Solution{///NO () !!
public:
int findMaxForm(vector<string>& strsArr, int m , int n){
vector<vector<int>> dp(m+1,vector<int>(n+1,0));
for(string str : strsArr){//最大for:用int临时记录0和1的数量:“物品”
int zeroNum = 0;
int oneNum = 0;
for(char ch : str){
if (ch=='0') zeroNum++;
else oneNum++;
}
for(int i=m;i>=zeroNum;i--){背包“0”
for(int j=n;j>=oneNum;j--){背包“1”
dp[i][j]=max(dp[i][j],dp[i-zeroNum][j-oneNum]+1);
///形式上完全是对称的
///参考dp[j] = max( dp[j], dp[j - weight[i](是0/1num)] + value[i](是1) );
}
}
}
return dp[m][n];
}
};
/需要再写一个acm模式的
完全背包理论基础
-
简介:
有N件物品和一个最多能背重量为W的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。每件物品都有无限个(也就是可以放入背包多次),求解将哪些物品装入背包里物品价值总和最大。
leetcode上没有纯完全背包问题,都是需要完全背包的各种应用,需要转化成完全背包问题。完全背包和01背包问题的不同:每种物品有无限件、遍历顺序。
-
遍历顺序的区别:
01背包内嵌的循环是从大到小遍历,为了保证每个物品仅被添加一次。
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]);
}
}
而完全背包的物品是可以添加多次的,所以要从小到大去遍历,即:
// 先遍历物品,再遍历背包
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]);
}
}
- 代码示例:
// 先遍历物品,再遍历背包(对于纯完全背包问题,其for循环的先后循环是可以颠倒的)
void test_CompletePack() {
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++) { // 遍历背包容量
dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
}
}
cout << dp[bagWeight] << endl;
}
int main() {
test_CompletePack();
}
- 对于纯完全背包问题,其for循环的先后循环是可以颠倒的;但如果题目稍稍有点变化,就会体现在遍历顺序上。
如果问装满背包有几种方式的话? 那么两个for循环的先后顺序就有很大区别了(见下一题),而leetcode上的题目都是这种稍有变化的类型。
518. 零钱兑换 II
-
这是一道典型的背包问题,一看到钱币数量不限,就知道这是一个完全背包。
但本题和纯完全背包不一样,纯完全背包是能否凑成总金额,而本题要求是凑成总金额的硬币组合数;
组合不强调元素之间的顺序,排列强调元素之间的顺序。 这和下文讲解遍历顺序息息相关! -
递推公式:
求装满背包有几种方法,一般公式都是:dp[j] += dp[j - nums[i]];
-
初始化:
dp[0]一定要为1:
dp[0] = 1是 递归公式的基础。从dp[i]的含义上来讲就是,凑成总金额0的货币组合数为1。(不要管具体能不能实现,主要是为了好算这样赋值的)
下标非0的dp[j]初始化为0:
这样累计加dp[j - coins[i]]的时候才不会影响真正的dp[j]。 -
遍历顺序:
纯完全背包的两个for循环的先后顺序都是可以的。
但本题就不行了:
本题是求凑出来的方案个数,且每个方案个数是为组合数,不能涉及顺序。
如果求组合数就是外层for循环遍历物品,内层for遍历背包。(和0-1背包一致)
如果求排列数就是外层for遍历背包,内层for循环遍历物品。(相反)
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]];
}
}
return dp[amount];
}
};
/*
**** i = 0, dp[i] = 1
coins[i] = 1
j = 1, dp[1] = 0
j - coins[i] = 0 , dp[j - coins[i]] = 1, dp[j] = 1
coins[i] = 1
j = 2, dp[2] = 0
j - coins[i] = 1 , dp[j - coins[i]] = 1, dp[j] = 1
coins[i] = 1
j = 3, dp[3] = 0
j - coins[i] = 2 , dp[j - coins[i]] = 1, dp[j] = 1
coins[i] = 1
j = 4, dp[4] = 0
j - coins[i] = 3 , dp[j - coins[i]] = 1, dp[j] = 1
coins[i] = 1
j = 5, dp[5] = 0
j - coins[i] = 4 , dp[j - coins[i]] = 1, dp[j] = 1
**** i = 1, dp[i] = 1
coins[i] = 2
j = 2, dp[2] = 1
j - coins[i] = 0 , dp[j - coins[i]] = 1, dp[j] = 2
///初始化从0处开始,每个i用到一次,意义是dp[新硬币面值]+=1,这个1是只用到1个这个新硬币,这是之前所有面值硬币所做不到的。
coins[i] = 2
j = 3, dp[3] = 1
j - coins[i] = 1 , dp[j - coins[i]] = 1, dp[j] = 2
coins[i] = 2
j = 4, dp[4] = 1
j - coins[i] = 2 , dp[j - coins[i]] = 2, dp[j] = 3///此时dp2已经在2元硬币这里更新为2了,dp4只需要考虑多【一】个2元硬币时候的更新
coins[i] = 2
j = 5, dp[5] = 1
j - coins[i] = 3 , dp[j - coins[i]] = 2, dp[j] = 3
**** i = 2, dp[i] = 2
coins[i] = 5
j = 5, dp[5] = 3
j - coins[i] = 0 , dp[j - coins[i]] = 1, dp[j] = 4
*/
377. 组合(排列)总和 Ⅳ
【代】称它为排列总和,因为顺序不同的序列被视作不同的组合。本题与动态规划:518.零钱兑换II 就是一个鲜明的对比,一个是求排列,一个是求组合,遍历顺序完全不同:
排列——先包后物。
本题仅仅是求排列总和的个数;如果本题要把排列都列出来的话,只能使用【回溯】爆搜。
C++测试用例有两个数相加超过int的数据,所以需要在if里加上dp[i] < INT_MAX - dp[i - num]。(??
求装满背包有几种方法,递推公式一般都是dp[i] += dp[i - nums[j]];
class Solution {
public:
int combinationSum4(vector<int>& nums, int target) {
vector<int> dp(target + 1, 0);
dp[0] = 1;
for (int j = 0; j <= target; j++) { // 遍历背包
//(另一道题)相反顺序(先物品后背包)下是这样:for (int j = coins[i]; j <= amount; j++) {
// 遍历背包,因为j在前面只能从绝对数值0开始,所以只能在这里加上一个判断条件,不符合就跳过,只操作j >= nums[i]和溢出的例子
for (int i = 0; i< nums.size(); i++) { // 遍历物品
if (j >= nums[i] && dp[j] < -dp[j - nums[i]] + INT_MAX ) {
dp[j] += dp[j - nums[i]];/// 无关字母,恒定操作dp[背包容量]
}
}
}
return dp[target];
}
};
322.零钱兑换
本题钱币数量可以无限使用,那么是完全背包。所以遍历的内循环是正序
本题是要求最少硬币个数,硬币是组合数还是排列数都无所谓!因为并不是求有多少种兑硬币的方式,不是“累加”类型的题!!!!
所以两个for循环先后顺序怎样都可以(只要递归公式带min而不是求装满背包的那个经典公式)(但是组合一般都比排列更少所以用的组合对应的先物品后背包。)
初始化:
dp大小固定!不论什么变体都是包+1!
dp[0] = 0:凑足总金额为0所需钱币的个数一定是0;
dp[j]:考虑到递推公式的特性(带min),必须初始化为一个最大的数,所以下标非0的元素都是应该是最大值。
class Solution {
public:
int coinChange(vector<int>& coins, int amount) {
vector<int> dp(amount+1, INT_MAX);//dp大小固定!不论什么变体///INT_MAX!!
dp[0] = 0;
for(int i = 0; i<coins.size(); i++){///WRONG +1//先物品
for(int j = coins[i]; j<=amount; j++){//后背包
if(dp[j-coins[i]]<INT_MAX) dp[j] = min(dp[j], dp[j-coins[i]]+1);
///WRONG TYPICAL dp[j] += dp[j-coins[i]];
}
}
if(dp[amount]==INT_MAX) return -1;
return dp[amount];
}
};
279.完全平方数
just多一个构造硬币的步骤
答案是直接写的,把j*j当作coins[i]
class Solution {
public:
int numSquares(int n) {
vector<int> coins;
for(int i = 0; i*i<=n; i++) coins.push_back(i*i);
vector<int>dp(n+1, INT_MAX);
dp[0] = 0;
for(int i = 1; i<coins.size(); i++){//物品
for(int j = coins[i]; j<=n; j++) dp[j] = min(dp[j], dp[j-coins[i]]+1);//背包
//dp[j-coins[i]]+1最开始是dp[0]+1,所以务必要保证dp[0]是最初经历的元素且其值是0,要是INT+MAX+1就溢出了
//if(dp[j-coins[i]]!=INT_MAX) 可以用来保证合理性
//if(j>=coins[i]) 也可
}
if(dp[n]==INT_MAX) return -1;
return dp[n];
}
};
139.单词拆分(需要自己写,没全懂,for非常规)*
单词就是物品,字符串s就是背包,单词能否组成字符串s,就是问物品能不能把背包装满。
拆分时可以重复使用字典中的单词,说明就是一个完全背包!
-
DP意义
dp[i] : 字符串结尾在为i的话,dp[i]为true,表示**[0,i-1]这个区间**可以拆分为一个或多个在字典中出现的单词。
dp规模永远固定:target/amount/本题string s的 size+1!!! -
递归公式
递推公式是用“加法”:
if ( [j, i] 这个区间的子串出现在字典里 && dp[j]是true)
那么 dp[i] = true。 -
分别初始化
(能用T/F表示的就不用计数,能简化就简化,类似279)
dp[0]初始为true完全就是为了推导公式。(或者取非0的初始值的反面?)
下标非0的dp[i]初始化为false,只要没有被覆盖,说明都是不可拆分。 -
遍历顺序
是无所谓,非排列非组合
但本题还有特殊性,因为是要求子串,最好是遍历背包放在外循环,将遍历物品放在内循环
(WHY?????)可能因为第二层for依赖第一层吧?
如果要是外层for循环遍历物品,内层for遍历背包,就需要把所有的子串都预先放在一个容器里。(如果不理解的话,可以自己尝试这么写一写就理解了)(??但实际不就是放了吗??)
class Solution{
public:
bool wordBreak(string s , vector<string>& wordDict){
unordered_set<string> wordSet(wordDict.begin(), wordDict.end());vec转uset,用来快速查找
vector<bool> dp(s.size()+1, false);/永远固定:target/amount/本题string s的 size+1!!!
dp[0] = true;
for(int i = 1; i <=s.size(); i++){
//先包 ??????正常是 =0 和 <诶。。。(个人猜测:有点像上一题,代答也是从1开始的。dp需要保证从0开始,但由于j<i所以i需要从1开始??但i怎么能取s.size呢,最开始是word=s.substr(0, 1)对应第一个元素,最后就是word=s.substr(size-1, 1)对应取到最后一个元素了所以这样设计的吗
for(int j = 0; j < i; j++){//后物,实际上也是遍历s实现的,取s从s[0]到s[0~i-1]的所有字串,判断算不算物,不是遍历字典元素!//j <wordDict.size()WRONG RIGHT: j < i 物品<背包,此时的背包容量就是j
string word = s.substr(j, i - j); //substr(起始位置,截取的个数)
if(dp[j]==true && wordSet.find(word)!= wordSet.end()) dp[i]= true;//if(前一段是true && 后一段能找到)//注意前一段不包括s[j]!//处理dp[背包]
}
}
return dp[s.size()];
}
};
背包问题总结
0. 经典五步
确定dp数组及下标的含义
确定递推公式
dp数组如何初始化
确定遍历顺序
举例推导dp数组
背包最关键的两部:递推公式和遍历顺序——
2. 如何确定递推公式
问装满背包有几种方法 : dp[j]+=dp[j-nums[i]]
,对应题目如下:
动态规划:494.目标和
动态规划:518.零钱兑换II
动态规划:377.组合总和Ⅳ
动态规划:70.爬楼梯进阶版(完全背包)
问能否能装满背包(或者最多装多少):dp[j]=max(dp[j],dp[j-nums[i]]+nums[i]);
对应题目如下:
动态规划:416.分割等和子集
动态规划:1049.最后一块石头的重量II
问背包装满最大价值 :dp[j]=max(dp[j],dp[j-weight[i]]+value[i]);
,对应题目如下:
动态规划:474.一和零
问装满背包所有物品的最小个数:dp[j]=min(dp[j-coins[i]]+1,dp[j]);
,对应题目如下:
动态规划:322.零钱兑换
动态规划:279.完全平方数
4. 如何确定遍历顺序
01背包:
二维dp数组01背包先遍历物品还是先遍历背包都是可以的,且第二层for循环是从小到大遍历。
一维dp数组01背包只能先遍历物品再遍历背包容量,且第二层for循环是从大到小遍历。
背包种类 | dp维数 | 遍历顺序 | 遍历顺序 | 备注 |
---|---|---|---|---|
01 | 二维 | 都可 | 从小到大 | |
01 | 一维 | 先物后包 | 从大到小 | |
完全 | 一维 | 都可 | 从小到大 | 纯完全,不一定装满 |
求最小数 | 一维 | 都可 | 从小到大 | 完全 |
求组合数 | 一维 | 先物后包 | 从小到大 | 完全 |
求排列数 | 一维 | 先包后物 | 从小到大 | 完全 |
特殊题 | 一维 | 先包后物 | 从小到大 | 完全 |
完全背包:
纯完全背包的一维dp数组实现,先物品还是先背包都是可以的,且第二层for循环是从小到大遍历。
如果求最小数,两层for先后顺序无所谓了:
求最小数:动态规划:322.零钱兑换、动态规划:279.完全平方数
如果求组合数就是外物品,内背包。
如果求排列数就是外背包,内物品。
相关题目如下:
求组合数:动态规划:518.零钱兑换II
求排列数:动态规划:377.组合总和Ⅳ、动态规划:70.爬楼梯进阶版(完全背包)
特殊题目,string字典单词拆分:先背包后物品
额外:set <—> unordered_set
参考【https://blog.csdn.net/bryant_zhang/article/details/111600209】:
unordered_set容器具有以下几个特性: 直接存值 互不相等 不会排序 set : 会排序 unordered_map : 存键值对+重复 map : 键值对+排序+重复(按key从小到大排序)
一般来说,在如下情况,适合使用set:
1、我们需要有序的数据(不同元素)。
2、我们必须打印/访问数据(按排序顺序)。
3、我们需要知道元素的前任/继承者。一般来说,在如下情况,适合使用unordered_set:
1、我们需要保留一组元素,不需要排序。
2、我们需要单元素访问,即不需要遍历。
3、仅仅只是插入、删除、查找的话。
额外:substr
一种构造string的方法
形式 :
s.substr(pos, len)
返回值:
string,包含s中从pos开始的len个字符的拷贝
(pos的默认值是0,len的默认值是s.size() - pos,即不加参数会默认拷贝整个s): [0, size]–>[0,1]
相当于:取[pos, pos+len-1]
末项 = 首项+ (项数-1)*公差
string word = s.substr(j, i - j);
对应substr(起始位置(含起始位置),截取的个数): s[j~i-1]
额外: 自己总结的DP代码模板
dp[包大小+1]; 先物for: i = 0; i < coins.size(); i++ 后包for: j = coins[i]; j <= amount; j++ 后续视情况加if **处理dp[ 包 ]** 先包for: i = 0 ; i <= amount; i++ 后物for:j = 0; j < coins.size(); j++ for内部:if(i >= coins[j]) { 【if(包≥物)】 **处理dp[ 包 ]** } 注意i和j的含义不要搞反
打家劫舍3道题
统一类型:
不能连续偷
总体思路:
(1)dpi表示能偷到的最大金额;
(2)分成两部分,偷,则之前一天不偷;不偷,则之前一天考虑(max前一天偷/不偷)
198. 打家劫舍
-
DP定义:
dp[i]:考虑下标i(包括i)以内的房屋,最多可以偷窃的金额为dp[i]。
注意dp大小和nums一致,下标也一致没有错位。 -
递推公式:
决定dp[i]的因素就是第i房间偷还是不偷。
如果偷第i房间,那么dp[i] = dp[i - 2] + nums[i] ;
如果不偷第i房间,那么dp[i] = dp[i - 1],即考虑i-1房,(注意这里是考虑,并不是一定要偷i-1房,这是很多同学容易混淆的点)
另外即使是考虑了i-1房也不会导致连着选(举例即可得知) -
初始化:
从递推公式dp[i] = max(dp[i - 2] + nums[i], dp[i - 1]);可以看出,递推公式的基础就是dp[0] 和 dp[1]
从dp[i]的定义上来讲,dp[0] 一定是 nums[0],dp[1]就是nums[0]和nums[1]的最大值即:dp[1] = max(nums[0], nums[1]);
由于涉及前一个和前两个,但题目给的1 <= nums.length <= 100,因此要专门考虑size小于2的情况:0和1;
遍历顺序只有1层,但要从2开始。
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[1], nums[0]);
for(int i = 2; i < nums.size(); i++){
dp[i] = max(dp[i-1], dp[i-2]+nums[i]);
}
return dp[nums.size()-1];
}
};
213. 打家劫舍II
房屋成环,不相邻 =》 首尾不碰头 =》转化成两种情况,0 ~ size-2和1 ~ size-1
注意以下下标的表示方法
遇到细节和答案不同,建议直接重写或者隔天重写,而不是死记(?)
本题技巧:
两种情况用一个函数统一表示,但是参数都是同一个num,只变下标是a和b,省去了修改数组。
class Solution{
public:
int rob(vector<int>&nums){
if(nums.size()==1) return nums[0];
/if(nums.size()==2) return max(nums[1], nums[0]);
return max(robRange(nums, 0, nums.size()-2), robRange(nums, 1, nums.size()-1));用-2和-1才是对应了dp[i]=nums[i]
}
int robRange(vector<int>&nums, int a, int b){
if(a==b) return nums[a];普适角度,不从1、2出发
vector<int> dp(nums.size());//WRONG(b-a+1);下标需要始终与nums一致
dp[a]=nums[a];
dp[a+1]=max(nums[a],nums[a+1]);
for(int i = a+2; i<=b ;i++){&& a+2<=b
dp[i]=max(dp[i-1], dp[i-2]+nums[i]);
}
return dp[b];
}
};
337.打家劫舍III(树形DP)(需要再看)
- 回溯法:
记忆化递推
使用map保存计算过的结果,这样如果计算过孙子了,那么计算孩子的时候可以复用孙子节点的结果。
但此方法对一个节点 偷与不偷得到的最大金钱都没有做记录,而是需要实时计算。
class Solution {
public:
unordered_map<TreeNode* , int> umap; // 记录计算过的结果
int rob(TreeNode* root) {
if (root == NULL) return 0;
if (root->left == NULL && root->right == NULL) return root->val;
if (umap[root]) return umap[root]; // 如果umap里已经有记录则直接返回
// 偷父节点
int val1 = root->val;
if (root->left) val1 += rob(root->left->left) + rob(root->left->right); // 跳过root->left
if (root->right) val1 += rob(root->right->left) + rob(root->right->right); // 跳过root->right
// 不偷父节点
int val2 = rob(root->left) + rob(root->right); // 考虑root的左右孩子
umap[root] = max(val1, val2); // umap记录一下结果
return max(val1, val2);
}
};
- DP法:
这道题目算是树形dp的入门题目,在树上进行状态转移,
以递归三部曲为框架,融合动规五部曲。
【https://www.programmercarl.com/0337.%E6%89%93%E5%AE%B6%E5%8A%AB%E8%88%8DIII.html#%E6%80%9D%E8%B7%AF】
(1). 本题dp数组就是一个长度为2的数组
怎么标记树中每个节点的状态呢?
递归的过程中,系统栈会保存每一层递归的参数。
(或许,可以借鉴买卖股票的思路,只不过这里二维数组不是现式在dp里的)
class Solution {
public:
int rob(TreeNode* root) {
vector<int> a = robTree(root);
return max(a[0], a[1]);
}
vector<int>robTree(TreeNode* root){
if(root == NULL) return {0, 0};
//后序遍历:左右中
//用定义过的左和右变量分别记录,因为需要变量来记录返回值
vector<int>left = robTree(root->left);
vector<int>right = robTree(root->right);
int val1 = max(left[0], left[1]) + max(right[0], right[1]);// 不偷:考虑左右子树,不管左右子树自己偷不偷,取各自最大
int val2 = root->val + left[0] + right[0];// 偷:取自己值 + 左右子树的不偷
return {val1, val2};///注意此处没有要求初始化v<int>,因为函数签名处定义过了
}
};
买卖股票6道题
要点:
DP是二维数组;
买入统一用dp[i][1]表示,表示的是第i天【是】买入股票的【状态】,并不是说一定要第i天买入股票;
卖出用[0];卖出是当天价格一出来就买了,不存在当天参与运算后再卖出的情况
dp[i][j]的j:
定义按买入卖出顺序,买卖1次01,不限次01,k次1+2*k;
格式非常统一,只和j-1有关(看后面第123题,每个dp[i][1234]的形式即可知),只和i-1有关(存疑,见188,用i也能通过)
如何确定j有几个:
分两大类:
买卖次数是1或无穷:按持有和不持有分成两大类,具体再细分(如有冷冻期、手续费等)
买卖次数是k:分成1+2*k类(还没买,第i次持有,第i次不持有)
注意是“最多k次”(不过,见股票总结篇评论区,如果是必须k次,代码也一样,理解为最后一天多次买卖从而补齐)
121. 买卖股票最佳时机
只买卖一次版
// 贪心解法:
记录左边最小值和右边最大值
代码很有艺术性,直接一次遍历+更新result
// DP解法:
分为两大状态:持有和不持有。
第i天不持有股票:dp[i][0]
可以由两个状态推出来:
第i-1天就不持有股票,那么就保持现状,所得现金就是昨天不持有股票的所得现金 即:dp[i - 1][0]
第i天卖出股票,所得现金就是按照今天股票佳价格卖出后所得现金即:prices[i] + dp[i - 1][1]
那么dp[i][0]取最大的,dp[i][0] = max(dp[i - 1][0], prices[i] + dp[i - 1][1]);
第i天持有股票:dp[i][1]
可以由两个状态推出来:
第i-1天就持有股票,那么就保持现状,所得现金就是昨天持有股票的所得现金 即:dp[i - 1][1]
第i天才买入股票,所得现金就是买入今天的股票后所得现金即:-prices[i]
那么dp[i][1]选最大的,dp[i][1] = max(dp[i - 1][1], -prices[i]);
dp[0][0] = 0;
dp[0][1] -= prices[0];
dp[5][0]就是最终结果.因为本题中不持有股票状态所得金钱一定比持有股票状态得到的多!
class Solution {
public:
int maxProfit(vector<int>& prices) {
vector<vector<int>>dp(prices.size(), vector<int>(2));
dp[0][0]=0;
dp[0][1]=-prices[0];
for(int i = 1; i<prices.size(); i++){
dp[i][0] = max(prices[i]+dp[i-1][1], dp[i-1][0]);//今天没有持有,是因为今天卖了(昨天持有)或者还没有买(昨天未持有)(因为只有一次买卖)
dp[i][1] = max(dp[i-1][1], -prices[i]);//今天持有,是因为还没卖(昨天持有)或者今天买了(昨天未持有)
}
return dp[prices.size()-1][0];
}
};
122. 买卖股票的最佳时机II
// 本题区别于 I 之处:可以多次
//贪心解法:其实我们需要收集每天的正利润就可以!
// 本题中理解利润拆分是关键点! 不要整块的去看,而是把整体利润拆为每天的利润。
class Solution{
public:
int maxProfit( vector<int>& prices){
int result = 0;
for(int i = 0; i < prices.size() - 1; i++){
result += max(0,prices[i+1]-prices[i] );
}
return result;
}
};
//DP 解法:
// 和121. 买卖股票的最佳时机是一样的逻辑,卖出股票收获利润(可能是负值)天经地义!
// 本题和121的代码几乎一样,唯一的区别在:
// dp[i][0] = max(dp[i - 1][0], dp[i - 1][1] - prices[i]);
// 这正是因为本题的股票可以买卖多次,所以买入股票的时候,可能会有之前买卖的利润,即dp[i - 1][0]可能非0,所以是dp[i - 1][0] - prices[i]。
class Solution{
public:
int maxProfit(vector<int>& prices){
vector<vector<int>> dp(prices.size(), vector<int>(2));
dp[0][0] =0;
dp[0][1] =-prices[0];
for(int i = 1; i<prices.size();i++){
dp[i][0]=max(dp[i-1][0], dp[i-1][1]+ prices[i]);
dp[i][1]=max(dp[i-1][1], dp[i-1][0]-prices[i]);
// 注意这里是和121唯一不同的地方。
//121:dp[i][1] = max(dp[i-1][1], 0-prices[i]);
//注意看上面两行,已经初现统一性
}
return dp[prices.size()-1][0];size()-1是0(卖掉了)不是1
}
};
123.买卖股票的最佳时机III
只能买卖≤2次
dp[i][j]中 i表示第i天,j为 [0 - 4] 五个状态,dp[i][j]表示第i天状态j所剩最大现金。
【【注意以下几种是如何定义出来的!】】
0:没有操作
1:第一次买入
2:第一次卖出
3:第二次买入
4:第二次卖出
【【【注意!】】】dp[i][1],表示的是第i天【是】买入股票的【状态】,并不是说一定要第i天买入股票
注意注释部分
class Solution {
public:
int maxProfit(vector<int>& prices) {
if(prices.size()==1) return 0;不是prices [0]因为无法有一次买卖
vector<vector<int>> dp(prices.size(), vector<int>(5,0));
dp[0][0]=0;
dp[0][1]=-prices[0];
dp[0][2]=0;/注意是0
dp[0][3]=-prices[0];
dp[0][4]=0;/注意是0
for(int i = 1; i<prices.size();i++){
dp[i][0]=dp[i-1][0];//是“还没有买入”,不是还没买or第一次卖掉了or第二次卖掉了
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]);//注意都是i-1!!
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];
}
};
188.买卖股票的最佳时机IV
牢记上一题,本题就是把2换成k了,然后用的“版本二”(通用表示法),如下:
class Solution {
public:
int maxProfit(int k, vector<int>& prices) {
if(prices.size()==0 || prices.size()==1)return 0;
vector<vector<int>> dp(prices.size(), vector<int>(k*2+1,0));///00 1(0)- 3(1)- 40
for(int i = 0; i<k;i++){
dp[0][i*2+1] = -prices[0];
}
for(int i = 1; i < prices.size(); i++){
dp[i][0]=dp[i-1][0];
for(int j = 0; j<k; j++){
dp[i][j*2+1] = max(dp[i-1][j*2+1], dp[i-1][j*2]-prices[i]);
//最后一项用i也通过了,即,dp[i][j*2]-prices[i](见二刷对应的2次提交记录)。。。
dp[i][j*2+2] = max(dp[i-1][j*2+2], dp[i-1][j*2+1]+prices[i]);
}
}
return dp[prices.size()-1][k*2];
}
};
309.最佳买卖股票时机含冷冻期
是122(无限多次)的延伸,因此是在0-1上面的延伸,即,在分类依据上分为两大类:买入和卖出状态;
买入股票状态:
状态一:今天买入股票,或者是之前就买入了股票然后保持
卖出股票状态
状态二:>=两天前就卖出了股票,度过了冷冻期,一直没操作,今天保持卖出股票状态
状态三:今天卖出了股票
状态四:今天为冷冻期状态,但冷冻期状态不可持续,只有一天!
注意点:
把卖出分为3种,依然是按时间顺序分为3种
max函数里面只能有两个参数!!!想比较3个就嵌套max,见下
最后return的选择范围
class Solution {
public:
int maxProfit(vector<int>& prices) {
if(prices.size()==1)return 0;
vector<vector<int>> dp(prices.size(), vector<int>(4,0));
dp[0][0]=-prices[0];
// dp[0][1]=0;
// dp[0][2]=0;
// dp[0][3]=0;
for(int i = 1; i<prices.size();i++){
dp[i][0]=max(dp[i-1][0],max(dp[i-1][1], dp[i-1][3])-prices[i]);///max函数里面只能有两个参数!!!
dp[i][1]=max(dp[i-1][1],dp[i-1][3]);
dp[i][2]=dp[i-1][0]+prices[i];
dp[i][3]=dp[i-1][2];
//这样看着顺一点:0-持有, 1-当天卖,2-冷冻期, 3-冷冻期后一天或多天保持未持有状态
dp[i][持]=max(dp[i-1][持],max(dp[i-1][久], dp[i-1][冷])-prices[i]);///max函数里面只能有两个参数!!!
dp[i][卖]=dp[i-1][持]+prices[i];
dp[i][冷]=dp[i-1][卖];
dp[i][久]=max(dp[i-1][久],dp[i-1][冷]);
}
return max(dp[prices.size()-1][1], max(dp[prices.size()-1][2], dp[prices.size()-1][3]));
}
};
714. 买卖股票的最佳时机含手续费
依然是122(无限多次)的延伸,因此是在0-1上面的延伸,即,在分类依据上分为两大类:买入和卖出状态。
class Solution {
public:
int maxProfit(vector<int>& prices, int fee) {
if(prices.size()==1)return 0;
vector<vector<int>> dp(prices.size(),vector<int>(2,0));
dp[0][1]=-prices[0];
for(int i = 1; i<prices.size(); i++){
dp[i][0]=max(dp[i-1][0], dp[i-1][1]+prices[i]-fee);//假设是卖出的时候付手续费
dp[i][1]=max(dp[i-1][1], dp[i-1][0]-prices[i]);
}
return dp[prices.size()-1][0];
}
};
最长子序列7道题
(Y:符合条件;N:不符合)
(连续 - 不连续)(与数组自身比,一维DP - 两个数组比,二维DP)(一维求最长递增,二维求最长公共)
不连续求最长递增:
Ydp[i] = max(dp[i], dp[j]+1);
与dp[现]有关,N不操作就更新dp[现];
res = max(res, dp[i]);
连续求最长递增:
Ydp[i]=dp[i-1]+1;
与dp[现]无关,N不操作就不改变dp[现]初值;
res = max(res, dp[i]);
连续求最长公共:
Ydp[i][j]=dp[i-1][j-1]+1;
与dp[现]无关,N不操作就不改变dp[现]初值;
res=max(res,dp[i][j]);
不连续求最长公共:
Ydp[i][j] = dp[i - 1][j - 1] + 1
;与dp[现]有关
Ndp[i][j] = max(dp[i - 1][j], dp[i][j - 1])
;N有操作,更新dp[现];
没有res,return最后一项(个人认为是因为求最长公共是累加的过程,所以最后一个就是累加的最终结果,因而不需要res记录)
连续求最大子序和:(有点类似连续求最长递增)
Ydp[i] = max(dp[i-1]+nums[i], nums[i]);
res = max(res, dp[i]);
300. 最长递增子序列*
用了两层for
一维DP
定义:dp[i]表示i之前包括i的以nums[i]结尾最长上升子序列的长度
推导:dp[i]是根据每个dp[j] (j < i)推导出来的
初始化: 每一个i,对应的dp[i](即最长上升子序列)起始大小至少都是1.
注意这里不是要dp[i] 与 dp[j] + 1进行比较,而是我们要取dp[j] + 1的最大值, 用dp[i]记录!同时不同的dp[j]之间不会彼此覆盖只会互相关联,有点像哈希表。同理后面用res记录dp[i]的最大值。
其中,为什么是nums[i]和nums[j]比较?因为不是连续子序列!!可以断开!!所以做收集即可!!
class Solution {
public:
int lengthOfLIS(vector<int>& nums) {
int n = nums.size();
if(n==1)return 1;
vector<int> dp(n,1);//初始化: 每一个i,对应的dp[i](即最长上升子序列)起始大小至少都是1.
dp[0]=1;
int res = 1;//初始化: 每一个i,对应的dp[i](即最长上升子序列)起始大小至少都是1.
for(int i = 1; i<n; i++){
for(int j = 0; j<i; j++){
if (nums[i]>nums[j]) dp[i] = max(dp[i], dp[j]+1);
}
res = max(res, dp[i]);
}
return res;
}
};
674. 最长[连续]递增序列
不要按上一题的套路,二者有明显区别
要自己想
只需要一层遍历;想象成很多个从x轴开始的直角三角形
因为本题要求连续递增子序列,所以就必要比较nums[i + 1]与nums[i],而不是去比较nums[j]与nums[i] (j是在0到i之间遍历)。
既然不用j了,那么也不用两层for循环,本题一层for循环就行,比较nums[i + 1] 和 nums[i]:
if(nums[i]>nums[i-1]) dp[i]=dp[i-1]+1;
:遇到更大的下一个数才+1,否则维持初始值1
class Solution {
public:
int findLengthOfLCIS(vector<int>& nums) {
int n = nums.size();
if(n==1)return 1;
vector<int> dp(n, 1);
int res = 1;
for(int i = 1; i<n; i++){
if(nums[i]>nums[i-1]) dp[i]=dp[i-1]+1;
res = max(res, dp[i]);
//cout<<dp[i]<<" "<<i<<endl;
}
return res;
}
};
// // 不连续,count从头开始
718.最长[连续]重复子数组
给两个整数数组 A 和 B ,返回两个数组中公共的、长度最长的子数组的长度。
题目中说的子数组,其实就是
【连续】子序列。这种问题动规最拿手;
上一题二维版,共同点:都是求某连续子集,迭代公式都是dp(现在)=dp(现在-1)+1。。。
-
动规法:
二维dp数组,大小是(n1+1)*(n2+2),数组的0 ~ n-1对应dp的1 ~ n
dp[i][j] :以下标i - 1为结尾的A,和以下标j - 1为结尾的B,最长重复子数组长度为dp[i][j]
i j由i-1 j-1推导出来(和背包区分,这里肯定是两个数组同步遍历的)
算法主体是dp[i-1][j-1], dp[i][j]在前面计数第一次出现 -
滚动数组法(没太理解)(个人感觉类似背包,不重点掌握)
class Solution {
public:
int findLength(vector<int>& nums1, vector<int>& nums2) {
int n1= nums1.size();
int n2= nums2.size();
/二维动归解法
vector<vector<int>> dp(n1+1, vector<int>(n2+1,0));/+1
int res = 0;
for(int i = 1; i<=n1; i++){/<=
for(int j = 1; j<=n2; j++){O(n*m)
if(nums1[i-1]==nums2[j-1]) dp[i][j]=dp[i-1][j-1]+1;算法主体是dp[i-1][j-1], dp[i][j]在前面计数
if(dp[i][j]>res) res = dp[i][j];
}
}
return res;
/滚动数组(没太理解)
vector<int>dq(n2+1, 0);//是 n2因为起作用的是遍历nums2的j
int res = 0;
for(int i = 1; i<=n1; i++){
for(int j = n2; j>0; j--){从后往前遍历
if(nums1[i-1]==nums2[j-1]) dq[j]=dq[j-1]+1;是j不是i此时遍历B数组的时候,就要从后向前遍历,这样避免重复覆盖。
else dq[j]=0;///注意不是j-1
if(dq[j]>res) res = dq[j];
}
}
return res;
}
};
1143.最长[不连续]公共子序列
和上一题区别:要求不连续
和上一题共同点:都要求最长的子序列长度
动规五部曲分析如下:
-
确定dp数组(dp table)以及下标的含义
dp[i][j]:长度为[0, i - 1]的字符串text1与长度为[0, j - 1]的字符串text2的最长公共子序列为dp[i][j]
再次,用ij表示i-1j-1,相应的,dp大小是(size1+1,size2+1),遍历顺序是从[1~size],返回是dp[size1][size2] -
确定递推公式
主要就是两大情况: text1[i - 1] 与 text2[j - 1]相同,text1[i - 1] 与 text2[j - 1]不相同
如果text1[i - 1] 与 text2[j - 1]相同,那么找到了一个公共元素,所以dp[i][j] = dp[i - 1][j - 1] + 1
;
如果text1[i - 1] 与 text2[j - 1]不相同,那就看看text1[0, i - 2]与text2[0, j - 1]的最长公共子序列 和 text1[0, i - 1]与text2[0, j - 2]的最长公共子序列,取最大的。即:dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])
;
class Solution {
public:
int longestCommonSubsequence(string text1, string text2) {
///no if?
vector<vector<int>> dp(text1.size()+1,vector<int>(text2.size()+1, 0));
for(int i = 1; i<=text1.size(); i++){1 ! <= !
for(int j = 1; j<=text2.size();j++){1 ! <= !
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()];
}
};
1035.不相交的线(变体1)
只要相对顺序不改变,链接相同数字的直线就不会相交。
本题说是求绘制的最大连线数,其实就是求两个字符串的最长公共子序列的长度!
那么本题就和我们刚刚讲过的这道题目动态规划:1143.最长公共子序列就是一样一样的了
//完全一样的默写都有错!!!需要重来!!
//第二天默写直接忘了一维二维(两个数列对比就是二维)!!!和递推公式!!!(本题对称所以取max)(?)
class Solution {
public:
int maxUncrossedLines(vector<int>& nums1, vector<int>& nums2) {
vector<vector<int>>dp(nums1.size()+1, vector<int>(nums2.size()+1,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-1][j], dp[i][j-1]);
}
}
return dp[nums1.size()][nums2.size()];
}
};
583.两个字符串的删除操作(变体2)(也在编辑距离部分)
解法二:
本题和1143.最长公共子序列 基本相同,只要求出两个字符串的最长公共子序列长度即可,那么除了最长公共子序列之外的字符都是必须删除的,最后用两个字符串的总长度减去两个最长公共子序列的长度就是删除的最少步数。
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 = 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]+1;
else dp[i][j] = max(dp[i-1][j], dp[i][j-1]);
}
}
int res = dp[word1.size()][word2.size()];
return word1.size() + word2.size() - res - res;///注意不要乘2
}
};
53. 最大子序和
这道题目用贪心也很巧妙,但有一点绕,需要仔细想一想,在贪心篇也有该题。
动规:
和之前几道题略有区别:
// dp[i]的定义:以i为终点的连续最大子序列。
//所以最后结果并不是dpi,而是所有dpi的最大值。技巧是写在同一个for里边获取新的dp边更新res。
class Solution {
public:
int maxSubArray(vector<int>& nums) {
vector<int> dp (nums.size(), 0);
dp[0] = nums[0];
int res = nums[0];
for(int i = 1; i<nums.size();i++){
dp[i] = max(dp[i-1]+nums[i], nums[i]);
res = max(res, dp[i]);
}
return res;
}
};
编辑距离4道题
392. 判断子序列
给定字符串 s 和 t ,判断 s 是否为 t 的子序列。
(这道题可以用双指针的思路来实现,时间复杂度就是O(n))
编辑距离的入门题目,因为从题意中我们也可以发现,只需要计算删除的情况,不用考虑增加和替换的情况。
所以掌握本题也是对后面要讲解的编辑距离的题目打下基础。
dp定义:【上一节就出现了,这里再次】
dp[i][j] 表示以下标i-1为结尾的字符串s,和以下标j-1为结尾的字符串t,相同子序列的【长度】为dp[i][j]。
注意这里是判断s是否为t的子序列。即t的长度是大于等于s的。
递推公式:
如果不相等,即if (s[i - 1] != t[j - 1]),此时相当于t要删除元素,如果把当前t的元素t[j - 1]删除,那么dp[i][j] 的数值就是 看s[i - 1]与 t[j - 2](???????为什么不是tj,也就是下一个啊?不是从前往后遍历的吗,j-2已经比较过了呀??因为后面的不能纳入考虑范围,题解这样表示dp值不变、因为删掉了所以无法改变dp值的意思 )的比较结果了,即:dp[i][j] = dp[i][j - 1];
初始化:
dp[i][j]对应下标i-1,dp总长度是size+1,初始化在dp的0
看图理解,注意颜色部分
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];///依赖长的
}
}
return dp[s.size()][t.size()]==s.size()? true : false;
}
};
115. 不同的子序列(需要再看)
困难-但代码类似上一简单题-去理解;重在初始化
??uint64_t
“题目数据保证答案符合 32 位带符号整数范围。”
class Solution {
public:
int numDistinct(string s, string t) {
vector<vector<uint64_t>> dp(s.size()+1, vector<uint64_t>(t.size()+1));"题目数据保证答案符合 32 位带符号整数范围。"
for(int i = 0; i<=s.size();i++) dp[i][0]=1;s长
for(int j = 1; j<=t.size();j++) dp[0][j]=0;t短
//dp[0][0]=1;
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]+dp[i-1][j];///dp[长- 1][短]多加了一个短的上一个相等的次数
else dp[i][j] = dp[i-1][j];/这里也不同于上一题(but意义一样,都是dp[长- 1][短])
}
}
return dp[s.size()][t.size()];
}
};
这道题目如果不是子序列,而是要求连续序列的,那就可以考虑用KMP。
dp[i][j]定义:以i-1为结尾的s子序列中出现以j-1为结尾的t的个数为dp[i][j]。(s长,t短)
// 所以后续出现相同字母不是加一,因为不像上一题那样求长度
初始化:
// dp[i][0] 表示:以i-1为结尾的s可以随便删除元素,出现空字符串的个数。
// 那么dp[i][0]一定都是1,因为也就是把以i-1为结尾的s,删除所有元素,出现空字符串的个数就是1。
// dp[0][j]表示:空字符串s可以随便删除元素,出现以j-1为结尾的字符串t的个数。
// 那么dp[0][j]一定都是0,s如论如何也变成不了t。
// 最后就要看一个特殊位置了,即:dp[0][0] 应该是多少。
// dp[0][0]应该是1,空字符串s,可以删除0个元素,变成空字符串t。
本题st意义反过来了,为什么for循环还是先s后t?(是不是无所谓??
//运行示例发现并不对称:目前推测是dp初始化和递推公式不对称导致的
//本题确实并不对称,就是求短的在长的里面能有几种。如果想换成先【输入】短后【输入】长,则代码如下:
class Solution {
public:
int numDistinct(string s, string t) {
vector<vector<uint64_t>> dp(s.size()+1, vector<uint64_t>(t.size()+1));
for(int i = 1; i<=s.size();i++) dp[i][0]=0;改i初值
for(int j = 0; j<=t.size();j++) dp[0][j]=1;改j初值
//保证dp[0][0]=1;
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]+dp[i][j-1];改-1
else dp[i][j] = dp[i][j-1];改-1
}
}
return dp[s.size()][t.size()];
}
};
583.两个字符串的删除操作
(也在公共子序列部分)
//解法一:编辑距离方法
有点绕,用后一种(见上一节)
class Solution {
public:
int minDistance(string word1, string word2) {
vector<vector<int>> dp(word1.size() + 1, vector<int>(word2.size() + 1));
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] + 2, dp[i - 1][j] + 1, dp[i][j - 1] + 1});
}
}
}
return dp[word1.size()][word2.size()];
}
};
72. 编辑距离(需要再看)
困难题
思路精髓:只计数,不考虑怎么操作的(或者都考虑但是只取min,不求一步到位)
/后续这两个题都是先输入长的后输入短的。。。
和之前的共同点:
dp规模都是1~size+1,i对应字符的i-1位;
dp递归都是从二者相同/不相同出发看的:
if (word1[i - 1] == word2[j - 1])
不操作
if (word1[i - 1] != word2[j - 1])
增
删
换
dp初始都是第一行第一列对应是i和j;
dp遍历都是左到右上到下。
class Solution {
public:
int minDistance(string word1, string word2) {
vector<vector<int>> dp(word1.size()+1,vector<int>(word2.size()+1));
for(int i = 0; i<=word1.size();i++) dp[i][0] = i;
for(int j = 0; j<=word2.size();j++) dp[0][j] = j;
//dp[i][j]=0...
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]+1), (dp[i-1][j]+1), (dp[i][j-1]+1)});
}
}
return dp[word1.size()][word2.size()];
}
};
回文2道题
回文子串(上一题)是要连续的,回文子序列(本题)可不是连续的
此2题都是动态规划【经典题目】
647.回文子串
-
暴力解法
两层for循环,遍历区间起始位置和终止位置,然后判断这个区间是不是回文。时间复杂度:O(n^3) -
动归法:从外往内
动规并不一定只看i和i-1,也可以是两层遍历里的i和j
确定dp数组(dp table)以及下标的含义
dp[i][j]:布尔类型;表示区间范围[i,j] (注意是左闭右闭)的子串是否是回文子串,如果是dp[i][j]为true,否则为false。
遍历顺序可有有点讲究了。
首先从递推公式中可以看出,情况三是根据dp[i + 1][j - 1]是否为true,在对dp[i][j]进行赋值true的。
dp[i + 1][j - 1] 在 dp[i][j]的左下角,如图:
所以一定要从下到上,从左到右遍历,这样保证dp[i + 1][j - 1]都是经过计算的。
class Solution {
public:
int countSubstrings(string s) {
vector<vector<bool>> dp(s.size(), vector<bool>(s.size(),false));
int res = 0;
for(int i = s.size()-1; i>=0; i--){
for(int j = i; j<s.size(); j++){
if(i==j || (s[i]==s[j] && j-i==1) || (s[i]==s[j] && dp[i+1][j-1]==true) ){///i+=jWRONG
res++;
dp[i][j]=true;
}
}
}
return res;
}
};
- 双指针法:从内往外
动态规划的空间复杂度是偏高的,我们再看一下双指针法。
首先确定回文串,就是找中心然后向两边扩散看是不是对称的就可以了。
在遍历中心点的时候,要注意中心点有两种情况。
一个元素可以作为中心点,两个元素也可以作为中心点。
class Solution {
public:
int countSubstrings(string s) {
int result = 0;
for (int i = 0; i < s.size(); i++) {
result += extend(s, i, i, s.size()); // 以i为中心
result += extend(s, i, i + 1, s.size()); // 以i和i+1为中心
}
return result;
}
int extend(const string& s, int i, int j, int n) {//判断回文的函数:从中心向两边扩散
int res = 0;
while (i >= 0 && j < n && s[i] == s[j]) {
i--;
j++;
res++;//有一个算一个,abcba和bcb算两个(因为可重叠)
}
return res;
}
};
#####0830到这
516.最长回文子序列
dp[i][j]:字符串s在[i, j]范围内最长的回文子序列的长度为dp[i][j]。
递推:关键逻辑就是看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]看看哪一个可以组成最长的回文子序列。那么dp[i][j]一定是取最大的,即:dp[i][j] = max(dp[i + 1][j], dp[i][j - 1]);
初始化:
(1)首先要考虑当i 和j 相同的情况,从递推公式:dp[i][j] = dp[i + 1][j - 1] + 2; 可以看出 递推公式是计算不到 i 和j相同时候的情况。(?为啥往他俩相等去想?为啥计算不到?就死记住?)
所以需要手动初始化一下,当i与j相同,那么dp[i][j]一定是等于1的,即:一个字符的回文子序列长度就是1。
(2)其他情况dp[i][j]初始为0就行,这样递推公式:dp[i][j] = max(dp[i + 1][j], dp[i][j - 1]); 中dp[i][j]才不会被初始值覆盖。
class Solution {
public:
int longestPalindromeSubseq(string s) {
vector<vector<int>> dp(s.size(), vector<int>(s.size(), 0));
int n = s.size();
for(int i = 0; i<n; i++){
dp[i][i] = 1;
}
for(int i = n-1; i>=0; i--){
for(int j = i+1; j<n; 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]);///WRONG +1;
}
}
return dp[0][n-1];
}
};
用动规解决单调栈题目
42. 接雨水
【困难】【单调栈章节用动规解决】
//这个图就是大厂面试经典题目,接雨水! 最常青藤的一道题,面试官百出不厌!
//每次遍历列的时候,还要向两边寻找最高的列
/*
动态规划解法
在双指针解法中,我们可以看到只要记录左边柱子的最高高度 和 右边柱子的最高高度,就可以计算当前位置的雨水面积,这就是通过列来计算。
当前列の雨水面积:min(左边柱子的最高高度,记录右边柱子的最高高度) - 当前柱子高度。
为了得到两边的最高高度,使用了双指针来遍历,每到一个柱子都向两边遍历一遍,这其实是有重复计算的。
but if 我们把每一个位置的左边最高高度记录在一个数组上( maxLeft ),右边最高高度记录在一个数组上( maxRight ),这样就避免了重复计算,这就用到了动态规划。
当前位置,左边的最高高度是前一个位置的左边最高高度和本高度的最大值。
这样就找到递推公式。
*/
class Solution {
public:
int trap(vector<int>& height) {
if (height.size() <= 2) return 0;//!!!!!!
vector<int> maxLeft(height.size(), 0);!!!!!!注意这里是指定 vector 大小和值的方法,注意此处对每个值都赋值了(虽然后面又对重点位置单独赋值了),不同于84题!
vector<int> maxRight(height.size(), 0);
int size = maxRight.size();
// DP: 记录每个柱子左边柱子最大高度
maxLeft[0] = height[0];
for (int i = 1; i < size; i++) {
maxLeft[i] = max(height[i], maxLeft[i - 1]);
}
// DP: 记录每个柱子右边柱子最大高度
maxRight[size - 1] = height[size - 1];
for (int i = size - 2; i >= 0; i--) {
maxRight[i] = max(height[i], maxRight[i + 1]);
}
// 求和
int sum = 0;
for (int i = 0; i < size; i++) {
int count = min(maxLeft[i], maxRight[i]) - height[i];
if (count > 0) sum += count;//!!!!!!
}
return sum;
}
};
84. 柱状图中最大的矩形
【困难】【单调栈章节用动规解决】
// // 本题**动态规划**的写法整体思路和42. 接雨水是一致的,但要比42难一些。
// // 难就难在本题要记录记录每个柱子 左边第一个小于该柱子的下标,而不是左边第一个小于该柱子的高度。
// // 所以需要循环查找,也就是下面在寻找的过程中使用了 while ,详细请看下面注释,整理思路在题解:42. 接雨水中已经介绍了。
class Solution {
public:
int largestRectangleArea(vector<int>& heights) {
vector<int> minLeftIndex(heights.size());//是记录下标的数组
vector<int> minRightIndex(heights.size());
int size = heights.size();
// 记录每个柱子 左边第一个小于该柱子的下标
minLeftIndex[0] = -1; // 注意这里初始化,防止下面while死循环
for (int i = 1; i < size; i++) {// i 范围:[ 第二个,最后一个]
int t = i - 1;
// 这里不是用if,而是不断向左寻找的过程
while (t >= 0 && heights[t] >= heights[i]) t = minLeftIndex[t];
minLeftIndex[i] = t;//t 退出循环之时,就是符合条件的下标值
}
// 记录每个柱子 右边第一个小于该柱子的下标
minRightIndex[size - 1] = size; //!!!! !!!注意这里初始化,防止下面while死循环
for (int i = size - 2; i >= 0; i--) {// i 范围:[ 倒数第二个~--~第一个]
int t = i + 1;
// 这里不是用if,而是不断向右寻找的过程
while (t < size && heights[t] >= heights[i]) t = minRightIndex[t];
minRightIndex[i] = t;
}
// 求和
int result = 0;
for (int i = 0; i < size; i++) {
int sum = heights[i] * (minRightIndex[i] - minLeftIndex[i] - 1);//末项减首项除以公差加一,再减二
result = max(sum, result);
}
return result;
}
};
///一刷0716