一招秒杀力扣买卖股票系列
0. 前言
本文目标读者
本文适合阅读群体包括但不限于以下条件
- 具有一定的算法基础,例如动态规划等等
- 掌握相关语言(如Java、C/C++、Python、C#、Go等等)语法基础
- 喜爱在各大OJ平台(如力扣、牛客等等)刷题
1. 状态机的概念
-
百度百科定义:关于状态机的一个极度确切的描述是:它是一个有向图形,由一组节点和一组相应的转移函数组成。状态机通过响应一系列事件而“运行”。每个事件都在属于“当前” 节点的转移函数的控制范围内,其中函数的范围是节点的一个子集。函数返回“下一个”(也许是同一个)节点。这些节点中至少有一个必须是终态。当到达终态, 状态机停止。
-
我的理解:状态机就是事物的各种不同形态以及他们互相转化的过程及其条件有机组合的过程。
下面附上一张不太合理的解释图例:
2. 状态机的应用
2.1 LeetCode 714—买卖股票的最佳时机含手续费
题目链接:买卖股票的最佳时机含手续费
2.1.1 题目解析
如下图所示:给定示例1:prices = [1, 3, 2, 8, 4, 9], fee = 2
,当选择第一天买入,并在第四天卖出时,收益差为8 - 1为7元,但是由于题目限制需要包含手续费2元所以本轮收益差为5元,然后选择在第五天买入并在第六天卖出时,此时的收益差为9-4为5元,同样需要上交手续费2元,所以本轮收益差为3元,总计利润为8元,可以证明这时是最优选择!
2.1.2 算法思路(状态机)
想必通过上述的题目解析,读者已经可以明白本题的题目要求,那么接下来我将会运用状态机的思想带领大家解决该问题。
-
第一步:抽象出本题的状态,每天的状态只有两种情况,一种是持有股票,另一种是不持有股票
-
第二步:画出各个状态之间的转移关系
持有股票状态
-->持有股票状态
:当天什么都不需要做不持有股票状态
-->不持有股票状态
:当天什么都不需要做持有股票状态
-->不持有股票状态
:前一天处于持股状态,想要当天处于不持有股票状态,那么只需要在当天卖出股票,并且支付手续费fee不持有股票状态
-->持有股票状态
:前一天处于不持有股票状态,想要当天处于持有股票状态,那么只需要在当天买入股票即可
注意:手续费只有在卖出股票的时候需要考虑!
此时我们已经考虑完了各个状态之间相互转换的过程以及条件,接下来我们就可以套用动态规划的步骤来编写代码,在真正编写代码之前,我们还需要考虑状态具体定义、状态初始化、返回结果等等细节问题:
-
状态具体定义:我们可以用一个二维数组
dp[n][2]
来表示,其中dp[i][0]
表示在第i天结束后,处于持有股票状态的最大利润,dp[i][1]
表示在第i天结束后,处于非持有股票状态的最大利润 -
状态初始化:本题的状态初始化很简单,
dp[1][0]
表示即在第一天结束时,处于持有股票状态的最大利润,即恰好买入当天股票,此时利润为初始化为-prices[0]
,dp[1][1]
表示即在第一天结束时,处于非持有股票状态的最大利润,需要特别注意的是,此时有两种情况:- 一种是当天买入,并在当天卖出,但是需要支付手续费,所以初始利润为-fee
- 另一种是当天什么都不做,仍处于非持有股票状态,此时初始利润为0
由于需要考虑最大利润,所以
dp[1][1]
应当被初始化为0 -
返回值:题目需要返回最后一天结束后的最大利润,所以可以直接返回
dp[n][0]
与dp[n][1]
的较大值,但是我们可以分析出,最后一天不持有股票的最大利润是一定会大于持有股票的最大利润的,所以可以直接大胆返回dp[n][1]
2.1.3 AC代码(Java实现)
class Solution {
public int maxProfit(int[] prices, int fee) {
int n = prices.length;
int[][] dp = new int[n + 1][2];
// 状态初始化
dp[1][0] = -prices[0];
dp[1][1] = 0;
for (int i = 2; i <= n; i++) {
dp[i][0] = Math.max(dp[i - 1][0], dp[i - 1][1] - prices[i - 1]);
dp[i][1] = Math.max(dp[i - 1][1], dp[i - 1][0] + prices[i - 1] - fee);
}
// 返回值
return dp[n][1];
}
}
2.2 LeetCode 309—买卖股票的最佳时机含冷冻期
题目链接:买卖股票的最佳时机含冷冻期
2.2.1 题目解析
如下图所示:给定示例1:prices = [1, 2, 3, 0, 2]
,当在第一天买入股票,并在第二天卖出股票时,收益差为2-1为1元即在本轮利润为1元,此时由于题目要求限制,卖出股票后的那天处于冷冻期无法进行买入的操作。然后在第四天买入股票并在第五天卖出股票,此时收益差为2元,即本轮利润为2元,总计利润为3元,可以证明此时为最优选择!
2.2.2 算法思路(状态机)
想必通过上述的题目解析,读者已经可以明白本题的题目要求,那么接下来我将会运用状态机的思想带领大家解决该问题。
-
第一步:抽象出本题的状态,每天的状态存在三种可能性,一种是持有股票、一种是不持有股票、另外一种是处于冷冻期
-
第二步:画出各个状态之间的转移关系
持有股票状态
—>持有股票状态
:当天什么都不做!持有股票状态
-->非持有股票状态
:不可能实现!(读者可以自行简单验证)持有股票状态
–>冷冻期
:前一天处于持股状态,如果想要当天处于冷冻期就必须在当天卖出股票非持有股票状态
-->非持有股票状态
:当天什么都不做!非持有股票状态
-->持有股票状态
:只需买入当天股票即可非持有股票状态
-->冷冻期
:前一天处于非持有股票状态,此时只需要买入当天股票并在当天卖出股票冷冻期
-->冷冻期
:易证得不可能实现!冷冻期
-->持有股票状态
:不可能实现!(读者可以自行简单验证)冷冻期
-->非持有股票状态
:当天什么都不做!
注意:希望读者在往下继续阅读之前可以自己动手在纸上厘清各个状态之间转移的过程和条件!
此时我们已经考虑完了各个状态之间相互转换的过程以及条件,接下来我们就可以套用动态规划的步骤来编写代码,在真正编写代码之前,我们还需要考虑状态具体定义、状态初始化、返回结果等等细节问题:
-
状态具体定义:我们可以用一个二维数组
dp[n][3]
来表示,其中dp[i][0]
表示在第i天结束后,处于持有股票状态的最大利润,dp[i][1]
表示在第i天结束后,处于非持有股票状态的最大利润,dp[i][2]
表示在第i天结束后,处于冷冻期状态的最大利润。 -
状态初始化:本题的状态初始化很简单,
dp[1][0]
表示即在第一天结束时,处于持有股票状态的最大利润,即恰好买入当天股票,此时利润为初始化为-prices[0]
,dp[1][1]
表示即在第一天结束时,处于非持有股票状态的最大利润,即无需任何操作,此时最大利润为0,dp[1][2]
表示在第一天结束时,处于冷冻期的最大利润,需要注意,想要在第一天结束后处于冷冻期则必须买入第一天股票并在第一天卖出该股票,此时处于冷冻期并且最大利润为0 -
返回值:题目需要返回最后一天结束后的最大利润,所以可以直接返回
dp[n][0]
与dp[n][1]
与dp[n][2]
的最大值,但是我们可以分析出,最后一天不持有股票的最大利润是一定会大于持有股票的最大利润的,所以可以直接大胆返回dp[n][1]
与dp[n][2]
的较大值。
2.2.3 AC代码(Java实现)
class Solution {
public int maxProfit(int[] prices) {
int n = prices.length;
int[][] dp = new int[n + 1][3];
// 状态初始化
// dp[i][0]表示处于持有股票状态,dp[i][1]表示处于非持有股票状态,dp[i][2]表示处于冷冻期状态
dp[1][0] = -prices[0];
dp[1][1] = 0;
dp[1][2] = 0;
// 状态转移
for (int i = 2; i <= n; i++) {
dp[i][0] = Math.max(dp[i - 1][0], dp[i - 1][1] - prices[i - 1]);
dp[i][1] = Math.max(dp[i - 1][1], dp[i - 1][2]);
dp[i][2] = Math.max(dp[i - 1][0] + prices[i - 1], dp[i - 1][1]);
}
// 返回值
return Math.max(dp[n][1], dp[n][2]);
}
}
2.3 LeetCode 123—买卖股票的最佳时机Ⅲ
题目链接:买卖股票的最佳时机Ⅲ
2.3.1 题目解析
如下图所示:给定示例1:prices = [3, 3, 5, 0, 0, 3, 1, 4]
,当在第四天买入股票,并在第六天卖出股票时,收益差为3-0为3元即在本轮利润为3元,然后在第五天买入股票并在第六天卖出股票,此时收益差为4-1为3元,即本轮利润为3元,但是根据题目限制,最多只能进行两次交易,总计利润为6元,可以证明此时为最优选择!
2.3.2 算法思路(状态机)
想必通过上述的题目解析,读者已经可以明白本题的题目要求,那么接下来我将会运用状态机的思想带领大家解决该问题。
-
第一步:抽象出本题的状态,每天的状态存在两种可能性,一种是持有股票、一种是不持有股票
-
第二步:画出各个状态之间的转移关系
持有股票状态
—>持有股票状态
:当天什么都不做!持有股票状态
-->非持有股票状态
:前一天处于持股状态并且交易次数为j时,当天卖出股票此时交易次数变为j+1。非持有股票状态
-->非持有股票状态
:此时分为两种情况- 情况一:什么都不做
- 情况二:前一天处于非持股状态,当天买入股票并且卖出股票,利润不增加但是交易次数+1
非持有股票状态
-->持有股票状态
:在当天买入股票,此时交易次数不改变。
注意:希望读者在往下继续阅读之前可以自己动手在纸上厘清各个状态之间转移的过程和条件!
此时我们已经考虑完了各个状态之间相互转换的过程以及条件,接下来我们就可以套用动态规划的步骤来编写代码,在真正编写代码之前,我们还需要考虑状态具体定义、状态初始化、返回结果等等细节问题:
-
状态具体定义:由于本题设计维度太多(包含交易次数、是否持股、天数),我们可以通过增加个数的方式实现降维,我们可以用两个二维数组
dp1[n][2]
、dp2[n][2]
来表示所有状态,其中dp1[i][j]
表示在第i天结束后,处于持有股票状态并且交易次数为j的最大利润,dp2[i][j]
表示在第i天结束后,处于非持有股票状态并且交易次数为j的最大利润 -
状态初始化:本题的状态初始化较为麻烦,
dp1[1][0]
表示即在第一天结束时,处于持有股票状态的最大利润并且交易次数为0次的最大利润,即恰好买入当天股票,此时利润为初始化为-prices[0]
,dp2[1][0]
表示即在第一天结束时,处于非持有股票状态并且交易次数为0次的最大利润,即无需任何操作,此时最大利润为0 -
返回值:题目需要返回最多可以完成 两笔 交易的最大利润,所以要遍历
dp2
数组第n行,取出最大值
2.3.3 AC代码(Java实现)
class Solution {
public int maxProfit(int[] prices) {
int maxCount = 2; // 表示最大交易数
int n = prices.length;
int[][] dp_stockin = new int[n + 1][maxCount + 1]; // 表示持股状态
int[][] dp_stockout = new int[n + 1][maxCount + 1]; // 表示非持股状态
// 状态初始化
for (int i = 0; i <= maxCount; i++) {
dp_stockin[1][i] = -0x3f3f3f3f;
dp_stockout[1][i] = -0x3f3f3f3f;
}
dp_stockin[1][0] = -prices[0];
dp_stockout[1][0] = 0;
// 状态转移
for (int i = 2; i <= n; i++) {
for (int j = 0; j <= maxCount; j++) {
dp_stockin[i][j] = Math.max(dp_stockin[i - 1][j], dp_stockout[i - 1][j] - prices[i - 1]);
dp_stockout[i][j] = dp_stockout[i - 1][j];
if ((j - 1) >= 0) {
dp_stockout[i][j] = Math.max(Math.max(dp_stockin[i - 1][j - 1] + prices[i - 1], dp_stockout[i - 1][j - 1]), dp_stockout[i][j]);
}
}
}
// 返回值
int maxRet = -1;
for (int i = 0; i <= maxCount; i++) {
maxRet = Math.max(maxRet, dp_stockout[n][i]);
}
return maxRet;
}
}
疑惑解答:
dp_stockin[1][i]
和dp_stockout[1][i]
都被初始化为-0x3f3f3f3f
而不是0的原因是如果取0会干扰较大值的判断,因为第一天结束后处于持股状态此时利润为负数,不初始化为Integer.MAX_VALUE
的原因是可能会减去当天利润溢出变为正数,所以初始化为-0x3f3f3f3f
是比较合理的。- 题干中只要求最大交易次数为2次,为了不失一般性,我们将最大交易次数设计为一个变量
maxCount
,这也是为了下一题埋下伏笔。 Math.max(Math.max(dp_stockin[i - 1][j - 1] + prices[i - 1], dp_stockout[i - 1][j - 1])
这一行代码是有可能发生越界的,即当j为0时即交易次数为0次,所以我们需要在循环处理中特殊判断,只有当j - 1 >= 0
才可以执行这行代码。
2.4 LeetCode 188—买卖股票的最佳时机Ⅳ
题目链接:买卖股票的最佳时机Ⅳ
温馨提示:本题跟买卖股票的最佳时机Ⅲ的思路一致,只是将最大交易次数变成参数传递。但是我们在上一题中采用的代码就是作为变量考虑的,所以只需要将上述代码略微修改一下就能通过本题。
2.4.1 AC代码(Java实现)
class Solution {
public int maxProfit(int k, int[] prices) {
int maxCount = k; // 表示最大交易数
int n = prices.length;
int[][] dp_stockin = new int[n + 1][maxCount + 1]; // 表示持股状态
int[][] dp_stockout = new int[n + 1][maxCount + 1]; // 表示非持股状态
// 状态初始化
for (int i = 0; i <= maxCount; i++) {
dp_stockin[1][i] = -0x3f3f3f3f;
dp_stockout[1][i] = -0x3f3f3f3f;
}
dp_stockin[1][0] = -prices[0];
dp_stockout[1][0] = 0;
// 状态转移
for (int i = 2; i <= n; i++) {
for (int j = 0; j <= maxCount; j++) {
dp_stockin[i][j] = Math.max(dp_stockin[i - 1][j], dp_stockout[i - 1][j] - prices[i - 1]);
dp_stockout[i][j] = dp_stockout[i - 1][j];
if ((j - 1) >= 0) {
dp_stockout[i][j] = Math.max(Math.max(dp_stockin[i - 1][j - 1] + prices[i - 1], dp_stockout[i - 1][j - 1]), dp_stockout[i][j]);
}
}
}
// 返回值
int maxRet = -1;
for (int i = 0; i <= maxCount; i++) {
maxRet = Math.max(maxRet, dp_stockout[n][i]);
}
return maxRet;
}
}
3. 状态机的总结
在我看来,状态机与动态规划的思想是一致的!这些题目带给我们的启示就是如果在题目中能够较明显的抽象出状态的定义,那么就可以尝试使用动态规划的思想解决。并且当题目中的状态转移方程难以快速找到时,我们也可以用状态机的概念在纸上罗列各个状态相互转移的过程及其条件。最后,感谢阅读!!!