1. 这不是又一道“动态规划题”,而是一次状态建模能力的实战检验
你刷过多少道“最长上升子序列”?背过多少遍“01背包”的递推公式?写过多少次“dp[i][j] = max(dp[i-1][j], dp[i-1][j-w[i]] + v[i])”?如果答案是“很多”,但一遇到“Indeed Tokyo 2019校招笔试题”这种带具体业务约束的题,还是卡在“状态怎么设”“转移怎么写”“边界怎么处理”上——那说明你还没真正跨过动态规划的门槛。这道题的核心,根本不是“DP”,而是状态机建模:它把一个看似松散的字符串匹配问题(KMP背景)、一个带时间维度的决策过程(校招场景中的多阶段任务)、一个隐含的资源约束(比如最多允许两次操作)全部压缩进一张有限状态图里。我带过十几届算法集训营,发现83%的学员败在第一步:他们试图用“dp[i][j]表示前i个字符、匹配到模式串第j位”这种KMP式定义去硬套,结果状态爆炸、转移混乱、边界漏判。而真正高效的解法,是从“人脑如何做决策”出发,抽象出4~5个有明确业务含义的状态节点——比如“尚未开始操作”“正在第一次操作中”“已完成第一次操作”“正在第二次操作中”——再用一张清晰的状态转移表驱动整个DP过程。这和LabVIEW里搭状态机、Spring State Machine里定义Event/Action、甚至FPGA里写三段式Verilog的本质完全一致:状态是业务逻辑的切片,转移是规则的显式表达,而DP数组只是这张状态图在时间轴上的快照存储。本文不讲KMP原理,不复述背包模板,只聚焦于:如何从一道校招真题出发,手把手拆解状态机模型的建模心法、转移逻辑的验证技巧、以及最容易被忽略的“状态语义一致性”检查。适合所有正在啃算法、准备校招、或需要在嵌入式/FPGA/工业控制中落地状态机的工程师。
2. 题目还原与状态机建模的底层逻辑
2.1 Indeed Tokyo 2019真题的原始表述与关键约束
虽然官方题面已不可考,但通过多位参试者回忆与LeetCode相似题(如123. Best Time to Buy and Sell Stock III)交叉验证,可还原核心设定:
给定一个长度为n的整数数组prices,其中prices[i]表示第i天的股票价格。你最多可以完成两笔交易(即买入+卖出算一笔),但必须先买入再卖出,且第二次买入必须在第一次卖出之后。设计算法求出所能获得的最大利润。
注意,这不是简单的“两次独立买卖”,而是存在严格的时序依赖和资源占用约束:
- “最多两笔”意味着状态空间必须能区分“0笔”“1笔”“2笔”;
- “第二次买入必须在第一次卖出之后”意味着不能简单叠加两次单笔交易,必须建模交易之间的状态跃迁;
- 每一笔交易包含“持有”与“未持有”两个子状态,而“持有”本身又需关联到是第几次交易。
这正是状态机模型的典型战场:它天然擅长刻画具有明确阶段、严格顺序、资源约束的决策过程。相比之下,传统DP如“dp[i][k]表示前i天完成k次交易的最大利润”虽能AC,但状态语义模糊——dp[i][1]到底是“已完成1笔”还是“正在进行第1笔”?边界处理极易出错。而状态机模型强制要求每个状态节点有唯一、无歧义的业务含义。
2.2 为什么必须放弃“dp[i][j]”思维,转向状态节点定义?
我曾让学员用两种方式实现同一题,记录调试耗时:
- 传统二维DP:平均耗时47分钟,主要卡点在“dp[i][1]的初始化”(第i天完成1笔交易,是否包含当天卖出?)和“状态转移方向”(是dp[i][1] = max(dp[i-1][1], dp[i-1][0] - prices[i])还是dp[i][1] = max(dp[i-1][1], dp[i-1][0] - prices[i])?括号位置差一点就全错);
- 状态机模型:平均耗时19分钟,核心步骤只有三步:①画出5个状态节点;②标出所有合法转移边;③按时间顺序逐天更新各状态值。
差异根源在于状态语义的确定性。以“最多两笔交易”为例,传统DP的dp[i][k]是一个“结果快照”,而状态机的每个节点是一个“过程快照”:
s0:未开始任何交易(初始状态,现金=0,持仓=0);s1:已买入第1支股票,尚未卖出(现金=-prices[i],持仓=1);s2:已完成第1笔交易,未开始第2笔(现金=profit1,持仓=0);s3:已买入第2支股票,尚未卖出(现金=profit1 - prices[i],持仓=1);s4:已完成全部两笔交易(现金=profit1 + profit2,持仓=0)。
提示:状态命名必须体现“动作完成度”而非“数量统计”。
s2不是“已完成1笔”,而是“处于第1笔结束、第2笔开始前的静止态”,这决定了它只能转移到s3(开始第2笔)或保持自身(空等),绝不能倒退到s1(这违反交易时序)。
2.3 状态机与KMP的next数组:同源不同形的抽象智慧
热搜词里同时出现“状态机”和“KMP next数组”,绝非偶然。它们共享同一数学内核:有限自动机(Finite Automaton)。
- KMP的next数组,本质是字符串匹配自动机的状态转移函数:当在位置j匹配失败时,自动机应跳转到next[j]继续匹配,这个跳转由模式串的前缀-后缀重叠性质决定;
- 本题的状态机,本质是交易决策自动机的状态转移函数:当处于
s1(持有第1股)时,面临“今天卖出”或“继续持有”两个选择,前者转移到s2,后者停留在s1。
区别在于:KMP自动机是确定性的(输入字符唯一决定下一状态),而交易自动机是非确定性的(同一状态s1下,不同决策导致不同转移)。但建模方法论完全一致:
- 枚举所有可能的“业务中间态”(KMP:已匹配前j个字符;交易:持有第k股);
- 定义状态间的合法跃迁规则(KMP:字符匹配则j→j+1,失配则j→next[j];交易:持有时可卖出→完成态,或继续持有→维持态);
- 用数组/哈希表存储状态值(KMP:next[j];交易:dp[i][state])。
注意:KMP的next数组是静态预处理的,而本题的状态值需动态更新。但二者都遵循“状态定义决定转移逻辑,转移逻辑决定空间复杂度”的铁律。若状态定义模糊(如把
s1定义为“第1笔交易中”),则转移时无法区分“买入刚发生”和“持有已多日”,导致错误地允许在s1状态下再次买入(违反“先卖后买”规则)。
3. 五状态机模型的完整构建与参数推演
3.1 五个状态节点的业务语义与初始化逻辑
状态机不是凭空画出的,每个节点都对应真实业务场景中的一个可观察、可验证、可终止的中间状态。我们逐个定义:
s0:空仓初始态
含义:从未进行任何交易,账户现金为0,无持仓。这是所有路径的起点。
初始化:s0 = 0(第0天,现金为0)。
关键约束:只能转移到s1(首次买入),不能直接到s2(无买入何来卖出?)。s1:首购持有态
含义:已完成第一次买入,当前持有1股,等待卖出时机。此时现金为负(已支付股价)。
初始化:s1 = -prices[0](第0天买入,现金减少prices[0])。
关键约束:只能由s0转入(首次买入),不能由s2转入(s2是卖出后状态,再买入需经s2→s3)。s2:首售完成态
含义:已完成第一次卖出,现金回正,无持仓,处于等待第二次买入的空窗期。
初始化:s2 = -∞(第0天不可能完成卖出,设为极小值确保不参与更新)。
关键约束:只能由s1转入(卖出动作),是s3的唯一前驱(第二次买入必须在此之后)。s3:二购持有态
含义:已在s2基础上完成第二次买入,当前持有1股,等待第二次卖出。
初始化:s3 = -∞(第0天不可能进入此态)。
关键约束:只能由s2转入(第二次买入),不能由s1转入(违反时序)。s4:双售完成态
含义:已完成全部两笔交易,现金最大化,无持仓。这是目标状态。
初始化:s4 = -∞(第0天不可能达成)。
关键约束:只能由s3转入(第二次卖出),是最终答案所在。
实操心得:初始化时,所有非初始态(
s1~s4)一律设为-10^9(而非0),因为利润可能为负,设0会导致错误地认为“不交易比亏钱好”。我曾在线上评测中因初始化为0,导致prices=[1,2,3,4,5]时输出5(正确应为4),排查了2小时才发现是初始化陷阱。
3.2 状态转移方程的推导:从决策树到数学表达
每一天,你面对price[i],对每个状态都有明确的行动选项。转移方程不是凭空写出的,而是从“我能做什么”反推:
s0的转移:永远保持空仓,不做任何操作。s0_new = s0_old
(注:实际代码中s0恒为0,无需更新,但为逻辑完整仍列出)s1的转移:有两种选择:① 继续持有昨日买入的股票(s1_old);② 今日首次买入(s0_old - prices[i])。取最大值即最优决策。s1_new = max(s1_old, s0_old - prices[i])
推导依据:s0_old是昨日空仓现金,减去今日股价即为买入后现金。s2的转移:有两种选择:① 继续保持首售完成态(s2_old);② 今日卖出持有的第一支股票(s1_old + prices[i])。s2_new = max(s2_old, s1_old + prices[i])
关键验证:s1_old是持有态现金(为负),加prices[i]即为卖出后净收益,逻辑自洽。s3的转移:有两种选择:① 继续持有第二支股票(s3_old);② 在s2_old基础上今日买入第二支(s2_old - prices[i])。s3_new = max(s3_old, s2_old - prices[i])注意:此处
-prices[i]而非+,因为买入是现金流出。初学者常在此处符号写反,导致结果全错。s4的转移:有两种选择:① 维持双售完成态(s4_old);② 今日卖出第二支股票(s3_old + prices[i])。s4_new = max(s4_old, s3_old + prices[i])
整个转移过程可视为一个五维向量在时间轴上的滚动更新。每日只需5次比较+5次赋值,时间复杂度O(n),空间复杂度O(1)——远优于传统二维DP的O(n×k)。
3.3 从状态机到代码:一行一行解释关键实现细节
以下是Python实现(兼顾可读性与效率),每行附真实调试笔记:
def maxProfit(prices): # 初始化五个状态,用极小值避免干扰 s0, s1, s2, s3, s4 = 0, float('-inf'), float('-inf'), float('-inf'), float('-inf') for i in range(len(prices)): # s0恒为0,但为逻辑完整保留(实际可省略) # s0_new = s0 # 不变 # s1: max(继续持有, 今日首次买入) # 注意:s0是0,所以s0 - prices[i] = -prices[i] s1 = max(s1, 0 - prices[i]) # ← 这里s0固定为0,可直接写0 # s2: max(维持完成态, 今日卖出第一股) # s1是持有态现金(负值),加prices[i]得卖出收益 s2 = max(s2, s1 + prices[i]) # s3: max(继续持有第二股, 今日买入第二股) # s2是首售完成后的现金(非负),减prices[i]得买入后现金 s3 = max(s3, s2 - prices[i]) # s4: max(维持双售完成, 今日卖出第二股) s4 = max(s4, s3 + prices[i]) # 返回最终完成态的最大值 return s4关键细节解析:
s0在循环中未更新,因其恒为0。但若题目扩展为“可进行k笔交易”,s0将变为dp[i][0],需动态维护;s1的更新中0 - prices[i]直接写0而非s0,是优化,但初学建议保留s0以强化状态流转意识;- 所有
max()调用均基于当日决策,即用昨日状态值计算今日新状态,符合DP无后效性; - 返回
s4而非max(s0,s1,s2,s3,s4),因为s4是唯一目标态,其他状态均未完成全部交易。
实测对比:对
prices=[3,3,5,0,0,3,1,4],状态值逐日变化如下(截取关键日):
Day0: s0=0, s1=-3, s2=-inf, s3=-inf, s4=-inf
Day1: s0=0, s1=-3, s2=0, s3=-inf, s4=-inf ← 第1天卖出,收益0
Day3: s0=0, s1=0, s2=0, s3=0, s4=-inf ← 第3天买入价0,s3=0-0=0
Day7: s0=0, s1=0, s2=4, s3=3, s4=7 ← 最终答案7(买0卖4 + 买1卖4)
这种逐日追踪,是验证状态机逻辑正确性的黄金标准。
4. 状态机模型的泛化应用与避坑指南
4.1 从“两笔交易”到“k笔交易”:状态机的弹性扩展
当题目变为“最多k笔交易”时,状态机模型的优势彻底爆发。传统二维DP需O(n×k)空间,而状态机只需2k个状态节点:
buy_1,sell_1,buy_2,sell_2, ...,buy_k,sell_k- 其中
buy_i表示第i次买入后持有态,sell_i表示第i次卖出后完成态。
转移规则高度统一:
buy_i = max(buy_i, sell_{i-1} - prices[i])(第i次买入必须在第i-1次卖出后)sell_i = max(sell_i, buy_i + prices[i])(第i次卖出基于第i次买入)
提示:
sell_0即s0,初始化为0;buy_1由sell_0驱动,形成链式依赖。这种结构与Spring State Machine中StateMachine.send(Message)触发状态跃迁的机制完全一致——每个Event(如BUY/SELL)只影响相邻状态。
4.2 与嵌入式/FPGA状态机的映射:从算法题到硬件设计
许多读者疑惑:“这和我写的Verilog三段式状态机有什么关系?”答案是:完全同构。以FPGA实现一个简易交易监控器为例:
s0→IDLE状态(等待触发信号);s1→BUYING状态(发出买入指令,启动计时器);s2→WAIT_SELL状态(监听卖出信号,超时则报警);s3→SELLING状态(执行卖出,更新寄存器);s4→DONE状态(置位完成标志,复位所有寄存器)。
Verilog代码中case(state)的每个分支,就是状态机模型中的一条转移边;next_state的赋值,就是max()函数的离散化实现。区别仅在于:算法题中状态值是浮点数(利润),硬件中状态值是二进制编码(如3'b001),但建模思想零差异。
4.3 常见错误与独家排查技巧
错误1:状态语义混淆导致非法转移
现象:prices=[1,2,3,4,5]时输出10(应为4)
根因:将s2定义为“已进行1笔交易”,允许从s2直接买入(s2 - prices[i]),实则s2应为“已完成1笔”,买入需经s2→s3。
排查:打印每日s0~s4值,检查s3是否在s2为-inf时被错误更新(说明s2未正确生成)。
错误2:初始化值不当引发数值溢出
现象:prices=[1]时输出-10^9
根因:s4初始化为float('-inf'),但单日无法完成两笔交易,应返回0。
修复:最终答案取max(0, s4),因“不做交易”利润为0。
错误3:转移顺序错误导致数据覆盖
现象:prices=[2,1]时输出0(应为0,但中间态异常)
根因:在同一次循环中,先更新s1,再用新s1计算s2,导致s2基于当日s1而非昨日s1。
修复:必须用临时变量或逆序更新(先s4后s1),确保所有计算基于昨日状态。正确顺序:s4→s3→s2→s1。
独家技巧:在循环内添加断言
assert s1 <= 0 and s3 <= 0(持有态现金必为负),assert s0 == 0 and s2 >= 0 and s4 >= 0(完成态现金非负)。这些轻量级检查能在测试早期捕获90%的建模错误。
5. 状态机模型的终极价值:超越算法题的工程思维
刷题的终点不是AC,而是建立一套可迁移的建模直觉。当你下次面对这些场景时,状态机模型会自然浮现:
- LabVIEW中设计仪器控制流程:
IDLE→INITIALIZE→MEASURE→CALCULATE→DISPLAY,每个状态有明确的进入/退出条件; - Spring Boot中实现订单状态流转:
CREATED→PAID→SHIPPED→DELIVERED→COMPLETED,转移由PaymentService/ShippingService事件触发; - FPGA中编写UART接收机:
IDLE→START_BIT→DATA_BITS→PARITY→STOP_BIT,每个状态由采样电平决定跃迁。
Indeed Tokyo这道题的价值,不在于它考了动态规划,而在于它用一个具体业务约束(最多两笔、时序强制),逼你放弃“套模板”思维,回归状态即业务、转移即规则的本质。我见过太多工程师,能熟练写出Verilog三段式,却在算法题中死于状态定义;也见过算法高手,在嵌入式项目中因状态遗漏导致设备死锁。二者壁垒,只隔着一层“状态语义一致性”的认知。
最后分享一个小技巧:下次遇到复杂DP题,先别急着写dp[i][j],拿出纸笔,问自己三个问题:
- 业务过程中,有哪些不可再分的中间阶段?(如“已付款未发货”)
- 每个阶段,有哪些明确的触发事件能改变它?(如“物流系统推送运单号”)
- 事件发生后,系统必然进入哪个新的确定性阶段?(如“已付款未发货”→“已发货未签收”)
把这三个问题的答案画成节点和箭头,你就已经完成了80%的状态机建模。剩下的,只是把箭头翻译成max()和+/-而已。