团灭股票问题.md 33.3 KB
Newer Older
L
labuladong 已提交
1 2
# 团灭 LeetCode 股票买卖问题

3 4
<p align='center'>
<a href="https://github.com/labuladong/fucking-algorithm" target="view_window"><img alt="GitHub" src="https://img.shields.io/github/stars/labuladong/fucking-algorithm?label=Stars&style=flat-square&logo=GitHub"></a>
L
labuladong 已提交
5
<a href="https://appktavsiei5995.pc.xiaoe-tech.com/index" target="_blank"><img class="my_header_icon" src="https://img.shields.io/static/v1?label=精品课程&message=查看&color=pink&style=flat"></a>
6 7 8 9
<a href="https://www.zhihu.com/people/labuladong"><img src="https://img.shields.io/badge/%E7%9F%A5%E4%B9%8E-@labuladong-000000.svg?style=flat-square&logo=Zhihu"></a>
<a href="https://space.bilibili.com/14089380"><img src="https://img.shields.io/badge/B站-@labuladong-000000.svg?style=flat-square&logo=Bilibili"></a>
</p>

L
labuladong 已提交
10
![](https://labuladong.github.io/algo/images/souyisou1.png)
11

L
labuladong 已提交
12
**通知:[数据结构精品课](https://aep.h5.xeknow.com/s/1XJHEO) 已更新到 V2.0;[第 13 期刷题打卡](https://mp.weixin.qq.com/s/eUG2OOzY3k_ZTz-CFvtv5Q) 最后一天报名!另外,建议你在我的 [网站](https://labuladong.github.io/algo/) 学习文章,体验更好。**
13 14 15



L
labuladong 已提交
16
读完本文,你不仅学会了算法套路,还可以顺便解决如下题目:
17

L
labuladong 已提交
18 19 20 21 22 23 24 25 26
| LeetCode | 力扣 | 难度 |
| :----: | :----: | :----: |
| [121. Best Time to Buy and Sell Stock](https://leetcode.com/problems/best-time-to-buy-and-sell-stock/) | [121. 买卖股票的最佳时机](https://leetcode.cn/problems/best-time-to-buy-and-sell-stock/) | 🟢
| [122. Best Time to Buy and Sell Stock II](https://leetcode.com/problems/best-time-to-buy-and-sell-stock-ii/) | [122. 买卖股票的最佳时机 II](https://leetcode.cn/problems/best-time-to-buy-and-sell-stock-ii/) | 🟠
| [123. Best Time to Buy and Sell Stock III](https://leetcode.com/problems/best-time-to-buy-and-sell-stock-iii/) | [123. 买卖股票的最佳时机 III](https://leetcode.cn/problems/best-time-to-buy-and-sell-stock-iii/) | 🔴
| [188. Best Time to Buy and Sell Stock IV](https://leetcode.com/problems/best-time-to-buy-and-sell-stock-iv/) | [188. 买卖股票的最佳时机 IV](https://leetcode.cn/problems/best-time-to-buy-and-sell-stock-iv/) | 🔴
| [309. Best Time to Buy and Sell Stock with Cooldown](https://leetcode.com/problems/best-time-to-buy-and-sell-stock-with-cooldown/) | [309. 最佳买卖股票时机含冷冻期](https://leetcode.cn/problems/best-time-to-buy-and-sell-stock-with-cooldown/) | 🟠
| [714. Best Time to Buy and Sell Stock with Transaction Fee](https://leetcode.com/problems/best-time-to-buy-and-sell-stock-with-transaction-fee/) | [714. 买卖股票的最佳时机含手续费](https://leetcode.cn/problems/best-time-to-buy-and-sell-stock-with-transaction-fee/) | 🟠
| - | [剑指 Offer 63. 股票的最大利润](https://leetcode.cn/problems/gu-piao-de-zui-da-li-run-lcof/) | 🟠
27 28 29

**-----------**

L
labuladong 已提交
30
很多读者抱怨力扣上的股票系列问题奇技淫巧太多,如果面试真的遇到这类问题,基本不会想到那些巧妙的办法,怎么办?**所以本文拒绝奇技淫巧,而是稳扎稳打,只用一种通用方法解决所用问题,以不变应万变**
L
labuladong 已提交
31

L
fixurl  
labuladong 已提交
32
这篇文章参考 [英文版高赞题解](https://leetcode.com/problems/best-time-to-buy-and-sell-stock-with-transaction-fee/discuss/108870/Most-consistent-ways-of-dealing-with-the-series-of-stock-problems) 的思路,用状态机的技巧来解决,可以全部提交通过。不要觉得这个名词高大上,文学词汇而已,实际上就是 DP table,看一眼就明白了。
L
labuladong 已提交
33 34 35 36 37 38

先随便抽出一道题,看看别人的解法:

```cpp
int maxProfit(vector<int>& prices) {
    if(prices.empty()) return 0;
L
update  
labuladong 已提交
39 40 41
    int s1 = -prices[0], s2 = INT_MIN, s3 = INT_MIN, s4 = INT_MIN;

    for(int i = 1; i < prices.size(); ++i) {            
L
labuladong 已提交
42
        s1 = max(s1, -prices[i]);
L
update  
labuladong 已提交
43 44 45
        s2 = max(s2, s1 + prices[i]);
        s3 = max(s3, s2 - prices[i]);
        s4 = max(s4, s3 + prices[i]);
L
labuladong 已提交
46
    }
L
update  
labuladong 已提交
47
    return max(0, s4);
L
labuladong 已提交
48 49 50 51 52 53 54
}
```

能看懂吧?会做了吗?不可能的,你看不懂,这才正常。就算你勉强看懂了,下一个问题你还是做不出来。为什么别人能写出这么诡异却又高效的解法呢?因为这类问题是有框架的,但是人家不会告诉你的,因为一旦告诉你,你五分钟就学会了,该算法题就不再神秘,变得不堪一击了。

本文就来告诉你这个框架,然后带着你一道一道秒杀。这篇文章用状态机的技巧来解决,可以全部提交通过。不要觉得这个名词高大上,文学词汇而已,实际上就是 DP table,看一眼就明白了。

L
labuladong 已提交
55
这 6 道题目是有共性的,我们只需要抽出来力扣第 188 题「买卖股票的最佳时机 IV」进行研究,因为这道题是最泛化的形式,其他的问题都是这个形式的简化,看下题目:
L
labuladong 已提交
56

L
labuladong 已提交
57
![](https://labuladong.github.io/algo/images/股票问题/title.png)
L
labuladong 已提交
58

L
update  
labuladong 已提交
59
第一题是只进行一次交易,相当于 `k = 1`;第二题是不限交易次数,相当于 `k = +infinity`(正无穷);第三题是只进行 2 次交易,相当于 `k = 2`;剩下两道也是不限次数,但是加了交易「冷冻期」和「手续费」的额外条件,其实就是第二题的变种,都很容易处理。
L
labuladong 已提交
60

L
labuladong 已提交
61
下面言归正传,开始解题。
L
labuladong 已提交
62

L
update  
labuladong 已提交
63
### 一、穷举框架
L
labuladong 已提交
64

L
update  
labuladong 已提交
65
首先,还是一样的思路:如何穷举?
L
labuladong 已提交
66

L
labuladong 已提交
67
[动态规划核心套路](https://labuladong.github.io/article/fname.html?fname=动态规划详解进阶) 说过,动态规划算法本质上就是穷举「状态」,然后在「选择」中选择最优解。
L
labuladong 已提交
68

L
update  
labuladong 已提交
69
那么对于这道题,我们具体到每一天,看看总共有几种可能的「状态」,再找出每个「状态」对应的「选择」。我们要穷举所有「状态」,穷举的目的是根据对应的「选择」更新状态。听起来抽象,你只要记住「状态」和「选择」两个词就行,下面实操一下就很容易明白了。
L
labuladong 已提交
70 71 72 73 74 75 76 77

```python
for 状态1 in 状态1的所有取值
    for 状态2 in 状态2的所有取值
        for ...
            dp[状态1][状态2][...] = 择优(选择1选择2...)
```

L
update  
labuladong 已提交
78 79 80 81 82
比如说这个问题,**每天都有三种「选择」**:买入、卖出、无操作,我们用 `buy`, `sell`, `rest` 表示这三种选择。

但问题是,并不是每天都可以任意选择这三种选择的,因为 `sell` 必须在 `buy` 之后,`buy` 必须在 `sell` 之后。那么 `rest` 操作还应该分两种状态,一种是 `buy` 之后的 `rest`(持有了股票),一种是 `sell` 之后的 `rest`(没有持有股票)。而且别忘了,我们还有交易次数 `k` 的限制,就是说你 `buy` 还只能在 `k > 0` 的前提下操作。

很复杂对吧,不要怕,我们现在的目的只是穷举,你有再多的状态,老夫要做的就是一把梭全部列举出来。
L
labuladong 已提交
83

L
update  
labuladong 已提交
84
**这个问题的「状态」有三个**,第一个是天数,第二个是允许交易的最大次数,第三个是当前的持有状态(即之前说的 `rest` 的状态,我们不妨用 1 表示持有,0 表示没有持有)。然后我们用一个三维数组就可以装下这几种状态的全部组合:
L
labuladong 已提交
85 86 87

```python
dp[i][k][0 or 1]
L
update  
labuladong 已提交
88 89
0 <= i <= n - 1, 1 <= k <= K
n 为天数 K 为交易数的上限0  1 代表是否持有股票
L
labuladong 已提交
90 91 92 93 94 95 96 97 98 99
此问题共 n × K × 2 种状态全部穷举就能搞定

for 0 <= i < n:
    for 1 <= k <= K:
        for s in {0, 1}:
            dp[i][k][s] = max(buy, sell, rest)
```

而且我们可以用自然语言描述出每一个状态的含义,比如说 `dp[3][2][1]` 的含义就是:今天是第三天,我现在手上持有着股票,至今最多进行 2 次交易。再比如 `dp[2][3][0]` 的含义:今天是第二天,我现在手上没有持有股票,至今最多进行 3 次交易。很容易理解,对吧?

L
update  
labuladong 已提交
100 101 102
我们想求的最终答案是 `dp[n - 1][K][0]`,即最后一天,最多允许 `K` 次交易,最多获得多少利润。

读者可能问为什么不是 `dp[n - 1][K][1]`?因为 `dp[n - 1][K][1]` 代表到最后一天手上还持有股票,`dp[n - 1][K][0]` 表示最后一天手上的股票已经卖出去了,很显然后者得到的利润一定大于前者。 
L
labuladong 已提交
103 104 105

记住如何解释「状态」,一旦你觉得哪里不好理解,把它翻译成自然语言就容易理解了。

L
update  
labuladong 已提交
106 107 108
### 二、状态转移框架

现在,我们完成了「状态」的穷举,我们开始思考每种「状态」有哪些「选择」,应该如何更新「状态」。
L
labuladong 已提交
109

L
update  
labuladong 已提交
110
只看「持有状态」,可以画个状态转移图:
L
labuladong 已提交
111

L
labuladong 已提交
112
![](https://labuladong.github.io/algo/images/股票问题/1.png)
L
labuladong 已提交
113 114 115

通过这个图可以很清楚地看到,每种状态(0 和 1)是如何转移而来的。根据这个图,我们来写一下状态转移方程:

L
update  
labuladong 已提交
116
```python
L
labuladong 已提交
117
dp[i][k][0] = max(dp[i-1][k][0], dp[i-1][k][1] + prices[i])
L
update  
labuladong 已提交
118 119
              max( 今天选择 rest,        今天选择 sell       )
```
L
labuladong 已提交
120

L
update  
labuladong 已提交
121
解释:今天我没有持有股票,有两种可能,我从这两种可能中求最大利润:
L
labuladong 已提交
122

L
update  
labuladong 已提交
123
1、我昨天就没有持有,且截至昨天最大交易次数限制为 `k`;然后我今天选择 `rest`,所以我今天还是没有持有,最大交易次数限制依然为 `k`
L
labuladong 已提交
124

L
update  
labuladong 已提交
125 126 127 128 129
2、我昨天持有股票,且截至昨天最大交易次数限制为 `k`;但是今天我 `sell` 了,所以我今天没有持有股票了,最大交易次数限制依然为 `k`

```python
dp[i][k][1] = max(dp[i-1][k][1], dp[i-1][k-1][0] - prices[i])
              max( 今天选择 rest,         今天选择 buy         )
L
labuladong 已提交
130 131
```

L
update  
labuladong 已提交
132
解释:今天我持有着股票,最大交易次数限制为 `k`,那么对于昨天来说,有两种可能,我从这两种可能中求最大利润:
L
labuladong 已提交
133

L
update  
labuladong 已提交
134 135 136 137
1、我昨天就持有着股票,且截至昨天最大交易次数限制为 `k`;然后今天选择 `rest`,所以我今天还持有着股票,最大交易次数限制依然为 `k`

2、我昨天本没有持有,且截至昨天最大交易次数限制为 `k - 1`;但今天我选择 `buy`,所以今天我就持有股票了,最大交易次数限制为 `k`

L
labuladong 已提交
138
> 这里着重提醒一下,**时刻牢记「状态」的定义**,状态 `k` 的定义并不是「已进行的交易次数」,而是「最大交易次数的上限限制」。如果确定今天进行一次交易,且要保证截至今天最大交易次数上限为 `k`,那么昨天的最大交易次数上限必须是 `k - 1`。
L
update  
labuladong 已提交
139 140 141 142 143

这个解释应该很清楚了,如果 `buy`,就要从利润中减去 `prices[i]`,如果 `sell`,就要给利润增加 `prices[i]`。今天的最大利润就是这两种可能选择中较大的那个。

注意 `k` 的限制,在选择 `buy` 的时候相当于开启了一次交易,那么对于昨天来说,交易次数的上限 `k` 应该减小 1。

L
labuladong 已提交
144
> 修正:以前我以为在 `sell` 的时候给 `k` 减小 1 和在 `buy` 的时候给 `k` 减小 1 是等效的,但细心的读者向我提出质疑,经过深入思考我发现前者确实是错误的,因为交易是从 `buy` 开始,如果 `buy` 的选择不改变交易次数 `k` 的话,会出现交易次数超出限制的的错误。
L
update  
labuladong 已提交
145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161

现在,我们已经完成了动态规划中最困难的一步:状态转移方程。**如果之前的内容你都可以理解,那么你已经可以秒杀所有问题了,只要套这个框架就行了**。不过还差最后一点点,就是定义 base case,即最简单的情况。 

```python
dp[-1][...][0] = 0
解释因为 i 是从 0 开始的所以 i = -1 意味着还没有开始这时候的利润当然是 0

dp[-1][...][1] = -infinity
解释还没开始的时候是不可能持有股票的
因为我们的算法要求一个最大值所以初始值设为一个最小值方便取最大值

dp[...][0][0] = 0
解释因为 k 是从 1 开始的所以 k = 0 意味着根本不允许交易这时候利润当然是 0

dp[...][0][1] = -infinity
解释不允许交易的情况下是不可能持有股票的
因为我们的算法要求一个最大值所以初始值设为一个最小值方便取最大值
L
labuladong 已提交
162 163 164 165
```

把上面的状态转移方程总结一下:

L
update  
labuladong 已提交
166
```python
L
labuladong 已提交
167
base case
L
update  
labuladong 已提交
168 169
dp[-1][...][0] = dp[...][0][0] = 0
dp[-1][...][1] = dp[...][0][1] = -infinity
L
labuladong 已提交
170 171 172 173 174 175 176 177

状态转移方程
dp[i][k][0] = max(dp[i-1][k][0], dp[i-1][k][1] + prices[i])
dp[i][k][1] = max(dp[i-1][k][1], dp[i-1][k-1][0] - prices[i])
```

读者可能会问,这个数组索引是 -1 怎么编程表示出来呢,负无穷怎么表示呢?这都是细节问题,有很多方法实现。现在完整的框架已经完成,下面开始具体化。

L
update  
labuladong 已提交
178
### 三、秒杀题目
L
labuladong 已提交
179

L
labuladong 已提交
180 181 182
**第一题,先说力扣第 121 题「买卖股票的最佳时机」,相当于 `k = 1` 的情况**

![](https://labuladong.github.io/algo/images/股票问题/title1.png)
L
labuladong 已提交
183 184 185

直接套状态转移方程,根据 base case,可以做一些化简:

L
update  
labuladong 已提交
186
```python
L
labuladong 已提交
187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209
dp[i][1][0] = max(dp[i-1][1][0], dp[i-1][1][1] + prices[i])
dp[i][1][1] = max(dp[i-1][1][1], dp[i-1][0][0] - prices[i]) 
            = max(dp[i-1][1][1], -prices[i])
解释k = 0  base case所以 dp[i-1][0][0] = 0

现在发现 k 都是 1不会改变 k 对状态转移已经没有影响了
可以进行进一步化简去掉所有 k
dp[i][0] = max(dp[i-1][0], dp[i-1][1] + prices[i])
dp[i][1] = max(dp[i-1][1], -prices[i])
```

直接写出代码:

```java
int n = prices.length;
int[][] dp = new int[n][2];
for (int i = 0; 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];
```

L
update  
labuladong 已提交
210
显然 `i = 0``i - 1` 是不合法的索引,这是因为我们没有对 `i` 的 base case 进行处理,可以这样给一个特化处理:
L
labuladong 已提交
211 212

```java
L
update  
labuladong 已提交
213 214 215 216 217 218 219 220 221 222 223 224 225 226
if (i - 1 == -1) {
    dp[i][0] = 0;
    // 根据状态转移方程可得:
    //   dp[i][0] 
    // = max(dp[-1][0], dp[-1][1] + prices[i])
    // = max(0, -infinity + prices[i]) = 0

    dp[i][1] = -prices[i];
    // 根据状态转移方程可得:
    //   dp[i][1] 
    // = max(dp[-1][1], dp[-1][0] - prices[i])
    // = max(-infinity, 0 - prices[i]) 
    // = -prices[i]
    continue;
L
labuladong 已提交
227 228 229
}
```

L
labuladong 已提交
230
第一题就解决了,但是这样处理 base case 很麻烦,而且注意一下状态转移方程,新状态只和相邻的一个状态有关,所以可以用前文 [动态规划的降维打击:空间压缩技巧](https://labuladong.github.io/article/fname.html?fname=状态压缩技巧),不需要用整个 `dp` 数组,只需要一个变量储存相邻的那个状态就足够了,这样可以把空间复杂度降到 O(1):
L
labuladong 已提交
231 232

```java
L
update  
labuladong 已提交
233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250
// 原始版本
int maxProfit_k_1(int[] prices) {
    int n = prices.length;
    int[][] dp = new int[n][2];
    for (int i = 0; i < n; i++) {
        if (i - 1 == -1) {
            // base case
            dp[i][0] = 0;
            dp[i][1] = -prices[i];
            continue;
        }
        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];
}

// 空间复杂度优化版本
L
labuladong 已提交
251 252 253 254 255 256 257 258 259 260 261 262 263 264
int maxProfit_k_1(int[] prices) {
    int n = prices.length;
    // base case: dp[-1][0] = 0, dp[-1][1] = -infinity
    int dp_i_0 = 0, dp_i_1 = Integer.MIN_VALUE;
    for (int i = 0; i < n; i++) {
        // dp[i][0] = max(dp[i-1][0], dp[i-1][1] + prices[i])
        dp_i_0 = Math.max(dp_i_0, dp_i_1 + prices[i]);
        // dp[i][1] = max(dp[i-1][1], -prices[i])
        dp_i_1 = Math.max(dp_i_1, -prices[i]);
    }
    return dp_i_0;
}
```

L
update  
labuladong 已提交
265
两种方式都是一样的,不过这种编程方法简洁很多,但是如果没有前面状态转移方程的引导,是肯定看不懂的。后续的题目,你可以对比一下如何把 `dp` 数组的空间优化掉。
L
labuladong 已提交
266

L
labuladong 已提交
267 268 269 270 271
**第二题,看一下力扣第 122 题「买卖股票的最佳时机 II」,也就是 `k` 为正无穷的情况**

![](https://labuladong.github.io/algo/images/股票问题/title2.png)

题目还专门强调可以在同一天出售,但我觉得这个条件纯属多余,如果当天买当天卖,那利润当然就是 0,这不是和没有进行交易是一样的吗?这道题的特点在于没有给出交易总数 `k` 的限制,也就相当于 `k` 为正无穷。
L
labuladong 已提交
272

L
update  
labuladong 已提交
273
如果 `k` 为正无穷,那么就可以认为 `k``k - 1` 是一样的。可以这样改写框架:
L
labuladong 已提交
274 275 276 277 278 279 280 281 282 283 284 285 286 287

```python
dp[i][k][0] = max(dp[i-1][k][0], dp[i-1][k][1] + prices[i])
dp[i][k][1] = max(dp[i-1][k][1], dp[i-1][k-1][0] - prices[i])
            = max(dp[i-1][k][1], dp[i-1][k][0] - prices[i])

我们发现数组中的 k 已经不会改变了也就是说不需要记录 k 这个状态了
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])
```

直接翻译成代码:

```java
L
update  
labuladong 已提交
288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305
// 原始版本
int maxProfit_k_inf(int[] prices) {
    int n = prices.length;
    int[][] dp = new int[n][2];
    for (int i = 0; i < n; i++) {
        if (i - 1 == -1) {
            // base case
            dp[i][0] = 0;
            dp[i][1] = -prices[i];
            continue;
        }
        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];
}

// 空间复杂度优化版本
L
labuladong 已提交
306 307 308 309 310 311 312 313 314 315 316 317
int maxProfit_k_inf(int[] prices) {
    int n = prices.length;
    int dp_i_0 = 0, dp_i_1 = Integer.MIN_VALUE;
    for (int i = 0; i < n; i++) {
        int temp = dp_i_0;
        dp_i_0 = Math.max(dp_i_0, dp_i_1 + prices[i]);
        dp_i_1 = Math.max(dp_i_1, temp - prices[i]);
    }
    return dp_i_0;
}
```

L
labuladong 已提交
318 319 320
**第三题,看力扣第 309 题「最佳买卖股票时机含冷冻期」,也就是 `k` 为正无穷,但含有交易冷冻期的情况**

![](https://labuladong.github.io/algo/images/股票问题/title3.png)
L
labuladong 已提交
321

L
labuladong 已提交
322
和上一道题一样的,只不过每次 `sell` 之后要等一天才能继续交易,只要把这个特点融入上一题的状态转移方程即可:
L
labuladong 已提交
323

L
update  
labuladong 已提交
324
```python
L
labuladong 已提交
325 326 327 328 329 330 331 332
dp[i][0] = max(dp[i-1][0], dp[i-1][1] + prices[i])
dp[i][1] = max(dp[i-1][1], dp[i-2][0] - prices[i])
解释 i 天选择 buy 的时候要从 i-2 的状态转移而不是 i-1 
```

翻译成代码:

```java
L
update  
labuladong 已提交
333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361
// 原始版本
int maxProfit_with_cool(int[] prices) {
    int n = prices.length;
    int[][] dp = new int[n][2];
    for (int i = 0; i < n; i++) {
        if (i - 1 == -1) {
            // base case 1
            dp[i][0] = 0;
            dp[i][1] = -prices[i];
            continue;
        }
        if (i - 2 == -1) {
            // base case 2
            dp[i][0] = Math.max(dp[i-1][0], dp[i-1][1] + prices[i]);
            // i - 2 小于 0 时根据状态转移方程推出对应 base case
            dp[i][1] = Math.max(dp[i-1][1], -prices[i]);
            //   dp[i][1] 
            // = max(dp[i-1][1], dp[-1][0] - prices[i])
            // = max(dp[i-1][1], 0 - prices[i])
            // = max(dp[i-1][1], -prices[i])
            continue;
        }
        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-2][0] - prices[i]);
    }
    return dp[n - 1][0];
}

// 空间复杂度优化版本
L
labuladong 已提交
362 363 364 365 366 367 368 369 370 371 372 373 374 375
int maxProfit_with_cool(int[] prices) {
    int n = prices.length;
    int dp_i_0 = 0, dp_i_1 = Integer.MIN_VALUE;
    int dp_pre_0 = 0; // 代表 dp[i-2][0]
    for (int i = 0; i < n; i++) {
        int temp = dp_i_0;
        dp_i_0 = Math.max(dp_i_0, dp_i_1 + prices[i]);
        dp_i_1 = Math.max(dp_i_1, dp_pre_0 - prices[i]);
        dp_pre_0 = temp;
    }
    return dp_i_0;
}
```

L
labuladong 已提交
376 377 378
**第四题,看力扣第 714 题「买卖股票的最佳时机含手续费」,也就是 `k` 为正无穷且考虑交易手续费的情况**

![](https://labuladong.github.io/algo/images/股票问题/title4.png)
L
labuladong 已提交
379

L
labuladong 已提交
380
每次交易要支付手续费,只要把手续费从利润中减去即可,改写方程:
L
labuladong 已提交
381

L
update  
labuladong 已提交
382
```python
L
labuladong 已提交
383 384 385 386 387 388
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)
解释相当于买入股票的价格升高了
在第一个式子里减也是一样的相当于卖出股票的价格减小了
```

L
labuladong 已提交
389
> 如果直接把 `fee` 放在第一个式子里减,会有一些测试用例无法通过,错误原因是整型溢出而不是思路问题。一种解决方案是把代码中的 `int` 类型都改成 `long` 类型,避免 `int` 的整型溢出。
L
update  
labuladong 已提交
390 391

直接翻译成代码,注意状态转移方程改变后 base case 也要做出对应改变:
L
labuladong 已提交
392 393

```java
L
update  
labuladong 已提交
394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416
// 原始版本
int maxProfit_with_fee(int[] prices, int fee) {
    int n = prices.length;
    int[][] dp = new int[n][2];
    for (int i = 0; i < n; i++) {
        if (i - 1 == -1) {
            // base case
            dp[i][0] = 0;
            dp[i][1] = -prices[i] - fee;
            //   dp[i][1]
            // = max(dp[i - 1][1], dp[i - 1][0] - prices[i] - fee)
            // = max(dp[-1][1], dp[-1][0] - prices[i] - fee)
            // = max(-inf, 0 - prices[i] - fee)
            // = -prices[i] - fee
            continue;
        }
        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] - fee);
    }
    return dp[n - 1][0];
}

// 空间复杂度优化版本
L
labuladong 已提交
417 418 419 420 421 422 423 424 425 426 427 428
int maxProfit_with_fee(int[] prices, int fee) {
    int n = prices.length;
    int dp_i_0 = 0, dp_i_1 = Integer.MIN_VALUE;
    for (int i = 0; i < n; i++) {
        int temp = dp_i_0;
        dp_i_0 = Math.max(dp_i_0, dp_i_1 + prices[i]);
        dp_i_1 = Math.max(dp_i_1, temp - prices[i] - fee);
    }
    return dp_i_0;
}
```

L
labuladong 已提交
429
**第五题,看力扣第 123 题「买卖股票的最佳时机 III」,也就是 `k = 2` 的情况**
L
labuladong 已提交
430

L
labuladong 已提交
431
![](https://labuladong.github.io/algo/images/股票问题/title5.png)
L
labuladong 已提交
432

L
labuladong 已提交
433 434 435
`k = 2` 和前面题目的情况稍微不同,因为上面的情况都和 `k` 的关系不太大:要么 `k` 是正无穷,状态转移和 `k` 没关系了;要么 `k = 1`,跟 `k = 0` 这个 base case 挨得近,最后也没有存在感。

这道题 `k = 2` 和后面要讲的 `k` 是任意正整数的情况中,对 `k` 的处理就凸显出来了,我们直接写代码,边写边分析原因。
L
labuladong 已提交
436 437

```java
L
update  
labuladong 已提交
438
原始的状态转移方程没有可化简的地方
L
labuladong 已提交
439 440 441 442 443 444 445 446 447
dp[i][k][0] = max(dp[i-1][k][0], dp[i-1][k][1] + prices[i])
dp[i][k][1] = max(dp[i-1][k][1], dp[i-1][k-1][0] - prices[i])
```

按照之前的代码,我们可能想当然这样写代码(错误的):

```java
int k = 2;
int[][][] dp = new int[n][k + 1][2];
L
update  
labuladong 已提交
448 449 450 451 452 453 454
for (int i = 0; i < n; i++) {
    if (i - 1 == -1) {
        // 处理 base case
        dp[i][k][0] = 0;
        dp[i][k][1] = -prices[i];
        continue;
    }
L
labuladong 已提交
455 456 457 458 459 460 461 462
    dp[i][k][0] = Math.max(dp[i-1][k][0], dp[i-1][k][1] + prices[i]);
    dp[i][k][1] = Math.max(dp[i-1][k][1], dp[i-1][k-1][0] - prices[i]);
}
return dp[n - 1][k][0];
```

为什么错误?我这不是照着状态转移方程写的吗?

L
update  
labuladong 已提交
463
还记得前面总结的「穷举框架」吗?就是说我们必须穷举所有状态。其实我们之前的解法,都在穷举所有状态,只是之前的题目中 `k` 都被化简掉了。
L
labuladong 已提交
464

L
update  
labuladong 已提交
465
比如说第一题,`k = 1` 时的代码框架:
L
labuladong 已提交
466 467

```java
L
update  
labuladong 已提交
468 469
int n = prices.length;
int[][] dp = new int[n][2];
L
labuladong 已提交
470
for (int i = 0; i < n; i++) {
L
update  
labuladong 已提交
471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494
    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];
```

但当 `k = 2` 时,由于没有消掉 `k` 的影响,所以必须要对 `k` 进行穷举:

```java
// 原始版本
int maxProfit_k_2(int[] prices) {
    int max_k = 2, n = prices.length;
    int[][][] dp = new int[n][max_k + 1][2];
    for (int i = 0; i < n; i++) {
        for (int k = max_k; k >= 1; k--) {
            if (i - 1 == -1) {
                // 处理 base case
                dp[i][k][0] = 0;
                dp[i][k][1] = -prices[i];
                continue;
            }
            dp[i][k][0] = Math.max(dp[i-1][k][0], dp[i-1][k][1] + prices[i]);
            dp[i][k][1] = Math.max(dp[i-1][k][1], dp[i-1][k-1][0] - prices[i]);
        }
L
labuladong 已提交
495
    }
L
update  
labuladong 已提交
496 497
    // 穷举了 n × max_k × 2 个状态,正确。
    return dp[n - 1][max_k][0];
L
labuladong 已提交
498 499 500
}
```

L
update  
labuladong 已提交
501
> **PS:这里肯定会有读者疑惑,`k` 的 base case 是 0,按理说应该从 `k = 1, k++` 这样穷举状态 `k` 才对?而且如果你真的这样从小到大遍历 `k`,提交发现也是可以的**。
L
labuladong 已提交
502

L
labuladong 已提交
503
这个疑问很正确,因为我们前文 [动态规划答疑篇](https://labuladong.github.io/article/fname.html?fname=最优子结构) 有介绍 `dp` 数组的遍历顺序是怎么确定的,主要是根据 base case,以 base case 为起点,逐步向结果靠近。
L
update  
labuladong 已提交
504

L
labuladong 已提交
505
但为什么我从大到小遍历 `k` 也可以正确提交呢?因为你注意看,`dp[i][k][..]` 不会依赖 `dp[i][k - 1][..]`,而是依赖 `dp[i - 1][k - 1][..]`,而 `dp[i - 1][..][..]`,都是已经计算出来的,所以不管你是 `k = max_k, k--`,还是 `k = 1, k++`,都是可以得出正确答案的。
L
update  
labuladong 已提交
506

L
labuladong 已提交
507
那为什么我使用 `k = max_k, k--` 的方式呢?因为这样符合语义:
L
update  
labuladong 已提交
508 509 510

你买股票,初始的「状态」是什么?应该是从第 0 天开始,而且还没有进行过买卖,所以最大交易次数限制 `k` 应该是 `max_k`;而随着「状态」的推移,你会进行交易,那么交易次数上限 `k` 应该不断减少,这样一想,`k = max_k, k--` 的方式是比较合乎实际场景的。

L
labuladong 已提交
511
当然,这里 `k` 取值范围比较小,所以也可以不用 for 循环,直接把 k = 1 和 2 的情况全部列举出来也可以:
L
labuladong 已提交
512 513

```java
L
update  
labuladong 已提交
514 515 516 517 518
// 状态转移方程:
// dp[i][2][0] = max(dp[i-1][2][0], dp[i-1][2][1] + prices[i])
// dp[i][2][1] = max(dp[i-1][2][1], dp[i-1][1][0] - prices[i])
// dp[i][1][0] = max(dp[i-1][1][0], dp[i-1][1][1] + prices[i])
// dp[i][1][1] = max(dp[i-1][1][1], -prices[i])
L
labuladong 已提交
519

L
update  
labuladong 已提交
520
// 空间复杂度优化版本
L
labuladong 已提交
521
int maxProfit_k_2(int[] prices) {
L
update  
labuladong 已提交
522
    // base case
L
labuladong 已提交
523 524 525 526 527 528 529 530 531 532 533 534
    int dp_i10 = 0, dp_i11 = Integer.MIN_VALUE;
    int dp_i20 = 0, dp_i21 = Integer.MIN_VALUE;
    for (int price : prices) {
        dp_i20 = Math.max(dp_i20, dp_i21 + price);
        dp_i21 = Math.max(dp_i21, dp_i10 - price);
        dp_i10 = Math.max(dp_i10, dp_i11 + price);
        dp_i11 = Math.max(dp_i11, -price);
    }
    return dp_i20;
}
```

L
update  
labuladong 已提交
535
有状态转移方程和含义明确的变量名指导,相信你很容易看懂。其实我们可以故弄玄虚,把上述四个变量换成 `a, b, c, d`。这样当别人看到你的代码时就会大惊失色,对你肃然起敬。
L
labuladong 已提交
536

L
labuladong 已提交
537 538 539 540 541
**第六题,看力扣第 188 题「买卖股票的最佳时机 IV」,即 `k` 可以是题目给定的任何数的情况**

![](https://labuladong.github.io/algo/images/股票问题/title.png)

有了上一题 `k = 2` 的铺垫,这题应该和上一题的第一个解法没啥区别,你把上一题的 `k = 2` 换成题目输入的 `k` 就行了。
L
labuladong 已提交
542

L
labuladong 已提交
543
但试一下发现会出一个内存超限的错误,原来是传入的 `k` 值会非常大,`dp` 数组太大了。那么现在想想,交易次数 `k` 最多有多大呢?
L
labuladong 已提交
544

L
labuladong 已提交
545
一次交易由买入和卖出构成,至少需要两天。所以说有效的限制 `k` 应该不超过 `n/2`,如果超过,就没有约束作用了,相当于 `k` 没有限制的情况,而这种情况是之前解决过的。
L
labuladong 已提交
546

L
labuladong 已提交
547
所以我们可以直接把之前的代码重用:
L
labuladong 已提交
548 549 550 551

```java
int maxProfit_k_any(int max_k, int[] prices) {
    int n = prices.length;
L
update  
labuladong 已提交
552 553 554 555
    if (n <= 0) {
        return 0;
    }
    if (max_k > n / 2) {
L
labuladong 已提交
556
        // 复用之前交易次数 k 没有限制的情况
L
labuladong 已提交
557
        return maxProfit_k_inf(prices);
L
update  
labuladong 已提交
558
    }
L
labuladong 已提交
559

L
update  
labuladong 已提交
560 561 562
    // base case:
    // dp[-1][...][0] = dp[...][0][0] = 0
    // dp[-1][...][1] = dp[...][0][1] = -infinity
L
labuladong 已提交
563
    int[][][] dp = new int[n][max_k + 1][2];
L
update  
labuladong 已提交
564 565 566 567 568 569
    // k = 0 时的 base case
    for (int i = 0; i < n; i++) {
        dp[i][0][1] = Integer.MIN_VALUE;
        dp[i][0][0] = 0;
    }

L
labuladong 已提交
570 571
    for (int i = 0; i < n; i++) 
        for (int k = max_k; k >= 1; k--) {
L
update  
labuladong 已提交
572 573 574 575 576 577 578 579
            if (i - 1 == -1) {
                // 处理 i = -1 时的 base case
                dp[i][k][0] = 0;
                dp[i][k][1] = -prices[i];
                continue;
            }
            dp[i][k][0] = Math.max(dp[i-1][k][0], dp[i-1][k][1] + prices[i]);
            dp[i][k][1] = Math.max(dp[i-1][k][1], dp[i-1][k-1][0] - prices[i]);     
L
labuladong 已提交
580 581 582 583 584 585 586
        }
    return dp[n - 1][max_k][0];
}
```

至此,6 道题目通过一个状态转移方程全部解决。

L
labuladong 已提交
587 588 589 590 591 592 593 594 595
### 万法归一

如果你能看到这里,已经可以给你鼓掌了,初次理解如此复杂的动态规划问题想必消耗了你不少的脑细胞,不过这是值得的,股票系列问题已经属于动态规划问题中较困难的了,如果这些题你都能搞懂,试问,其他那些虾兵蟹将又何足道哉?

**现在你已经过了九九八十一难中的前八十难,最后我还要再难为你一下,请你实现如下函数**

```java
int maxProfit_all_in_one(int max_k, int[] prices, int cooldown, int fee);
```
L
labuladong 已提交
596

L
labuladong 已提交
597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676
输入股票价格数组 `prices`,你最多进行 `max_k` 次交易,每次交易需要额外消耗 `fee` 的手续费,而且每次交易之后需要经过 `cooldown` 天的冷冻期才能进行下一次交易,请你计算并返回可以获得的最大利润。

怎么样,有没有被吓到?如果你直接给别人出一道这样的题目,估计对方要当场吐血,不过我们这样一步步做过来,你应该很容易发现这道题目就是之前我们探讨的几种情况的组合体嘛。

所以,我们只要把之前实现的几种代码掺和到一块,**在 base case 和状态转移方程中同时加上 `cooldown` 和 `fee` 的约束就行了**

```java
// 同时考虑交易次数的限制、冷冻期和手续费
int maxProfit_all_in_one(int max_k, int[] prices, int cooldown, int fee) {
    int n = prices.length;
    if (n <= 0) {
        return 0;
    }
    if (max_k > n / 2) {
        // 交易次数 k 没有限制的情况
        return maxProfit_k_inf(prices, cooldown, fee);
    }

    int[][][] dp = new int[n][max_k + 1][2];
    // k = 0 时的 base case
    for (int i = 0; i < n; i++) {
        dp[i][0][1] = Integer.MIN_VALUE;
        dp[i][0][0] = 0;
    }

    for (int i = 0; i < n; i++) 
        for (int k = max_k; k >= 1; k--) {
            if (i - 1 == -1) {
                // base case 1
                dp[i][k][0] = 0;
                dp[i][k][1] = -prices[i] - fee;
                continue;
            }

            // 包含 cooldown 的 base case
            if (i - cooldown - 1 < 0) {
                // base case 2
                dp[i][k][0] = Math.max(dp[i-1][k][0], dp[i-1][k][1] + prices[i]);
                // 别忘了减 fee
                dp[i][k][1] = Math.max(dp[i-1][k][1], -prices[i] - fee);
                continue;
            }
            dp[i][k][0] = Math.max(dp[i-1][k][0], dp[i-1][k][1] + prices[i]);
            // 同时考虑 cooldown 和 fee
            dp[i][k][1] = Math.max(dp[i-1][k][1], dp[i-cooldown-1][k-1][0] - prices[i] - fee);     
        }
    return dp[n - 1][max_k][0];
}

// k 无限制,包含手续费和冷冻期
int maxProfit_k_inf(int[] prices, int cooldown, int fee) {
    int n = prices.length;
    int[][] dp = new int[n][2];
    for (int i = 0; i < n; i++) {
        if (i - 1 == -1) {
            // base case 1
            dp[i][0] = 0;
            dp[i][1] = -prices[i] - fee;
            continue;
        }

        // 包含 cooldown 的 base case
        if (i - cooldown - 1 < 0) {
            // base case 2
            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], -prices[i] - fee);
            continue;
        }
        dp[i][0] = Math.max(dp[i - 1][0], dp[i - 1][1] + prices[i]);
        // 同时考虑 cooldown 和 fee
        dp[i][1] = Math.max(dp[i - 1][1], dp[i - cooldown - 1][0] - prices[i] - fee);
    }
    return dp[n - 1][0];
}
```

你可以用这个 `maxProfit_all_in_one` 函数去完成之前讲的 6 道题目,因为我们无法对 `dp` 数组进行优化,所以执行效率上不是最优的,但正确性上肯定是没有问题的。

最后总结一下吧,本文给大家讲了如何通过状态转移的方法解决复杂的问题,用一个状态转移方程秒杀了 6 道股票买卖问题,现在回头去看,其实也不算那么可怕对吧?
L
labuladong 已提交
677

L
update  
labuladong 已提交
678
关键就在于列举出所有可能的「状态」,然后想想怎么穷举更新这些「状态」。一般用一个多维 `dp` 数组储存这些状态,从 base case 开始向后推进,推进到最后的状态,就是我们想要的答案。想想这个过程,你是不是有点理解「动态规划」这个名词的意义了呢?
L
labuladong 已提交
679 680 681

具体到股票买卖问题,我们发现了三个状态,使用了一个三维数组,无非还是穷举 + 更新,不过我们可以说的高大上一点,这叫「三维 DP」,怕不怕?这个大实话一说,立刻显得你高人一等,名利双收有没有,所以给个在看/分享吧,鼓励一下我。

682

L
labuladong 已提交
683

L
labuladong 已提交
684 685 686
<hr>
<details>
<summary><strong>引用本文的文章</strong></summary>
L
labuladong 已提交
687

L
labuladong 已提交
688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711
 - [最优子结构原理和 dp 数组遍历方向](https://labuladong.github.io/article/fname.html?fname=最优子结构)
 - [经典动态规划:0-1 背包问题](https://labuladong.github.io/article/fname.html?fname=背包问题)

</details><hr>




<hr>
<details>
<summary><strong>引用本文的题目</strong></summary>

<strong>安装 [我的 Chrome 刷题插件](https://mp.weixin.qq.com/s/X-fE9sR4BLi6T9pn7xP4pg) 点开下列题目可直接查看解题思路:</strong>

| LeetCode | 力扣 |
| :----: | :----: |
| - | [剑指 Offer 63. 股票的最大利润](https://leetcode.cn/problems/gu-piao-de-zui-da-li-run-lcof/?show=1) |

</details>



**_____________**

L
labuladong 已提交
712
**《labuladong 的算法小抄》已经出版,关注公众号查看详情;后台回复关键词「**进群**」可加入算法群;回复「**全家桶**」可下载配套 PDF 和刷题全家桶**
L
labuladong 已提交
713 714

![](https://labuladong.github.io/algo/images/souyisou2.png)