
最近在力扣上面刷题把Best Time to Buy and Sell Stock这个系列的股票交易问题完整过了一遍。以前总觉得这类题就是套几个公式背一背状态转移就完事直到自己动手推导、把边界case一个个踩过去才意识到里面藏着动态规划从入门到进阶的一条非常典型的学习路径。这个系列至少有六道题从“只能交易一次”到“不限次数”再到“最多K笔”“卖掉后要冷冻一天”“每笔还要扣手续费”难度一点点往上叠。如果你刚好在刷力扣或者准备算法面试这套题几乎是绕不过去的。这篇文章我按自己的理解把这系列题的核心思路、状态机建模、代码实现和常见坑都整理出来希望能帮你省点时间。1. 系列题目概览与核心需求拆解我先把这一系列题目拢到一起看。最经典的是第 121 题要求你只能做一笔交易也就是一次买入一次卖出求最大利润。紧接着 122 题允许你无限次交易但每次只能持有一股相当于你可以多次低买高卖。第 123 题限制最多完成两笔交易第 188 题则把限制泛化成最多 K 笔。309 题加了一个交易日冷却期卖掉股票后要隔一天才能再次买入。714 题则是每笔交易要扣一笔固定的手续费。这些题目看似条件各不相同但骨子里有两条主线一是“低买高卖”的朴素逻辑二是动态规划里的状态机思想。第一道题可能用一次遍历就能解但你很难用它去套后面的复杂版本。而一旦你掌握了状态机的建模方式整个系列的所有题目都可以归到同一个框架下只是状态数量和转移条件不一样。所以我在下面会先从最笨的暴力枚举讲起再一步步升级到通用DP模型。1.1 从“一次交易”到“任意多次”的难度递进拿 121 题来说第一次做的人很容易想到遍历每一天作为买入日再遍历它后面的每一天作为卖出日算差值取最大。这就是暴力枚举时间复杂度 O(n²)在小数据规模下能过但输入到十万级别就直接超时。于是你自然会想到用一次遍历记录历史最低价格边走边算当前价格和历史最低价的差值把复杂度降到 O(n)。到了 122 题限制变成可以无限次交易。这时有一个直觉既然没有次数限制那每一段上涨都值得吃满把相邻两天价格差为正的部分全部加起来就是最大利润。这个解法很妙但它和 121 题的思路完全不一样容易让人怀疑自己是不是跳过了什么。如果你直接上手 DP会发现“今天持有的状态”和“今天不持有的状态”是可以互相转移的这样就引出了状态机。123 题最多两笔交易一下子把问题变复杂你不能再简单地累加上升段因为两笔交易之间必然存在一个分割点你要避免把连续上涨区间拆成多笔也要避免在下跌阶段反复交易。暴力做法是先枚举分割点左边一次交易、右边一次交易但这样重复计算很多需要预处理好前缀和后缀最大值数组。这个做法思路清晰但还不是最优雅的。188 题把数量泛化到 K 笔直接三维 DP天数、已交易次数、是否持股。到这里你就能真切感受到动态规划为什么适合这类问题因为它把“在第 i 天进行了 j 次交易当前持有或未持有股票”的每个状态都记录下来天然避免了枚举分割点的冗余。1.2 为什么股票交易题是动态规划的绝佳入门很多人学动态规划时被“最小路径和”“最长递增子序列”劝退因为状态定义太虚拟不直观。而股票题目里天然存在两个状态持有现金还是持有股票。你要做的只是决定每天都操作买入、卖出、或者什么也不做。而且这个系列能清晰地展示状态怎么压缩。以 121 题为例用二维 DP 可以开一个n × 2的数组dp[i][0]表示第 i 天结束时手里没股票的最大现金dp[i][1]表示第 i 天结束时手里持有一股股票的最大现金。但你很快会发现第 i 天的状态只依赖前一天所以完全可以用两个变量来回滚动把空间复杂度从 O(n) 降到 O(1)。这种“先写出正确但占空间的版本再优化成滚动数组”的思路在所有 DP 题目里都有通用价值。更妙的是当你掌握了通用状态机你会发现 309 题、714 题不过是在状态转移方程里加一个时间差或者扣一笔费用。所以这个系列很适合作为“一题一法”到“多题一法”的过渡材料。2. 暴力枚举先能跑再谈优化很多人包括我刚遇到 121 题时第一反应就是两层循环。外层枚举买入的日子内层枚举卖出的日子算最大差值。这个思路没错代码也短但你可能没认真分析过为什么它慢以及慢在哪里。2.1 双层循环的思路与代码实现假想一个场景你在水果店买苹果老板告诉你未来七天的价格你只能买一次再卖一次。最简单的方法是穷举所有“哪一天买、哪一天卖”的组合。代码如下def max_profit_brute_force(prices): n len(prices) best 0 for i in range(n): for j in range(i 1, n): best max(best, prices[j] - prices[i]) return best这个代码逻辑上完全正确。如果价格序列是[7, 1, 5, 3, 6, 4]它能算出最大利润 5第 2 天买入第 5 天卖出。但它的内层循环会把每一对买卖组合都算一遍总共要算 n(n-1)/2 次。当 n 是 10 万时你需要执行约 50 亿次减法任何在线评测都无法接受。这里还有一个容易忽略的细节题目要求你必须在买入之后卖出所以内层j必须从i1开始。有些人想省事直接用j从 0 开始那就可能出现利润为负数还当作有效答案的情况。2.2 暴力法的复杂度瓶颈与优化方向瓶颈在于重复计算。我们在内层循环里对每个买入日i都要遍历它后面的所有卖出日。但仔细想一想对于某个固定的买入日i真正关心的其实是它后面所有价格中的最大值只要拿max(prices[i1:])减去prices[i]就够了。可惜计算每个后缀最大值也需要额外遍历整体还是 O(n²)。更优的做法是反过来思考我不再固定买入日而是固定卖出日。在第j天卖出时为了让利润最大应该在前面的日子里找到价格最低的那一天买入。所以只需要动态维护“截至今天之前的最小价格”每到一个新价格就尝试用当前价格减历史最低价更新答案。代码瞬间变成一次循环def max_profit(prices): min_price float(inf) best 0 for price in prices: min_price min(min_price, price) best max(best, price - min_price) return best这个一次遍历解法的核心其实是贪心。你维护的min_price相当于“历史最佳买入点”而best始终记录着“如果今天卖出能拿到的最大利润”。这个方法空间 O(1)时间 O(n)是 121 题的标准解法。我建议哪怕你已经会了最优解也动手写一遍暴力版。因为很多 DP 状态转移方程的推导本质上就是从暴力解里找重叠子问题。暴力法是帮你理解题意的锚点不是纯粹的废话。3. 动态规划状态机一次交易的标准解法当题目升级到“最多两笔交易”甚至“最多 K 笔交易”时一次遍历的贪心就失效了。这时候你需要一种更通用的描述方式把“第几天”“交易了几次”“是否持有股票”这三个信息组合到一起这就是状态机 DP。3.1 状态定义与转移方程推导以 121 题为例我从二维 DP 开始讲方便后面扩展。定义dp[i][0]表示第 i 天结束时手里不持有股票时拥有的最大现金dp[i][1]表示第 i 天结束时手里恰好持有一股股票时拥有的最大现金。初始资金假设为 0所以不持股时现金为 0持股时现金相当于花出去了多少钱是负数。转移有两种情况今天结束时手里没有股票可能是昨天也没有股票今天什么都没做也可能是昨天持有股票今天把它卖了。所以dp[i][0] max(dp[i-1][0], dp[i-1][1] prices[i])今天结束时手里有股票可能是昨天就有股票今天继续持有也可能是昨天没有股票今天刚买入。注意“只能做一笔交易”的限制这里买入前不能有任何卖出所以早期买入只能从初始状态转移过来。如果直接用max(dp[i-1][1], dp[i-1][0] - prices[i])会让“卖出后再买入”成为可能这在一笔交易条件下是错误的。因此更严谨的写法是把买入特判为-prices[i]或者引入交易次数维度。有趣的是如果题目只要求算一次交易直接用一维动态规划也能解即dp[i]表示第 i 天卖出的最大利润转移是维护前面最小价格。但为了统一后续题型我推荐直接上手二维状态机。你甚至可以写出伪代码def max_profit_dp(prices): n len(prices) if n 0: return 0 dp0, dp1 0, -prices[0] for i in range(1, n): old_dp0 dp0 dp0 max(dp0, dp1 prices[i]) dp1 max(dp1, -prices[i]) # 只能买一次所以用 -prices[i] 而不是 dp0 - prices[i] return dp0这里有个很关键的细节买入时用的是-prices[i]而不是dp0 - prices[i]就是因为限定了只能做一次交易。如果你的dp0已经包含了一次卖出的利润那你再用它去买入那不就是两次交易了吗所以“一笔交易”和“多笔交易”在状态转移上本质区别就在这一行。3.2 空间压缩从二维数组到滚动变量在上面的代码里我已经把两个数组压缩成了两个变量dp0和dp1。原因是状态转移只依赖前一天的两个状态跟更早的日子没有任何关系。这种优化看起来很小但当你真的去写 188 题的 K 笔交易时数组空间是n × K × 2如果 K 也接近 n内存就可能爆掉。滚动数组可以省下一大块内存。注意在滚动更新时dp0的旧值即昨天的值会被dp1依赖所以要么先算dp0再算dp1要么用一个临时变量把旧值存下来。上面的代码我用old_dp0存了旧值但其实 121 题里dp1不依赖dp0新值所以顺序无关紧要。但在多次交易版本里你必须在更新dp1时使用旧的dp0这就是很多初学者写出“一天内买又卖”离谱结果的原因。3.3 通用状态机不同限制条件统一建模一旦你接受用“是否持股”作为状态的维度后续所有题目都可以往这个模型里套。下面我列一个对比表能一眼看出各题的区别题目核心限制状态维度特殊转移121最多 1 笔交易持股/不持股外加交易次数买入只能从初始态来122不限交易次数持股/不持股买入可以从卖出态来123最多 2 笔交易持股/不持股外加 0/1/2 次交易需要按次数拆开188最多 K 笔交易持股/不持股外加 0..K 次交易三维 DP / 滚动压缩309不限次数卖出后冷冻 1 天持股/不持股/冷静期买入需要考虑前两天状态714不限次数每笔扣手续费持股/不持股卖出时减去 fee这张表大约是我刷完整个系列后自己画的。你如果也想高效刷题建议自己也画一张并且把每一题的转移方程手写一遍。因为通用建模之后剩下的全是细节而细节恰恰是最容易出错的地方。4. 扩展题型的核心差异与细节解析系列里的 122、123、188、309、714 看起来都长得很像但你要是不注意限制条件的字眼很容易把答案搞错。我按分类把这些差异拆开说。4.1 多次交易累加所有上升段先说 122 题。既然不限交易次数有人会误以为要模拟所有买卖点其实根本不用。你可以想象自己在盯盘只要后一天价格比前一天高你就能提前在低点买入、高点卖出然后把这段利润装进口袋。当然现实里你不能无限买卖但题目允许的话这个操作就是最优的。所以 122 题最简解法是def max_profit(prices): profit 0 for i in range(1, len(prices)): if prices[i] prices[i-1]: profit prices[i] - prices[i-1] return profit这个解法有点“开后门”的感觉但它是严格正确的。因为它把每一段上涨都拆分成了相邻两天的差值数学上等于每天做一次即时卖出并重新买入。用状态机 DP 也能得到同样的答案而且状态转移更显式def max_profit_dp(prices): n len(prices) if n 0: return 0 dp0, dp1 0, -prices[0] for i in range(1, n): old_dp0 dp0 dp0 max(dp0, dp1 prices[i]) dp1 max(dp1, old_dp0 - prices[i]) return dp0注意这里dp1用的是old_dp0 - prices[i]意思是“前一天不持股时可能已经赚了几笔的现金用来买入新股”所以可以无限次交易。这个差别正是 121 和 122 的命门。4.2 含手续费与冷冻期状态机加约束714 题与 122 题唯一的区别是每笔交易要扣手续费。手续费可以当作卖出时额外减去一笔钱也可以在买入时减去。常见写法是卖出时减去feedp0 max(dp0, dp1 prices[i] - fee)需要注意如果使用dp1 prices[i] - fee那么卖出后现金会变少。这是合理的。可有些人会搞混手续费是一次性还是每笔都扣其实题目已经说清楚“每笔交易都要收手续费”一买一卖算一笔所以只在卖出时扣一次就够了。309 题的冷冻期则更有意思。它要求卖出股票的下一天不能买入。这意味着当你今天想买入时必须确认昨天没有卖出过。于是状态机需要多一个维度冷冻状态。你可以定义三个状态hold持有、sold卖出当天、rest冷静期。其实更简单的办法是在计算买入时跳过前一天的卖出状态。我给你一个比较直接的三状态 DPdef max_profit(prices): n len(prices) if n 0: return 0 rest, hold, sold 0, -prices[0], 0 for i in range(1, n): pre_rest rest pre_hold hold pre_sold sold rest max(pre_rest, pre_sold) # 今天不操作且不在冷冻期 hold max(pre_hold, pre_rest - prices[i]) # 只有休息状态才能买入 sold pre_hold prices[i] # 只有持有才能卖出卖出后进入冷冻期 return max(rest, sold)这里的核心是hold只能从rest转移因为卖出后的第二天必须冷静不能买入。我建议你把三个变量用纸笔推一轮比如用价格序列[1,2,3,0,2]算完后利润应该是 3。推过一次你就明白为什么要用pre_rest而不用更新后的rest。4.3 K笔交易三维DP与边界处理123 和 188 题把次数约束变成数字。以 123 题为例最多两笔交易。暴力做法是枚举第一笔卖出日和第二笔买入日但那样会 O(n²)。状态机做法是给 DP 增加交易次数维度dp[i][k][0/1]表示第 i 天已经完成了 k 笔交易当前是否持股。这里有个容易混淆的点到底在买入时计数还是卖出时计数两种约定都行但必须保持一致。我习惯在买入时计数这样卖出时就不再加次数。状态转移如下不持股状态dp[i][k][0] max(dp[i-1][k][0], dp[i-1][k-1][1] prices[i])注意这里的第二项表示在第 i 天卖出完成了一笔交易所以 k-1 到 k持股状态dp[i][k][1] max(dp[i-1][k][1], dp[i-1][k][0] - prices[i])你会发现写出来其实特别规整。但初始化容易翻车dp[0][0][1]应该在第一天就持股现金为-prices[0]而dp[0][k][1]对 k0 应该设为负无穷表示不可能。代码示意如下def max_profit_k(K, prices): n len(prices) if n 0 or K 0: return 0 # 初始化两个状态数组分别表示不持股和持股 dp0 [0] * (K 1) dp1 [-float(inf)] * (K 1) for price in prices: for k in range(1, K 1): # 先算不持股 dp0[k] max(dp0[k], dp1[k] price) # 再算持股买入算一次交易这里用 dp0[k-1] dp1[k] max(dp1[k], dp0[k-1] - price) return dp0[K]注意这里dp0表示不持股但已经完成了k笔交易dp1表示持股且已经完成了k笔交易买入时已计数。每一轮价格都要更新所有 k而且必须从 1 到 K 正向遍历因为dp0[k-1]是这一天的值还是上一天的值在滚动数组里我们需要保证dp0[k-1]是上一天的值所以应该逆序遍历 k否则同一个价格会重复买入。正确做法是for price in prices: for k in range(K, 0, -1): dp0[k] max(dp0[k], dp1[k] price) dp1[k] max(dp1[k], dp0[k-1] - price)逆序的原因是dp0[k-1]在更新后可能已经包含了今天的操作如果正序遍历你再拿它去买就相当于在一天内完成了一次卖出和一次买入这在逻辑上会产生一次不存在的交易。这个坑我在实际写 188 题时踩过调试了很久才想起来滚动数组的方向问题。5. 常见问题与排查技巧实录刷这一系列题的时候我遇到过一些挺典型的困惑这里挑几个有代表性的说说。5.1 晦涩的状态转移到底怎么理解很多人看到dp[i][0] max(dp[i-1][0], dp[i-1][1] prices[i])会问为什么dp[i-1][1]加上今天的价格就是卖掉后的现金你要把dp[i-1][1]当成“昨天结束时持有的股票所对应的现金价值”它本身是个负数或者一个相对值。比如昨天持股花了 1 元买今天价格 5 元那么dp[i-1][1] prices[i]就相当于把昨天那 1 元成本收回来再赚 4 元。所以这个式子里的dp[i-1][1]并不是可支配现金而是持股状态下折算的净值。理解到这一层你就能看懂所有状态机转移。我发现最有效的理解方式是“把每一笔交易看成一次现金流的进和出”。买入就是现金流出卖出就是现金流入。DP 记录的是“当前手里的现金净值”而不是累计利润。初始现金是 0买入后现金是-prices[i]卖出后现金增加。这样每个状态都是一个账户余额转移方程自然就写出来了。5.2 边界条件与初始化踩坑记录边界条件往往是这类题的大坑。第一数组为空或只有一个元素时最大利润一定是 0因为根本做不了买卖。第二如果 K 大于天数的一半实际上相当于不限交易次数因为买卖需要两个不同的日期最多也就 n/2 笔。有些题还会给你一个很大的 K 值如果你硬开n × K的数组内存直接爆掉这时候要先做K min(K, n // 2)的剪枝。第三初始化时任何“不可能达到”的状态应该用负无穷而不是 0。比如在第 0 天就完成了 1 笔交易且持股这是不可能的如果初始化为 0后面的转移会算出“凭空买入”的利润结果就错了。我建议刷题前先把所有状态数组打印出来看几个关键日期第 0 天、第 1 天、最后一个交易日。手动模拟一遍 121 题的二维 DP你就知道为什么要给dp[0][1]赋值-prices[0]而不是 0。往往一个小初始化错误就能让整个动态规划逻辑变得貌似合理但答案错误。5.3 刷题顺序与时间安排建议我推荐按这个顺序来121 题一次交易先用暴力解再用一次遍历贪心最后用双状态 DP 写一遍。122 题多次交易对比一次遍历贪心与双状态 DP理解买入状态如何从“只能初始态”变成“可以从卖出态来”。714 题手续费在 122 题基础上加一个常数训练代码改写能力。309 题冷冻期引入第三个状态强迫自己画状态转移图。123 题两笔交易先尝试预处理前缀和后缀再用状态机 DP体会两种思路的差异。188 题K 笔交易二维滚动数组重点练习逆序遍历 K。这样一轮下来大概两三天时间。不要急着一口气写完所有题而是每写一道题都拿它和前一道做对比找出状态转移方程变化的根本原因。这个系列里还有一个我特别想强调的点状态机 DP 并不只是应付股票题。很多线性 DP比如打家劫舍、买卖股票的最佳时机、带有冷却期的任务安排本质上都是同一套思路。你把这一组题吃透以后再去看其他“二维状态 约束条件”的 DP 题会明显觉得顺手很多。最后分享一个我自己总结的小技巧写状态机 DP 时先不要急着压缩空间先开一个二维或三维数组老老实实把状态转移写对再去把数组换成变量。很多人一上来就写滚动数组结果方向一变bug 就藏在里面。先把朴素版本跑通再优化这才是刷这种题最稳的节奏。