一、动态规划的解题步骤
- 确定dp数组以及下标的含义
- 确定递推公式
- dp数组如何初始化
- 确定遍历顺序
- 举例推导dp数组
二、基础动态规划问题
1.斐波那契数
class Solution {
public:
int fib(int n) {
if(n<2)
return n;
vector<int> dp(n+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.使用最小花费爬楼梯
class Solution {
public:
int minCostClimbingStairs(vector<int>& cost) {
vector<int> dp(cost.size()+1);
//初始化,可以自由选择从第0阶开始网上爬还是第1阶开始往上爬
dp[0]=0;//到达第0阶需要花费0
dp[1]=0;//到达第1阶需要花费0
//dp[i]表示到达第i的费用
for(int i=2;i<=cost.size();i++)
{
//到达第i阶有两种方法:从i-1阶往上爬一阶/从i-2阶往上爬两阶
dp[i]=min(dp[i-1]+cost[i-1],dp[i-2]+cost[i-2]);
}
//返回从倒数两个台阶中那个台阶跳到终点的花费低
return dp[cost.size()];
}
};
3.不同路径
class Solution {
public:
int uniquePaths(int m, int n) {
vector<vector<int>> dp(m,vector<int>(n));
dp[0][0]=1;
//到达每一个格子dp[i][j]的方法有两种:
//dp[i-1][j]往下移、dp[i][j-1]往右移
for(int i=0;i<m;i++)
{
for(int j=0;j<n;j++)
{
if(i-1>=0&&j-1>=0)
dp[i][j]=dp[i-1][j]+dp[i][j-1];
else if(i-1>=0)
dp[i][j]=dp[i-1][j];
else if(j-1>=0)
dp[i][j]=dp[i][j-1];
}
}
return dp[m-1][n-1];
}
};
4.不同路径||
class Solution {
public:
int uniquePathsWithObstacles(vector<vector<int>>& obstacleGrid) {
//如果终点有障碍物,则肯定到不了,return 0
if(obstacleGrid[obstacleGrid.size()-1][obstacleGrid[0].size()-1]==1)
return 0;
vector<vector<int>> dp(obstacleGrid.size(),vector<int>(obstacleGrid[0].size()));
dp[0][0]=1;//初始化
//到达每一个格子dp[i][j]的方法有两种:
//dp[i-1][j]往下移、dp[i][j-1]往右移【要求dp[i-1][j]、dp[i][j-1]没有障碍物】
for(int i=0;i<obstacleGrid.size();i++)
{
for(int j=0;j<obstacleGrid[0].size();j++)
{
if(i-1>=0&&obstacleGrid[i-1][j]!=1)
dp[i][j]+=dp[i-1][j];
if(j-1>=0&&obstacleGrid[i][j-1]!=1)
dp[i][j]+=dp[i][j-1];
}
}
return dp[obstacleGrid.size()-1][obstacleGrid[0].size()-1];
}
};
5.不同的二叉搜索树
//确定初始值dp[0]以及dp[1]
//节点个数为n时,需要一个根节点,因此左右两颗子树可以分到n-1个节点
//树的种类=左树的种类*右树的种类
//(左树节点个数为0、1...,右树节点个数为n-1、n-2.....)
class Solution {
public:
int numTrees(int n) {
vector<int> dp(n+1);
dp[0]=1;
dp[1]=1;
for(int i=2;i<=n;i++)
{
for(int j=0;j<i;j++)
{
dp[i]+=(dp[j]*dp[i-1-j]);
}
}
return dp[n];
}
};
6.单词划分
//使用背包问题解决单词拆分,需要注意!!!
//一旦dp[i]表示字符串为(s.begin(),s.begin()+i)区间是可以被字典表示,则dp[i]将不能被赋值false
class Solution {
public:
bool wordBreak(string s, vector<string>& wordDict) {
//dp[i]表示字符串为(s.begin(),s.begin()+i)区间是否可以被字典表示
vector<bool> dp(s.length()+1,false);
dp[0]=true;
for(int j=0;j<=s.length();j++)
{
for(int i=0;i<wordDict.size();i++)
{
if(j>=wordDict[i].length())
{
string ss(s.begin()+j-wordDict[i].length(),s.begin()+j);
//一旦dp[i]表示字符串为(s.begin(),s.begin()+i)区间是可以被字典表示,则dp[i]将不能被赋值false
if(ss==wordDict[i]&&dp[j]==false)
dp[j]=dp[j-wordDict[i].length()]&&true;
}
}
}
return dp[s.length()];
}
};
7.整数拆分
class Solution {
public:
int integerBreak(int n) {
vector<int> dp(n+1);
dp[0]=1;
dp[1]=1;
for(int i=2;i<=n;i++)
{
for(int j=1;j<i;j++)
{
//推导公式注意,需要在三种情况中取最大值
//情况一:dp[i]保存当前dp[i]
//情况二:j*(i-j)
//情况三:j * dp[i - j],相当于是拆分(i - j)
dp[i]=max(dp[i],max(j*(i-j),j*dp[i-j]));
}
}
return dp[n];
}
};
2.剪绳子|
class Solution {
public:
int cuttingRope(int n) {
vector<int> dp(n+2);
dp[2]=1;
dp[3]=2;
for(int i=4;i<=n;i++)
{
for(int j=1;j<i;j++)
{
//关键理解,每个数的最大内积是可能存在的情况
//j*(i-j), dp[j]*dp[i-j], j*dp[i-j] 别忘了dp[i]自己
//将j遍历[1:i-1],获取最大值
dp[i]=max(j*(i-j),max(dp[j]*dp[i-j],max(dp[i],j*dp[i-j])));
}
}
return dp[n];
}
};
3.剪绳子||
中心思想:尽可能将绳子以长度 3等分为多段时,乘积最大。【不要问为啥】
切分规则:
最优: 3 。把绳子尽可能切为多个长度为 3 的片段,留下的最后一段绳子的长度可能为 0,1,2 三种情况。
次优: 2 。若最后一段绳子长度为 2 ;则保留,不再拆为 1+1 。
最差: 1 。若最后一段绳子长度为 1 ;则应把一份 3+1替换为 2+2,因为 2×2>3×1。
算法流程:
当 n≤3 时,按照规则应不切分,但由于题目要求必须剪成 m>1 段,因此必须剪出一段长度为 1 的绳子,即返回 n−1。
当 n>3 时,求 n 除以 3 的 整数部分 a 和 余数部分 b (即 n=3a+b ),并分为以下三种情况:
当 b=0 时,直接返回 %1000000007;
当 b=1 时,要将一个 1+3 转换为 2+2,因此返回 (−1×4)%1000000007;
当 b=2 时,返回 (×2)%1000000007。
class Solution {
public:
int cuttingRope(int n) {
if(n <= 3)
return n - 1;
int b = n % 3, p = 1000000007;
long ret = 1;
int lineNums=n/3; //线段被我们分成以3为大小的小线段个数
for(int i=1;i<lineNums;i++) //从第一段线段开始验算,3的ret次方是否越界。注意是验算lineNums-1次。
ret = 3*ret % p;
if(b == 0)
return (int)(ret * 3 % p); //刚好被3整数的,要算上前一段
if(b == 1)
return (int)(ret * 4 % p); //被3整数余1的,要算上前一段
return (int)(ret * 6 % p); //被3整数余2的,要算上前一段
}
};
三、背包问题
1.01背包
题目描述整体为,给定背包容量weight,物品数组,物品价值array[]={val1,val2,val3....},物品重量huge[]={weight1,weight2,weight3...}。问如何将物品装进背包,使得背包中物品的总价值最大。
1.初始化dp数组,dp[i]表示当背包容量为i时可以装的最大物品总价值;
2.遍历顺序,个人习惯,对于0/1背包问题,每个物品只能用一次的情况下,采用外物品,内背包容量的遍历方式。由于每个物品只能用一次,所以背包容量遍历要倒序;
3.推导公式:dp[i]={dp[i],dp[i-huge[j]]+val[j]}; 表示当前背包中物品的最大价值总和取决于是否添加物品j。
- 如果添加物品j,则需要在dp[i-huge[j]]的基础上添加,dp[i-huge[j]]+val[j];
- 如果不添加物品j,则直接保存上一次不存在物品j时,背包容量为i的结果dp[i]。
1.分割等和子集
//题目可以转换成背包容量为SUM/2的背包问题;
class Solution {
public:
bool canPartition(vector<int>& nums) {
int SUM=0;
for(int i=0;i<nums.size();i++)
SUM+=nums[i];
//首先判断物品总数无法均分成两份,直接返回false
if(SUM%2!=0)
return false;
//创建一维数组dp,dp[j]表示背包容量位j时可以装的最大物品总数
vector<int> dp(SUM/2+1,0);
for(int i=0;i<nums.size();i++)
{
//在遍历背包容量时,需要倒序遍历,并当容量小于当前物品时不再继续当前层遍历【剪枝操作】
for(int j=SUM/2;j>=nums[i];j--)
{
//推导公式:背包容量为j所能装的最大物品数
dp[j]=max(dp[j],dp[j-nums[i]]+nums[i]);
}
}
//当容量为SUM/2时背包内物品数也为SUM/2说明可以均分
if(dp[SUM/2]==SUM/2)
return true;
return false;
}
};
2.最后一块石头的重量||
//将一堆石头尽可能平均分配成两堆,多出的石头即为最后会剩下的石头块
//问题转换为:将石头尽可能分配到容量大小为SUM/2的堆中,两堆石头相撞,一堆完全粉碎,另一堆剩下的即为最终保留的大小
class Solution {
public:
int lastStoneWeightII(vector<int>& stones) {
int sum=0;
for(int i=0;i<stones.size();i++)
sum+=stones[i];
vector<int> dp(sum/2+1,0);
//获得容量大小为sum/2的堆能够装下的石头总量
for(int i=0;i<stones.size();i++)
{
//倒序遍历,并当容量小于当前物品时不再继续当前层遍历【剪枝操作】
for(int j=sum/2;j>=stones[i];j--)
{
dp[j]=max(dp[j],dp[j-stones[i]]+stones[i]);
}
}
return sum-2*dp[sum/2];
}
};
3.目标和
//问题转换,转换成容量为(SUM-target)/2,物品为nums[i]的背包问题
//dp[i]表示背包容量为i时,物品的选择组合种数
class Solution {
public:
int findTargetSumWays(vector<int>& nums, int target) {
int SUM=0;
for(int i=0;i<nums.size();i++)
{
SUM+=nums[i];
}
//情况一:目标值比数组中元素之和还大,应该排除,否则下面创建dp数组大小可能会<0,导致报错
if(target>SUM)
return 0;
//情况二:原数组元素之和-目标值后的数不能均分,应该排除
if((SUM-target)%2!=0)
return 0;
//情况三:转换成容量为(SUM-target)/2,物品为nums[i]的背包问题
//dp[i]表示背包容量为i时,物品的选择组合种数
vector<int> dp((SUM-target)/2+1);
dp[0]=1;
for(int i=0;i<nums.size();i++)
{
for(int j=(SUM-target)/2;j>=nums[i];j--)
{
dp[j]+=dp[j-nums[i]];
}
}
return dp[(SUM-target)/2];
}
};
4.一和零
//这个问题实质上也是背包问题,只不过是二维背包问题【注意!!!!】
//一维背包问题:物品数组(只有A类属性)、背包容量(只有A类容量限制)
//二维背包问题:物品数组(既有A类属性又有B类属性)、背包容量(既有A类容量限制又有B类容量限制)
class Solution {
public:
vector<int> get01(string s)
{
vector<int> array(2);
for(int i=0;i<s.length();i++)
{
if(s[i]=='0')
array[0]++;
else
array[1]++;
}
return array;
}
int findMaxForm(vector<string>& strs, int m, int n) {
//dp[i][j]表示容量0为i,容量1为j的最大子集数量数
vector<vector<int>> dp(m+1,vector<int>(n+1));
dp[0][0]=0;
for(int k=0;k<strs.size();k++)
{
vector<int> array=get01(strs[k]);
//推导公式只是将一维的dp[i]=max(dp[i],dp[i-strs[k]]+1)变成二维的
for(int i=m;i>=array[0];i--)//0
{
for(int j=n;j>=array[1];j--)//1
{
dp[i][j]=max(dp[i][j],dp[i-array[0]][j-array[1]]+1);
}
}
}
return dp[m][n];
}
};
2.完全背包
有N件物品和一个最多能背重量为W的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。每件物品都有无限个(也就是可以放入背包多次),求解将哪些物品装入背包里物品价值总和最大。完全背包和01背包问题唯一不同的地方就是,每种物品有无限件。
由于每个物品可以无限次放入,所以内循环遍历是正序遍历。
求使得背包中物品总价值最大的集合数量,根据集合的类型分为两种:
- 如果所求的是组合(无顺序要求),则外循环为物体,内循环为背包容量;
- 如果所求的是排列(有顺序要求),则外循环为背包容量,内循环为物体;
一、完全背包——排列
1.爬楼梯
//完全背包问题求排列
class Solution {
public:
int climbStairs(int n) {
//dp[i]为爬上i层楼又多少种方法
vector<int> dp(n+1);
dp[0]=1;
vector<int> array{1,2};
for(int i=0;i<=n;i++)
{
for(int j=0;j<2;j++)
{
if(i>=array[j])
//使用累加表示!!!!!!!!!
//到达d[i]没有使用物品array[j]的方法数+从dp[i-array[j]]使用物品array[j]的方法数
dp[i]+=dp[i-array[j]];
}
}
return dp[n];
}
};
2.组合总和IV
//【注意!!!注意!!!注意!!!】
//在用动态规划求解数组中元素组合种类个数时
//如果所求的是组合(无顺序要求),则外循环为物体,内循环为背包容量
//如果所求的是排列(有顺序要求),则外循环为背包容量,内循环为物体
class Solution {
public:
int combinationSum4(vector<int>& nums, int target) {
vector<int> dp(target+1);
dp[0]=1;//类似于这种确定组合数的题目,初始值dp[0]通常为1,不确定的话可以画表推理
for(int i=0;i<=target;i++)
{
for(int j=0;j<nums.size();j++)
{
//C++测试用例有两个数相加超过int的数据
//所以需要在if里加上dp[i] < INT_MAX - dp[i - num]。
if(i>=nums[j]&&dp[i]<INT_MAX-dp[i-nums[j]])
dp[i]+=dp[i-nums[j]];
}
}
return dp[target];
}
};
2、完全背包——组合(求组合的数量)
1.零钱兑换
class Solution {
public:
int change(int amount, vector<int>& coins) {
//dp[j]:总金额为j时,硬币组合数
vector<int> dp(amount+1);
dp[0]=1;
for(int i=0;i<coins.size();i++)
{
//由于每个硬币(物体)可以重复使用,所以内物品循环为小--->大
for(int j=coins[i];j<=amount;j++)
{
//dp[j]表示还没有硬币i的时候,要组合成总金额为j的组合数量
//dpdp[j-coins[i]]表示有了硬币i时,使用该硬币组合成总金额为j的组合数量
//总数即为硬币[0,i]可以组成总金额为j的组合数
dp[j]=dp[j]+dp[j-coins[i]];
}
}
return dp[amount];
}
};
2、完全背包——组合(求组合中,元素数量最少的组合有多少个元素组成)
1.完全平方数
//这道题与上一道零钱兑换一样
//求装满背包的最少物品数,尤其需要注意dp数组的初始化,以及推导公式的使用条件
class Solution {
public:
int numSquares(int n) {
//dp[i]表示,和为i的完全平方数最少个数
//【初始化最要命】
//初始化dp[i]==INT_MAX说明何为i的完全平方数组合不存在
vector<int> dp(n+1,INT_MAX);
dp[0]=0;//表示和为0的完全平方数个数为0
for(int i=1;i*i<=n;i++)
{
for(int j=i*i;j<=n;j++)
{
//不需要像"零钱兑换"那样存在下面的条件,因为平方数和为j-i*i的平方数组合总是存在的(毕竟有1)
// if(dp[j-i*i]!=INT_MAX)
dp[j]=min(dp[j],dp[j-i*i]+1);
}
}
return dp[n];
}
};
2.零钱兑换
【下面两行最重要!!!】
- 首先将dp数组初始化为INT_MAX。表示金额总数为i时,无法用硬币组合表示。
- 如果dp[j-coins[i]]==INT_MAX,说明没有硬币可以组合成dp[j-coins[i]],那么也就不可能加上硬币coins[i]组合成dp[j]了。
//完全背包的问题,每个硬币可以重复使用
class Solution {
public:
int coinChange(vector<int>& coins, int amount) {
//dp[i]表示,总金额为i时可以最少用dp[i]各硬币表示
//【下面两行初始化最重要!!!】
//首先将dp数组初始化为INT_MAX。表示金额总数为i时,无法用硬币组合表示
//dp[0]=0表示,初始化总金额为0时,需要0个硬币表示(也可以画表格推导)
vector<int> dp(amount+1,INT_MAX);
dp[0]=0;
//求的时组合不是排列,所以内外循环顺序可以颠倒
for(int i=0;i<coins.size();i++)
{
for(int j=coins[i];j<=amount;j++)
{
//如果dp[j-coins[i]]==INT_MAX,说明没有硬币可以组合成dp[j-coins[i]]
//那么也就不可能加上硬币coins[i]组合成dp[j]了
if(dp[j-coins[i]]!=INT_MAX)
dp[j]=min(dp[j],dp[j-coins[i]]+1);
}
}
if(dp[amount]==INT_MAX)
return -1;
return dp[amount];
}
};
四、打家劫舍
1.打家劫舍
class Solution {
public:
int rob(vector<int>& nums) {
//如果只有一间房屋,则直接返回
if(nums.size()==1)
return nums[0];
//dp[i]表示偷到第i所房屋时,已经偷窃到的最大金额总数
vector<int> dp(nums.size());
//初始化dp[0]\dp[1]
dp[0]=nums[0];
dp[1]=max(nums[0],nums[1]);
for(int i=2;i<nums.size();i++)
{
//决定dp[i]的因素就是第i房间偷还是不偷。
//如果偷第i间,第i-1间偷的结果不算(不管第i间偷没偷)
//如果不偷第i间,则直接继承第i-1间偷的结果
dp[i]=max(dp[i-2]+nums[i],dp[i-1]);
}
return dp[nums.size()-1];
}
};
2.打家劫舍||
注意:由于所有的房屋都被围城了一圈,所以在考虑将第一间房屋纳入偷窃的范围,则最后一间房屋就不能纳入偷窃的范围(不管偷窃金额最大情况下最后一间房屋偷没偷)。同理,考虑将最后一间房屋纳入偷窃的范围,则第一间房屋就不能纳入偷窃的范围。
//由于房屋是成环状排列,因此分两种情况考虑:
//情况一:考虑可能包含首元素,但一定不包含尾元素;
//情况二:考虑可能包含尾元素,但一定不包含首元素;
class Solution {
public:
int robRange(vector<int>& nums,int start,int end)
{
if(end-start+1==1)
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++)
{
dp[i]=max(dp[i-2]+nums[i],dp[i-1]);
}
return dp[end];
}
int rob(vector<int>& nums) {
if(nums.size()==1)
return nums[0];
//情况一:考虑可能包含首元素,但一定不包含尾元素;
int result1=robRange(nums,0,nums.size()-2);
//情况二:考虑可能包含尾元素,但一定不包含首元素;
int result2=robRange(nums,1,nums.size()-1);
return max(result1,result2);
}
};
3.打家劫舍|||
这道题不算真正意义上的动态规划,应该归类为贪心算法。
对于每一个节点root都有两个状态:
状态一:偷取当前节点的房屋能够获得的最大金额root[1];
状态二:不偷取当前节点的房屋能够获得最大的金额root[0];
- 偷当前节点,则在两个叶子节点中就只能选择不偷的那个数组元素 :val1=cur->val+left[0]+right[0];
- 不偷当前节点,而是偷当前节点的子节点,则在两个叶子节点中可以选择偷或者不偷的那个数组元素: val2=max(left[1],left[0])+max(right[1],right[0]);
//【注意!!!注意!!!注意!!!】二叉树的dp动态规划,采用后序遍历
//对于每个节点都设置一个二元数组,分别存储是否偷取当前节点金额所得到的金额数
//情况一:如果偷取当前节点的金额,则在两个叶子节点上只能分别获取不包含左右两个叶子节点金额的那部分金额
//情况二:如果不偷取当前节点的金额,则在两个叶子节点上既可以对是否包含叶子节点金额的二元数组元素取选取金额较大的数组元素
class Solution {
private:
vector<int>backtravle(TreeNode* cur)
{
if(cur==nullptr)
return {0,0};//叶子节点,不管是在该节点上偷与不偷,偷到的金额都是0
vector<int> left=backtravle(cur->left);
vector<int> right=backtravle(cur->right);
int val1;
int val2;
//偷当前节点,则在两个叶子节点中就只能选择不偷的那个数组元素
val1=cur->val+left[0]+right[0];
//不偷当前节点,而是偷当前节点的子节点,则在两个叶子节点中可以选择偷或者不偷的那个数组元素
val2=max(left[1],left[0])+max(right[1],right[0]);
return {val2,val1};
}
public:
int rob(TreeNode* root) {
vector<int> result=backtravle(root);
return max(result[0],result[1]);
}
};
五、股票问题
采用动态规划解决买卖股票问题有一个通用的解法,具体如下:
1.dp数组,根据当天股票买卖人的状态设定dp数组的维数,dp[i][j]表示第i股票买卖人在j状态下的最大利润。
2.分析造成dp[i][j]即第i天状态j的情况有哪些,去其中利润最大的情况。
3.初始化dp[0][j]根据实际状态进行初始化。
1.只允许进行单次买卖
//dp[i][0]表示第i天处于买入状态下,获取的最大利润
//dp[i][1]表示第i天处于卖出状态下,获取的最大利润
class Solution {
public:
int maxProfit(vector<int>& prices) {
vector<vector<int>> dp(prices.size(),vector<int>(2));
dp[0][0]=-prices[0];
dp[0][1]=0;
for(int i=1;i<prices.size();i++)
{
//第i天的买入状态可能情况有两种:1.继承第i-1天的买入状态;2.第i天刚买的【由于全程只会发生一次股票买卖行为,所以不可能是在第i-1天卖出状态下第i天刚买的】
dp[i][0]=max(dp[i-1][0],-prices[i]);
//第i天卖出状态可能情况有两种:1.继承第i-1天的卖出状态;2.在第i-1天买入状态的情况下第i天刚卖出
dp[i][1]=max(dp[i-1][1],dp[i-1][0]+prices[i]);
}
return dp[prices.size()-1][1];
}
};
2.不限股票买卖次数
//dp[i][0]表示第i天处于买入状态下,获取的最大利润
//dp[i][1]表示第i天处于卖出状态下,获取的最大利润
class Solution {
public:
int maxProfit(vector<int>& prices) {
vector<vector<int>> dp(prices.size(),vector<int>(2));
dp[0][0]=-prices[0];
dp[0][1]=0;
for(int i=1;i<prices.size();i++)
{
//第i天的买入状态可能情况有两种:1.继承第i-1天的买入状态;2.第i-1天卖出状态下第i天刚买的
dp[i][0]=max(dp[i-1][0],dp[i-1][1]-prices[i]);
//第i天的卖出状态可能情况有两种:1.继承第i-1天的卖出状态;2.在第i-1天买入状态下第i天刚卖出
dp[i][1]=max(dp[i-1][1],dp[i-1][0]+prices[i]);
}
return dp[prices.size()-1][1];
}
};
3.指定股票买卖次数
//dp[i][j]表示第i天,j状态下的利润,在第i天的时候,此时的交易一共存在如下五种状态:
//无操作状态dp[i][0],说明第i-1天也是无操作状态,此时dp[i][0]=dp[i-1][0];
//第一次购入股票状态dp[i][1],造成这个状态有两种情况:1.延续了昨天的第一次购买股票状态;2.今天刚购买第一支股票,昨天是无操作状态,此时dp[i][1]=max(dp[i-1][1],dp[i][0]-prices[i]);
//第一次卖出股票状态dp[i][2],造成这个状态有两种情况:1.延续了昨天第一次卖出股票的状态;2.今天刚卖出第一支股票,昨天是第一次购入股票状态,此时dp[i][2]=max(dp[i-1][2],dp[i][1]+prices[i]);
//第二次购入股票状态dp[i][3],造成这个状态有两种情况:1.延续了昨天的第二次购买股票状态;2.今天刚购买第二支股票,昨天是第一次卖出股票状态,此时dp[i][3]=max(dp[i-1][3],dp[i][2]-prices[i]);
//第二次卖出股票状态dp[i][4],造成这个状态有两种情况:1.延续了昨天第二次卖出股票的状态;2.今天刚卖出第二支股票,昨天是第二次购入股票状态,此时dp[i][4]=max(dp[i-1][4],dp[i][3]+prices[i]);
class Solution {
public:
int maxProfit(vector<int>& prices) {
//dp[i][j]表示第i天,j状态下的利润
vector<vector<int>> dp(prices.size(),vector<int>(5));
//初始化
dp[0][0]=0;//第1天无操作,利润为0
dp[0][1]=-prices[0];//第一天第一次买入,利润为-prices[0]
dp[0][2]=0;//第一天第一次卖出,当天完成第一次买入卖出,所以利润也是0
dp[0][3]=-prices[0];//第一天第二次买入,情况为当天完成了第一次的买入卖出,所以利润为-prices[0]
dp[0][4]=0;//第二天卖出,当天完成第二次买入卖出,所以利润也是0
for(int i=1;i<prices.size();i++)
{
dp[i][0]=dp[i-1][0];
dp[i][1]=max(dp[i-1][1],dp[i][0]-prices[i]);
dp[i][2]=max(dp[i-1][2],dp[i][1]+prices[i]);
dp[i][3]=max(dp[i-1][3],dp[i][2]-prices[i]);
dp[i][4]=max(dp[i-1][4],dp[i][3]+prices[i]);
}
return dp[prices.size()-1][4];
}
};
4.卖出股票含冷冻期
//买入股票状态dp[i][0]:上一次可能是买入状态,也可能是冷冻状态,但是绝不可能是卖出状态
//卖出股票状态dp[i][1]:上一次可能是买入状态,也可能是卖出状态,但是绝不可能是冷冻状态
//冷冻期状态dp[i][2]:上一次必然是卖出状态
class Solution {
public:
int maxProfit(vector<int>& prices) {
vector<vector<int>> dp(prices.size(),vector<int>(3));
dp[0][0]=-prices[0];//第一天买入股票,利润为-prices[0]
dp[0][1]=0;//第一天买出股票,当天买入卖出,利润为0
dp[0][2]=0;//第一天就冷冻,所以利润为0
for(int i=1;i<prices.size();i++)
{
//买入股票状态dp[i][0]:上一次可能是买入状态,也可能是冷冻状态,但是绝不可能是卖出状态
dp[i][0]=max(dp[i-1][2]-prices[i],dp[i-1][0]);
//卖出股票状态dp[i][1]:上一次可能是买入状态,也可能是卖出状态,但是绝不可能是冷冻状态
dp[i][1]=max(dp[i-1][0]+prices[i],dp[i-1][1]);
//冷冻期状态dp[i][2]:上一次必然是卖出状态
dp[i][2]=dp[i-1][1];
}
return dp[prices.size()-1][1];
}
};
5.含手续费的股票买卖
//股票买入状态dp[i][0],造成这个状态的原因有两种情况:1.第i天刚购入股票,第i-1天处于股票卖出状态 2.维持第i-1天的购入股票状态
//股票卖出状态dp[i][1],造成这个状态的原因有两种情况:1.第i天刚卖出股票,第i-1天处于购入股票状态 2.维持第i-1天的股票卖出状态
class Solution {
public:
int maxProfit(vector<int>& prices, int fee) {
vector<vector<int>> dp(prices.size(),vector<int>(2));
dp[0][0]=-prices[0];//第一天买入股票,利润为-prices[0]
dp[0][1]=0;//第一天卖出股票,初始化利润为0【因为即使当天买入卖出还要搭上手续费,所以最大利润就是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]-fee);
}
return dp[prices.size()-1][1];
}
};
六、子序列问题
1.公共子序列问题
子序列是原数组中按照索引顺序但是非连续的集合。
1.最长递增子序列
注意:dp数组初始化,dp[i]表示以i为终点的最长递增子序列的长度,因此最终整个字符串的最长递增子序列不是dp[s.size()-1],因为可能最长递增系序列的终点不是原数组末尾元素。所以原数组的最长递增子序列的长度需要在求解dp[i]的过程中取最大值。
动态规划遍历方法:针对每一个nums[i]都会寻找[0:i]之间以小于nums[i]结尾的最长递增子序列长度,从而获得以nums[i]结尾的最长递增子序列长度
class Solution {
public:
int lengthOfLIS(vector<int>& nums) {
//dp[i]表示以dp[i]为结尾的最大递增子序长度
//初始化dp[i]=1,因为只有1个数也长度为1
vector<int> dp(nums.size(),1);
int M=0;
//针对每一个nums[i]都会寻找[0:i]之间以小于nums[i]结尾的最长递增子序列长度
//从而获得以nums[i]结尾的最长递增子序列长度
for(int i=1;i<nums.size();i++)
{
for(int j=0;j<i;j++)
{
if(nums[j]<nums[i])
dp[i]=max(dp[i],dp[j]+1);
M=max(M,dp[i]);
}
}
return M;
}
};
2.最长递增子序列的个数
本题要点,组织两个数组:dp,count
其中dp[i]表示以nums[i]结尾的最长递增子序列长度;
其中count[i]表示以nums[i]结尾的最长递增子序列个数;
计算过程中时刻更新整个数组中最长递增子序列长度,最后用这个长度去count数组匹配,求和;
//本题要点,组织两个数组:dp,count
//其中dp[i]表示以nums[i]结尾的最长递增子序列长度
//count[i]表示以nums[i]结尾的最长递增子序列个数
//在计算过程中,时刻更新整个数组中最长递增子序列长度,最后用这个长度去count数组匹配,求和
class Solution {
public:
int findNumberOfLIS(vector<int>& nums) {
//maxLenth定义为最长递增子序列的长度
int maxLength=1;
//dp[i]表示[0:i]区间内最长连续递增的子序列长度
//初始化为1,所有元素本身就是一个长度为1的子序列
vector<int> dp(nums.size(),1);
//其中count[i]表示以元素nums[i]结尾的最长递增子序列个数
vector<int> count(nums.size(),1);
for(int i=1;i<nums.size();i++)
{
for(int j=0;j<i;j++)
{
if(nums[j]<nums[i])
{
//如果dp[i]<dp[j]+1,说明此时要更新最长递归子序列的长度
if(dp[i]<dp[j]+1)
{
//更新以nums[i]结尾的最长递增子序列的组合数
count[i]=count[j];
//更新以nums[i]结尾的最长递增子序列的长度
dp[i]=dp[j]+1;
}
//如果dp[i]==dp[j]+1,则累积最长递增子序列为dp[i]的组合数
else if(dp[i]==dp[j]+1)
count[i]+=count[j];
}
}
//时刻更新最长递增子序列长度
maxLength=max(maxLength,dp[i]);
}
//在count数组中寻找递增子序列长度与maxLength相等的组合数,并求和
int sum=0;
for(int i=0;i<nums.size();i++)
{
if(dp[i]==maxLength)
sum+=count[i];
}
return sum;
}
};
3.最长公共子序列
//此处题目要求的是【最大重合子序列,不要求连续!!!】
class Solution {
public:
int longestCommonSubsequence(string text1, string text2) {
//dp[i][j]表示text1[0:i]text2[0:j]的公共最长子数组的长度
vector<vector<int>> dp(text1.length()+1,vector<int>(text2.length()+1));
for(int i=0;i<text1.length();i++)
{
for(int j=0;j<text2.length();j++)
{
//如果当前遍历的text1[i]==text2[j],则其最大重合子序列为dp左上角+1
if(text1[i]==text2[j])
{
dp[i+1][j+1]=dp[i][j]+1;
}
//如果当前遍历的text1[i]!=text2[j],则其最大重合子序列为max(dp[i][j+1],dp[i+1][j])
else
dp[i+1][j+1]=max(dp[i][j+1],dp[i+1][j]);
}
}
return dp[text1.length()][text2.length()];
}
};
4.不相交的线
求不相交的线的条数,与求nums1和nums2的最大公共子序列的题目方法一致,只不过是将公共子序列的长度换成线的条数。
5.判断子序列
与求最大公共子序列题目方法一致,只不过是判断最大公共子序列是否等于较短的那个字符串的长度。
6.两个字符串的删除操作
与求最大公共子序列的方法一样,只不过本题是求出最大公共子序列后,要删除的步数=word1.length()+word2.length()-2*最大公共子序列长度。
2.子数组和问题
注意:dp[i]表示字符串dp[0:i]的以元素nums[i]为结尾的最大数组和,所以整个数组中的最大子数组和需要在求dp[i]的过程中对比保存
class Solution {
public:
int maxSubArray(vector<int>& nums) {
//dp[i]表示遍历到nums的第i个点时,此时手里保留的连续子数组之和。
//【是手中保留的,不是最大连续子数组之和,只要手中保留的是正数,就可以为后续提供助力!!!!】
vector<int> dp(nums.size());
dp[0]=nums[0];
int result=dp[0];
for(int i=1;i<nums.size();i++)
{
//如果dp[i-1]<0,他只会对dp[i]进行拖累,所以dp[i]将不再对dp[i-1]进行累积
if(dp[i-1]<0)
{
dp[i]=nums[i];
}
else
{
//如果dp[i-1]>=0,他会对dp[i]形成助力,所以dp[i]将对dp[i-1]进行累积
dp[i]=nums[i]+dp[i-1];
}
//最大和要在过程中获得
result=max(dp[i],result);
}
return result;
}
};
3.不同的子序列
对于上述问题乍一看无从下手,无法直接获得dp数组的推导公式。关于这类推导公式无法直接看出的或者无法对dp数组初始化的问题,有以下处理方法:
1.确定dp数组的中dp[i][j]代表的意思;
2.画表格,列出几行例子去寻找规律;
- dp[i][j]表示字符串s[0,i-1]在字符串t[0,j-1]中出现的次数;
- dp[i+1][j]表示字符串s[0,i]在字符串t[0,j-1]中出现的次数;
- 所以字符串s[0,i]在字符串t[0,j]中出现的次数=dp[i][j]+dp[i+1][j];
if(s[i]==t[j])
dp[i+1][j+1]=dp[i][j]+dp[i+1][j];
else
- dp[i+1][j]表示字符串s[0,i]在字符串t[0,j-1]中出现的次数
dp[i+1][j+1]=dp[i+1][j];
//关于这类推导公式无法直接看出的或者无法对dp数组初始化的问题,有以下处理方法
//1.确定dp数组的中dp[i][j]代表的意思
//2.画表格,列出几行例子去寻找规律
class Solution {
public:
int numDistinct(string t, string s) {
//dp[i][j]表示字符串s[0:i]在字符串t[j]中作为子序列出现的次数
vector<vector<uint64_t>> dp(s.length()+1,vector<uint64_t>(t.length()+1));
//dp数组初始化,空字符串在字符串t[j]中出现的次数为1
for(int i=0;i<=t.length();i++)
{
dp[0][i]=1;
}
for(int i=0;i<s.length();i++)
{
for(int j=i;j<t.length();j++)
{
//dp[i][j]表示字符串s[0,i-1]在字符串t[0,j-1]中出现的次数
//dp[i+1][j]表示字符串s[0,i]在字符串t[0,j-1]中出现的次数
//所以字符串s[0,i]在字符串t[0,j-1]中出现的次数=dp[i][j]+dp[i+1][j];
if(s[i]==t[j])
dp[i+1][j+1]=dp[i][j]+dp[i+1][j];
else
//dp[i+1][j]表示字符串s[0,i]在字符串t[0,j-1]中出现的次数
dp[i+1][j+1]=dp[i+1][j];
}
}
return dp[s.length()][t.length()];
}
};
4.连续子序列
//此处用的是动态规划
class Solution {
public:
int strStr(string haystack, string needle) {
vector<vector<int>> dp(needle.length()+1,vector<int>(haystack.length()+1,0));
for(int i=0;i<=needle.length();i++)
dp[i][0]=1;
for(int j=0;j<=haystack.length();j++)
dp[0][j]=1;
for(int i=0;i<needle.length();i++)
{
for(int j=i;j<haystack.length();j++)
{
if(needle[i]==haystack[j]&&dp[i][j]==1)
dp[i+1][j+1]=1;
}
}
for(int i=1;i<=haystack.length();i++)
{
if(dp[needle.length()][i]==1)
return i-needle.length();
}
return -1;
}
};
七、编辑距离
编辑距离
1.dp数组初始化,dp[i+1][j+1]表示,word1[0:i]转换成word2[0:j]所使用的最少操作数;
当word2的长度为0时,操作数就等于word1的长度(都是删除操作),dp[i][0]=i;
当word1的长度为0时,操作数就等于word2的长度(都是删除操作),dp[0][j]=j;
2.推导公式分为下面几种情况:
//情况一:如果新多出来的两个字符相等,则其操作数与dp[i-1][j-1]时一样
if(word1[i]==word2[j])
dp[i+1][j+1]=dp[i][j];
else
{
//情况二:如果新多出来的两个字符不相等,则有如下三种处理方法:
//1.在word1[i-1]的基础上在word1[i]位置上增加一个与word2[j]一样的字符
//2.在word2[j-1]的基础上在word2[j]位置上增加一个与word1[i]一样的字符
//3.将现在word1[i-1]word2[j-1]的基础上将字符word1[i]word2[j]换成一样的
dp[i+1][j+1]=min(min(dp[i][j+1],dp[i+1][j]),dp[i][j])+1;
}
class Solution {
public:
int minDistance(string word1, string word2) {
//dp[i+1][j+1]表示,word1[0:i]转换成word2[0:j]所使用的最少操作数
vector<vector<int>> dp(word1.length()+1,vector<int> (word2.length()+1));
//当word2的长度为0时,操作数就等于word1的长度(都是删除操作)
for(int i=0;i<=word1.length();i++)
{
dp[i][0]=i;
}
//当word1的长度为0时,操作数就等于word2的长度(都是删除操作)
for(int j=0;j<=word2.length();j++)
{
dp[0][j]=j;
}
for(int i=0;i<word1.length();i++)
{
for(int j=0;j<word2.length();j++)
{
//【关键在于多种情况下的推导公式!!!!】
//如果新多出来的两个字符相等,则其操作数与dp[i-1][j-1]时一样
if(word1[i]==word2[j])
dp[i+1][j+1]=dp[i][j];
else
{
//如果新多出来的两个字符不相等,则有如下三种处理方法:
//1.在word1[i-1]的基础上在word1[i]位置上增加一个与word2[j]一样的字符
//2.在word2[j-1]的基础上在word2[j]位置上增加一个与word1[i]一样的字符
//3.将现在word1[i-1]word2[j-1]的基础上将字符word1[i]word2[j]换成一样的
dp[i+1][j+1]=min(min(dp[i][j+1],dp[i+1][j]),dp[i][j])+1;
}
}
}
return dp[word1.length()][word2.length()];
}
};
八、回文字符串
用动态规划解决回文字符串主要有两类问题:判断字符串中的回文字符串的数量(连续)、计算字符串中最大回文子序列的长度(不连续)。
题目类型一:判断字符串中的回文字符串的数量(连续)
1.dp数组的初始化,i表示截取的字符串头,j表示截取的字符串尾,dp[i][j]表示字符串s[i : j]是否为回文子序列,如果是dp[i][j]=1,否则dp[i][j]=0;
2.推导公式有下面三种情况:
(1)s[i]=s[j]即字符串两端的字符相等,当字符串长度为1或2时,判定该字符串时回文字符串dp[i][j]=1;
(2)s[i]=s[j]即字符串两端的字符相等,当字符串长度大于2时,字符串s[i : j]是否为回文字符串取决于字符串s[i+1:j-1],所以dp[i][j]=dp[i+1][j-1];
(3)s[i]!=s[j]如果一个字符串的两端都不相等,则这个字符串肯定不是回文字符串dp[i][j]=0;
3.遍历顺序,因为dp[i][j]状态取决于dp[i+1][j-1],所以遍历顺序一定是一个正序一个倒序。并且需要保证dp[i+1][j-1]先于dp[i][j]被处理,所以是i倒序,j正序。
class Solution {
public:
int countSubstrings(string s) {
//dp[i][j]表示s[i:j]是否为回文子串,是则dp[i][j]=1,否则dp[i][j]=0
vector<vector<int>> dp(s.length(),vector<int>(s.length(),0));
int count=0;
//【遍历顺序是最关键的!!!!】
//外循环为截取的字符串尾,内循环为截取的字符串头
for(int i=s.length()-1;i>=0;i--)
{
//字符串的头一定要大于等于字符串的尾,所以初始条件为j=i
for(int j=i;j<s.length();j++)
{
//如果一个字符串的两端都不相等,则这个字符串肯定不是回文字符串
if(s[i]!=s[j])
dp[i][j]=0;
else
{
//情况一:i==j,单个字符串就是回文字符串
//情况二:j-i==1相邻两个相等字符组成的字符串是回文字符串
if(j-i<=1)
{
dp[i][j]=1;
count++;
}
//情况三:s[i]==s[j]时,如果s[i+1:j-1]是回文字符串,则s[i:j]是回文字符串
else if(dp[i+1][j-1]==1)
{
dp[i][j]=1;
count++;
}
}
}
}
return count;
}
};
题目类型二:计算字符串中最大回文子序列的长度(不连续)
1.初始化dp数组,i表示截取的字符串头,j表示截取的字符串尾,dp[i][j]表示字符串s[i : j]的最大回文子序列的长度。
2.推导公式,判断字符串s[i : j]中最大回文子序的长度有如下几种情况:
(1)当s[i]=s[j],s[i:j]长度为1或2时,则直接给定回文子串的长度为1或2,dp[i][j]=j-i+1;
(2)当s[i]=s[j],字符串长度大于2时,字符串s[i:j]中最大回文子序的长度取决于字符串s[i+1:j-1],dp[i][j]=dp[i+1][j-1]+2;
(3)当字符串两端的元素不相等时,s[i : j]字符串回文子序列的长度可以继承的状态有两种去除字符串头s[i]、去除字符串尾s[j],所以dp[i][j]=max(dp[i][j-1],dp[i+1][j]);
3.遍历顺序,因为dp[i][j]状态取决于dp[i+1][j-1],所以遍历顺序一定是一个正序一个倒序。并且需要保证dp[i+1][j-1]先于dp[i][j]被处理,所以是i倒序,j正序。
class Solution {
public:
int longestPalindromeSubseq(string s) {
//dp[i][j]表示s[i:j]中子序列回文字符串最大长度
vector<vector<int>> dp(s.length(),vector<int>(s.length()));
//【遍历顺序是最关键的!!!!】
//外循环为截取的字符串尾,内循环为截取的字符串头
for(int i=s.length()-1;i>=0;i--)
{
//字符串的头一定要大于等于字符串的尾,所以初始条件为j=i
for(int j=i;j<s.length();j++)
{
//当s[i]=s[j]时,有两种情况
//情况一:s[i:j]长度为1或2,则直接给定回文子串的长度为1或2
//情况二:s[i:j]的回文子串长度=s[i+1:j-1]的回文子串长度+2
if(s[i]==s[j])
{
if(j-i<=1)
dp[i][j]=j-i+1;
else
dp[i][j]=dp[i+1][j-1]+2;
}
else
{
//当字符串两端的元素不相等时
//s[i:j]字符串回文子序列的长度有两种情况:
//情况一:去除字符串头s[i]
//情况二:去除字符串尾s[j]
dp[i][j]=max(dp[i][j-1],dp[i+1][j]);
}
}
}
return dp[0][s.length()-1];
}
};
3.最长回文子串
动态规划,确定字符串s[i : j]是否为回文子串,并时刻更新最大回文子串的长度和首尾索引,最后在原字符串中截取该段即为原字符串的最长回文子序。
class Solution {
public:
string longestPalindrome(string s) {
int length=1;
int start=0;
int end=0;
//dp[i][j]表示字符串s[i:j]是否是回文字符串
vector<vector<int>> dp(s.length(),vector<int> (s.length(),0));
for(int j=0;j<s.length();j++)
{
for(int i=0;i<=j;i++)
{
//如果首尾字符相等,该字符串才有可能成为回文字符串,否则直接dp[i][j]=0
if(s[i]==s[j])
{
//情况一,字符串长度为1,则该字符串是回文子串
if(j-i==0)
dp[i][j]=1;
//情况二:字符串长度大于2,出首尾字符外中间字符串为回文子串,首尾字符相等,该字符串是回文子串
//情况三:字符串长度为2,且首尾字符相等,该字符串是回文子串
else if(j-i>1&&dp[i+1][j-1]==1||j-i==1)
{
dp[i][j]=1;
//时刻记录回文子串的最大长度,以及该子串的首尾位置
if(j-i+1>=length)
{
length=j-i+1;
start=i;
end=j;
}
}
}
}
}
//根据最大子串的首尾位置获取最大子串。
string result;
for(int i=start;i<=end;i++)
result+=s[i];
return result;
}
};
分割回文子串!!!!!!!!!
//步骤一:通过动态规划,获取原字符串s中所有的回文字符串组合,dp[i][j]==0表示s[i:j]是回文子串
//步骤二:通过动态规划,设置新的DP数组,DP[i]表示字符串s[0:i]的最少分割次数。
class Solution {
vector<vector<int>> dp;
public:
//步骤一:通过动态规划,获取原字符串s中所有的回文字符串组合,dp[i][j]==0表示s[i:j]是回文子串
void step1(string s,vector<vector<int>> &dp)
{
for(int j=0;j<s.length();j++)
{
for(int i=0;i<s.length();i++)
{
if(s[i]==s[j])
{
if(j-i==0)
dp[i][j]=1;
else if(j-i==1||j-i>1&&dp[i+1][j-1]==1)
dp[i][j]=1;
}
}
}
}
int minCut(string s) {
dp=vector<vector<int>>(s.length(),vector<int>(s.length(),0));
step1(s,dp);
//DP[i]表示字符串s[0:i]的最少分割次数。
vector<int> DP(s.length());
//先假设将每个单独的字符都分割出来,所以DP[i]=i
for(int i=0;i<s.length();i++)
{
DP[i]=i;
}
for(int i=0;i<s.length();i++)
{
//如果s[0:i]是回文字符串,分割次数为0,即DP[i]=1
if(dp[0][i]==1)
DP[i]=0;
//如果s[0:i]不是回文字符串,则分析当s[j+1,i]是回文字符串时,DP[i]=min(DP[j]+1,DP[i]);
else
{
for(int j=0;j<i;j++)
{
if(dp[j+1][i]==1)
DP[i]=min(DP[j]+1,DP[i]);
}
}
}
return DP[s.length()-1];
}
};
参考代码随想录