如果你刷过 LeetCode 的股票买卖系列,一定绕不开“冷冻期”这道坎。普通版的买卖股票允许无限次交易,贪心都能做;加了手续费,核心状态还是两个;可一旦题目写上“卖出股票后,你无法在第二天买入股票(即冷冻期为 1 天)”,这道题的性质立刻就变了。今天这篇是系列第 87 课,我准备把“股票含冷冻期”这个问题里里外外讲透:从状态机怎么建、转移方程怎么来,到代码怎么写、哪里最容易踩坑,最后再聊聊这个模型放在真实交易场景里到底能干什么、不能干什么。这个过程不需要你有多强的数学背景,只要会看表格,跟着手推一遍,基本就很难再忘。
1. 冷冻期这道题,和你做过的股票买卖题到底差在哪
1.1 一个“卖出后必须休息一天”的额外约束
先把题目说清楚。给你一个数组prices,prices[i]表示第 i 天的股票价格。你可以尽可能多地完成交易,也就是可以无限次买入卖出,但有一个硬约束:卖出股票后,第二天不能买入股票,必须再等一天。换句话说,卖出后的次日是一个“冷冻日”,当天只能看着行情,不能动手建仓。
这就是 LeetCode 309 题的标准设定。常见叫法包括“最佳买卖股票时机含冷冻期”“股票含冷冻期”“带冷却时间的股票交易”。如果你之前刷过 121、122、123、188 这些股票题,会发现这一题在三者之间非常微妙:它和 122(无限次交易)一样不限制交易次数,但因为有冷冻期,贪心不再正确;它和 188(限制交易次数)一样需要更复杂的状态,但它的复杂点不是多一维“交易次数”,而是要多一个“冷却状态”。
我第一次做这题的时候,第一反应是“在 122 的贪心代码上稍微改改不就行了?”结果一跑用例就被教育了。那个额外约束看着不起眼,实际上把整个决策结构都改变了。
1.2 为什么贪心会翻车:一个反直觉的小例子
普通无限次买卖股票用贪心很好写:遍历所有相邻价格,只要后一天比前一天高,就把差价累加进去。因为不限制交易次数,每一段上涨你都能吃到,这是标准解法。
但同样一个数组,加上冷冻期,贪心就会高估利润。
用 LeetCode 官方示例prices = [1, 2, 3, 0, 2]来算:
- 普通贪心做法:把所有正差价加起来,(2-1) + (3-2) + (2-0) = 1 + 1 + 2 = 4。
- 正确答案是 3。最优路径是:第 1 天买(价格 1),第 2 天卖(价格 2),赚 1;然后进入冷冻期;第 4 天买(价格 0),第 5 天卖(价格 2),赚 2;合计 3。
贪心想吃三段上涨,但实际上你吃完“1 买 2 卖”之后,第 3 天是被锁住的,不能立刻买价格 3 的股票。这中间出现了一个“机会错位”,贪心策略无法判断该放弃哪一段,只能全局去算。这就是为什么这道题不能继续用贪心,必须上动态规划。
1.3 和其他股票买卖变体的对比
我把几个变体放在一起对比了一下,这样更容易看出冷冻期到底改变了什么:
| 题目变体 | 状态维度 | 核心难点 |
|---|---|---|
| 普通无限次交易 | 持有 / 空仓 | 无附加值,贪心可解 |
| 带手续费 | 持有 / 空仓 | 转移时扣手续费,状态仍两个 |
| 限制最大 K 笔 | 持有 / 空仓 + K 维 | 交易次数维度的状态压缩 |
| 含冷冻期 | 持有 / 空仓 / 冷却 | 必须显式建模“冷却”状态 |
冷冻期特殊就特殊在:它不修改买入卖出的成本,也不限制交易总数,却强制你在“卖出”和“下一次买入”之间插入一段不可操作的时间。想在动态规划里表达这种强制休息,就必须把“冷却中”单独作为一个状态记录下来。
2. 状态机建模:把“冷却 CD”拆成三种可转移的状态
2.1 三个状态的定义与边界
很多入门动态规划的人容易困在“我要不要用二维数组、要不要多一层循环”这件事上。其实做状态机题,第一步不是写代码,而是给状态命名和划线。股票含冷冻期这个问题,收盘后你只可能处在三种状态之一:
dp[i][0]:第 i 天收盘后,手里持有股票时的最大利润。这个状态下,你可以在后续卖出,也可以继续持有。dp[i][1]:第 i 天收盘后,手里没有股票,并且正处于冷冻期的最大利润。“冷冻期”的含义是:今天刚卖出过股票,所以第 i+1 天你不能买入。dp[i][2]:第 i 天收盘后,手里没有股票,且不在冷冻期的最大利润。这个状态代表“空仓且可买”,可以观望,也可以在下一秒买入。
注意我一直在强调“第 i 天收盘后”。这一点很关键,因为买卖动作发生在一个时间切片内,而状态描述的是切片结束后的结果。只有把时间点统一到“收盘”,状态转移才不会产生“当天卖了又买”这种越界。
2.2 状态间哪些边存在:一张文字版状态转移图
不用画复杂的图,所有合法转移都能数出来:
- 持有 → 持有:前一天就持股,今天继续拿着不卖。
- 持有 → 冷却:今天把手里股票卖出,收盘后进入冷冻期。
- 冷却 → 空仓可买:昨天刚卖,今天冷冻期到期,收盘后恢复可买状态。
- 空仓可买 → 持有:在前一天空仓且不在冷冻的前提下,今天买入股票。
- 空仓可买 → 空仓可买:空仓观望,什么都不做。
同时也要清楚哪些边不存在:
- 冷却 → 持有:不存在。因为冷冻期意味着今天不能买入。
- 空仓可买 → 冷却:不存在。又不卖股票,怎么可能进入冷冻状态。
这几条“边”其实就是后续转移方程的地图。你能画出这些箭头,方程基本就自己长出来了。
2.3 为啥非要多一个状态,不能偷懒
有人会想:不持有股票的情况下,用一个“空仓”状态不就行了?冷冻期不就是“空仓但限制买入”吗,判断一下日期不就可以了?
理论上是能强行用 if 判断日期来救,但状态一旦不显式建模,转移逻辑就会变得零零散散。比如你需要在买入前检查“上一个卖出日是否是昨天”,代码里就要额外维护一个卖出日变量,还要在多个分支里同步更新。状态一多,很容易漏。而把“冷却”变成独立状态之后,所有规则都体现在状态图上,代码只是照着抄,逻辑清晰也不容易错。
打个比方:这就像竞技游戏里的技能冷却 CD。技能放完之后,图标进入灰色转圈,你不能立刻再按一次,必须等它转完。冷却状态就是这个“灰色转圈”的图标,你不能假装它可以提前点。
3. 状态转移方程与手算 dp 表:用 5 天价格把过程摊开看
3.1 三个方程是怎么长出来的
基于上面的状态定义,逐个推导。
先看dp[i][0](收盘后持有股票)。它只有两个来源:
- 昨天收盘时就持有,今天继续持有。利润就是
dp[i-1][0]。 - 昨天收盘时是空仓可买状态,今天买入。利润是
dp[i-1][2] - prices[i],因为买入要花钱。
注意这里不能从“冷却”状态买入,因为冷却期当天禁止买入。所以:
dp[i][0] = max(dp[i-1][0], dp[i-1][2] - prices[i])再看dp[i][1](收盘后处于冷却)。它只有一个来源:
- 昨天收盘时持有股票,今天卖出。利润是
dp[i-1][0] + prices[i]。
那为什么不是max(dp[i-1][1], dp[i-1][0] + prices[i])这种形式?因为冷却状态不能“延续”——你昨天已经冷却,今天不可能继续处在“刚卖出”的冷冻状态,昨天冷却结束后,今天应该落回空仓可买。强行把dp[i-1][1]延续到今天,会让冷冻期被无限拉长,语义就错了。所以:
dp[i][1] = dp[i-1][0] + prices[i]最后看dp[i][2](收盘后空仓且可买)。它也有两个来源:
- 昨天收盘后在冷却,今天冷冻到期,恢复可买。利润是
dp[i-1][1]。 - 昨天收盘后就在空仓可买状态,今天继续观望。利润是
dp[i-1][2]。
dp[i][2] = max(dp[i-1][1], dp[i-1][2])这三个方程分别对应状态图里的三条主要转移路径,没有多余也没有遗漏。
3.2 用 [1, 2, 3, 0, 2] 手推一张 dp 表
跟着例子走一遍,把每个值都算出来,比背公式有用得多。初始prices = [1, 2, 3, 0, 2]。
第 0 天,价格 1:
| i | prices[i] | dp[i][0](持有) | dp[i][1](冷却) | dp[i][2](空仓可买) |
|---|---|---|---|---|
| 0 | 1 | -1 | 0 | 0 |
第 0 天买入的话,花 1 元,利润计为 -1;不买则利润 0。dp[0][1]填 0 是初始化的处理,语义上第 0 天不可能是“刚卖出”,但填 0 不会污染后面的结果,原因后面再讲。
第 1 天,价格 2:
| i | prices[i] | dp[i][0] | dp[i][1] | dp[i][2] |
|---|---|---|---|---|
| 0 | 1 | -1 | 0 | 0 |
| 1 | 2 | -1 | 1 | 0 |
计算过程:
dp[1][0] = max(-1, 0 - 2) = -1。继续持有第 0 天买的股票,比第 1 天重新买入更划算。dp[1][1] = -1 + 2 = 1。第 0 天买、第 1 天卖,赚 1 元,进入冷冻期。dp[1][2] = max(0, 0) = 0。
第 2 天,价格 3:
| i | prices[i] | dp[i][0] | dp[i][1] | dp[i][2] |
|---|---|---|---|---|
| 0 | 1 | -1 | 0 | 0 |
| 1 | 2 | -1 | 1 | 0 |
| 2 | 3 | -1 | 2 | 1 |
计算过程:
dp[2][0] = max(-1, 0 - 3) = -1。dp[2][1] = -1 + 3 = 2。第 0 天买、第 2 天卖,赚 2 元。dp[2][2] = max(dp[1][1], dp[1][2]) = max(1, 0) = 1。第 1 天卖出赚了 1 元,今天冷冻结束,所以“空仓可买”状态对应的利润就是 1。
注意,第 1 天卖出后,第 2 天是不能买入的,但可以在第 2 天收盘后恢复到“空仓可买”,为第 3 天的买入做准备。这就是冷却状态在时间轴上的意义。
第 3 天,价格 0:
| i | prices[i] | dp[i][0] | dp[i][1] | dp[i][2] |
|---|---|---|---|---|
| 0 | 1 | -1 | 0 | 0 |
| 1 | 2 | -1 | 1 | 0 |
| 2 | 3 | -1 | 2 | 1 |
| 3 | 0 | 1 | -1 | 2 |
计算过程:
dp[3][0] = max(-1, 1 - 0) = 1。这里发生了很有意思的跳变:昨天“空仓可买”状态已经积累了 1 元利润,今天用 0 元买入股票,所以账面利润仍是 1。这笔买入非常合算。dp[3][1] = -1 + 0 = -1。今天从持有状态卖出,只能卖 0 元,不划算。dp[3][2] = max(2, 1) = 2。第 2 天卖出赚 2 元,今天冷却结束,空仓可买状态的利润是 2。
第 4 天,价格 2:
| i | prices[i] | dp[i][0] | dp[i][1] | dp[i][2] |
|---|---|---|---|---|
| 0 | 1 | -1 | 0 | 0 |
| 1 | 2 | -1 | 1 | 0 |
| 2 | 3 | -1 | 2 | 1 |
| 3 | 0 | 1 | -1 | 2 |
| 4 | 2 | 1 | 3 | 2 |
计算过程:
dp[4][0] = max(1, 2 - 2) = 1。继续持有第 3 天 0 元买的股票最合适。dp[4][1] = 1 + 2 = 3。第 3 天买入(0 元),第 4 天卖出(2 元),加上之前 1 元利润,总共 3 元。dp[4][2] = max(-1, 2) = 2。
最终答案取最后一天不持有股票的两种状态较大者:max(dp[4][1], dp[4][2]) = max(3, 2) = 3。
手推一遍你就会发现,冷冻期并不会阻止你吃到大段利润,而是要求你在时间轴上“错开”交易节奏。dp 表里每次从dp[i-1][1]跳到dp[i][2],就是在模拟“熬过冷冻期,重新获得入场资格”。
3.3 再回答一个经典疑问:为什么答案不取持有状态
看表的时候,dp[4][0] = 1,相比答案 3 小不少,但万一某个用例里持有状态利润更大呢?其实最后一天手里还持有股票,这笔利润是没有实现的浮盈。题目问的是“你能获得的最大利润”,一个负责任的交易系统最终要把持仓卖出结算。标准做法是返回不持有的两个状态中的较大者,也就是max(dp[n-1][1], dp[n-1][2])。
也有不少题解写return max(dp[n-1]),把三个状态都取一遍最大值,碰巧绝大多数测试用例也能过,但那是巧合。真到面试,面试官问你为什么持有也能取,你说不清就会减分。建议一开始就养成只取不持有状态的习惯。
4. 代码落地:O(n) 空间版、滚动数组版与 Java 实现
4.1 最直白的 Python 实现
直接根据 dp 表写代码,是最不容易出错的方式:
def maxProfit(prices): n = len(prices) if n < 2: return 0 dp = [[0] * 3 for _ in range(n)] dp[0][0] = -prices[0] dp[0][1] = 0 dp[0][2] = 0 for i in range(1, n): dp[i][0] = max(dp[i - 1][0], dp[i - 1][2] - prices[i]) dp[i][1] = dp[i - 1][0] + prices[i] dp[i][2] = max(dp[i - 1][1], dp[i - 1][2]) return max(dp[n - 1][1], dp[n - 1][2])这里dp[0][0] = -prices[0]表示第 0 天买入股票,利润记为负的成本。如果第一天不买,后面买入时就会通过dp[i-1][2] - prices[i]来体现,所以不用额外初始化“第一天不买”的情况,因为dp[0][2] = 0已经代表空仓可买且利润为 0。
4.2 滚动数组优化:把空间从 O(n) 降到 O(1)
观察转移方程可以发现,第 i 天的计算只依赖第 i-1 天的三个状态,整个历史 dp 表都不需要保留。所以可以用三个变量不断滚动更新。这也是面试里常见的优化考点。
def maxProfit(prices): n = len(prices) if n < 2: return 0 hold = -prices[0] # dp[i][0] sold = 0 # dp[i][1] rest = 0 # dp[i][2] for i in range(1, n): pre_hold, pre_sold, pre_rest = hold, sold, rest hold = max(pre_hold, pre_rest - prices[i]) sold = pre_hold + prices[i] rest = max(pre_sold, pre_rest) return max(sold, rest)注意这里必须用三个临时变量pre_hold、pre_sold、pre_rest先保存旧值。如果直接写:
hold = max(hold, rest - prices[i]) sold = hold + prices[i]那sold用的是当天刚更新过的hold,而不是昨天的hold,会把“今天买入后又卖出”这种不存在的操作算进去。这是我写过最快的 bug 之一,特别隐蔽。
4.3 Java 版本
面试手撕代码用 Java 的同学可以看这个版本:
public int maxProfit(int[] prices) { int n = prices.length; if (n < 2) { return 0; } int hold = -prices[0]; int sold = 0; int rest = 0; for (int i = 1; i < n; i++) { int preHold = hold; int preSold = sold; int preRest = rest; hold = Math.max(preHold, preRest - prices[i]); sold = preHold + prices[i]; rest = Math.max(preSold, preRest); } return Math.max(sold, rest); }C++ 版本就是把Math.max换成max,其余一模一样。
4.4 时间复杂度和状态数为什么是“刚刚好”
这个解法的时间复杂度是 O(n),空间复杂度最差 O(n)、最优 O(1)。很多初学者会问:能不能再降时间复杂度?答案是不行,因为你必须看一遍所有价格才能做决策。那状态数能不能从 3 压到 2?也不行,因为你无法用两个状态同时表达“持有”“冷却”“空仓可买”三种互斥情况。这里的 3 就是一个“刚好够用”的建模粒度。
5. 最容易翻车的五个细节,以及冷冻期变成 K 天的扩展思路
5.1 五个细节逐个说
- 返回值选错
前面说了,最后一天还可能处于持有状态,但那不是已实现利润。不要图省事写return max(dp[n-1]),尤其用滚动数组时更要明确返回Math.max(sold, rest)。
dp[i][1]写成了带 max 的形式
很多第一次写冷冻期的人会把卖出状态写成:
dp[i][1] = max(dp[i - 1][1], dp[i - 1][0] + prices[i])这个写法会让“冷却状态”沿时间轴一直延续。表面上看不出大问题,但手推几次就会发现某些交易路径被重复计算,利润可能被错误放大。正确的做法是:今天处于冷却,唯一原因只能是“今天卖出”,所以没有 max,直接是dp[i-1][0] + prices[i]。
dp[i][2]来源漏掉一个
dp[i][2]代表“空仓且可买”,它既能来自“昨天冷却刚结束”,也能来自“昨天已经空仓可买继续观望”。漏掉任何一个,都会导致状态迁移不完整。写的时候要刻意把两个来源都写上。
- 初始化问题
dp[0][0]必须是-prices[0],表示第 0 天买入的成本。dp[0][1]和dp[0][2]都填 0。不要把dp[0][0]填 0,否则第一天买入的成本没算进去,后面所有利润都会虚高。
- 长度为 0 或 1 时直接返回 0
价格数组只有 1 天或为空时,没有任何交易空间,直接返回 0。很多题解在循环里自然规避了,但如果你在滚动数组初始化时直接取prices[0],空数组会越界报错,所以开头判空一定要写。
5.2 面试追问:如果冷冻期变成 K 天怎么解
这是从这一题衍生出来的常见进阶问题。冷冻期为 1 天时,三种状态刚好覆盖;如果冷冻期变成 2 天、3 天甚至 K 天,三状态就不够用了,因为“冷却中”还需要知道还剩几天才能恢复买入资格。
解法思路是:把冷却状态拆成 K 个子状态。例如cool[j]表示“距离解冻还有 j 天”时的最大利润,每天让这些冷却状态向前滚动一格,只有cool[0]才允许转入买入。状态总数从 3 变成 K+2,时间复杂度会变成 O(nK),但 K 通常是小常数,实际也可以接受。
我在实际面试中被追问过这个点,面试官真正想听的不是完整代码,而是你能不能意识到“状态需要细分到能表达剩余冷却时间”,并且能说清楚为什么要按冷却天数拆状态。把这个想明白,说明你对状态机建模是真正理解了,而不是死背了三行转移方程。
5.3 一个容易混淆的交易规则:为什么不能卖出当天再买
还有个小细节值得澄清一下。状态机里的买入发生在“空仓可买 → 持有”这条边,卖出发生在“持有 → 冷却”这条边。如果你把买入和卖出都理解为“当天某个时刻的动作”,理论上会出现“上午卖出、下午买入”这种操作。但题目明确说“卖出股票后,无法在第二天买入”,这里并没有禁止当天再买,只是冷冻期让“第二天不能买”,那当天卖出当天再买算不算违规?
算。因为你不能同时参与多笔交易,且卖出后你手里没有股票,再买入就是新开仓,这时候会遇到“是否处于冷冻期”的问题。更严谨地说,标准解法的时间模型里,买入发生在第 i 天的开始或决策点,卖出发生在第 i 天收盘后进入冷却,所以“同一天卖出再买入”在状态机里根本没有对应路径。写代码时不用额外处理,但面试被问到时要能解释清楚,这是状态机时间粒度设计的一部分。
6. 从做题到做策略:冷冻期模型在真实交易里怎么用
6.1 回测里的“交易冷却期”是很常见的设置
很多个人做量化回测时会遇到一个痛点:策略在震荡行情里被反复打脸,今天买明天卖、明天卖后天买,手续费和滑点吃掉了大部分收益。于是有些人会手动加一道规则:每次卖出后 N 天内不再开仓,强制让自己冷静一段时间。
这个规则本质上就是一个“冷却期”。状态机模型可以直接把这个规则做进回测引擎里。引擎维护三种状态:持仓、冷却、空仓可买,每次卖出后把账户状态切到冷却,冷却计数结束再切回可买。这样比在策略代码里到处写“距离上次卖出已经过了几天”的 if 判断要干净得多,也不容易漏掉边界情况。
这也是我喜欢拿 LeetCode 309 做例子的原因:看起来是道算法题,但状态建模的思路放到真实策略回测里完全适用。
6.2 真实市场约束:T+1 和结算周期
真实交易市场也有“最接近冷冻期”的规则。A 股实行 T+1,当天买入的股票当天不能卖出,必须等下一个交易日,这相当于在“买入”后插入了一个持仓锁定期。如果想把这种约束也放进状态机,可以再加一个“当天买入尚未可卖”的状态,买入后先进入该状态,下一天才允许卖出。
美股虽然没有 T+1 买入锁定,但某些监管规则会限制频繁交易,比如日内交易账户的购买力限制。从建模角度来看,这些都可以抽象成“交易之间需要间隔”的冷却状态,思路和本题一致。
不过必须提醒一句:算法题里的价格序列是已知的,动态规划算出来的是“事后最优利润”,也就是上帝视角下的理论上界。真实交易里没人知道明天涨跌,所以你不能把这道题的解法直接当预测模型去实盘用。它的价值更多在于:给你提供一套清晰的建模框架,用于回测、策略热度和规则推演。
6.3 行为金融视角:冷却期为什么能减少乱操作
我自己在看交易相关书籍时发现,强制冷却期这招在很多专业机构里也以“交易纪律”的形式存在。比如某些基金经理会规定,止损卖出某只股票后,至少等一周才能考虑重新买回来。这背后的逻辑不是技术分析,而是防止情绪化交易——刚止损完容易不服气想立刻追回来,结果往往买在下跌中继;强制等待几天,情绪消退后决策质量会高很多。
冷冻期这个约束,从数学上优化了交易节奏,从行为上则是一种反人性的保护机制。刷题时记住这个背景,再看“含冷冻期”这几个字,会更有实感。
最后分享一点我带新人刷题时的体会。股票类 DP 题,尤其是这道含冷冻期的题目,真正难的不是背三个转移方程,而是你能不能把“卖出后的第二天不能买”这句话,先翻译成一张状态图,再把状态图翻译成数组下标。一旦你习惯了这个流程,后面遇到类似的“买卖股票 IV”“打家劫舍”等状态机题目,都是同一个套路。我自己现在写这类题,几乎不背模板,拿到题先画状态,边界一标,代码就是照着状态图抄。这个习惯建议你也练起来,比多刷五十道同类题管用得多。