刷 LeetCode 刷到"买卖股票的最佳时机"这题的时候,我第一反应是"这不就是找最低点买入、最高点卖出嘛",随手一个双重循环就交了。结果 WA 之后看了眼评论区,才发现这原来是一整个家族:买卖一次、买卖无数次、最多两次、最多 K 次、带手续费、带冷冻期。每一道题看起来差不多,但解法从贪心一路升级到三维动态规划,恰好是算法面试里状态机 DP 的最佳入门素材。这篇文章我就用 C++ 把这个系列完整地拆一遍,把我自己踩过的坑、优化过的写法、还有在本地环境编译运行时碰到的各种奇奇怪怪的问题全部记录下来,希望对正在刷这道题的人有点帮助。
1. 这个系列到底在考什么:从一行代码到状态机 DP
1.1 一道题的多个变体:121/122/123/188/309/714
先说一个你可能也遇到过的情况:第一次看"买卖股票的最佳时机 II",发现题解只有几行代码,甚至比第一题还短。那时候我产生了严重的自我怀疑——难道第一题我写错了?后来才意识到,这个系列根本不是同一个题的难度递进,而是六个不同的建模问题。
| 题号 | 约束条件 | 核心思路 | 时间复杂度 |
|---|---|---|---|
| 121 | 只能买卖一次 | 维护历史最低价 | O(n) |
| 122 | 可以买卖无数次 | 贪心累加正利润 | O(n) |
| 123 | 最多买卖两次 | 分段状态机 DP | O(n) |
| 188 | 最多买卖 K 次 | 三维 DP + 滚动数组 | O(n*k) |
| 309 | 含冷冻期 | 三态状态机 | O(n) |
| 714 | 含手续费 | 状态机加扣费 | O(n) |
你会发现,121 最简单,122 反而比 123 简单得多。原因在于"可以无限次交易"这个约束让问题退化成了"每天都可以结算利润",而"最多两次交易"逼迫你必须记录当前是第几次交易,这就引入了维度的概念。理解了这一点,整个系列的学习路径就清晰了:先掌握"持有/不持有"两种状态,再往里面加入"交易次数""冷冻期""手续费"这些附加维度。
1.2 两个核心变量的拆解:交易次数与持有状态
所有的股票问题,本质上都是在问你:今天的决策到底由哪些信息决定?我把它们拆成两个变量:
- 第一,当前是第几笔交易。每一笔交易的完整定义是"一次买入 + 一次卖出",买入时交易次数增加,卖出时交易才算完成。
- 第二,当前是否持有股票。只有"未持有"才能买入,只有"持有"才能卖出。
这两个变量互相独立,却共同决定了状态空间的大小。买卖一次的 121 题只需要"是否持有",最多买卖 K 次的 188 题则需要同时记录"已进行到第几笔"和"是否持有",于是状态数就是(K+1) * 2。这就是为什么 188 题看起来是六道题里最复杂的——它的状态空间最大,代码量自然最多。
1.3 什么时候用贪心,什么时候必须上 DP
我最早犯的错误,就是用 121 题的思路去套 122 题:先找个最低点,再找个最高点,然后重复。运行出来结果不对之后,我才认真想了一个问题:为什么 121 可以只用一次扫描解决,而 122 却需要每天都考虑买卖?
关键区别在于"交易次数限制"是否改变了决策的局部性。122 题没有次数限制,所以每一天都可以独立判断"今天比昨天贵,我就假装昨天买了今天卖",这段利润和前后天没有任何关联,于是贪心成立。121 题只能交易一次,你今天的决策会影响后续所有决策的组合,这时候就必须动态规划来维护状态。个人经验:看到"最多 K 次"这个表述,优先把思路往 DP 上靠,别在贪心上浪费时间。
2. 基础版:一次交易的扫描法,C++ 边界处理细节
2.1 维护历史最低价而不是差分数组
121 题的经典解法不是从前向后找最大值,也不是从后向前找最小值,而是遍历时维护一个minPrice变量,表示到当前位置为止出现过的最低价格。每次遇到更高的价格,就结算一次"当前价格减历史最低价"的利润,取最大值。
#include <vector> #include <algorithm> class Solution { public: int maxProfit(std::vector<int>& prices) { int n = static_cast<int>(prices.size()); if (n < 2) { return 0; } int minPrice = prices[0]; int maxProfit = 0; for (int i = 1; i < n; ++i) { if (prices[i] < minPrice) { minPrice = prices[i]; } else if (prices[i] - minPrice > maxProfit) { maxProfit = prices[i] - minPrice; } } return maxProfit; } };为什么要维护minPrice而不是维护一个差分数组prices[i] - prices[i-1]的累加?因为"只能交易一次"要求你找到一个区间[buyDay, sellDay],使prices[sellDay] - prices[buyDay]最大。差分数组的累加适合无限次交易的场景,而一次交易的区间利润无法由若干相邻差的简单累加直接得出。维护最小值本质上等价于对每个卖出日,都去配对历史最佳买入日,这是一种"线性扫描 + 历史最优"的典型套路。
2.2 空数组、单元素、int 溢出:容易被忽略的防御性编程
第一次提交时我只写了主循环,没有判空,LeetCode 直接给我报了一个 access violation。从那以后我养成了一个习惯:凡是对数组做首元素访问的算法,第一行永远是判空和长度检查。
if (n < 2) { return 0; }n < 2这个条件同时覆盖了空数组和单元素数组——空数组取不到prices[0],单元素数组无论如何都不能完成一次买卖,利润必然为 0。这个写法比n == 0的单独判断更紧凑,也避免了一个很容易犯的顺序错误:先访问prices[0]再判断n == 0,顺序反了依然会崩溃。
关于 int 溢出,绝大多数 LeetCode 测试用例用 32 位 int 完全没问题,因为价格通常在 0 到 10^4 之间,差值不超过 10^4。但这个系列里一旦引入"手续费"和"K 次交易"的复杂状态转移,中间变量可能会出现负数,比如用INT_MIN表示"不可能持有股票"的状态。这时候如果不注意INT_MIN + prices[i]的溢出行为,程序可能在本地跑得好好的,提交后却出现诡异的错误。我自己的惯例是:涉及明确的上下界时,用std::max/std::min去夹逼,避免做无意义的减法。
2.3 为什么这个版本不能直接推广到 K 次
有了 121 题的成功经验,我试过把 121 的解扩展成 K 次:遍历整个数组,每次找到一段上升区间就累加。这个"伪贪心"在 122 题是可以的,但在 123 题和 188 题不行,因为"最多 K 次"意味着我要做的是选 K 段互不重叠的利润区间,并让总和最大。如果直接贪心地选最高的 K 段上升区间,区间之间可能重叠,也可能因为选择当前最高段而错过了组成更高总和的方案。这个问题就必须用带"交易次数"维度的 DP 来解,而不是在单次交易遍历上打补丁。
3. 无限次交易:贪心的正确性与 C++ 实现
3.1 相邻差值和:把交易拆成可重叠的段
122 题我一直记得一句非常形象的话:"与其说我们在做买卖,不如说我们在把每天的价格变化拆成无数个小段,凡是上涨的小段都收入囊中,凡是下跌的小段都不碰。"
int maxProfit(std::vector<int>& prices) { int profit = 0; for (int i = 1; i < static_cast<int>(prices.size()); ++i) { if (prices[i] > prices[i - 1]) { profit += prices[i] - prices[i - 1]; } } return profit; }严谨一点的证明思路是这样的:任意一次完整交易[buy, sell]的利润都可以拆成:
prices[sell] - prices[buy] = (prices[buy+1] - prices[buy]) + (prices[buy+2] - prices[buy+1]) + ... + (prices[sell] - prices[sell-1])因为"无限次交易"不受次数限制,我们可以把一次跨越多个交易日的长交易拆成若干相邻交易日的短交易,总利润不变。于是每天只需要判断相邻两天是否涨价,涨价就累加,跌价就无视。这就是贪心正确性的核心:全局最优解可以完全由局部决策堆叠而成,而不需要跨天协调。
3.2 accumulate 与手写循环的取舍
有人问我:既然 122 题是累加相邻正差,为什么不直接用 C++ 标准库的std::accumulate?比如:
#include <numeric> int maxProfit(std::vector<int>& prices) { int profit = 0; for (int i = 1; i < prices.size(); ++i) { profit += std::max(0, prices[i] - prices[i - 1]); } return profit; }真要用 accumulate 也能写,但要配合 lambda 捕获前一个元素:
int maxProfit(std::vector<int>& prices) { if (prices.empty()) return 0; int profit = std::accumulate( prices.begin() + 1, prices.end(), 0, [prev = prices[0]](int acc, int cur) mutable { int delta = cur - prev; prev = cur; return acc + std::max(0, delta); } ); return profit; }这个写法是可行的,但我个人不推荐在面试或笔试中用。原因有两个:第一,lambda 里的mutable捕获对很多初学者来说不直观,报错时容易懵;第二,笔试环境里的编译器版本不确定,C++20 之前对 lambda 捕获的语法支持差异可能导致编译失败。手写一个简单 for 循环,可读性更好,也没有任何性能劣势,因为std::accumulate并不会帮你做向量化或并行化。
3.3 从贪心到通用状态机的桥接
贪心解法固然简洁,但它是"只适用于无限次交易"的局部最优策略。一旦你尝试把交易次数限制为 K 次,贪心立刻失效,必须回到状态机 DP。这里给大家一个我在学习时体会很深的桥接点:
考虑两个变量cash和hold。
cash表示当前不持有股票的最大现金余额。hold表示当前持有股票的最大现金余额。
初始状态cash = 0,hold = -prices[0]。每天的状态转移是:
cash = max(cash, hold + prices[i]) hold = max(hold, cash_prev - prices[i])这个框架可以推广到 188 题、309 题和 714 题,只是增加相应维度。因为它不依赖"无限次交易"这个前提,而是显式地追踪每一笔交易的开始和结束。可以说,贪心是状态机在特殊约束下的退化形式。
4. 最多 K 次交易:三维 DP、滚动数组、缓存局部性
4.1 DP 定义与转移方程
188 题是系列里最"硬核"的一题。题目要求最多完成 K 笔交易,求最大利润。状态定义如下:
dp[i][j][0]表示第 i 天结束时,已经完成了 j 笔交易,当前不持有股票的最大利润。dp[i][j][1]表示第 i 天结束时,已经完成了 j 笔交易,当前持有股票的最大利润。
这里的"已经完成 j 笔交易"有一个坑:很多人会把"买入时 j 加一"和"卖出时 j 加一"搞混,导致结果差一笔交易。我建议统一口径:买入时交易次数增加,卖出时交易次数不变。也就是说,当你想从"不持有"转到"持有"时,这意味着开启了一个新的交易周期,j要加一。卖出只是把这个周期结算掉,j不再变化。
转移方程:
不持有(卖出): dp[i][j][0] = max(dp[i-1][j][0], dp[i-1][j][1] + prices[i]) 持有(买入): dp[i][j][1] = max(dp[i-1][j][1], dp[i-1][j-1][0] - prices[i])第二个方程里dp[i-1][j-1][0] - prices[i]的含义是:昨天完成 j-1 笔交易且不持有股票时,今天买入,开启第 j 笔交易。这保证了交易次数从 j-1 平移到 j。
初始化部分,dp[0][0][0] = 0,dp[0][j][1]在j = 0时表示"还没有任何交易就持有股票",这在逻辑上是非法状态,通常初始化为INT_MIN表示不可达,防止它参与 max 比较。
三维数组的完整 C++ 实现可以是:
#include <vector> #include <algorithm> #include <climits> class Solution { public: int maxProfit(int k, std::vector<int>& prices) { int n = static_cast<int>(prices.size()); if (n < 2) { return 0; } // 实际能完成的最大交易次数不可能超过 n/2,因为每次交易至少买入和卖出两天 k = std::min(k, n / 2); std::vector<std::vector<std::vector<int>>> dp( n, std::vector<std::vector<int>>(k + 1, std::vector<int>(2, 0))); for (int j = 0; j <= k; ++j) { dp[0][j][1] = INT_MIN; // 第 0 天不可能持有股票(除了买入) } dp[0][0][1] = -prices[0]; // 第 0 天买入,完成 0 笔交易后持股 dp[0][0][0] = 0; for (int i = 1; i < n; ++i) { for (int j = 0; j <= k; ++j) { // 不持有:今天不操作,或卖出 dp[i][j][0] = std::max(dp[i - 1][j][0], dp[i - 1][j][1] + prices[i]); if (j > 0) { // 持有:今天不操作,或从 j-1 笔交易的不持股状态买入 dp[i][j][1] = std::max(dp[i - 1][j][1], dp[i - 1][j - 1][0] - prices[i]); } else { dp[i][j][1] = std::max(dp[i - 1][j][1], INT_MIN); } } } int ans = 0; for (int j = 0; j <= k; ++j) { ans = std::max(ans, dp[n - 1][j][0]); } return ans; } };注意一个很实用的优化:k = std::min(k, n / 2)。因为一次完整的交易至少要经历买入、卖出两个交易日,所以最多能完成的交易次数不可能超过n / 2。这个优化在 n 很小、k 很大的场景下能把状态空间缩小一个量级,避免无谓的内存和循环。
4.2 两层循环的方向:为什么 j 要倒序
如果完全用三维 DP,空间复杂度是 O(n*k),在 n=100000、k=100000 时直接爆炸。经典优化是用滚动数组把第一维压掉,只保留"昨天"和"今天"两个状态。这时候一个非常容易踩的坑是内层循环的方向。
看下面这段"错误"的滚动数组实现:
for (int i = 1; i < n; ++i) { for (int j = 1; j <= k; ++j) { dp[j][0] = std::max(dp[j][0], dp[j][1] + prices[i]); dp[j][1] = std::max(dp[j][1], dp[j - 1][0] - prices[i]); } }问题出在哪里?在于dp[j][0]更新之后,紧接着的dp[j][1]会用到这个新的dp[j][0]。在"二维滚动、状态间存在耦合"的模型里,dp[j][1] = max(dp[j][1], dp[j-1][0] - prices[i])需要的是更新当天之前的dp[j-1][0],还是更新当天之后的dp[j-1][0]?
正确答案是后者。因为买入发生在卖出之前,同一天的买入应该基于当天卖出完成后的利润。如果我们正序遍历 j,dp[j][1]可能会用到已经被prices[i]更新过的dp[j-1][0],导致在同一天里既卖出又买入,这等于把同一天当作两个交易日来用,结果会偏大。
所以 j 必须倒序遍历:
for (int i = 1; i < n; ++i) { for (int j = k; j >= 1; --j) { dp[j][0] = std::max(dp[j][0], dp[j][1] + prices[i]); dp[j][1] = std::max(dp[j][1], dp[j - 1][0] - prices[i]); } }倒序遍历的核心逻辑是:更新dp[j][1]所需的dp[j-1][0]必须来自"昨天"而不是"今天"。从 k 往 1 走时,dp[j-1][0]还没被今天的卖出逻辑更新过,因此它仍然保持昨天的值。这是一个很多教程没有展开讲、但实际笔试很容易翻车的点。
4.3 空间优化的 C++ 实现对比与测试结果
做了滚动数组之后,空间复杂度从 O(n*k) 降到 O(k)。进一步优化,可以发现dp[j][0]和dp[j][1]可以拆成两个独立的一维数组cash[j]和hold[j],避免vector<vector<int>>的两次间接寻址:
class Solution { public: int maxProfit(int k, std::vector<int>& prices) { int n = static_cast<int>(prices.size()); if (n < 2) { return 0; } k = std::min(k, n / 2); std::vector<int> cash(k + 1, 0); std::vector<int> hold(k + 1, INT_MIN); for (int i = 0; i < n; ++i) { for (int j = k; j >= 1; --j) { cash[j] = std::max(cash[j], hold[j] + prices[i]); hold[j] = std::max(hold[j], cash[j - 1] - prices[i]); } } return cash[k]; } };手里没有专业 profiling 工具,但我在本地的随机大数据测试里对比过两种写法,vector<vector<int>>和两个独立vector<int>的耗时差距大约在 10% 到 20% 之间。原因在于vector<vector<int>>的内存布局是"外层 vector 存内层 vector 的指针",访问dp[j][0]和dp[j][1]时要经历两次指针间接跳转,缓存命中率偏低;而两个独立一维数组每次访问都是直接地址,连续性好很多。
这里分享一个我自己做性能对比时用的方法:生成 n=100000、k=100000 的随机价格序列,在开启-O2优化的情况下分别跑十次取平均值。注意输出中间结果会被编译器优化掉,必须把结果累加到一个volatile变量里,或者直接打印出来,否则可能出现"两版都快得离谱"的假象。
5. 带手续费与冷冻期的变体:状态机建模
5.1 手续费扣在买入还是卖出
714 题在 122 题的基础上加了一个手续费fee,每次交易完成后要交一笔固定费用。有两种等价处理方式:买入时扣除手续费,或者卖出时扣除手续费。我在代码里习惯写成买入时扣除:
class Solution { public: int maxProfit(std::vector<int>& prices, int fee) { int n = static_cast<int>(prices.size()); if (n < 2) { return 0; } int cash = 0; // 不持有股票的最大利润 int hold = -prices[0] - fee; // 持有股票,已经扣过手续费 for (int i = 1; i < n; ++i) { int prevCash = cash; cash = std::max(cash, hold + prices[i]); hold = std::max(hold, prevCash - prices[i] - fee); } return cash; } };为什么建议扣在买入而不是卖出?因为这样可以保证hold变量始终代表"当前持有这只股票的全部成本",后续比较是否换股时,不需要在卖出时反复加减手续费,代码更简洁。而且从建模角度讲,"交易成本"在开启交易的一瞬间就产生了,更符合直觉。
一个容易出错的地方是hold = std::max(hold, prevCash - prices[i] - fee)里的prevCash用的是更新前的cash,而不是更新后的cash。如果直接写cash - prices[i] - fee,当天就可能出现"先卖出再买入"的重复交易,手续费也被算了一次。这个坑和 188 题里 j 的倒序是同源的。
5.2 冷冻期三态之间的转移
309 题在 122 题的基础上规定:卖出股票的第二天不能买入(进入冷冻期),第三天才能恢复。这个约束无法用"持有/不持有"两态简单表示,因为"不持有"还分"刚卖出处于冷冻期"和"可以自由买入"两种子状态。所以需要三态:
- 状态 0:不持有股票,且处于冷冻期(当天刚卖出)。
- 状态 1:不持有股票,且不在冷冻期(可以买入)。
- 状态 2:持有股票。
状态转移:
状态 0 只能由昨天持股并卖出转移而来: dp[i][0] = dp[i-1][2] + prices[i] 状态 1 可以由昨天的状态 0 或状态 1 转移而来: dp[i][1] = max(dp[i-1][0], dp[i-1][1]) 状态 2 可以由昨天持股(不操作)或昨天状态 1 买入转移而来: dp[i][2] = max(dp[i-1][2], dp[i-1][1] - prices[i])对应 C++ 代码:
class Solution { public: int maxProfit(std::vector<int>& prices) { int n = static_cast<int>(prices.size()); if (n < 2) { return 0; } // dp0: 不持股且冷冻 // dp1: 不持股且非冷冻 // dp2: 持股 int dp0 = 0; int dp1 = 0; int dp2 = -prices[0]; for (int i = 1; i < n; ++i) { int newDp0 = dp2 + prices[i]; int newDp1 = std::max(dp0, dp1); int newDp2 = std::max(dp2, dp1 - prices[i]); dp0 = newDp0; dp1 = newDp1; dp2 = newDp2; } return std::max(dp0, dp1); } };写成一个std::array<int, 3>而不是三个独立变量也可以,但从可读性上讲,独立命名dp0/dp1/dp2更直观,面试时不容易说错。需要注意的是三变量之间不能原地更新,必须先用新变量存好,再一次赋值,否则dp0更新后会影响dp2的计算,产生"同一天卖出又买入"的错误。这个错误我在前几次写 309 题时反复出现,后来养成了任何滚动状态都先算临时变量的习惯,才彻底解决。
5.3 常数空间版本代码
上面 309 题的代码已经是常数空间了。很多人看到 "dynamic programming" 以为一定有二维数组,其实最优的 DP 往往可以用几个变量完成,因为状态转移只依赖"昨天"。如果你在面试中先写出二维数组版本,再主动优化成常数空间,会是非常加分的加分项。
714 题的手续费版本也是常数空间。到这里你会发现,整个系列的"最优解"空间复杂度基本都是 O(1) 或 O(k),只有 188 题因为引入交易次数维度,空间复杂度才会上升到 O(k)。这可以说是一个规律:状态机的状态数量决定空间复杂度上限,滚动数组只是把状态从"每一天一份"压缩成"只存昨天"。
6. 实操总结:本地环境、编译优化与面试建议
6.1 VS Code 下配置 C++ 编译环境与常见坑
刷题归刷题,很多人的 C++ 本地环境配置问题其实比算法本身还折腾。我见过好几个初学者卡在 "VSCode 能写代码但无法编译运行" 这一步,最后直接放弃了。这里分享一下我的配置思路。
在 Windows 上,用 VS Code 写 C++ 最省心的组合是 MinGW-w64 + VS Code C/C++ 插件。MinGW-w64 提供了 g++ 编译器,VS Code 的任务系统负责编译和运行。安装时最好直接下载 x86_64-posix-seh 版本的压缩包,手动解压后把bin目录加到系统 PATH。不要用在线安装器,国内网络经常下到一半失败。
配置.vscode/tasks.json时,核心就是让 VS Code 调用 g++ 编译当前文件:
{ "version": "2.0.0", "tasks": [ { "label": "C++ 编译运行", "type": "cppbuild", "command": "g++", "args": [ "-std=c++17", "-O2", "-Wall", "${file}", "-o", "${fileDirname}/${fileBasenameNoExtension}.exe" ], "group": { "kind": "build", "isDefault": true }, "problemMatcher": ["$gcc"] } ] }很多人在这一步会碰到一个跟算法题无关但很恼火的报错:g++ 不是内部或外部命令。这基本就是 PATH 没配置好,重新打开一个终端确认g++ --version能输出版本信息即可。
另外,Windows 上还经常弹出"Visual C++ Redistributable"安装提示,这个和 VS Code 无关,是某些软件运行 MSVC 编译出来的程序时需要的运行库。如果你的代码是 g++ 编译的,不需要它;如果你用的是 MSVC 工具链编译,那最好装一下最新版的 VC++ Redistributable。这个组件确实经常安装失败,网上还有一种叫 "AIO" 的合集包,把 2015-2022 等多个版本打包在一起,比逐个安装省事很多,但注意只在官方或可信来源下载。
6.2 刷题时的 IO 优化与编译选项
LeetCode 的题目模板不涉及命令行输入,你只需要实现Solution类里的方法。但在 OJ 或笔试平台里,很多题目需要自己处理输入输出。如果你用cin和cout,一定记得在main开头写:
std::ios::sync_with_stdio(false); std::cin.tie(nullptr);这两行代码的原理是切断 C 的stdio和 C++ 的iostream之间的同步,并解开cin和cout的绑定关系,让输入输出不再频繁冲刷缓冲区。实测在输入量达到几十万行时,性能差可能有好几倍。
另一个小事:不要在同一个程序里混用scanf/printf和cin/cout。一旦用了sync_with_stdio(false),C 风格和 C++ 风格的流各自独立缓冲,输出顺序可能错乱。这个坑在笔试调试时特别隐蔽,因为数据量小的时候根本看不出来。
关于编译选项,本地自测时建议开-O2和-Wall。-Wall能帮你发现很多隐性问题,比如变量未使用、比较时符号性不匹配等。刷题状态下通常不需要-g调试信息,但如果你用 GDB 打断点,还是加上比较好。
6.3 面试作答顺序与语言特性的交叉考点
面试官问股票系列时,大概率不是让你直接默写 188 题,而是从 121 或 122 切入,再逐步加约束。我建议的回答顺序是:
- 先说清楚交易的定义:一次买入 + 一次卖出算一笔完整交易。
- 从 121 题开始,用"历史最低价"做一次遍历;如果面试官追问,再推出状态机模型。
- 把状态机模型统一成
cash和hold两个变量,然后根据不同约束扩展维度。 - 最后讨论空间优化和时间复杂度。
C++ 本身的语言特性也会顺带被考察。比如面试官可能会问std::vector的底层原理,或者你为什么用std::max而不是手写三目运算,以及 lambda 捕获、std::array和原生数组的区别。这些内容网上常被总结成"八股",但其实都挺实用:vector的扩容机制影响你预估内存峰值,lambda 的捕获方式影响std::accumulate等算法的可读性。
我个人还有一个小建议:练习这些 DP 题时,尽量不要再写魔鬼缩进和复用变量名,比如把dp、temp、res混着用。刷题时追求简洁没错,但到了手写白板或在线共享文档面试时,变量名本身就是一种沟通工具。像cash和hold这类有明确语义的名字,会让面试官更容易跟住你的思路。
这个系列刷完之后,我再去看其他状态机 DP 题(比如打家劫舍变体、买卖股票最优持仓),明显感觉建模速度上来了。说到底,股票问题就是一个"维护当前状态下最优值,再按约束转移"的经典模型。把 121 到 714 这六道题吃透,C++ 的实现细节和状态机 DP 的核心思想基本就都有了。最后再分享一个我自己一直在用的小技巧:任何状态转移相关的题,先写清楚状态定义和转移方程,再动手敲代码,尤其是涉及交易次数和冷冻期这种附加维度时,先画一画状态之间的箭头,比盯着屏幕硬写靠谱得多。