news 2026/10/16 8:11:08

买卖股票最佳时机:一次遍历与状态机DP全解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
买卖股票最佳时机:一次遍历与状态机DP全解析

最近在整理股票交易这一系列题目时,第一道就被直觉坑了一次。力扣121题,买卖股票的最佳时机,题面短得让人放松警惕:给你一个数组prices,prices[i]表示第i天的股票价格,只允许你选择某一天买入,并在未来的某一天卖出,求能拿到的最大利润;如果拿不到利润,就返回0。很多人看完第一反应是“这不就是最大值减最小值吗”,结果一连串测试用例直接教做人。这篇文章就把这道题的完整思路、代码实现、边界情况和它跟后续股票题的关系全部拆开讲一遍,新手可以照着写,老手也能借机复盘一下状态机是怎么一步步推导出来的。

1. 题目到底在说什么:读懂顺序约束才是关键

1.1 原题要求与直觉陷阱

先看题面的硬性条件。数组长度大概是1到10的5次方,价格范围是0到10的4次方,输入输出都很干净。真正的约束只有一个:买入日必须早于卖出日,官方原话是“未来的某一个不同的日子”才能卖出,所以当天买当天卖是不被允许的。利润为负时不应该做这笔交易,直接返回0。

这个约束看起来不起眼,却是整道题唯一的命门。直接取最大值减最小值,等于默认了你可以先卖后买,或者默认了最低点一定出现在最高点之前。实际数据里根本没有这么理想。举个很直观的数组:[7, 1, 5, 3, 6, 4],最大值是7,最小值是1,7减1等于6,但正确答案是5。因为7在第0天出现,如果第0天买入,之后股价从来没高过7,你只会亏损或平价,根本拿不到6的利润。真正赚钱的操作是在价格为1的那天买入,等到价格为6的那天卖出,利润5。

所以这道题从表面上是在问“数组里两个数字之间的最大差值”,本质上却带着一个隐藏的前后顺序条件。这种“带顺序约束的最值问题”,在算法题里非常常见,典型代表就是股票系列和柱状图相关题目。刷题的时候如果只盯着数字大小,不看位置关系,第一步就会掉进坑里。

1.2 三组手算用例,看懂“先买后卖”

我每次给同事讲这道题,都会先让他们手算三组用例,跑完基本就对题意没疑问了。

第一组是官方示例:prices = [7, 1, 5, 3, 6, 4]。逐个看:第0天价格7,后面没有比它更高的价格;第1天价格1,后面最高到6,利润5;第2天价格5,后面最高到6,利润1;第3天价格3,后面最高到6,利润3;第4天价格6,后面只剩4,利润为负;第5天之后没有卖出日。所以最大利润出现在“第1天买入、第4天卖出”,结果是5。

第二组是下跌序列:[7, 6, 4, 3, 1]。任何一天买入,之后的价格只会更低,每一笔交易利润都是负数。既然题目允许不交易,那就不买不卖,返回0。这组用例专门用来防止代码返回负数。

第三组是我自己加的迷惑用例:[3, 2, 6, 5, 0, 3]。很多人一眼看到最大值6和最小值0,脱口而出利润是6,结果错了。因为0出现在6的后面,如果你在价格为0的那天买入,之后最高只能卖到3,利润是3;而真正的最大值是第1天以2买入,第2天以6卖出,利润4。这组用例很好地说明了一个道理:不能只看极值,要看每个卖出点之前的历史最低买入价。

1.3 把题目抽象成数学表达

手算完三组用例之后,可以尝试把问题写得严谨一点。假设买入下标是i,卖出下标是j,那么必须满足 i < j。目标函数是:

max(prices[j] - prices[i]),其中 0 <= i < j < n。

这个表达式还可以换个角度看。既然i必须在j之前,那对于每一个固定的卖出日j,最优的买入日一定是“前j-1天里价格最低的那一天”。于是公式变成:

ans = max over j of (prices[j] - min(prices[0..j-1]))。

