LeetCode 股票买卖系列 best time to buy and sell stock
股票买卖是leetcode很经典的一个系列,也是面试常见题目。这一系列共有6道题,分别是:
121.买卖股票的最佳时机
122.买卖股票的最佳时机 II
123.买卖股票的最佳时机 III
188.买卖股票的最佳时机 IV
309.最佳买卖股票时机含冷冻期
714. 买卖股票的最佳时机含手续费
接下来我们逐一分析这些题目。实际上此类问题基本都能够通过动态规划求解。
121.买卖股票的最佳时机
给定一个数组,它的第 i 个元素是一支给定股票第 i 天的价格。
如果你最多只允许完成一笔交易(即买入和卖出一支股票一次),设计一个算法来计算你所能获取的最大利润。
注意:你不能在买入股票前卖出股票。
本题是此类问题的原型题目,题目较简单。给定一个数组表示每天的股价,我们可以选择一天买入,之后的某一天卖出,求能够取得的最大收益。本题最简单的思路就是枚举所有的买入-卖出对,计算所有的收益,并选取最大的,这样时间复杂度显然是
O
(
N
2
)
O(N^2)
O(N2)。但仔细思考我们就会发现很多计算是无意义的,最大的收益一定来自能取到的最大的价格差,因此,如果我们讨论第k天卖出,那么买入的时机一定是0到k-1天价格最低的那天,除此之外其他几天的收益不可能高于这个数,无需计算。
那么这样我们可以维护一个二维数组dp,其中dp[i][0]记录截止第i天的最低买入价格, dp[i][1]记录截止第i天的最大收益,那显然:
d
p
[
i
]
[
0
]
=
m
i
n
(
d
p
[
i
−
1
]
[
0
]
,
p
r
i
c
e
s
[
i
]
dp[i][0] = min(dp[i-1][0],prices[i]
dp[i][0]=min(dp[i−1][0],prices[i]
d
p
[
i
]
[
1
]
=
m
a
x
(
d
p
[
i
−
1
]
[
1
]
,
p
r
i
c
e
s
[
i
]
−
d
p
[
i
−
1
]
[
0
]
)
dp[i][1] = max(dp[i-1][1],prices[i]-dp[i-1][0])
dp[i][1]=max(dp[i−1][1],prices[i]−dp[i−1][0])
也就是如果当天价格高于之前的最低买入价格,那么最低价维持不变,否则更新为当天的价格;当天卖出能产生的收益低于之前的最大收益,就维持不变,否则就改为当天才卖出。
仔细观察就可以发现,实际上维持一个数组并无意义,因为我们更新时只依赖于两个变量,因此只需要两个变量分别记录最低价格与最大收益,并在遍历时更新即可。
C++代码如下:
int maxProfit1(vector<int>& prices)
{
int buy = INT_MAX, profit = 0;
for (auto p : prices) {
profit = max(profit, p - buy);
buy = min(buy, p);
}
return profit;
}
当然,我们这里为了贴合实际操作,选择了**“买入价格”与“卖出收益”作为维护的变量。更一般的,我们定义两个状态,分别是当天买入与当天卖出,两个变量分别记录买入和卖出的最大收益(全部以收益的形式维护)**。这样在操作上更为直观,能够方便理解这一DP以及状态转换的过程。区别在于两种方法的初始化与更新条件不同。由于买入是负收益,其收益是价格的相反数(付钱),所以初始化为最小的负数,后续更新过程的正负号也要变化。卖出收益等于最大的买入收益叠加一个卖出挣的钱,这个钱就是prices[i],而买入的收益则是每天价格相反数的最大值。这样的思路在后续题目还会看到。
代码如下:
int maxProfit1(vector<int>& prices)
{
int buy = INT_MIN, profit = 0;
for (auto p : prices) {
profit = max(profit, p + buy);
buy = max(buy, -p);
}
return profit;
}
122.买卖股票的最佳时机 II
给定一个数组,它的第 i 个元素是一支给定股票第 i 天的价格。
设计一个算法来计算你所能获取的最大利润。你可以尽可能地完成更多的交易(多次买卖一支股票)。
注意:你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。
本题在121的基础上做了一定的改动,能够无限买卖,可以交易任意次,求最大收益。交易的限制的必须卖出才能再买入。
本题与上一题只能交易一次不同,允许无限交易。那么直觉上,应该是有利可图就交易,换言之,只要一天的价格比前一天高,我们就前一天买入后卖出,赚一个差价。
C++代码如下:
int maxProfit2(vector<int>& prices)
{
if (prices.size() <= 1) return 0;
int max_profit = 0;
for (int i = 1; i < prices.size(); ++i) {
if (prices[i] > prices[i - 1])
max_profit += prices[i] - prices[i - 1];
}
return max_profit;
}
123.买卖股票的最佳时机 III
给定一个数组,它的第 i 个元素是一支给定的股票在第 i 天的价格。
设计一个算法来计算你所能获取的最大利润。你最多可以完成 两笔 交易。
注意: 你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。
本题在前两题基础上更进一步,不能无限交易而是最多交易两次。
实际上本题可以从第1题基础上衍生出来,第一题只能交易一次,因此存在2种状态(买入和卖出),那本题能交易两次,对应四个状态:第一次买入,第一次卖出,第二次买入,第二次卖出。同样的,我们维护这四个状态下的最大收益:
int maxProfit3(vector<int>& prices) {
int buy1 = INT_MIN, buy2 = INT_MIN,pro1=0,pro2=0;
for (auto p : prices) {
buy1 = max(buy1, -p);
pro1 = max(pro1, p + buy1);
buy2 = max(buy2, -p + pro1);
pro2 = max(pro2, p + buy2);
}
return max(pro1,pro2);
}
注意这里的pro2表示第二次卖出后的最大(总)收益,而不单单是第二次交易单次交易的收益,因此最终pro2为所求,而不需要叠加pro1.
状态转移的情况:
buy1是第一次买入的最大收益,是价格相反数的最大值;
pro1是第一次卖出的最大收益,在buy1的基础上叠加一个卖出挣的钱;
buy2是第二次买入的最大收益,在pro1的基础上花费一定的价格;
pro2是第二次卖出的最大收益,在buy2的基础上加一个卖出挣的钱;
以上更新都是在新的一天的价格计算那结果上与之前的结果取最大值。
另外有可能第一交易后第二次永远亏钱,这样还不如之交易一次(题目是最多交易2次,而不是强制),所以返回值考虑pro1和pro2的最大值。
实际上本解法也是从O(N)空间动规优化而来:通过数组dp记录截止到每一天处于每个状态的最大收益,由于是“截止到当天的最大收益”,不强制要求当天进行操作,比如第i天第一次卖出的最大收益很可能是第2天就卖了而后面收益不够所以啥也没干,所以维护每天的数值并无意义,一个状态只要对应一个值就够了,故可以优化为上面的代码。
int maxProfit3(vector<int>& prices) {
int len = prices.size();
vector<vector<int>>dp(len, vector<int>(4, 0));
//0:第一次买
//1:第一次卖
//2:第二次买
//3:第二次卖
dp[0][0] = -prices[0];
dp[0][2] = INT_MIN;
for (int i = 1; i < len; ++i) {
dp[i][0] = max(dp[i - 1][0], -prices[i]);
dp[i][1] = max(dp[i - 1][1], dp[i - 1][0] + prices[i]);
dp[i][2] = max(dp[i - 1][2], dp[i - 1][1] - prices[i]);
dp[i][3] = max(dp[i - 1][3], dp[i - 1][2] + prices[i]);
}
return max(dp[len - 1][1], dp[len - 1][3]);
}
188.买卖股票的最佳时机 IV
给定一个数组,它的第 i 个元素是一支给定的股票在第 i 天的价格。
设计一个算法来计算你所能获取的最大利润。你最多可以完成 k 笔交易。
注意: 你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。
本题又做了一定的改动,最多交易次数为k次。这里可以根据k的大小分类讨论一下:
如果k大于prices数组长度的一半,那么意味着等价于无限次交易,因为交易一次必须要2天,卖出后才能再买入,所以如果k超过数组长度的一半,本题就简化为122题的贪心算法,等价于无限买卖;
反之,意味着交易上限为k,可以看做是123题的扩展。
通过121和123可以看出,只讨论若干次买卖问题的时候,我们只要维护一定数量的状态,以及在这些状态下的最大收益,就能根据每一天的价格与状态间的转移关系更新这些数值,并找到最大的收益。
对于1次交易,我们维护2个状态;2次交易则是4个状态;**对于k次交易,需要维护k2个状态(每一次交易的买入、卖出)**这样我们定义k2的二维数组dp记录这些状态的最大收益。注意dp[i][0]表示第i次买入的最大收益,dp[i][1]表示第i次卖出的最大收益,这个i跟天数没有关系。我们每一天,都要更新一遍2k个数据。
那么状态之间如何更新呢?
第i次买入的状态的最大收益就是 之前的最大结果,与i-1次卖出后今天再买入的收益,二者中的较大值;同理卖出的最大收益,就是之前结果,与第i次买入后今天卖出的收益中较大的。换言之,买入就是前一次卖出今天在买进与今天啥都不干收益比较,卖出就是这一次买入后今天卖出于今天啥都不干收益比较。
状态转移方程如下。
d
p
[
i
]
[
0
]
=
m
a
x
(
d
p
[
i
]
[
0
]
,
d
p
[
i
−
1
]
[
1
]
−
p
)
dp[i][0] = max(dp[i][0], dp[i - 1][1] - p)
dp[i][0]=max(dp[i][0],dp[i−1][1]−p)
d
p
[
i
]
[
1
]
=
m
a
x
(
d
p
[
i
]
[
1
]
,
d
p
[
i
]
[
0
]
+
p
)
dp[i][1] = max(dp[i][1], dp[i][0] + p)
dp[i][1]=max(dp[i][1],dp[i][0]+p)
第一次买入是个特例,因为没有上一次的卖出收益,所以只要跟价格的相反数-p去比较就行。
int solution::maxProfit4(int k, vector<int>& prices)
{
int len = prices.size();
if (k < 1 || len < 2) return 0;
if (k >= len / 2) {
int max_profit = 0;
for (int i = 1; i < prices.size(); ++i) {
if (prices[i] > prices[i - 1])
max_profit += prices[i] - prices[i - 1];
}
return max_profit;
}
else {
vector<vector<int>>dp(k, vector<int>(2, 0));
for (int i = 0; i < k; ++i) dp[i][0] = INT_MIN;
for (auto p : prices) {
dp[0][0] = max(dp[0][0], -p);
dp[0][1] = max(dp[0][1], dp[0][0] + p);
for (int i = 1; i < k; ++i) {
dp[i][0] = max(dp[i][0], dp[i - 1][1] - p);
dp[i][1] = max(dp[i][1], dp[i][0] + p);
}
}
return dp[k - 1][1];
}
}
309.最佳买卖股票时机含冷冻期
给定一个整数数组,其中第 i 个元素代表了第 i 天的股票价格 。
设计一个算法计算出最大利润。在满足以下约束条件下,你可以尽可能地完成更多的交易(多次买卖一支股票):
你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。
卖出股票后,你无法在第二天买入股票 (即冷冻期为 1 天)。
本题在122的基础上加以限制,不限制交易次数,但是每次卖出后要冷冻1天才能在买入。那么这道题就与其他题目有所不同,我们继续从状态定义入手。
首先每天的状态可以有三种:分别定义为f0,f1,f2
1.手里有股票,f0
3.手里没有股票,当天卖出的,第二天将处于冷冻期无法交易:f1
2.手里没有股票,不是当天卖出的,不处于冷冻,第二天可以买入:f2
由于状态转移存在依赖关系,我们使用不带上标 ′ ' ′ 的名字表示前一天各状态的最大收益,带上标的表示更新后的最大收益,更新结束再重新赋值即可。
状态转移方程如下:
f
0
′
=
m
a
x
(
f
0
,
f
2
−
p
r
i
c
e
s
[
i
]
)
f0' = max(f0, f2-prices[i])
f0′=max(f0,f2−prices[i])
f
1
′
=
f
0
+
p
r
i
c
e
s
[
i
]
f1' = f0+prices[i]
f1′=f0+prices[i]
f
2
′
=
m
a
x
(
f
1
,
f
2
)
f2' = max(f1,f2)
f2′=max(f1,f2)
持股,可能是一直持股今天什么也没干,也可能是今天刚买入的(今天要买入,昨天必须是f2而不是f1);
没有股票且为当天卖出:只能是昨天及之前买入的,并以今天的价格卖出,所以一定是之前持股的最大收益加上当天的价格;
没有股票且不是当天卖出:那要么是前一天卖的,要么是更早卖的,总之今天没有任何操作,是前一天f1与f2的较大值。
由于状态之间相互依赖,直接赋值更新会影响结果,所以先赋值给带上标的变量,再更新。
int solution::maxProfit(vector<int> & prices)
{
if (prices.empty()) return 0;
int f0 = -prices[0],f1=0,f2=0;
for (int i = 1; i < prices.size(); ++i) {
int newf0 = max(f0, f2 - prices[i]);
int newf1 = f0 + prices[i];
int newf2 = max(f1, f2);
f0 = newf0;
f1 = newf1;
f2 = newf2;
}
return max(f1, f2);
}
714.买卖股票的最佳时机含手续费
给定一个整数数组 prices,其中第 i 个元素代表了第 i 天的股票价格 ;非负整数 fee 代表了交易股票的手续费用。
你可以无限次地完成交易,但是你每笔交易都需要付手续费。如果你已经购买了一个股票,在卖出它之前你就不能再继续购买股票了。
返回获得利润的最大值。
注意:这里的一笔交易指买入持有并卖出股票的整个过程,每笔交易你只需要为支付一次手续费。
本题不限交易次数,但是每次交易,都要支付一定的手续费。虽然无限交易,但无法直接应用122题的贪心策略,原因在于,如果价格一段时间连续递增,如3,5,7,9,第122题的贪心相当于不停地3买入5卖出、5买入7卖出,7买入9卖出,总收益2+2+2=6,没有手续费的话其总收益就等于最后的9-3=6;而一旦考虑手续费,我们就会发现二者不等了,中间的交易白白支付了手续费,最大收益应当是只交易一次。
因此本题仍然从动规角度入手。类似的,我们依然定义买入(持股)与卖出(不持股)两个状态的最大收益,分别是buy与sell。
对于卖出:要么是之前已卖出当天无操作,要么是前面买入持股今天卖出去的,注意卖出时收益减掉手续费,最大收益是上述两种情况的最大值
对于买入:要么是一直持股,要么是之前卖掉了今天再买回来。
这里再解释一下为什么sell和buy的更新顺序是无所谓的。
假如我们今天价格p很高,导致sell变化了,也就是
s
e
l
l
<
b
u
y
+
p
−
f
e
e
sell<buy+p-fee
sell<buy+p−fee,
先更新sell后
s
e
l
l
=
b
u
y
+
p
−
f
e
e
sell = buy+p-fee
sell=buy+p−fee,
此时
s
e
l
l
−
p
=
b
u
y
+
p
−
f
e
e
−
p
=
b
u
y
−
p
<
b
u
y
sell-p = buy+p-fee-p = buy-p<buy
sell−p=buy+p−fee−p=buy−p<buy,
则后面更新buy时会维持原值不变;
如果我们调换顺序,由于p很大,
s
e
l
l
’
=
b
u
y
+
p
−
f
e
e
>
s
e
l
l
sell’ = buy+p-fee>sell
sell’=buy+p−fee>sell,
而
s
e
l
l
−
p
<
s
e
l
l
′
−
p
<
b
u
y
sell-p<sell'-p<buy
sell−p<sell′−p<buy
所以即使先更新buy,buy的值也是不变的,那自然,后更新sell也不会发生变化。
所以先后更新并无差别,反之p很小时亦然。
int maxProfitFee(vector<int>& prices, int fee)
{
int sell= 0, buy= INT_MIN;
for (auto p : prices) {
sell= max(sell, buy+ p - fee);
buy= max(buy, sell- p);
}
return sell;
}
这里注意到714与122只有手续费的差别,那如果我们不采用上述122贪心的思维方式,直接带入本题解法,但是去掉手续费,是否能解决122题呢?
答案是可以的,本题更新sell时略掉fee就是122的解法。二者思路不同,贪心策略是能赚就交易,比如上面说的3,5,7,9,贪心算法会买卖3次,每次赚2元,一共6元;而动规则是“攒一波大的”,先3买入5卖出,后来发现7更赚,就改为7卖出,再改为9卖出,一样赚6元。在没有手续费的时候,二者操作结果是一样的。
小结
总结来说,这一系列的股票类问题都能通过动态规划来解决,由于买入和卖出之间有着很明确的状态转移关系,一定有先买后卖,卖出后不持股才能继续买的要求,所以状态转移方程相对明确。我们这里可以采取统一的策略,将买入也定义为一种收益(负收益),并维护各状态下的最大收益,根据状态转化规则进行更新。
这些时间复杂度 O ( N ) O(N) O(N)空间复杂度 O ( 1 ) O(1) O(1)(188题时间复杂度 O ( N k ) O(Nk) O(Nk)空间复杂度 O ( k ) O(k) O(k),但k不大于N/2,否则退化为122)的解法并非一蹴而就的,遇到此类问题,我们可以先分析一下状态空间与转移策略,选择dp数组,甚至多维数组进行算法的初步验证,并在此基础上进行优化,分析出每个状态究竟依赖那些状态,无关的可以删掉不维护。
更新变量时,要思考更新的顺序是否相关,若相关那么怎样的顺序是正确的,直接覆盖是否有问题,有问题可以借助中间变量等。
另外,维护变量的初始化,要根据实际意义、后续是取最大值还是最小值加以判断。