从"只买一次"到"最多两次",这三道题放一起学是真的爽。我在代码随想录算法训练营打卡到第四十五天,集中把121买卖股票的最佳时机、122买卖股票的最佳时机II、123买卖股票的最佳时机III刷完了。以前看股票类动态规划总觉得状态又多又绕,但这一天练完之后,我发现自己突然能"看懂"状态转移了,关键在于把"买入卖出"改成"持有与不持有"去理解,再逐步加入交易次数的维度。这篇文章把我梳理过的解题思路、手推过程、初始化细节和踩坑记录完整写出来,希望对正在刷动态规划的朋友有帮助,尤其是准备面试、需要把股票系列一次吃透的人。
1. 从"只买一次"到"最多两次":股票系列为什么放在同一天
1.1 三道题的本质区别
先看题目本身。121要求只能选择某一天买入、未来某一天卖出,最多完成一笔交易;122不限制交易次数,但手里同时只能持有一股;123把交易次数限制为最多两笔。表面看是三个独立题目,实际上是从"交易次数"这个维度不断加码:1次、无限次、2次。
这个顺序安排得非常讲究。如果直接上手123,大概率会被四个状态绕晕;但如果先吃透121的"持有/不持有"状态,再看122只改一行代码的转移方程,最后到123自然就明白为什么状态要从2个变4个。代码随想录里Carl也一直强调,动态规划题目先定状态、再写递推、然后初始化、最后遍历,这套五步法在股票系列里体现得特别完整。
1.2 面试考察率极高,为什么
股票系列在面试里出现频率很高,因为它考察的核心是"状态设计"能力,而不是背题型。121可以考察基础的动态规划或一次遍历优化;122可以考察贪心和动态规划两种思路,进一步观察你能否说清楚"为什么贪心成立";123直接考察"在状态里加入交易次数维度"的能力。难度梯度明显,面试官可以根据候选人的水平随时切换题目深度。
更关键的是,这三道题是后续更难题目的地基。188题(最多K次交易)、309题(含冷冻期)、714题(含手续费),全部是这套状态机框架的变形。训练营把这三道题放在第四十五天,相当于先用它们把"状态机DP"的套路打通,后面的延伸题就顺理成章了。
1.3 一个很容易被忽略的共同前提
三题都有一个隐含规则:必须在买入之后才能卖出,而且任意时刻最多持有一股。这意味着状态设计无论如何都绕不开"持有"和"不持有"两个基本状态,区别只在于如何记录交易次数。
我当初踩过的坑是,总想着用二维数组记录"第几天买入、第几天卖出",结果变量一多就乱。后来才意识到,动态规划里只要抓住当天的结束状态,再把操作抽象成"状态之间的转移",问题就清晰了。这也是为什么下面每一道题我都会从状态定义开始讲,而不是直接甩代码。
2. 121题:持有与不持有两种状态,先吃透状态机最核心的概念
2.1 状态定义:dp[i][0]不是"第i天买入"
121题的状态定义是这套系列的基础。我们用二维dp数组:
- dp[i][0]:第 i 天结束时,手里持有股票的最大现金
- dp[i][1]:第 i 天结束时,手里不持有股票的最大现金
这里最容易产生误解的是 dp[i][0]。它不是"第 i 天买入"的意思,而是"第 i 天结束之后,我处于持有股票的状态"。这个状态可能是今天买入的,也可能是前几天买入、一直持有到现在。一定要先把"状态"和"动作"区分开,否则后面的转移公式会全部理解偏。
为什么这样定义?因为最终我们要的是最后一天手里不持有股票时的最大现金,也就是 dp[n-1][1]。中间过程里,持有和不持有两个状态交替出现,靠它们之间的转移来描述买入和卖出动作。
2.2 递推公式推导:为什么买入时只能用 -prices[i]
第 i 天结束时处于持有状态,只有两种可能。第一种,昨天就持有,今天什么都不做,那么 dp[i][0] = dp[i-1][0]。第二种,昨天不持有,今天买入,由于只能交易一次,买入之前的现金是初始的0,所以买入后现金变成 -prices[i]。两者取最大值:
dp[i][0] = max(dp[i-1][0], -prices[i])
注意这里买入时没有写 dp[i-1][1] - prices[i],因为121题只能买卖一次。如果允许股票买入前先卖出其他股票获得利润,那就不是"只交易一次"了。这个限制就藏在"减去的是固定0"这个写法里,而不是额外加一个计数器。
第 i 天结束时处于不持有状态,同样两种可能。第一种,昨天就不持有,今天继续观望,dp[i][1] = dp[i-1][1]。第二种,昨天持有,今天卖出,那么利润是 dp[i-1][0] + prices[i]。取最大值:
dp[i][1] = max(dp[i-1][1], dp[i-1][0] + prices[i])
2.3 初始化与遍历顺序
第0天是基础:dp[0][0] = -prices[0],表示第0天买入股票后现金变成负数;dp[0][1] = 0,表示第0天不持有,手里现金为0。从 i = 1 开始遍历,每一天依赖前一天的状态,所以从左到右正序遍历即可。
一个小边界:如果 prices 为空,直接返回0。这个不写的话,LeetCode上会直接报错。
2.4 代码实现:二维写法与一次遍历写法
先贴标准动态规划代码:
int maxProfit(vector<int>& prices) { if (prices.empty()) return 0; vector<vector<int>> dp(prices.size(), vector<int>(2, 0)); dp[0][0] = -prices[0]; dp[0][1] = 0; for (int i = 1; i < prices.size(); 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]); } return dp[prices.size()-1][1]; }当然,121题本身还有一个更简单的思路:遍历价格,维护历史最低点,不断更新 max(prices[i] - minPrice)。这个解法时间复杂度也是O(n),空间O(1),面试时如果只问121可以直接写。但训练营里学动态规划,核心目的是为后面的122和123打基础,所以先用二维dp把状态机模型立起来,再谈其他优化。
2.5 手推一个例子,把状态"看"出来
我建议读者拿 prices = [7, 1, 5, 3, 6, 4] 手动推一遍。第0天持有是 -7。第1天价格跌到1,持有状态变成 max(-7, -1) = -1,说明改成在第1天买入更划算;不持有状态还是 max(0, -7+1) = 0。第2天价格5,持有还是 -1,不持有变成 max(0, -1+5) = 4,说明在第1天买、第2天卖能赚4。后面几天的推演中,dp[5][1] 最终是5,也就是整个数组的最优解:在第1天买、第4天卖,利润5。
把dp数组打印出来看一遍,比单纯看公式有用得多。Carl反复强调打印dp数组来验证,我在股票系列第一次真正体会到这句话的价值。
2.6 常见的三个坑
第一个坑:把 dp[i][0] 理解成"第i天买入",导致接下来看122时会理不清为什么买入公式变成了 dp[i-1][1] - prices[i]。
第二个坑:初始化写成 dp[0][0] = 0,这样递推结果会完全错误。第0天持有股票时现金是负的,不是0。
第三个坑:忘了处理 prices 长度为0或1的边界。长度为1时结果应该是0,因为没法完成买入卖出一轮操作。
3. 122题:改一行递推,把"只能买一次"变成"可以无限次买卖"
3.1 状态含义不变,但买入的资金来源变了
122题允许无限次交易,但依然规定任意时刻最多持有一股。状态定义不变:dp[i][0] 持有,dp[i][1] 不持有。真正的变化在转移公式:
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])
和121相比,唯一区别就是 dp[i][0] 的买入部分从 -prices[i] 变成了 dp[i-1][1] - prices[i]。这一行改动背后的语义是:因为可以多次交易,买入新股票时,手里可能已经攒下了之前买卖赚到的利润,所以要用"昨天不持有状态的最大现金"减去今天的股票价格。
我当时想通这件事之后,真的有种"啊原来如此"的感觉。121是白手起家,只能拿初始资金去买一次;122是滚雪球,每次买入都可以把此前赚到的利润投入进去。这两种约束的差异,精准地体现在这一行代码里。
3.2 为什么不能用 dp[i][1] - prices[i]
这里有个细节很多人会问:买入时为什么用昨天的 dp[i-1][1],而不是今天的 dp[i][1]?
因为 dp[i][1] 本身可能已经包含了"今天卖出"带来的收益。如果我用 dp[i][1] - prices[i],相当于在同一天先卖出、再买入。虽然在真实规则里只要价格波动足够大,同一天先卖后买并不违规,但在状态转移里这样写会形成循环依赖,逻辑上说不清。所以必须严格使用昨天的不持有状态,保证每一天的状态只从前一天转移过来,这是动态规划无后效性的基础。
3.3 贪心解法:把每一段上涨都吃进嘴里
122还有一个非常简洁的贪心解法:只要今天的价格比昨天高,就认为昨天买入、今天卖出,累加所有正差价。
int maxProfit(vector<int>& prices) { int result = 0; for (int i = 1; i < prices.size(); i++) { if (prices[i] > prices[i-1]) { result += prices[i] - prices[i-1]; } } return result; }为什么这个贪心是对的呢?因为交易次数无限,我可以把一段连续上涨拆成若干个"昨天买、今天卖"的小段,每一段利润相加,等于从头持到尾的利润。比如价格从1涨到5,拆成1到2、2到3、3到4、4到5四段,总利润是4,和直接从1买5卖一样。如果价格中间有回调,比如1涨到3再跌到2再涨到4,贪心会吃到1到3和2到4两段,这正好是在回调时卖出、回调后重新买入的最优操作。
用这个思路对比121就特别有意思了。121不能用贪心累加正差价,因为只允许买一次,遇到下跌段你不能先卖再买。这是判断"贪心能否使用"的一个经典例子:约束条件直接决定了策略空间。
3.4 手动推演验证:为什么最终答案是7
我以 [7, 1, 5, 3, 6, 4] 为例,推一遍核心过程。
第0天:dp[0][0] = -7,dp[0][1] = 0。 第1天(价格1):dp[1][0] = max(-7, 0-1) = -1,说明改成第1天买入更好;dp[1][1] = max(0, -7+1) = 0。 第2天(价格5):dp[2][0] = max(-1, 0-5) = -1;dp[2][1] = max(0, -1+5) = 4。这表示在第1天买、第2天卖,赚4元。 第3天(价格3):dp[3][0] = max(-1, 4-3) = 1,注意这里持有状态从 -1 变成了 1,因为前一天不持有现金是4,买入价3,剩下1元持有;dp[3][1] = max(4, -1+3) = 4,继续持有不卖更优。 第4天(价格6):dp[4][0] = max(1, 4-6) = 1;dp[4][1] = max(4, 1+6) = 7,说明第3天买入、第4天卖出,整体利润变成7。 第5天(价格4):dp[5][0] = max(1, 7-4) = 3;dp[5][1] = max(7, 1+4) = 7。
最终答案是7。从推演中能清楚看到,121和122的差异就发生在第3天:dp[3][0] 不再是 -1,而是 1,正是因为手里已经攒了4元利润,买入时不再是"从0开始亏"。这个案例值得多推几遍。
4. 123题:状态从2个变4个,交易次数如何编码进dp数组
4.1 为什么二维状态不够用了
123题要求最多完成两笔交易。问题来了:如果用 dp[i][0] 和 dp[i][1] 只表示持有和不持有,我们无法知道当前持有的这笔交易是第一笔还是第二笔。因为第一笔和第二笔的利润计算方式不同:第一笔买入时初始现金是0,第二笔买入时初始现金是第一笔卖出后的利润。所以状态里必须记录"现在处于第几次交易中"。
代码随想录的处理方式是直接拆成四个状态:
- dp[i][0]:第一次持有
- dp[i][1]:第一次不持有(第一笔交易已经完成)
- dp[i][2]:第二次持有
- dp[i][3]:第二次不持有(第二笔交易已经完成)
本质上相当于在两个基本状态里加入了交易次数的信息。这也是后面188题"最多K次交易"直接用2乘K个状态来表示的雏形。
4.2 四个递推公式,逐行拆解
有了四个状态,转移公式就非常清晰了:
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])第一行和121完全一样,因为第一次买入时手里没有利润,初始现金就是0。第二行和121的不持有状态也完全一样,第一次卖出时直接加上持有状态的现金。第三行开始有区别:第二次买入时,必须先完成第一次卖出,也就是要基于 dp[i-1][1](第一次不持有的最大现金)减去 prices[i]。第四行同理,第二次卖出时用 dp[i-1][2](第二次持有的最大现金)加上 prices[i]。
这四个公式可以看作一个环形链条:第一次持有到第一次卖出到第二次持有到第二次卖出。理解这个链条之后,123就一点都不难了。
4.3 初始化最容易错:dp[0][2]为什么也是 -prices[0]
初始化部分有一个必须重视的细节。第0天的四个状态:
dp[0][0] = -prices[0] dp[0][1] = 0 dp[0][2] = -prices[0] dp[0][3] = 0很多人不理解 dp[0][2] 为什么会是 -prices[0],而不是一个极小值。原因在于:题目没有禁止同一天内"先买后卖再买",所以在第0天,我们完全可以看作"买了一次、卖了一次、再买第二次",虽然实际利润没有变化,但状态上允许第二次持有从第0天就开始。
如果把 dp[0][2] 初始化成 INT_MIN,后续转移里 INT_MIN + prices[i] 在某些语言里会溢出,而且在严格递增的测试用例中可能得不到正确答案。我第一次写的时候直接照抄了代码,没想明白,后来手动推演 [1,2,3,4,5] 才发现这个初始化对最终结果影响巨大。记住,这里不是随便定的,是为了让第二次交易在第一天就能"待命"。
4.4 代码实现:二维数组版本
int maxProfit(vector<int>& prices) { if (prices.empty()) return 0; vector<vector<int>> dp(prices.size(), vector<int>(4, 0)); dp[0][0] = -prices[0]; dp[0][1] = 0; dp[0][2] = -prices[0]; dp[0][3] = 0; for (int i = 1; i < prices.size(); 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[prices.size()-1][1], dp[prices.size()-1][3]); }我特意在返回值里写了 max(dp[n-1][1], dp[n-1][3])。虽然因为允许同一天重复交易,最终 dp[n-1][3] 通常不小于 dp[n-1][1],但写成 max 在逻辑上更严谨,也更容易向面试官解释:最多做两笔交易,做一笔可能比做两笔更优,比如第二次交易没找到合适机会时。
4.5 滚动变量优化:需要注意更新顺序
用四个变量代替二维数组:
int maxProfit(vector<int>& prices) { if (prices.empty()) return 0; int hold1 = -prices[0], cash1 = 0; int hold2 = -prices[0], cash2 = 0; for (int i = 1; i < prices.size(); i++) { hold1 = max(hold1, -prices[i]); cash1 = max(cash1, hold1 + prices[i]); hold2 = max(hold2, cash1 - prices[i]); cash2 = max(cash2, hold2 + prices[i]); } return cash2; }这里有一个坑:更新顺序必须保持先 hold1、cash1,再 hold2、cash2。因为同日内的 hold2 依赖 cash1 的最新值,如果先更新 hold2,cash1 用的还是昨天的数据,逻辑就错了。面试时我建议先把二维数组版本讲清楚,再主动提出可以滚动优化,然后说明这个顺序原因,属于明显的加分项。
4.6 两个手推示例,验证初始化思路
第一个例子:[1,2,3,4,5]。初始化 hold1=-1, cash1=0, hold2=-1, cash2=0。到第5天时,cash2=4。表面看答案和122相同,因为单调上涨时做一笔就够;但正因为 dp[0][2] = -1,第二笔交易才能从第一天就跟上节奏,否则中间状态会出现偏差。
第二个例子:[3,3,5,0,0,3,1,4]。这个例子答案是6,用滚动变量推一遍会更直观。第一天价格3,四个变量都不变。第三天价格5,cash1变成2,意味着先赚了一笔;随后价格跌到0,hold2变成2,即把第一笔赚的2元现金换成持有股票;最后价格4时卖出,cash2变成6。整个过程刚好对应"第一笔在3到5赚2元,第二笔在0到4赚4元"。如果只用一个持有状态,完全没法区分这两笔交易,这就是状态扩容的意义。
5. 三题对比:一个模板通吃,空间优化与边界处理
5.1 把三题放到同一张表里看
我整理了这三道题最核心的转移公式对比,方便大家复盘:
| 题目 | 状态数量 | 持有状态买入时的写法 | 最终返回值 |
|---|---|---|---|
| 121 | 2个 | -prices[i] | dp[n-1][1] |
| 122 | 2个 | dp[i-1][1] - prices[i] | dp[n-1][1] |
| 123 | 4个 | 第一次:-prices[i];第二次:dp[i-1][1] - prices[i] | max(dp[n-1][1], dp[n-1][3]) |
122和123的第二次交易,买入时都使用了 dp[i-1][1] 形式,本质都是"用已经落袋的利润去买下一次股票"。而121因为只允许一次,买入时只能用初始资金。这个对比看明白后,股票系列的共性就浮出水面了。
5.2 一个统一的状态机框架
如果要把这个系列抽象成模板,可以这样理解:股票题的状态由两个维度组成,一是当前是否持有股票,二是已经完成的交易次数。121是交易次数上限1,122是无限次,123是上限2。188题的思路就是把这套状态推广成 dp[i][j][k],其中 j 表示是否持有,k 表示已完成交易次数。
代码随想录的训练营没有直接让我们上188,而是先用这三道题铺垫状态机思考方式,个人觉得这个节奏非常合理。先把123的四个状态理清楚,再去写188的二维数组,会自然很多。
5.3 空间优化的通用思路
二维数组版本的复杂度是O(n)空间,但每道题其实都只需要保留前一天的状态。121和122可以只用两个变量滚动,123可以用四个变量滚动。如果编译器允许,还可以在遍历中直接原地更新,像我在4.5节写的那样。
我建议做题时先写出二维版本,AC之后再改成滚动变量版本,不要一上来就写优化版。因为滚动变量对更新顺序要求高,一旦出错很隐蔽。比如123的滚动顺序问题,我至少踩过两次,都是在LeetCode评论区看到别人的解法才意识到自己错在哪。
5.4 边界情况的统一验证清单
无论哪道题,提交前都检查这几个用例:
- 空数组:直接返回0,不然后续访问 prices[0] 会越界。
- 只有一个元素:返回0,因为当天买入无法当天卖出(虽然同一天可以买卖的话,121要求"未来某天卖出",所以单元素不能产生利润;但123因为允许同一天先买后卖再买,单元素依然是0)。
- 单调递减数组:比如 [5,4,3,2,1],所有题答案都是0,因为任何时候卖出都亏。
- 单调递增数组:比如 [1,2,3,4,5],121答案是4,122和123答案也都是4,因为最长的一整段利润就是4,多次拆解并不会改变总额。
- 先跌后涨、有多个波峰波谷的数组:重点验证状态在波峰卖出、波谷买入的切换是否正确。
6. 训练营刷题经验:股票系列的学习顺序、面试表达与踩坑清单
6.1 我的学习顺序建议
如果你刚接触股票系列,我强烈建议不要直接做123。先把121的持有/不持有状态用纸笔推一遍,做到不看代码能写出转移方程。然后做122,重点关注"买入公式那一行差异",并手推一个多波峰波谷的例子。最后再上123,先自己尝试设计状态,看看能不能想到用四个状态去表示两次交易。卡住再看题解,理解会深很多。
我在训练营里是前一天先预习、当天听讲解、晚上再自己把代码从零写一遍。股票系列这套流程走下来,印象特别深。
6.2 面试中怎么讲这系列题
如果面试遇到股票题,我建议按这个顺序表达:
第一,先说清楚状态定义,直接说"我用dp[i][0]表示第i天持有股票的最大现金,dp[i][1]表示不持有的最大现金",并强调这里记录的是当天结束时的状态。
第二,推导转移公式,重点解释买入动作对应的转移是从哪个状态来的,为什么单次交易和多次交易的写法不同。
第三,说边界和初始化,特别是123的 dp[0][2] 为什么是 -prices[0],这能体现你对状态初始化的理解深度。
第四,主动提空间优化,说出滚动变量的更新顺序。
这样讲下来,面试官能快速判断你不是背题,而是真正理解状态机DP。我甚至在模拟面试里试过,把123完整讲清楚确实比直接写代码更有说服力。
6.3 我实际踩过的坑清单
简单列一下我在刷这三道题时真正出过错的地方:
- 121里把 dp[i][0] 想成"当天买入",导致后面看122时完全不能理解 dp[i-1][1] - prices[i] 的含义,重新看了两遍视频才绕出来。
- 122写贪心解法时,一开始下意识觉得"应该找波峰波谷",后来发现逐天累加正差价才是最简单可靠的做法。找波峰波谷实现起来容易错,需要额外处理连续相等价格的情况。
- 123初始化 dp[0][2] 时写过 INT_MIN,结果 LeetCode 上有一个严格递增的大数组用例直接答案错误,排查了很久才发现是初始化的问题,后来把dp数组打印出来才定位到。
- 123滚动优化时先写了 hold2 再写 cash1,导致持仓成本计算错误,后来把顺序调成先 hold1/cash1 再 hold2/cash2 才通过。
6.4 给后续题目留的钩子
股票系列还有三道常见变种:309题加了冷冻期,卖出后第二天不能买入,需要在状态里再加一个"冷静期"维度;714题加了手续费,买入时要把手续费计入成本;188题要求最多K次交易,直接把四个状态泛化成2乘K个状态。有了121、122、123打底,这些变种的核心思路都是同一个状态机框架,区别只是状态数量和转移细节。代码随想录训练营在这个时间点安排这三道题,确实是为后面这些题目做铺垫。
我自己刷到第四十五天最大的体会是,动态规划题最怕的不是公式复杂,而是状态定义模糊。股票系列恰好是训练状态定义能力最好的素材:从两个状态到四个状态,从单次交易到多次交易,每一步的递进都有明确理由。把这三道题吃透之后,再回头看其他DP题,很多"为什么要这样设状态"的问题都能自己回答了。