写到这里其实解法已经浮出水面了:从头到尾扫一遍,边扫边维护“遇到过的最低价格”,然后用当前价格减去这个历史最低价,得到一个“如果今天卖出能赚多少”的候选答案,最后在这些候选中取最大值。

还有一个更进阶的视角,把价格数组转换成每日差价数组diff[i] = prices[i] - prices[i-1]。一次先买后卖的交易利润,恰好等于某一段连续涨跌幅之和,所以原题就转化成了“在差分数组里找最大连续子段和”,也就是力扣53题最大子序和。这个视角不是必须的,但它能把题目归类到更通用的“差分与子段”框架里,对刷题思维很有帮助。

2. 解法演进:从暴力穷举到一次遍历

2.1 暴力法:O(n^2)能过,但效率离谱

不管题目多简单,我建议你先写一版暴力解,不是为了提交,而是为了校验后续高效解法的正确性。暴力法的思路非常直白:枚举所有可能的买入日i和卖出日j,计算prices[j] - prices[i],取最大值。对应代码大概是:

def max_profit_brutal(prices): n = len(prices) ans = 0 for i in range(n): for j in range(i + 1, n): profit = prices[j] - prices[i] if profit > ans: ans = profit return ans

复杂度是O(n^2),空间O(1)。当数组长度是10的5次方时,循环次数接近50亿次,在力扣的评测环境下绝对超时。所以暴力解只用来验证逻辑,不能作为最终提交方案。

但暴力法有一个场景很有价值:对拍。写完高效算法后,可以随机生成几千组长度很小的价格数组,同时跑暴力版和优化版,对比输出是否完全一致。一旦优化版在某个边界用例上翻车,暴力版能帮你快速定位是哪一步状态没有维护对。这个技巧在做复杂题时尤其有用,121这种简单题反而可以省掉,但习惯值得养成。

2.2 一次遍历:维护历史最低价的贪心思路

暴力法慢在重复扫描。对于每一个卖出日j,暴力法都要回头重新找prices[0..j-1]的最小值。那不如正过来想:我反正要从前到后扫一遍,不如每扫到一个新位置,就顺手把这个值记录下来,跟之前的历史最低价比较,不断更新“历史最低价”。这样当我要算“今天卖出能赚多少”的时候,不需要回头遍历,直接拿今天价格减历史最低价就行。

核心代码逻辑只有四行:

min_price = float('inf') max_profit = 0 for price in prices: min_price = min(min_price, price) max_profit = max(max_profit, price - min_price)

每一步都在做一个局部最优选择:要么用今天的价格作为新的买入参考,要么保留之前的更低买入价;同时看看如果今天卖出,能不能刷新最大利润。因为“历史最低价”天然只包含当前日期之前的价格,所以先买后卖的顺序约束被自动满足了。这就是为什么它是正确解法的根本原因。

时间复杂度降到了O(n),空间复杂度只有O(1)。这个复杂度基本也到了理论下界,因为你至少要读一遍数组才能知道最大利润,所以没有任何算法能比O(n)更快。

2.3 动态规划视角:状态定义与空间压缩

很多人好奇,这道题到底算贪心还是动态规划?其实两边都说得通。换个方式定义状态:用dp[i]表示“前i天(到第i天结束时)能获得的最大利润”。那么第i天有两种选择:什么都不做,沿用dp[i-1];或者选择在今天卖出,那买入价格就必须是前i-1天里的历史最低价,利润是prices[i] - min_price。状态转移方程就是:

dp[i] = max(dp[i-1], prices[i] - min_price)。

因为dp[i]只依赖dp[i-1]和当前扫描到的历史最低价,所以根本不需要一整张dp表,滚动压缩一下就是一个变量。这个压缩过程很有意思:你会发现最后剩下的max_profit,本质上就是dp数组的最后一个值。很多动态规划题的状态转移写出后,第一步优化都是观察依赖关系,把二维数组变成一维,再变成几个变量。121题就是一个最典型的压缩样例。

再往深处走一步,还可以用持股状态来描述这道题。定义两个状态:hold表示“当前手上持有一股股票时的最大收益”,not_hold表示“当前空仓时的最大收益”。初始时not_hold=0,hold是负无穷,因为还没买任何股票。每天可以选择买入、卖出或持有:

