文章目录
前言
之前就做过leetcode的买卖股票系列的题,被他们折磨的够呛,今天决定写一篇自己关于买卖股票系列问题的理解,用动态规划的基本思路带你逐层理解并解决这类问题,争取干掉买卖股票问题。
01 动态规划解决该类问题的通用思路
动态规划无非就是定义状态,确定状态转移方程,确定base case初始条件,返回最终结果。问题的难点就在于状态的定义,股票买卖问题最容易想到的状态就是dp[i]:表示当天的利润,但这个状态会有一些问题,因为当我们分析状态转移方程时,发现好像缺少了什么东西,没错就是股票当天是否持有的状态,缺失状态怎么办?那我们就在dp数组在加一维就可以了,那么新的dp数组就是dp[n][2],其中第二维表示当天是否持有股票的状态(0:不持有,1:持有)。买卖股票系列问题绝大部分可以用这个二维数组表示其状态,只是具体题目dp数组的第二维数组的含义稍有不同而已。
02 leetcode121(买卖一次)
定义状态
这一题的状态很好确认就是上文中我们提到的状态:
dp[i][0]:表示到了第i天不持有股票所获得的最大利润
dp[i][1]:表示到了第i天持有股票所获得的最大利润
确认状态转移方程
本题中状态转移方程就是dp[i][0],dp[i][1]怎样由前一天的状态转移而来。
dp[i][0]:当天不持有股票可能是昨天不持有股票,今天仍然不持有股票即dp[i][0] = dp[i-1][0],也有可能是昨天持有股票今天卖掉股票即dp[i][0] = dp[i-1][1] + prices[i] ,而且dp[i][0]表示的是当天不持有股票的最大收益,因此要取二者之间的最大值。
所以:dp[i][0] = Max(dp[i-1][0], dp[i-1][1] + prices[i])
同理,dp[i][1]:当天持有股票可能是昨天持有股票,今天仍然持有股票即dp[i][1] = dp[i-1][1],也有可能是昨天不持有股票今天买入股票即dp[i][1] = - prices[i] ,而且dp[i][1]表示的是当天持有股票的最大收益,因此要取二者之间的最大值。
所以:dp[i][1] = Max(dp[i-1][1], - prices[i])
这里可能有人会问为什么dp[i][1]状态中的昨天不持有股票今天买入股票的状态转移方程不是 dp[i][1] = dp[i][0] - prices[i] ?注意看题目:你只能选择某一天买入股票,言下之意就是你只能买卖股票一次,只能买卖一次的意思就是说我当天持有股票是由于当天买入股票而持有的状态只能是当天买入,和昨天是否买入的状态无关。而 dp[i][1] = dp[i][0] - prices[i] 这个状态转移方程成立的前提是可以买卖多次,那么昨天不持有股票今天买入股票的状态转移方程就和昨天的有关(可以买卖多次),这个状态转移方程也就是leetcode122买卖多次的状态转移方程,下文也会讲解。
Base Case 初始状态表示
很显然第一天持不持有股票所获得的最大利润就是0,第一天持有股票所获得的最大利润就是 -prices[0](第一天买入股票,利润为负数)。
dp[0][0] = 0;
dp[0][1] = -prices[0];
最终返回值
想要利润最大肯定是返回最后一天不持股的状态
完整代码
public int maxProfit(int[] prices) {
int n = prices.length;
// dp(n,0)表示第n天不持有股票的状态
// dp(n,1)表示第n天持有股票的状态
int dp[][] = new int[n][2];
// base case
dp[0][0] = 0;
dp[0][1] = -prices[0];
// 状态转移方程
for (int i = 1; i < n; i++) {
dp[i][0] = Math.max(dp[i-1][0],dp[i-1][1]+prices[i]);
dp[i][1] = Math.max(dp[i-1][1],-prices[i]);
}
// 返回最后一天不持股的状态
return dp[n-1][0];
}
02 leetcode122(买卖多次)
本题和上文讲解的leetcode121题目基本一样,唯一的区别是本题中股票可以买卖多次,这里的状态转移方程稍微有点不同,其他内容如定义状态、basecase基本一致,这里就不再赘述。
定义状态
dp[i][0]:表示到了第i天不持有股票所获得的最大利润
dp[i][1]:表示到了第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])
这里注意当天持有股票(由于昨天不持股今天买入股票)的状态转移方程为:
dp[i][1] = dp[i-1][0] - prices[i],因为股票可以买卖多次,因此当天买入肯定是要考虑到昨天的因素,如果买卖一次,当天买入就不用考虑到昨天的买入了也就是 - prices[i]。
我想你应该能理解昨天不持股今天买入股票的以下两个状态转移方程的区别了吧。
dp[i][1] = Max(dp[i-1][1], dp[i-1][0] - prices[i])
dp[i][1] = Max(dp[i-1][1], - prices[i])
Base Case 初始状态表示
dp[0][0] = 0;
dp[0][1] = -prices[0];
完整代码
public int maxProfit(int[] prices) {
int n = prices.length;
// dp(n,0)表示第n天不持有股票的状态
// dp(n,1)表示第n天持有股票的状态
int dp[][] = new int[n][2];
// base case
dp[0][0] = 0;
dp[0][1] = -prices[0];
// 状态转移方程
for (int i = 1; i < n; i++) {
dp[i][0] = Math.max(dp[i-1][0],dp[i-1][1]+prices[i]);
dp[i][1] = Math.max(dp[i-1][1],dp[i-1][0]-prices[i]);
}
return dp[n-1][0];
}
02 leetcode 714(买卖多次且含手续费)
本题和leetcode122(买卖多次)基本一致,唯一不同点就是本题中增加了手续费。定义状态、basecase内容和leetcode122一致。既然增加了手续费,那么我们在当天卖出的时候将手续费减掉即可。
确定状态转移方程
在卖出时将手续费减掉
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])
Base Case 初始状态表示
dp[0][0] = 0;
# 这里定义的是在卖出时减掉手续费,买入时就不用减了
dp[0][1] = -prices[0];
完整代码
public int maxProfit(int[] prices, int fee) {
int n = prices.length;
int[][] dp = new int[n][2];
dp[0][0] = 0;
dp[0][1] = -prices[0];
for (int i = 1; i < n; i++) {
dp[i][0] = Math.max(dp[i-1][0], dp[i-1][1] + prices[i] - fee);
dp[i][1] = Math.max(dp[i-1][1], dp[i-1][0] - prices[i]);
}
return dp[n-1][0];
}
思考一下,如果定义在买入时减掉手续费,那么base case和状态转移方程该如何定义呢?可以试试看
02 leetcode123(买卖两次)
本题与上文中的股票系列问题不同的是最多完成两笔交易,加上了这个条件我们的状态又该如何定义呢?
定义状态
我们可以这样定义dp数组为dp[i][5],也就是一天有5种状态:
0:截止到第i天没有任何操作所获得的最大利润
1:截止到第i天是第一次买入状态所获得的最大利润
2:截止到第i天是第一次卖入状态所获得的最大利润
3:截止到第i天是第二次买入状态所获得的最大利润
4:截止到第i天是第二次卖入状态所获得的最大利润
确定状态转移方程
很明显dp[i][0]一直为0,因为没有任何操作,所以最大利润为0。
dp[i][1]状态表示截止到第i天是第一次买入状态,那么达到这个状态有两种方式:
- 第i-1天也是第一次买入状态,延续第i-1天的状态:dp[i][1] = dp[i-1][1]
- 第i天买入股票(之前都没有买过股票),dp[i][1] = dp[i-1][0] - prices[i]
取二者之间最大值:dp[i][1] = Max(dp[i-1][0] - prices[i], dp[i-1][1])
dp[i][2]状态表示截止到第i天是第一次卖入状态,那么达到这个状态有两种方式:
- 第i-1天也是第一次卖入状态,延续第i-1天的状态:dp[i][2] = dp[i-1][2]
- 第i天卖出入股票(之前都买过一次股票),dp[i][2] = dp[i-1][1] + prices[i]
取二者之间最大值: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])
Base Case 初始状态表示
很显然dp[0][0]一定为0(因为没有任何操作),dp[0][1] = -prices[0];
dp[0][2]表示第一次卖出所获得的的最大利润,在第0天卖出我们可以当做他的最大利润为0;同理dp[0][4]我们可以认为在第0天卖出的最大利润也为0;
dp[0][3]表示在第0天第二次买入,在第0天不管是第几次买入利润都是-prices[0]
dp[0][0] = 0;
dp[0][1] = -prices[0];
dp[0][2] = 0;
dp[0][3] = -prices[0];
dp[0][4] = 0;
本题中的状态初始化稍微有点抽象,因为一天只能操作一次,所以第0天的状态就有特殊性了,尤其是第0天第一次卖出,第0天第二次买入卖出这三个状态的值,大家就可以认为第0天可以操作很多次,这样有助于理解第0天各个状态的值。
最终返回值
最后一天的第二次卖出状态的值
完整代码
public int maxProfit3(int[] prices) {
int n = prices.length;
int[][] dp = new int[n][5];
/**
* 0:没有操作
* 1:第一次买入
* 2:第一次卖出
* 3:第二次买入
* 4:第二次卖出
*/
// base case
dp[0][1] = -prices[0];
dp[0][3] = -prices[0];
for (int i = 1; i < n; i++) {
dp[i][0] = dp[i-1][0]; // 可以省略,默认都是0
dp[i][1] = Math.max(dp[i-1][0] - prices[i], dp[i-1][1]);
dp[i][2] = Math.max(dp[i-1][1] + prices[i], dp[i-1][2]);
dp[i][3] = Math.max(dp[i-1][2] - prices[i], dp[i-1][3]);
dp[i][4] = Math.max(dp[i-1][3] + prices[i], dp[i-1][4]);
}
return dp[n-1][4];
}
02 leetcode188(买卖k次)
本题和leetcode123(买卖两次)大致相同,唯一不同的就是本题买卖股票次数的限制为k是一个变量。其实仔细一看leetcode123(买卖两次)就是本题的特殊情况,完全可以根据其推导出本题的状态定义以及状态转移方程。
定义状态
0:截止到当天没有任何操作所获得的最大利润
1:截止到当天是第一次买入状态所获得的最大利润
2:截止到当天是第一次卖入状态所获得的最大利润
3:截止到当天是第二次买入状态所获得的最大利润
4:截止到当天是第二次卖入状态所获得的最大利润
......
......
2*k-1:截止到当天是第k次买入状态所获得的最大利润
2*k:截止到当天是第k次卖入状态所获得的最大利润
因此dp数组可以定义为dp[i][2*k+1]
确定状态转移方程
观察leetcode123(买卖两次)的状态转移方程我们可以发现:
当第i天的状态j为奇数时:dp[i][j] = Max(dp[i-1][j-1] - prices[i], dp[i-1][j])
当第i天的状态j为偶数时:dp[i][j] = Max(dp[i-1][j-1] + prices[i], dp[i-1][j])
Base Case 初始状态表示
观察leetcode123(买卖两次)的初始状态我们可以发现:
当第0天的状态i为奇数时:dp[0][i] = -prices[0],其他状态默认为0.
for (int i = 1; i < 2*k+1; i+=2) {
dp[0][i] = -prices[0];
}
最终返回值
最后一天的第k次卖出状态
完整代码
public int maxProfit(int k, int[] prices) {
int n = prices.length;
if (n == 0) return 0;
int[][] dp = new int[n][2*k+1];
for (int i = 1; i < 2*k+1; i+=2) {
dp[0][i] = -prices[0];
}
for (int i = 1; i < n; i++) {
for (int j = 1; j < 2*k+1; j++) {
if (j % 2 == 1)
dp[i][j] = Math.max(dp[i-1][j-1] - prices[i], dp[i-1][j]);
else
dp[i][j] = Math.max(dp[i-1][j-1] + prices[i], dp[i-1][j]);
}
}
return dp[n-1][2*k];
}
02 leetcode309(买卖多次且含冻结期)
本题是可以多次买卖股票,但是限制就是含冷冻期,那么这个冷冻期我们该怎么理解呢?冷冻期对于我们买入股票有很大影响,我们今天能否买入股票关键在于昨天的状态,如果昨天的状态是不持股状态,今天也不一定可以买入,因为如果昨天的不持股状态是由昨天卖出股票导致的,那么会进入冷冻期,我们今天是不可以买入的,也就是说今天可以买入股票的条件是昨天不持股且昨天不持股的状态不是由昨天卖出股票导致的。
因此我们要设置两个不持股的状态:
- 当天不持股且不是由当天卖出股票导致
- 当天不持股且是由当天卖出股票导致
另外一个状态是当天持有股票,有了这三个状态,我们其实就把冷冻期这个问题化解了,因此dp数组可以定义为dp[i][3]
定义状态
dp[i][0]:第i天不持股且不是由当天卖出股票导致
dp[i][1]:第i天持股
dp[i][2]:第i天不持股且是由当天卖出股票导致
确定状态转移方程
dp[i][0](第i天不持股且不是由当天卖出股票导致)可以由以下两种方式转换而来:
- 延续第i-1天的0状态:dp[i][0] = dp[i-1][0]
- 延续第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](第i天持股)可以由以下两种方式转换而来:
- 延续第i-1天的1状态:dp[i][1] = dp[i-1][1]
- 第i天买入股票,并且第i-1天不持股且第i-1天没有卖出过股票(只有这样第i天才不在冷冻期),dp[i-1][1] = dp[i-1][0] - prices[i]
取二者的最大值:dp[i][1] = Max(dp[i-1][0] - prices[i], dp[i-1][1])
dp[i][2]只能由第i天卖出股票转换而来:dp[i][2] = dp[i-1][1] + prices[i];
Base Case 初始状态表示
// 第0天持股状态的最大利润为-prices[0]
dp[0][1] = -prices[0];
// 第0天的其他状态的最大利润都是0,java中默认初始值就是0
最终返回值
最终返回最后一天不持股的两种状态中的最大值
完整代码
public int maxProfit(int[] prices) {
int n = prices.length;
// 如果只有1天,那么最大利润就是0
if (n <= 1) return 0;
int[][] dp = new int[n][3];
// base case
dp[0][1] = -prices[0];
for (int i = 1; i < n; i++) {
// 状态转移方程
dp[i][0] = Math.max(dp[i-1][0], dp[i-1][2]);
dp[i][1] = Math.max(dp[i-1][0] - prices[i], dp[i-1][1]);
dp[i][2] = dp[i-1][1] + prices[i];
}
// 返回最后一天不持股的两种状态的最大值
return Math.max(dp[n-1][0], dp[n-1][2]);
}
总结
本文讲解了如何通过动态规划解决买卖股票系列问题,按照题型类别,逐步推理解题思路,解决该类问题关键点就在于通过增加状态来化解题目中增加的限制条件,状态一旦确定后,便开始思考状态转移方程,然后确认base case以及最终返回的结果。