C++动态规划与贪心算法精解:股票买卖最佳时机问题全攻略

📅 2026/7/19 10:32:04
C++动态规划与贪心算法精解:股票买卖最佳时机问题全攻略
1. 项目概述从“最佳时机”到“最优策略”买卖股票的最佳时机问题几乎是每个学习算法和数据结构的C开发者都会遇到的经典题目。它不像“Hello World”那样简单也不像复杂的图形学项目那样庞大但它却像一块试金石精准地检验着一个人对动态规划、贪心算法以及问题建模的基本功。我第一次接触这个问题时以为它只是简单的“低买高卖”后来才发现它背后隐藏着一系列层层递进的逻辑迷宫从只能交易一次到可以交易无数次再到带有冷却期或手续费每一次条件的变化都对应着一种全新的思考方式和状态定义。这个问题的核心价值在于它完美地将一个现实世界的金融决策问题抽象成了一个可以用计算机程序高效求解的数学模型。对于C开发者而言解决它不仅仅是写出一个能通过在线评测系统OJ的代码更是锻炼如何用C的高效特性如向量容器、迭代器、状态压缩来优雅地实现算法思想。网络上相关的“C八股文”或“面经”里它出现的频率极高因为它能快速考察候选人的逻辑思维、代码实现和边界条件处理能力。无论你是正在准备信息素养大赛、求职面试还是单纯想夯实算法基础深入理解这个问题的各种变体及其C解法都是一笔稳赚不赔的投资。2. 核心问题拆解与算法思想选择买卖股票问题不是一个单一问题而是一个系列。我们必须先明确具体规则才能选择正确的算法武器。最常见的几种变体包括只能进行一次交易这是最基础的形式即在整个价格序列中找到一次买入和一次卖出的最大利润差且卖出必须在买入之后。可以进行无限次交易在同一天你可以卖出后再买入。目标是抓住所有上涨的波段。最多可以进行两次交易你最多可以完成两笔独立的交易买-卖买-卖不能交叉。最多可以进行k次交易这是通用形式限制交易次数为k。包含冷冻期卖出股票后无法在第二天买入即有一天的冷冻期。包含手续费每笔交易一次完整的买入卖出需要支付固定的手续费。面对如此多的变体盲目编码是行不通的。我们需要分析其内在逻辑从而归类并选用核心算法思想。2.1 动态规划状态机模型的普适解法对于大多数限制了交易次数k次及以内或带有额外条件冷冻期、手续费的问题动态规划是当之无愧的“瑞士军刀”。其核心在于定义清晰的状态并找出状态之间的转移方程。一个非常强大且通用的DP状态定义是dp[i][k][0 or 1]。i表示第i天0 i n。k表示允许的最大交易次数。注意一次交易由买入和卖出组成这里k通常定义为已完成的最大买入次数或剩余的交易次数两种定义会影响状态转移的细节但思想一致。0 or 1表示当前是否持有股票。0表示不持有空仓1表示持有持仓。为什么这样定义因为股票问题的所有操作买入、卖出、持有都只与两个核心因素相关时间第几天和你当前的状态手里有没有股票。交易次数k是一个资源限制。这个三维状态几乎可以覆盖所有变体。状态转移方程围绕着两个动作展开rest保持状态和action买入或卖出。例如对于dp[i][k][0]第i天最多还能进行k次交易手上没有股票它可以从两种状态转移而来昨天也没股票今天选择休息dp[i-1][k][0]昨天持有股票今天选择卖出dp[i-1][k][1] prices[i](卖出获得当天价格的钱) 所以dp[i][k][0] max(dp[i-1][k][0], dp[i-1][k][1] prices[i])同理对于dp[i][k][1]第i天最多还能进行k次交易手上持有股票昨天就持有股票今天休息dp[i-1][k][1]昨天没有股票今天选择买入dp[i-1][k-1][0] - prices[i](买入花费当天价格的钱注意交易次数k在买入时减少) 所以dp[i][k][1] max(dp[i-1][k][1], dp[i-1][k-1][0] - prices[i])初始状态Base Case需要仔细考虑dp[-1][...][0] 0天数从0开始第-1天还没开始利润当然是0。dp[-1][...][1] -infinity第-1天不可能持有股票用负无穷表示不可能的状态。dp[...][0][0] 0交易次数为0时不允许交易空仓利润为0。dp[...][0][1] -infinity交易次数为0时不允许持有股票。这个框架是理解所有DP解法的基石。在实际编码中我们通常会用数组来模拟这个三维DP表并根据具体问题对k进行优化例如当k无穷大或k大于n/2时可以退化为二维甚至一维DP。2.2 贪心算法特定场景下的高效捷径对于可以进行无限次交易的变体动态规划虽然也能解但杀鸡用了牛刀。此时贪心算法拥有无与伦比的简洁和高效。其思想直观得惊人既然可以交易无数次那么所有的利润都来自于相邻两天的正差价。只要第二天的价格比第一天高我就可以在第一天买入第二天卖出赚取这个差价。把所有这样的正差价累加起来就是最大利润。为什么贪心算法在这里是正确且最优的我们可以把连续几天的上涨例如价格序列[1, 3, 5]看作多次小的上涨1-3, 3-5。总利润(5-1) 4等于(3-1) (5-3) 4。贪心算法正是捕捉了每一次小的上涨其累加和与一次性在最低点买入、最高点卖出的利润是相等的。它避免了复杂的状态记录时间复杂度是O(n)空间复杂度是O(1)。2.3 其他辅助思想一次交易问题可以转化为寻找最大差值问题用min_price记录历史最低价同时计算当前价格与历史最低价的差值并更新最大利润。这本质上是动态规划状态压缩到极致的特例。两次交易问题可以分割数组分别计算左右两段的最大利润转化为一次交易问题然后求和找最大值。更通用的解法还是基于上述DP框架将k设为2。注意选择算法时务必先明确题目条件。很多初学者容易混淆“无限次交易”和“k次交易”用错算法会导致逻辑错误或效率低下。一个简单的判断方法是如果题目没有明确限制交易次数或者明确说可以完成多笔交易通常先考虑是否满足贪心条件无限次否则就用DP。3. C实现详解与代码剖析理论清晰后我们进入实战环节。我将用C实现几个最具代表性的变体并详细解释代码中的每一个关键点。我们会使用vectorint来存储股价序列这是C中处理动态数组最标准、最高效的方式之一。3.1 变体一只能进行一次交易问题给定一个数组prices它的第i个元素prices[i]表示一支给定股票第i天的价格。你只能选择某一天买入这只股票并选择在未来的某一个不同的日子卖出该股票。设计一个算法来计算你所能获取的最大利润。如果你不能获取任何利润返回 0。思路我们只需要遍历价格数组在每一天我们做两件事更新历史最低价格 (min_price)。计算如果我在历史最低点买入今天卖出能赚多少钱 (prices[i] - min_price)并更新最大利润 (max_profit)。C实现#include vector #include algorithm #include climits // 用于INT_MAX int maxProfitOneTransaction(const std::vectorint prices) { if (prices.empty()) return 0; // 边界条件空数组 int min_price INT_MAX; // 初始化一个非常大的值 int max_profit 0; for (int price : prices) { // 更新历史最低价潜在的买入点 min_price std::min(min_price, price); // 计算当前卖出的最大利润当前价 - 历史最低价 int profit_if_sell_today price - min_price; // 更新全局最大利润 max_profit std::max(max_profit, profit_if_sell_today); } return max_profit; }代码剖析INT_MAX来自climits头文件用于初始化min_price确保第一次比较时任何价格都会比它小。循环中min_price的更新必须在计算利润之前。因为今天的价格不能作为今天的买入价当天买入卖出利润为0且题目要求不同日子。时间复杂度 O(n)空间复杂度 O(1)。这是最优解法。3.2 变体二可以进行无限次交易问题在每一天你可以决定是否买入和/或卖出股票。你在任何时候最多只能持有一股股票。你也可以先购买然后在同一天出售。返回你能获得的最大利润。思路贪心算法收集所有上涨波段。C实现int maxProfitInfiniteTransactions(const std::vectorint prices) { int max_profit 0; for (size_t i 1; i prices.size(); i) { // 如果今天比昨天价格高就累加这段利润 if (prices[i] prices[i - 1]) { max_profit prices[i] - prices[i - 1]; } } return max_profit; }代码剖析极其简洁。从第二天开始遍历只要今天比昨天贵就把差价算作利润。size_t是用于数组索引的无符号类型避免与int比较时产生警告。时间复杂度 O(n)空间复杂度 O(1)。3.3 变体三最多可以完成两笔交易问题你最多可以完成两笔交易。你不能同时参与多笔交易你必须在再次购买前出售掉之前的股票。思路采用通用DP框架k2。但我们可以进行状态压缩因为k值很小。定义四个状态buy1: 进行第一次买入操作后剩余的最大资金负数表示花费。sell1: 完成第一次交易后手中的最大资金。buy2: 进行第二次买入操作后剩余的最大资金。sell2: 完成第二次交易后手中的最大资金即最终答案。C实现int maxProfitTwoTransactions(const std::vectorint prices) { int n prices.size(); if (n 2) return 0; // 初始化状态 int buy1 -prices[0]; // 第一天就买入第一笔 int sell1 0; // 第一天不可能完成第一次卖出 int buy2 -prices[0]; // 第一天买入第一笔后在同一天“卖出并立刻再买入”第二笔允许吗 // 实际上buy2的初始化需要小心。更严谨的初始化是 // buy1 -prices[0], sell1 0, buy2 -prices[0], sell2 0; // 但下面通过迭代更新sell1会在第一天后变为0buy2会在第二天基于更新的sell1计算。 // 一种更清晰且正确的初始化是 buy1 -prices[0]; sell1 0; buy2 -prices[0]; // 表示在第一天已经以某种方式持有了第二笔股票实际上不可能但作为DP初始值 sell2 0; for (int i 1; i n; i) { // 状态转移注意顺序应该先更新sell再更新buy或者用临时变量保存旧值 // 这里采用先保存旧值的方式避免新值影响后续计算 int new_buy1 std::max(buy1, -prices[i]); // 今天不操作或者今天第一次买入 int new_sell1 std::max(sell1, buy1 prices[i]); // 今天不操作或者今天第一次卖出 int new_buy2 std::max(buy2, sell1 - prices[i]); // 今天不操作或者今天第二次买入用第一次卖出的钱 int new_sell2 std::max(sell2, buy2 prices[i]); // 今天不操作或者今天第二次卖出 // 同步更新 buy1 new_buy1; sell1 new_sell1; buy2 new_buy2; sell2 new_sell2; } return sell2; // 最终状态是完成两次交易后不持有股票 }代码剖析这是状态压缩的动态规划。我们将三维DP数组压缩成了四个变量。状态转移的顺序很重要。因为buy1和sell1会互相依赖buy2依赖于sell1sell2依赖于buy2。在同一个循环中更新时必须使用旧值即i-1天的状态来计算新值i天的状态。上面的代码通过引入临时变量new_xxx来保证这一点。另一种常见写法是直接按依赖关系顺序更新但需要理解其内在逻辑。时间复杂度 O(n)空间复杂度 O(1)。3.4 变体四最多可以完成k笔交易问题你最多可以完成 k 笔交易。你不能同时参与多笔交易你必须在再次购买前出售掉之前的股票。思路当k很大时例如k n/2问题退化为无限次交易可以用贪心。否则使用标准的二维动态规划。C实现#include vector #include algorithm int maxProfitKTransactions(int k, const std::vectorint prices) { int n prices.size(); if (n 2 || k 1) return 0; // 特殊情况如果k大于等于n/2相当于可以交易无限次 if (k n / 2) { return maxProfitInfiniteTransactions(prices); // 复用之前的贪心函数 } // DP数组dp[i][j] 表示进行j次交易这里j定义为交易次数在第i天结束时的最大收益 // 但更常见的定义是dp[i][j][0/1] 压缩掉一维。 // 我们使用两个数组buy[j] 和 sell[j] 分别表示在第i天恰好完成第j笔交易买入后和卖出后的最大收益。 // 另一种更直观的定义是 // dp[j][0]: 最多进行j次交易且当前不持有股票的最大利润 // dp[j][1]: 最多进行j次交易且当前持有股票的最大利润 // 这里采用第一种“恰好完成”的定义初始化更清晰。 std::vectorint buy(k 1, INT_MIN); // buy[j]: 完成第j次买入后的最大利润负值 std::vectorint sell(k 1, 0); // sell[j]: 完成第j次卖出后的最大利润 for (int price : prices) { // 注意需要从大到小更新j因为buy[j]依赖于sell[j-1]昨天的状态 // 如果从小到大更新sell[j-1]会被今天的数据污染。 for (int j k; j 1; --j) { // 第j次卖出要么保持原状要么在持有第j次买入的股票后今天卖出 sell[j] std::max(sell[j], buy[j] price); // 第j次买入要么保持原状要么在第j-1次卖出后今天买入 buy[j] std::max(buy[j], sell[j - 1] - price); } // 对于第一次交易buy[1]需要特殊初始化吗不需要因为sell[0]始终为0。 // 在第一次循环中buy[1] max(INT_MIN, 0 - price) -price这是正确的。 } return sell[k]; // 返回完成k次交易后的最大利润 }代码剖析if (k n / 2)是一个重要的优化。因为n天最多只能进行n/2次有效交易买和卖各占一天当k超过这个数限制就失效了等同于无限次交易。buy和sell数组的长度是k1索引j代表恰好完成第j次交易。buy[0]和sell[0]作为边界条件sell[0]始终为00次交易利润为0buy[0]无意义初始化为INT_MIN。内层循环j必须从大到小遍历。这是状态压缩DP的关键点。因为buy[j]依赖于sell[j-1]上一轮循环即前一天的值。如果从小到大遍历当计算buy[j]时sell[j-1]已经被今天的新值更新了这就使用了错误的状态同一天的状态导致结果错误。从大到小遍历保证了使用的sell[j-1]是昨天的旧值。时间复杂度 O(n*k)空间复杂度 O(k)。当k很大时退化为 O(n^2)因此之前的贪心优化至关重要。4. 进阶变体冷冻期与手续费理解了核心框架后处理冷冻期和手续费就只是对状态转移方程进行微调。4.1 包含冷冻期问题卖出股票后你无法在第二天买入股票 (即冷冻期为 1 天)。思路在标准无限次交易DP的基础上增加一个状态。我们可以定义三个状态hold持有股票时的最大利润。notHold_cooldown不持有股票且处于冷冻期即今天不能买。notHold不持有股票且不处于冷冻期即今天可以买。C实现int maxProfitWithCooldown(const std::vectorint prices) { int n prices.size(); if (n 2) return 0; int hold -prices[0]; // 第一天买入 int notHold 0; // 第一天什么都不做 int notHold_cd 0; // 第一天不可能处于冷冻期 for (int i 1; i n; i) { int pre_hold hold; int pre_notHold notHold; int pre_notHold_cd notHold_cd; // 今天持有股票要么昨天就持有要么今天买入昨天必须不持有且不在冷冻期 hold std::max(pre_hold, pre_notHold - prices[i]); // 今天不持有股票且不在冷冻期要么昨天就不持有也不在冷冻期要么昨天刚从冷冻期出来 notHold std::max(pre_notHold, pre_notHold_cd); // 今天不持有股票且在冷冻期只可能来自昨天卖出股票即昨天持有 notHold_cd pre_hold prices[i]; } // 最后一天最大利润一定是不持有股票的状态持有股票没卖掉是亏的 return std::max(notHold, notHold_cd); }4.2 包含手续费问题你可以无限次地完成交易但是你每笔交易都需要付手续费。如果你已经购买了一个股票在卖出它之前你就不能再继续购买股票了。返回获得利润的最大值。思路在无限次交易的贪心或DP中考虑手续费即可。对于DP只需在卖出时减去手续费。C实现DP版int maxProfitWithFee(const std::vectorint prices, int fee) { int n prices.size(); if (n 2) return 0; int hold -prices[0]; // 第一天买入利润为 -price[0] int notHold 0; // 第一天不持有利润为0 for (int i 1; i n; i) { int pre_hold hold; int pre_notHold notHold; // 今天持有要么昨天就持有要么今天买入从昨天不持有的状态来 hold std::max(pre_hold, pre_notHold - prices[i]); // 今天不持有要么昨天就不持有要么今天卖出从昨天持有的状态来并支付手续费 notHold std::max(pre_notHold, pre_hold prices[i] - fee); } return notHold; // 最后不持有股票 }贪心算法含手续费的注意事项 含手续费的无限次交易问题贪心算法需要调整。不能简单累加所有正差价因为频繁交易可能被手续费吞噬利润。正确的贪心策略是在考虑手续费后只有当股价上涨超过一个“阈值”与上次买入价手续费相关时才卖出。实现起来稍复杂DP法则更为直观通用。5. 调试技巧、常见陷阱与性能优化5.1 调试与验证构造边界测试用例空数组[]- 应返回0。单元素数组[1]- 应返回0。单调递减数组[5,4,3,2,1]- 应返回0。单调递增数组[1,2,3,4,5]- 验证不同算法的结果。包含平盘的数组[1,1,1,1]- 应返回0。随机数组用小的数组手动计算验证。使用打印调试在DP循环中打印关键状态变量如buy1,sell1,hold,notHold观察其变化是否符合预期。对比不同算法对于无限次交易问题用贪心和DP两种方法实现用相同输入验证结果是否一致。5.2 常见陷阱索引越界在访问prices[i-1]时确保i从1开始。在DP中注意dp数组的初始化大小。状态转移顺序错误尤其是在进行状态压缩时如k次交易和冷冻期问题更新状态的顺序至关重要。务必理清状态间的依赖关系使用旧值计算新值。整数溢出股价和利润可能很大使用int可能溢出。在面试或竞赛中如果题目没有明确范围可以考虑使用long long。但在LeetCode等平台的标准用例中int通常足够。误解“交易次数”明确k的定义是一次“买入卖出”算一次交易还是“买入”算一次。大多数题目指前者。我们的DP状态定义通常与“剩余可买入次数”或“已完成交易次数”挂钩需要保持一致。冷冻期初始化冷冻期状态notHold_cd在第0天开始前通常为负无穷或0需要根据定义仔细设定。5.3 性能优化空间优化如上述代码所示尽可能将二维甚至三维DP压缩到一维或几个变量。这是此类问题优化的关键。提前终止对于一次交易问题如果已经找到的max_profit大于后续可能的最大价格差理论上可以提前结束但通常不值得因为判断本身有开销。K过大优化如前所述当k n/2时直接退化为贪心算法将时间复杂度从 O(nk) 降为 O(n)。6. 从算法到工程在项目中应用与扩展虽然这是一个算法题但其思想在量化交易、风险控制等金融科技领域有直接应用。在真实的C项目中你可能会遇到更复杂的情况流式数据股价数据是实时流Stream你无法存储全部历史数据。这时一次交易问题的解法依然有效维护一个历史最小值和当前最大利润但无限次交易问题需要重新思考可能需要维护一个状态机在数据流上滑动。多支股票问题扩展到可以同时关注多支股票但资金有限。这就变成了一个带权重的组合优化问题复杂度急剧上升可能需要更复杂的优化算法如整数规划。加入交易成本模型手续费可能不是固定的而是按比例收取。买卖价差Bid-Ask Spread也需要考虑。这需要在状态转移方程中修改成本计算部分。与数据库结合股价数据存储在SQL或NoSQL数据库中。你需要编写高效的C程序可能使用libpqxx或mongocxx来查询数据并进行批量计算这时算法的效率直接影响到系统的吞吐量。对于C开发者来说掌握这些算法不仅是应对面试更是培养一种将现实问题抽象为可计算模型的能力。当你熟练地用vector、std::max、清晰的状态变量来解决此类问题时你就在训练自己写出高效、清晰、易于维护的C代码的肌肉记忆。这种能力是远比记住“C map的底层是红黑树”这样的八股文更宝贵的财富。下次当你看到股价波动图时或许你会本能地开始思考状态和转移这就是算法思维的内化。