not_hold = max(not_hold, hold + price) hold = max(hold, -price)

买入就是花price块钱获得一股,收益记为-price;卖出就是把手上的股票卖掉,收益变成hold+price。这个状态机模板看着比一次遍历复杂,但它才是整个股票系列的通用模板。后面遇到最多交易两笔、有冷冻期、有手续费,都是在hold和not_hold这两个状态上增加维度。

3. 代码实现与细节打磨:三种语言版本对照

3.1 Python版:最简可读写法

Python提交版本可以写成下面这样:

from typing import List class Solution: def maxProfit(self, prices: List[int]) -> int: min_price = float('inf') max_profit = 0 for price in prices: if price < min_price: min_price = price profit = price - min_price if profit > max_profit: max_profit = profit return max_profit

我用float('inf')初始化min_price,而不是直接用prices[0],这样能省去对空数组的判空。如果prices为空,循环根本不会执行,直接返回0。

这里有一个很多人分不清的细节:为什么先更新min_price再计算profit也是对的?假设当前价格恰好是新的历史最低价,比如数组是[3, 2],扫描到2时,先更新min_price=2,然后profit=2-2=0。这相当于做了一次“当天买入当天卖出”的空操作,利润是0,并不影响结果。如果谈严格语义,更准确的做法应该是先算price - 旧的min_price,再更新min_price。两种方式在121题结果完全一致,我推荐后面那种,因为它更符合“先看能不能卖出,再决定是否更新买入价”的逻辑,迁移到多笔交易时不容易出错。

3.2 C++版:注意整型边界

力扣上的C++写法,大多数人会写成这样:

#include <vector> #include <algorithm> #include <climits> class Solution { public: int maxProfit(std::vector<int>& prices) { int minPrice = INT_MAX; int maxProfit = 0; for (int price : prices) { minPrice = std::min(minPrice, price); maxProfit = std::max(maxProfit, price - minPrice); } return maxProfit; } };

这段代码里有两个常见细节。第一,INT_MAX来自<climits>头文件,如果漏了会编译报错。第二,价格范围在10的4次方以内,int完全够用,不会溢出。但养成用INT_MAX初始化“最小值候选变量”是一个好习惯,后面遇到更大数值范围的题,比如利润可能超过int范围时,你会自然而然想到改成long long,而不是在某一天突然被溢出卡住。

如果你喜欢提前返回的话,可以在函数开头加一句:

if (prices.size() < 2) return 0;

这一步不是必须的,因为原来的逻辑已经能处理长度0和1的情况,但提前返回能让函数意图更明确:“只有至少两天才能完成一次买卖”。

3.3 状态机模板:股票系列的统一姿势

进阶选手可以直接用状态机的模板来写121题,代码比一次遍历的直观版多,但为后续题目铺好了路:

class Solution: def maxProfit(self, prices: List[int]) -> int: hold = -float('inf') not_hold = 0 for price in prices: not_hold = max(not_hold, hold + price) hold = max(hold, -price) return not_hold

这段代码的理解重点在于状态转移的先后顺序。要先用旧的hold去更新not_hold,再用今天的价格去更新hold。如果反过来,先更新hold再计算not_hold,就会让一笔买入和一笔卖出发生在同一天,等于制造了一次“当天循环交易”。在121题里因为只允许一次交易,结果可能歪打正着不报错,但到了122题允许多次交易时,这个顺序问题会直接算错利润。所以我强烈建议一开始就按“先卖后买”的顺序写状态转移,这份谨慎会帮你少踩很多坑。

4. 现场实录:常见错误与排查清单

4.1 刷题时最容易踩的五个坑

第一个坑就是直接max(prices) - min(prices),已经反复强调过,顺序约束不满足。

第二个坑是用prices[0]初始化min_price,同时忘记处理空数组。prices为空时,prices[0]直接抛异常。虽然力扣的测试用例几乎不会给空数组,但换到面试场景或自己写工具函数时,这不难遇到。我的习惯是一律用足够大的哨兵值初始化这种“候选最小值”变量。

