ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

股票含冷冻期问题详解:状态机建模与动态规划转移方程

股票含冷冻期问题详解:状态机建模与动态规划转移方程 如果你刷过 LeetCode 的股票买卖系列一定绕不开“冷冻期”这道坎。普通版的买卖股票允许无限次交易贪心都能做加了手续费核心状态还是两个可一旦题目写上“卖出股票后你无法在第二天买入股票即冷冻期为 1 天”这道题的性质立刻就变了。今天这篇是系列第 87 课我准备把“股票含冷冻期”这个问题里里外外讲透从状态机怎么建、转移方程怎么来到代码怎么写、哪里最容易踩坑最后再聊聊这个模型放在真实交易场景里到底能干什么、不能干什么。这个过程不需要你有多强的数学背景只要会看表格跟着手推一遍基本就很难再忘。1. 冷冻期这道题和你做过的股票买卖题到底差在哪1.1 一个“卖出后必须休息一天”的额外约束先把题目说清楚。给你一个数组pricesprices[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 天收盘后手里没有股票并且正处于冷冻期的最大利润。“冷冻期”的含义是今天刚卖出过股票所以第 i1 天你不能买入。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 天价格 1iprices[i]dp[i][0]持有dp[i][1]冷却dp[i][2]空仓可买01-100第 0 天买入的话花 1 元利润计为 -1不买则利润 0。dp[0][1]填 0 是初始化的处理语义上第 0 天不可能是“刚卖出”但填 0 不会污染后面的结果原因后面再讲。第 1 天价格 2iprices[i]dp[i][0]dp[i][1]dp[i][2]01-10012-110计算过程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 天价格 3iprices[i]dp[i][0]dp[i][1]dp[i][2]01-10012-11023-121计算过程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 天价格 0iprices[i]dp[i][0]dp[i][1]dp[i][2]01-10012-11023-121301-12计算过程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 天价格 2iprices[i]dp[i][0]dp[i][1]dp[i][2]01-10012-11023-121301-1242132计算过程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 变成 K2时间复杂度会变成 O(nK)但 K 通常是小常数实际也可以接受。我在实际面试中被追问过这个点面试官真正想听的不是完整代码而是你能不能意识到“状态需要细分到能表达剩余冷却时间”并且能说清楚为什么要按冷却天数拆状态。把这个想明白说明你对状态机建模是真正理解了而不是死背了三行转移方程。5.3 一个容易混淆的交易规则为什么不能卖出当天再买还有个小细节值得澄清一下。状态机里的买入发生在“空仓可买 → 持有”这条边卖出发生在“持有 → 冷却”这条边。如果你把买入和卖出都理解为“当天某个时刻的动作”理论上会出现“上午卖出、下午买入”这种操作。但题目明确说“卖出股票后无法在第二天买入”这里并没有禁止当天再买只是冷冻期让“第二天不能买”那当天卖出当天再买算不算违规算。因为你不能同时参与多笔交易且卖出后你手里没有股票再买入就是新开仓这时候会遇到“是否处于冷冻期”的问题。更严谨地说标准解法的时间模型里买入发生在第 i 天的开始或决策点卖出发生在第 i 天收盘后进入冷却所以“同一天卖出再买入”在状态机里根本没有对应路径。写代码时不用额外处理但面试被问到时要能解释清楚这是状态机时间粒度设计的一部分。6. 从做题到做策略冷冻期模型在真实交易里怎么用6.1 回测里的“交易冷却期”是很常见的设置很多个人做量化回测时会遇到一个痛点策略在震荡行情里被反复打脸今天买明天卖、明天卖后天买手续费和滑点吃掉了大部分收益。于是有些人会手动加一道规则每次卖出后 N 天内不再开仓强制让自己冷静一段时间。这个规则本质上就是一个“冷却期”。状态机模型可以直接把这个规则做进回测引擎里。引擎维护三种状态持仓、冷却、空仓可买每次卖出后把账户状态切到冷却冷却计数结束再切回可买。这样比在策略代码里到处写“距离上次卖出已经过了几天”的 if 判断要干净得多也不容易漏掉边界情况。这也是我喜欢拿 LeetCode 309 做例子的原因看起来是道算法题但状态建模的思路放到真实策略回测里完全适用。6.2 真实市场约束T1 和结算周期真实交易市场也有“最接近冷冻期”的规则。A 股实行 T1当天买入的股票当天不能卖出必须等下一个交易日这相当于在“买入”后插入了一个持仓锁定期。如果想把这种约束也放进状态机可以再加一个“当天买入尚未可卖”的状态买入后先进入该状态下一天才允许卖出。美股虽然没有 T1 买入锁定但某些监管规则会限制频繁交易比如日内交易账户的购买力限制。从建模角度来看这些都可以抽象成“交易之间需要间隔”的冷却状态思路和本题一致。不过必须提醒一句算法题里的价格序列是已知的动态规划算出来的是“事后最优利润”也就是上帝视角下的理论上界。真实交易里没人知道明天涨跌所以你不能把这道题的解法直接当预测模型去实盘用。它的价值更多在于给你提供一套清晰的建模框架用于回测、策略热度和规则推演。6.3 行为金融视角冷却期为什么能减少乱操作我自己在看交易相关书籍时发现强制冷却期这招在很多专业机构里也以“交易纪律”的形式存在。比如某些基金经理会规定止损卖出某只股票后至少等一周才能考虑重新买回来。这背后的逻辑不是技术分析而是防止情绪化交易——刚止损完容易不服气想立刻追回来结果往往买在下跌中继强制等待几天情绪消退后决策质量会高很多。冷冻期这个约束从数学上优化了交易节奏从行为上则是一种反人性的保护机制。刷题时记住这个背景再看“含冷冻期”这几个字会更有实感。最后分享一点我带新人刷题时的体会。股票类 DP 题尤其是这道含冷冻期的题目真正难的不是背三个转移方程而是你能不能把“卖出后的第二天不能买”这句话先翻译成一张状态图再把状态图翻译成数组下标。一旦你习惯了这个流程后面遇到类似的“买卖股票 IV”“打家劫舍”等状态机题目都是同一个套路。我自己现在写这类题几乎不背模板拿到题先画状态边界一标代码就是照着状态图抄。这个习惯建议你也练起来比多刷五十道同类题管用得多。
返回列表