力扣121题“买卖股票的最佳时机”我愿称之为股票系列的开胃菜,也是力扣热题100里的常客。很多刷题的人对这道题又爱又恨,爱是因为它看起来简单,恨是因为简单背后藏着贪心、动态规划两种经典解法的思想碰撞。我当初第一次刷这道题时,满脑子都是“找最小值和最大值不就行了吗”,结果一提交就发现自己天真了——你找到全局最大值也没用,因为最大值可能出现在最小值前面,股票交易可不允许你穿越回去买入。今天这篇文章,我把这道题的贪心解法给你掰开揉碎讲清楚,顺手送你一套可以直接“抄作业”的Python模板,以及我刷题过程中踩过的坑和总结的排查思路。无论你是刚接触算法的初学者,还是正在准备面试、想系统刷力扣的求职者,这篇文章都值得你花几分钟读完。
1. 题目到底在问什么:先别急着写代码
很多朋友拿到这道题的第一反应就是打开编辑器开始写for循环,这其实是个好习惯,但在此之前我更建议你先花两分钟把题面读透。只有把题目真正理解到位,后面的解法才能顺理成章,而不是靠背模板。
1.1 题面拆解:一次买入、一次卖出
题目给定一个数组 prices ,其中 prices[i] 表示股票在第 i 天的价格。你只能选择某一天买入,然后在未来某一天卖出,要求计算出能获得的最大利润。这里的约束条件有几个关键信息值得注意:
- 只能交易一次,也就是一次买入加一次卖出,不能卖了再买。
- 买入必须在卖出之前。比如第3天买入,最早只能在第4天卖出,不能当天买当天卖(有的变种题允许当天买卖,这题默认不允许,但实际上当天买卖没有意义,因为买卖价格相同利润为0)。
- 如果不能获得任何正利润,也就是价格一路下跌,那最大利润就是0,而不是负数。换句话说,你可以选择不交易。
咱们把这道题放到真实场景里类比一下。假设你看中了一只股票,但你没有买入它的历史价格,你只有一个未来价格的预测列表。你自然希望在价格最低的那天买入,在之后价格最高的那天卖出,这样收益最大。但难点在于,你不知道哪天是最低价——因为价格是一天一天揭晓的,你只能在已经走过的日子里找答案。
这个“只能看过去,不能看未来”的约束,就是这道题的灵魂所在。它排除了一种错误的直觉解法:先找到整个数组的最小值和最大值,然后直接相减。因为最小值可能在最大值后面,这种跨时间的交易是无效的。理解了这一层,你再看接下来要讲的贪心解法,就会觉得它是那么自然而合理。
1.2 这道题为什么配得上贪心算法这个标签
“贪心算法”这四个字看起来很高大上,其实核心思想就一句话:每一步都做出当前看起来最好的选择,期望最终结果是全局最优的。
放在这道题里,贪心体现在两个方面:
第一,遍历到第 i 天时,我们记住前 i-1 天里价格最低的那一天。这件事的成本极低,只需要一个变量不断更新,但它保证了“买入点”永远是历史最优的。
第二,在每一天计算“如果我今天卖出,能赚多少钱”,也就是当前价格减去历史最低价,然后维护这个差值的最大值。
你可能会问:这个做法每一步选的都是“局部最优”,凭什么最终结果就是“全局最优”?这个问题的答案是:因为题目限制只能交易一次,我们从最低点买入、在后面某一天卖出,这个交易结构本身决定了收益只取决于两个因素——买入价格和卖出价格。只要我们在遍历过程中不断用“历史最低价”作为候选买入点,那么任何可能的“卖在更高点”的方案,都会被我们枚举到。换句话说,最优的方案一定满足“买入价是历史最低价”这个条件,所以用贪心逐步更新是不会漏掉最优解的。
这也是为什么这道题虽然也可以用动态规划解,但贪心是更简洁、更符合直觉的方案。理解了“为什么贪心可行”,比你背十遍代码都管用。
2. 从暴力到贪心:完整思考过程分享
我第一次面对这道题的时候,脑子里冒出来的解法其实是暴力遍历。别笑,很多刚从学校出来的朋友第一反应跟我一样。暴力解法的思路毫无技巧:枚举所有可能的买入日和卖出日,计算它们的差价,取最大值。
这里我先把暴力解法的代码贴出来,虽然它不会通过大数据量的测试用例,但它是理解后续优化的重要跳板。
def maxProfit(prices): n = len(prices) max_profit = 0 for i in range(n): for j in range(i + 1, n): profit = prices[j] - prices[i] if profit > max_profit: max_profit = profit return max_profit这个解法的时间复杂度是O(n²),空间复杂度是O(1)。当数组长度很小的时候,它完全没问题,但一旦给到你几万条价格数据,两层循环就会变得非常吃力。力扣的判题系统中,这道题的数组长度最高可达10⁵,O(n²)的算法必然超时。
那优化从哪里入手呢?如果你仔细观察暴力解法里重复做的事情,就会发现一个规律:对于每一个卖出日 j,我们其实只需要知道 0 到 j-1 天中出现过的最低价格,用它作为潜在买入点就够了。为什么要遍历所有 i?因为更高的买入价格一定不会产生更大的利润,我们没必要为同一个卖出日尝试所有历史买入点,只需要记住最小的那一个即可。
这就是贪心策略的核心:在遍历过程中,始终维护一个变量记录“已经遇到过的最小价格”,然后计算当前价格与这个最小价格的差值,更新最大利润。于是代码从两层循环变成了单层循环,时间复杂度从O(n²)降到了O(n)。
你可能会在这个地方产生一个疑惑:这种“只维护历史最小值”的做法,会不会错过某些场景下的最优解?比如某一天价格不是历史最低,但它之后涨得更多,是不是应该在那一天买入?我们说这不会,因为任何有效方案都必须买入在前、卖出在后。如果某一天价格不是历史最低,意味着在此之前有更低的买入点。在相同的卖出日下,用更低的买入价一定获得更高的利润。所以,我们只需要锁定“截至当前的最低买入价”这一个候选就足够了,所有更优的方案都会在这个候选买入价的基础上产生。
这就是贪心在这道题里成立的根本原因。它不是玄学,而是建立在“买入价越低越好”这个单调关系之上的。想明白这一点,你对贪心算法的理解就不再停留在“背模板”的阶段了。
3. Python实现与细节精讲:从代码到逐行分析
聊完思路,接下来是你们最关心的问题:代码怎么写。我先把完整代码放出来,这段代码已经通过力扣的判题测试,可以直接用,当然我建议你先自己理解一遍,然后再动手敲进编辑器里跑一跑。
def maxProfit(prices): min_price = float('inf') max_profit = 0 for price in prices: if price < min_price: min_price = price elif price - min_price > max_profit: max_profit = price - min_price return max_profit很多初学者看到这段代码,第一反应是:就这?对,就这。但越是精简的代码,越考验你对每个细节的把控。下面对代码逐行拆解。
初始化部分,min_price 设置为正无穷大,max_profit 设置为0。为什么 min_price 要设置成正无穷,而不是 prices[0] 或者一个很大的数?因为正无穷可以保证循环第一次执行时,price < min_price 一定成立,从而顺利把第一个元素赋给 min_price。如果你设置成 prices[0],那也没问题,但需要单独处理数组为空的情况;设置成正无穷则天然免疫数组为空的边界条件,即使 prices 是空列表,返回值也是0,不会报错。
循环体内的逻辑是这道题的精髓。每一轮迭代,我们做两个判断:
第一个判断,当前价格是否刷新历史最低价。如果刷新了,就更新 min_price。注意,这一步不需要同时计算利润,因为“当天买入当天卖出”没有意义,利润为0,和维护 max_profit 的初始值没有区别。这里我用的是 if 而不是 if-else 的变体,严格来说写成两个 if 也是对的,但用 elif 可以保证在同一天不会先更新最低价、再以这个新低点卖出算一笔0利润,让逻辑更清晰。
第二个判断,当前价格减去历史最低价,看能不能刷新最大利润。这一步其实就是模拟“如果在今天卖出,我能赚多少钱”。因为最低价是历史最优买入点,所以这个差值就是截至今天,能获得的最大利润。
我建议你拿一个具体的例子手推一遍这个过程。假设 prices = [7, 1, 5, 3, 6, 4]:
- 第1天价格7,min_price从inf变为7,max_profit保持0。
- 第2天价格1,1 < 7,min_price变为1,max_profit还是0。
- 第3天价格5,5不小于1,计算5-1=4,max_profit更新为4。
- 第4天价格3,3不小于1,计算3-1=2,不大于4,max_profit保持4。
- 第5天价格6,6不小于1,计算6-1=5,max_profit更新为5。
- 第6天价格4,4不小于1,计算4-1=3,不大于5,max_profit保持5。
最终返回5,对应第2天买入(价格1)、第5天卖出(价格6)的利润,完全正确。
再测一个典型的下行数组 prices = [7, 6, 4, 3, 1]:
- 第一天7,min_price变成7,max_profit保持0。
- 第二天6,min_price变成6,profit=0,max_profit还是0。
- 第三天4,min_price变成4,profit=0。
- 第四天3,min_price变成3,profit=0。
- 第五天1,min_price变成1,profit=0。
返回0。这对应题目要求:不能获得正利润时返回0,选择不交易。
代码看完了,我还要多提一嘴“为什么先更新最低价、再更新最大利润”的顺序。有的朋友可能会写成先算 profit 再更新 min_price,这也没问题。但如果反过来,你在同一天先更新了 min_price 为当天的更低价格,再用这个更低价格去做利润计算,那得到的利润是0或者一个错误的“当天买卖”值。虽然不会影响最终结果(因为0小于任何正利润),但这个逻辑瑕疵在面试时被追问会比较尴尬。我建议代码顺序就保持上面写的那样,先检查是不是新低,如果不是新低再尝试更新利润,思路清清楚楚。
4. 实际刷题中的常见问题与排查技巧
代码写出来了,但你真正在力扣上刷题时,可能会遇到一些细节问题。有的是边界条件处理不当,有的是对题目理解有偏差,还有的是面试中面试官突然追问“为什么贪心是对的”把你问懵了。下面我把这些高频问题整理成一张速查表,并附上我的排查思路和独家心得。
4.1 常见问题速查表
| 问题现象 | 原因分析 | 解决方法 |
|---|---|---|
| 空数组返回报错 | 直接访问 prices[0] 初始化 min_price | 用 float('inf') 初始化,或先判断数组长度 |
| 单元素数组返回错误 | 循环逻辑不完整,没有处理无法交易的情况 | 单元素数组利润必然为0,代码天然返回0 |
| 结果输出负数 | 把不交易的情况也算成负收益 | max_profit 初始化为0,保证不交易时返回0 |
| 超时 | 用了双层循环暴力求解 | 改用贪心,单次遍历 O(n) |
| 与动态规划解法搞混 | 对贪心和动归的边界认识不清 | 理解本题贪心可行是因为“一次交易+买入价越低越好” |
4.2 min_price 初始值到底怎么选
我在刷题群里经常看到有人问:min_price 能不能初始化成 prices[0]?答案是能,但你要多写几行防御代码。如果用 prices[0],你需要先判断数组是否为空,否则会抛出索引越界异常。用 float('inf') 则完全不用管这些,这是力扣解题里非常常见的一个技巧,在后续很多题目中都能用到。
不过有一点要注意,有些变种题目会要求你返回具体的买入日和卖出日,而不只是最大利润。这时候 float('inf') 初始化的方式仍然适用,但你需要额外记录更新 min_price 时的下标,以及更新 max_profit 时的区间。这属于121题的延伸,建议你把基础版本吃透后再去挑战。
4.3 面试时如何回答面试官的连环追问
刷题最终还是要服务于面试,所以我特别想分享一点面试经验。面试官考这道题时,通常不会满足于“你会写贪心解法”这个结果,他更想看到你完整的思维链路。
我建议你按这个顺序展示思路:
- 先说出暴力解法:枚举所有买入卖出时间对,O(n²),这是最朴素的思路。
- 再指出暴力解法的瓶颈:重复枚举了大量不可能产生最优解的买入点。
- 然后自然地引出贪心:因为买入价越低利润越高,所以我们只需要维护历史最低价。
- 最后补充正确性证明:任何最优方案中,买入日一定是卖出日之前的最低价,否则可以替换成更低的买入价获得更高利润。
按照这条链路走下来,面试官会认为你不仅会写代码,还具备分析问题和沟通方案的能力。这比闷头直接把代码甩出来要好太多。
5. 从121题出发:贪心和动态规划的边界你分清楚了吗
聊到这里,肯定有朋友会问:我看网上很多题解是用动态规划写的,跟贪心有什么区别?哪个更好?这是刷题绕不开的一个问题,尤其是股票系列有好几道题,理解清楚算法之间的边界能帮你举一反三。
先说说本题的动态规划解法。定义 dp[i] 表示第 i 天卖出能获得的最大利润,那么递推关系是:
dp[i] = max(dp[i-1], prices[i] - min_price)
其中 min_price 是前 i 天的最低价。这个递推的意思是:要么第 i 天不卖,沿用前一天的利润;要么第 i 天卖出,利润是当前价格减去历史最低价。
把这个动态规划的空间优化一下,你会发现一个惊人的事实:dp 数组根本不需要保留,只需要维护一个 dp 的“滚动最大值”,也就是我们贪心解法里的 max_profit。也就是说,这道题用贪心写出来的代码,本质上就是动态规划空间优化后的形式。
那么什么时候必须用动态规划、不能用贪心呢?答案是当交易次数变成变量时。比如122题允许无限次交易,贪心依然有效——你只需要把每段上涨的利润都累加起来就行。但到了123题“最多买卖两次”以及188题“最多买卖k次”,贪心就失效了,因为局部最优的多次买卖可能互相冲突,必须用动态规划在“交易次数”这个维度上做状态转移。
所以你可以在自己的知识体系里这样理解:贪心是这道题最精简的解法,而动态规划是股票系列的通法模板。当题目约束越少、交易结构越简单时,贪心就越可能直接命中答案;一旦题目加了次数限制、冷冻期、手续费这些变数,动态规划才是那个能兜底的工具。
5.1 最容易上手的扩展:122题为什么贪心更简单
122题和121题唯一的区别是:你可以尽可能多地完成交易,也就是在价格低点买入、高点卖出,可以交易很多次。这道题的贪心解法堪称艺术:
def maxProfit(prices): profit = 0 for i in range(1, len(prices)): if prices[i] > prices[i - 1]: profit += prices[i] - prices[i - 1] return profit核心思想是:只要今天比昨天价格高,就认为昨天买入、今天卖出是一次有效交易,把所有上涨波段的差额加起来。因为不限交易次数,所以每一次上涨都不能放过。代码比121题还短,但第一次看懂的人往往会被它的简洁震惊到。
我强烈建议你把121题和122题放在一起对比学习,这两道题能帮你把“一次交易”和“无限次交易”的贪心策略彻底区分开,理解这两个问题之后,你再去刷123题就不会那么痛苦了。
5.2 从股票系列看刷题的方法论
最后说点题外话。很多朋友刷题喜欢按“题号顺序”一路往下刷,但我的体会是,按“专题”刷效率更高。股票系列就是一个非常好的专题:121题一次交易,122题无限次交易,123题两次交易,188题k次交易,309题带冷冻期,714题带手续费。把这一系列放在一起,你能清晰地看到算法复杂度是如何一步步升级的,也能更好地理解贪心、动态规划各自的适用边界。
如果你决定按这个路线刷,我建议你每做完一道题都写一下题解笔记,不用多详细,哪怕只是在代码注释里写一句“这题和121题的区别是什么”都行。我自己刷题时有个习惯,每完成一个专题就强迫自己不看代码、用文字叙述一遍解题思路。这个方法让我对题目的理解远远超过看十遍题解。
写在最后的小技巧
我实际刷这道题时,最大的体会是:代码本身没有难度,难的是说服自己“贪心为什么是对的”。如果你某天在面试现场或者刷题群里被人问住了,不必慌张,把问题拉回到最基础的场景里想一遍——只允许买卖一次,那么任何一天的卖出利润都等于当前价格减去此前的历史最低价,而历史最低价是随着遍历不断被更新和发现的。想通了这一层,121题就真的是你的囊中之物了。
另外,还有一个我自己踩过的坑想分享给你:初学Python的时候,我总喜欢用 max() 函数去更新最大利润,写出来是 max_profit = max(max_profit, price - min_price),这当然是对的。但力扣判题系统对运行时间的统计很敏感,这种写法跟手动 if 判断相比,性能几乎没差别,所以你按自己的喜好来就行,不用刻意追求极致优化。真正重要的,是你把这道题的思路吃透,形成肌肉记忆,下次遇到类似问题能条件反射地想到“遍历时维护历史最优值”这个模式,这才是刷题最大的收获。