第三个坑是把max_profit初始化成负数,最后返回的也是负数。比如初始化成-float('inf'),对[7,6,4,3,1]这样的下跌序列,整个循环找不到一次正收益,最后返回负无穷,和题目要求的0不符。要么初始化为0,要么返回前加一句max(0, max_profit)。

第四个坑是状态更新的顺序。少数人为了省变量,会在同一个循环里反复横跳,结果出现“用今天的结果去计算今天的结果”的隐式顺序依赖。单看121题可能不炸,但会掩盖问题,将来换题必炸。

第五个坑是遍历方向配合错误。如果你选择从右往左遍历,那就不能继续维护历史最低价,应该反过来维护历史最高价,并且计算历史最高价 - 当前价格。方向变,语义要跟着变,混搭就全错了。

4.2 边界用例速查表

每次写完代码,我习惯用下面这张表把边界条件过一遍。你可以直接拿这些用例去跑自己的函数,输出不符就说明哪里理解有偏差。

输入数组预期输出说明
[]0空数组
[5]0只有一天,无法买卖
[1, 2]1最小买入后上涨
[2, 1]0下跌,不交易
[1, 1, 1]0价格持平,无利润
[3, 2, 6, 5, 0, 3]42买6卖,别被0和6迷惑
[7, 1, 5, 3, 6, 4]5官方示例
[7, 6, 4, 3, 1]0官方示例

这组用例覆盖了空输入、单元素、上涨、下跌、持平、误导极值、官方标准场景,基本能筛掉99%的讲解错误。如果你还想更严谨,就写一个自动化对拍函数,随机生成数组,把暴力解结果和一次遍历解结果做对比,全部一致才算真正过关。

4.3 性能实测:暴力法为什么会被“劝退”

我曾在本地随机生成过不同规模的数据,对比暴力解和一次遍历解的时间。数据规模是1000时,暴力解大约几十毫秒,一次遍历几乎可以忽略。数据规模到10的5次方时,暴力解的循环次数接近50亿次,耗时直接起飞到秒级以上,而一次遍历依然在毫秒级。数据规模再翻倍,差距只会更明显。

解法时间复杂度空间复杂度建议使用规模
暴力枚举O(n^2)O(1)n <= 1000
一次遍历(贪心)O(n)O(1)n <= 10^6 仍然轻松
状态机DPO(n)O(1)同上,适合扩展

这种“只扫一遍数组,且只维护两个变量”的算法对CPU缓存非常友好,因为从头到尾是顺序读取,不会有反复跳内存的现象。面试中写出这个解法,复杂度分析干净利落,基本就是标准答案。

5. 从121到股票全家桶:一道简单题的扩展价值

5.1 122题:多次交易为什么反而更简单

很多人刷完121,紧接着刷122题“买卖股票的最佳时机II”时会有点蒙:允许不限次数地买卖,不是应该更难吗?实际上122题比121更简单,因为它可以用一种更“无脑”的贪心:把所有上涨的相邻差值都加起来。

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

解释一下为什么能这么算。既然不限交易次数,那么任何一天上涨都可以被视为“昨天买入,今天卖出”,把这段正收益落袋为安。反过来,如果今天比昨天低,你就先不操作,等低价出现再买入。把每一天的正涨幅累加,就是总利润。

对比121和122,你会发现差分视角特别好用。122题求的是差分数组里所有正数之和,121题求的是差分数组里的最大连续子段和。一道题把所有正段相加,另一道题只能选其中一段最大的,本质差别全在“交易次数”这一个限制上。

5.2 123、188、309、714的进阶方向

刷到123题“买卖股票的最佳时机III”,限制变成最多交易两次,这时121题的两个状态就不够用了,得扩展成四个状态:第一笔买入、第一笔卖出、第二笔买入、第二笔卖出。

def max_profit(prices): buy1 = buy2 = -float('inf') sell1 = sell2 = 0 for price in prices: sell2 = max(sell2, buy2 + price) buy2 = max(buy2, sell1 - price) sell1 = max(sell1, buy1 + price) buy1 = max(buy1, -price) return sell2

这里有个非常重要的顺序:sell2必须在buy2之前计算,sell1必须在buy1之前计算。原因和前面3.3节说的一样,要防止同一天内用刚完成的第一笔交易利润去买第二笔,形成“循环交易”。

再往后,188题把交易次数推广到k,状态就从2个维度变成k维滚动数组;309题加上卖出后冷冻一天,转移时要注意跳过昨天;714题每次交易交手续费,买入时把手续费计入成本即可。只要把121题的hold/not_hold状态机吃透,后面这些题全是在它上面叠约束,不会产生额外的数据结构负担。这也是为什么我反复强调,刷题不能只背单个题的解法,要背模板和状态推导过程。

5.3 给刷题者的三条实操建议

第一,先把暴力法写出来。很多新手有个误区,觉得提交必须是最优解,写暴力法很丢人。实际上暴力法是用来“确认题意”的,尤其是121这类带顺序约束的题。把暴力法跑通,至少证明你理解了什么是先买后卖。

第二,背状态机模板,而不是背代码。121题的状态机只有两个变量,简单到可以默写,但它的价值在于迁移。以后遇到带交易次数、冷冻期、手续费的变体,你不需要重新发明轮子,只要问自己“状态有哪些、状态之间怎么转移”,答案会自动浮现。

第三,写完代码后固定跑一遍边界用例表。五分钟就能把空数组、单元素、全涨、全跌、全平、极值错位这六种情况验完。这套动作看起来机械,但能有效防止在面试场上因为一个corner case翻车。

最后再分享一点个人体会

我第一次刷这道题时,第一版写的也是max(prices)-min(prices),被测试用例怼回来之后去查题解,才彻底明白“未来的某一个不同的日子”这半句话的分量。后来我养成了一个习惯:凡是数组题,先问自己“顺序重不重要”。如果重要,大概率要维护一个前缀状态,比如历史最低价;如果顺序不重要,才可以直接考虑排序、哈希甚至双指针。121题虽然标着easy难度,但它几乎浓缩了股票系列的核心思想,也浓缩了一类带约束最值问题的通用解法。最后再分享一个小技巧:推导状态转移时,把卖出动作写成hold + price,而不是写成price - buyPrice。这样从一次交易推导到k次交易,代码结构和公式形态都不会乱,我后面刷123题和188题时,就是靠这个写法少走了很长一段弯路。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/16 8:10:16

C++17+SFML 手写2D游戏框架:TreeTycoon 架构解析

简介&#xff1a;这是一份面向C游戏开发学习者与2D游戏爱好者的完整项目源码与文档资料包&#xff0c;标题“TreeTycoon:大亨游戏。我认为”对应一款以树木种植经营为主题的模拟游戏。开发采用C17标准&#xff0c;并结合SFML多媒体库完成2D渲染、音频与交互逻辑&#xff1b;项目…

作者头像 李华
网站建设 2026/10/16 8:08:35

零服务器自动复盘系统:如何防止AI乱喊单的工程化实践

1. 项目缘起与核心问题拆解1.1 一个“零服务器”的自动化复盘系统到底在解决什么每天收盘后手动翻看几十只自选股的涨跌、成交量、资金流向&#xff0c;再写一段复盘笔记&#xff0c;这件事我坚持过不到两周就放弃了。不是懒&#xff0c;是重复劳动太消耗精力&#xff0c;而且人…

作者头像 李华
网站建设 2026/10/16 8:07:24

单门逻辑芯片SGM7SZ08:从引脚到应用的全方位解析

1. 单门逻辑是什么&#xff0c;为什么我的工具盒里一直给它留位置老规矩&#xff0c;先说一个我真实遇到的场景。前阵子改一块小控制板&#xff0c;功能是给一个传感器模块做时钟门控&#xff1a;传感器需要一个连续时钟&#xff0c;但系统在低功耗模式下要把这个时钟停掉&…

作者头像 李华