1
你的直觉非常接近真相了,但有一个关键的“会计记账”差错!如果不纠正这个差错,你写出的状态转移方程会把自己绕晕。
你提出的“计算股票的钱(现金+市值)”和“不计算股票的钱(纯现金)”,在现实炒股中是完美的(这叫总资产)。但在算法的动态规划里,我们不能这么记,因为股票每天的市值都在变,如果算进总资产,公式里会出现“今天的股价减去昨天的股价”,极其复杂。
为了写出简洁优美的方程,算法里有一个铁律:
所有的
dp值,只记录“手里的净现金余额”,绝不把股票市值算进去。
1. 纠正后的标准定义(请死记这个)
我们把0和1换一下,用更顺口的hold(持有)和empty(空仓):
empty[i](不持有股票):第i天结束时,我手里没有股票,账户里有多少净现金余额。hold[i](持有股票):第i天结束时,我手里持有 1 股股票,账户里有多少净现金余额。
注意:这里的“净现金余额”可以是负数(代表我借了钱或亏了本金)。
2. 为什么“持有股票”时,现金反而是负数?(用记账本说话)
假设你有一个记账本,初始余额是0元。
第 1 天(股价 7):你花 7 块钱买了 1 股。
- 你手里的现金变成了
-7(负债 7 块)。 - 所以
hold[1] = -7。 - 重点:此时你手里虽有一张价值 7 块的股票,但算法坚决不把它算进余额里,只记你花出去的现金流出。
- 你手里的现金变成了
第 2 天(股价 1):你发现股价跌了,后悔买贵了。
- 如果你换仓(把昨天 7 块的思维扔掉,今天重新买):你今天花 1 块钱买入,现金变成
-1。 - 由于
-1大于-7(亏得少),hold[2] = -1。 - 这个
-1代表的是“我为了持有这 1 股,历史上总共净流出了 1 元本金”。
- 如果你换仓(把昨天 7 块的思维扔掉,今天重新买):你今天花 1 块钱买入,现金变成
3. 把“卖出”看成“现金回血”
- 第 3 天(股价 5):你决定卖出。
- 你昨天持有股票时,账户现金余额是
-1(欠着 1 块)。 - 今天你把股票卖掉,得到 5 块钱现金入账。
- 还掉负债:
-1 + 5 = 4。 - 现在你空仓了,
empty[3] = 4。这 4 块钱就是你的净利润!
- 你昨天持有股票时,账户现金余额是
4. 为什么不能把“股票市值”算进hold里?
如果你定义hold[i]= “现金余额 + 当天股票市值”:
- 第 1 天买 7 块的股:
hold = -7 + 7 = 0。 - 第 2 天股价跌到 1:如果你还拿着,
hold = -7 + 1 = -6(浮亏)。 - 这时候你要写转移方程,就不得不区分“我今天有没有换股”,公式会变成:
hold[i] = max(hold[i-1] + (prices[i] - prices[i-1]), empty[i-1] - prices[i] + prices[i])
看,多了一个讨厌的(prices[i] - prices[i-1]),要把每天的涨跌都算进去,代码瞬间变复杂了。
而标准算法的高明之处在于:只要我不卖出,我就不管股票市值涨到天上还是跌到地下,我只记录“我当初花了多少钱(现金净流出)”。直到卖出的那一刻,我一次性把大笔现金加回来。
5. 用你的话重新表述(帮你纠正)
- ❌ 你之前的理解:
dp1有股票,计算股票的钱(现金+市值)。 - ✅正确的理解:
dp1有股票,只记录为了拥有这只股票,我累计花掉了多少现金(成本),这个数通常是负的。
用“借钱”的比喻一下你就通了:
hold[i](持有):我借了多少钱买股票(负债)。empty[i](空仓):我手头还剩多少钱(净资产)。
当你把股票卖掉时,就是用卖股票的钱去还债,剩下的就是赚的。
6. 最终的转移方程(用“现金余额”重写一遍)
今天持有股票(
hold[i]):
要么“躺平”(昨天就持有):hold[i-1];
要么“今天新买”(昨天空仓,花掉今天的股价):empty[i-1] - prices[i]。
公式:hold[i] = max(hold[i-1], empty[i-1] - prices[i])今天不持有股票(
empty[i]):
要么“躺平”(昨天就空仓):empty[i-1];
要么“今天卖出”(昨天持有,卖掉拿回现金):hold[i-1] + prices[i]。
公式:empty[i] = max(empty[i-1], hold[i-1] + prices[i])
看,没有+ 股票市值的复杂计算,只有纯粹的现金流入(+)和现金流出(-),多清爽!
现在,请忘掉“计算股票的钱”这个念头。把hold死死地记作“我的股票持仓成本(现金负债)”。当你把第 5 天hold = -1卖成 6 块时,-1 + 6 = 5这个计算,是不是瞬间就有了“还完债净赚 5 块”的实感?这种感觉对了,DP就通了。你现在再用这个“现金流出/流入”的模型,去理解empty[i]的递推,是不是毫无障碍了? 😊
2
你观察得极为敏锐!它们不一样是千真万确的,而且“理应”不一样!
因为这两个公式分别对应的是两道完全不同的题目!
- 公式 A:
hold[i] = max(hold[i-1], -prices[i])对应只能买卖一次(LeetCode 121,你当前正在做的题)。 - 公式 B:
hold[i] = max(hold[i-1], empty[i-1] - prices[i])对应可以买卖无限次(LeetCode 122,我上次给你留的思考题)。
我现在就帮你把这两个公式的“血缘关系”彻底拆解清楚,你会发现它们不仅不矛盾,而且 B 是 A 的“完全体”。
1. 为什么“只能买卖一次”时,公式是-prices[i]?
在“只能买卖一次”的规则下,有一个隐藏的绝对限制:在买入这一笔交易之前,你绝对没有进行过任何卖出操作。
- 因为从来没卖过,所以你在买入前,手里的利润
empty[i-1]永远等于 0(因为你从来没赚到过一分钱,本金不算利润)。 - 把
empty[i-1] = 0代入通用的 B 公式:hold[i] = max(hold[i-1], empty[i-1] - prices[i])
就变成了:hold[i] = max(hold[i-1], 0 - prices[i])
也就是:hold[i] = max(hold[i-1], -prices[i])
结论:A 公式(-prices[i])只是 B 公式在“从未赚过钱”这个特殊前提下的简化版。因为这道题只允许交易一次,所以直接用-prices[i]更简洁。
2. 为什么“买卖无限次”时,公式必须改成empty[i-1] - prices[i]?
现在假设你可以反复买卖(比如今天卖,明天买)。这时候,你在买入之前,可能已经通过前面的波段赚到了一些钱!
举个极端例子:
假设empty[i-1] = 100(昨天空仓时,你已经落袋为安赚了 100 块)。
今天股价是prices[i] = 1块。
如果你还用A 公式(
hold[i] = max(hold[i-1], -1)):
买入后的账户现金余额变成了-1。大错特错!你明明之前赚了 100 块,买 1 块钱的股票,账户里应该还剩 99 块现金,怎么可能是负数?如果你用B 公式(
hold[i] = max(hold[i-1], empty[i-1] - prices[i])):
买入后的账户现金余额 =100 - 1 = 99。这才符合现实!
3. 一张表让你彻底记住它们的区别
| 交易规则 | 买入(持有)公式 | 为什么? |
|---|---|---|
| 只买卖 1 次 | hold = max(hold, -price) | 之前从没赚过钱,利润为 0,所以花掉本金记为-price。 |
| 可以买卖无数次 | hold = max(hold, empty - price) | 之前可能已经赚了钱(empty>0),买入时必须把之前的利润加上,才是真实的现金余额。 |
4. 回到你现在的题目(LeetCode 121)
由于你当前这道题只允许买卖一次,我们用的是 A 公式(-prices[i])。但为了让 DP 的状态定义严格自洽,我们必须把empty(不持有)的初始值也想清楚:
- 第 0 天(还没开始):
hold[0] = -inf(不可能持有),empty[0] = 0(空仓,利润为 0)。 - 第 1 天(股价 7):
hold[1] = max(-inf, 0 - 7) = -7(今天买入,花掉 7 块本金)。empty[1] = max(0, -inf + 7) = 0(今天不卖,利润还是 0)。
- 你会发现,在这个只允许卖一次的模型里,
empty在这个过程中要么是 0,要么是卖出的利润,它永远不会在买入前变成正数(因为一卖就结束交易了,不会再买入)。
5. 给你最终的“定心丸”
你之所以疑惑,是因为我把两道题的解法混在一起讲了。现在请你把注意力完全拉回当前这道题(买卖一次)。
对于当前这道题,你最稳妥的 DP 写法就是:
defmaxProfit(prices):n=len(prices)# hold: 持有股票时的最大现金(负债最小); empty: 不持有股票时的最大现金(利润最大)hold=-prices[0]# 第0天买入empty=0# 第0天不买foriinrange(1,n):# 今天持有:要么昨天持有,要么今天买(之前利润为0,直接花掉今天的股价)hold=max(hold,-prices[i])# 今天不持有:要么昨天不持有,要么今天卖出(昨天持有的成本 + 今天股价)empty=max(empty,hold+prices[i])# 注意:这里的 hold 是更新后的还是更新前的?# 这里有个坑!必须用更新前的 hold!所以代码里要用临时变量存旧值。returnempty(注:上面代码里的hold + prices[i]必须用“昨天持有”的旧值计算,不能混用,否则会出现“当天买当天卖”的无效操作。不过这点不影响你对状态的理解,等你写代码时我会提醒你避开这个坑。)
总结一句话:你没有记错公式,是我把下一道题的答案提前剧透了。在你当前这道只交易一次的题里,-prices[i]就是绝对正确的标准写法。你现在能清楚地看到,如果改成empty - prices,对于本题来说,因为empty永远是 0,其实两者结果完全一样。这下心里的石头落地了吗?😊
3
你问到了动态规划最核心、最灵魂的问题!
你能主动思考“为什么一维dp[i]有时可以,有时不行”,说明你已经从“背公式”进化到“建模型”的阶段了。这绝对是质的飞跃。
为了彻底解答你的疑惑,我教你一个**“万能试金石”**,并帮你把empty和hold的定义彻底钉死在脑子里。
1. 先给你的定义“加个零”(彻底消除歧义)
你的定义完全正确,但为了严谨,我们加上一个隐含条件:
empty[i](不持有):第 i 天结束时,手里没有股票,相对于初始本金(设为0),账户里的净现金余额(净利润)。hold[i](持有):第 i 天结束时,手里持有 1 股股票,相对于初始本金(设为0),账户里的净现金余额(花掉的本金)。
加上“相对于初始本金”,负数就变成了“暂时借出的本金”,正数就是“落袋的利润”。
2. 为什么dp[i]表示“第 i 天最大利润”在这里必死?
我们先看一个反例,你就知道问题出在哪了。
假设第 3 天,你用一维dp[3] = 5(赚了 5 块)。现在问:第 4 天,我该怎么操作?
- 如果这 5 块钱是因为“我今天刚卖完股票,空仓”赚来的,那第 4 天我可以选择“买入”。
- 如果这 5 块钱是因为“我今天还握着股票没卖”赚来的(浮盈),那第 4 天我只能选择“卖出”或“持有”,绝对不能买入(因为手里已经有股了,题目限制只能持一股)。
发现问题了吗?dp[3] = 5这个单一的数字,把“空仓赚的钱”和“持仓浮盈的钱”混在了一起。当计算机走到第 4 天时,它根本不知道自己现在能不能买,也不知道自己手里有没有货可以卖。
结论:只要未来的决策(买/卖)依赖于“当前手里有没有股票”这个状态,你就必须把“是否持股”拆成两个状态。这叫**“无后效性”**被破坏了——过去的历史(怎么赚到这5块钱)影响了未来的决策能力。
3. 为什么有些题,一个dp[i]就能搞定?(对比分析)
为了让你对比,我们看经典题“最大子数组和”(LeetCode 53)。定义dp[i]= “以第 i 个元素结尾的最大子数组和”。为什么这里一个状态就够了?
- 因为这道题的决策是:“要不要把 nums[i] 接到前面的子数组后面”。
- 无论
dp[i-1]是怎么来的(是接了前面很长一串,还是刚从 i-1 开始),它都只是一个纯粹的数值。 - 到了第 i 天,我只需要比较
dp[i-1] + nums[i]和nums[i]哪个大。我不需要知道 dp[i-1] 对应的子数组到底长什么样,也不影响我今天的决策。
总结规律:
如果“过去的结果”对“未来的操作”没有任何限制(比如只做加法、只求连续和),一维
dp[i]就够了。如果“过去的结果”会留下“状态残留”(比如手里有没有股票、背包还剩多少容量、小偷有没有偷上一家),导致未来不能随意操作,就必须把残留的状态也定义进 DP 里。
4. 教你一套“状态定义分析法”(以后不再迷茫)
拿到一道 DP 题,按下面三步走,你永远不会定义错:
- 第一步:找“决策”。每天我能做什么操作?(在股票里:买、卖、躺平)。
- 第二步:找“限制”。这个操作受什么条件限制?(买的前提是手里没股;卖的前提是手里有股)。
- 第三步:定状态。“限制条件里的变量,就是状态的维度!”
- 限制里说“手里有没有股”?好,那我就把“有股”和“没股”作为区分,开两个数组。
- 如果限制里说“昨天不能交易(冷冻期)”?好,那我就把“昨天卖没卖”也加进状态,变成三个数组。
5. 用“抽屉原理”理解empty和hold
把你的账户想象成两个并列的抽屉:
hold抽屉:专门记录“如果我今天选择当股东,我这个股东目前的净现金流是多少(通常是负数)”。empty抽屉:专门记录“如果我今天选择当现金佬,我这个现金佬目前的净现金流是多少(利润)”。
关键认知:计算机同时计算这两个抽屉。它并不知道你会选哪个,但它必须把两个选择都算出来放着。等到最后一天,它看一眼empty抽屉(因为最后一天现金肯定比股票安全,且题目求最大现金),把里面的数字拿出来,就是答案。
6. 给你的终极记忆口诀
以后分析状态时,问自己一句“灵魂拷问”:
“如果我只知道最大利润这一个数字,明天开盘时,我知不知道自己该按‘买入键’还是‘卖出键’?”
- 如果知道-> 用 1 维
dp。 - 如果不知道(因为不知道手里有没有票)->必须拆分状态(
hold和empty)。
你现在回头看hold和empty,是不是觉得它们极其自然?它们本质上就是为“买入键”和“卖出键”分别准备的两个计算器。你现在的思路已经完全走在正轨上了!要不要趁热打铁,试着按这个“决策-限制-状态”三步法,分析一下“含冷冻期的股票买卖”需要几个状态? 😊
4
“含冷冻期的股票买卖”是 LeetCode 上的第 309 道题,正式名称是“最佳买卖股票时机含冷冻期” (Best Time to Buy and Sell Stock with Cooldown)。
它是在我们刚才讨论的“无限次买卖”模型(LeetCode 122)基础上,增加了一个新规则,让问题变得更复杂、也更有趣了。
📜 题目描述
给定一个整数数组prices,其中prices[i]表示第i天的股票价格。
设计一个算法来计算最大利润。你可以尽可能地完成更多的交易(多次买卖一支股票),但必须遵守以下约束:
- 不能同时参与多笔交易:你必须在再次购买前,出售掉之前持有的股票。
- 卖出股票后,你无法在第二天买入股票(即冷冻期为 1 天)。
🎯 核心区别:为什么更难了?
对比一下就更清楚了:
- 之前的“无限次买卖” (LeetCode 122):你今天卖了,明天觉得价格低,可以立刻再买回来。
- 现在的“含冷冻期” (LeetCode 309):你今天卖了,明天必须强制休息一天(冷冻期),最早也要等到后天才能再次买入。
这个“冷冻期”的限制,正是它需要我们动用更复杂的动态规划状态的原因。
📊 举个例子
题目给出了一个示例:
- 输入:
prices = [1, 2, 3, 0, 2] - 输出:
3 - 解释: 对应的交易状态为
[买入, 卖出, 冷冻期, 买入, 卖出]。
我们来拆解一下这个最优策略:
- 第1天(
价格=1):买入。 (花费 1) - 第2天(
价格=2):卖出。 (利润 1) - 第3天(
价格=3):冷冻期。 (虽然价格在涨,但因为第2天刚卖出,今天必须休息,不能交易) - 第4天(
价格=0):买入。 (价格极低,果断入手,花费 0) - 第5天(
价格=2):卖出。 (利润 2)
总利润 =(2-1) + (2-0) = 3。如果没这个冷冻期,最优策略可能是第1天买,第3天卖,获利2,所以这个限制让问题变得更复杂了。
🧠 解题思路前瞻:从 2 个状态到 3 个状态
你之前分析“无限次买卖”时,用hold(持有) 和empty(空仓) 两个状态就搞定了。但在“冷冻期”模型里,“空仓”这个状态必须被拆分开。
因为“空仓”的人有两种情况:
rest(冷静期):手里没股票,且今天没卖(处于正常状态,明天可以买入)。sold(刚卖出):手里没股票,且今天刚卖掉(处于冷冻期,明天必须休息,不能买入)。
所以,我们需要用3 个状态来描述每一天的情况:
hold:今天结束后,手里持有股票。sold:今天结束后,手里没有股票,并且今天刚卖出(进入冷冻期)。rest:今天结束后,手里没有股票,并且今天没有卖出(不在冷冻期)。
这 3 个状态之间的转移关系,正是解决这道题的核心,也就是你一直在思考的“状态转移方程”。
💎 总结
“含冷冻期的股票买卖”这道题,完美地印证了你之前的观点:“状态的定义非常重要,定义不对,关系就会很复杂”。
它通过增加一个“冷冻期”限制,迫使我们将“空仓”状态进一步细分,从而引入更精细的状态机模型来解决问题。
理解了题目和为什么需要3个状态后,你想不想挑战一下,根据我们之前分析“无限次交易”的经验,来试着推导一下这3个状态之间的转移方程呢?😊
5
恭喜你!你推出来的这一组方程是完全正确的!
而且我必须重点表扬你:你把sold[i] = hold[i-1] + price[i]改成了用i-1,这个细节改得极其精准!这彻底杜绝了“当天买当天卖”的无效操作。你能自己发现并纠正这个细节,说明你已经完全掌握了状态转移的核心逻辑——“今天的状态,必须由昨天的状态推导而来”。
现在,我帮你把你推导的方程“翻译”成大白话,并指出一个关于rest的微小术语混淆(不是你的错,是这个词天生容易歧义),让你彻底吃透它。
1. 验证你的三个方程(完全正确)
你的定义非常清晰,我们用“时间线”再顺一遍:
hold[i]:第 i 天结束时,持有股票。sold[i]:第 i 天结束时,刚卖完股票(处于冷冻期,手里没股)。rest[i]:第 i 天结束时,手里没股,且不在冷冻期(即“冷静期已过,明天可以买”)。
你的方程(再次确认无误):
hold[i] = max(hold[i-1], rest[i-1] - price[i])- 翻译:今天想持股。要么“躺平”拿着昨天的股;要么“今天买入”,但买入的前提是昨天处于
rest状态(因为只有不在冷冻期才能买)。
- 翻译:今天想持股。要么“躺平”拿着昨天的股;要么“今天买入”,但买入的前提是昨天处于
sold[i] = hold[i-1] + price[i]- 翻译:今天想卖出。必须昨天持有股票,今天卖掉,现金加
price[i]。(你用了 i-1,完美避开了当天买当天卖!)
- 翻译:今天想卖出。必须昨天持有股票,今天卖掉,现金加
rest[i] = max(rest[i-1], sold[i-1])- 翻译:今天处于“非冷冻期且空仓”。要么昨天也是这个状态(躺平);要么昨天刚卖掉(
sold[i-1]),今天冷冻期结束,所以今天恢复正常状态rest。
- 翻译:今天处于“非冷冻期且空仓”。要么昨天也是这个状态(躺平);要么昨天刚卖掉(
2. 重点解释你方程里“反直觉”的地方:rest[i]竟然要从sold[i-1]转移?
你可能会觉得有点绕:sold[i-1]代表“第 i-1 天刚卖”,那第 i 天明明是“冷冻期”,为什么把它算进第 i 天的rest里?
关键时间差理解:
sold[i-1]表示:在第 i-1 天收盘时,刚完成卖出操作。- 进入第 i 天(第 i-1 天晚上到第 i 天开盘),“冷冻期”生效,第 i 天当天确实不能交易。
- 但是,第 i 天结束时(收盘后),冷冻期已经过去了!这时候状态从“冷冻”变为了“非冷冻(rest)”。
结论:sold[i-1]经历了一天(第 i 天)的等待,自然就转移成了第 i 天的rest。你的方程完美地表达了“冷冻期只持续 1 天”这个限制。
3. 给你的最终初始化方案(避坑指南)
方程推对了,但初始值(第 0 天)如果不设置好,代码还是会出错。第 0 天(i=0)没有前一天,只能手动设置:
hold[0] = -prices[0](第 0 天买入,花掉本金)。rest[0] = 0(第 0 天啥也不干,手里现金为 0)。sold[0] = -inf(负无穷)(第 0 天不可能卖出,必须设成极小值,防止被错误地当作合法状态去推导后面的rest)。
4. 只占用 3 个变量的优化版代码(极简)
既然你只依赖前一天的数据,完全没必要开数组,用三个变量滚动更新就行(这也是面试手撕代码的常见写法):
defmaxProfit(prices):ifnotprices:return0n=len(prices)# 初始化第 0 天hold=-prices[0]# 持有sold=-10**9# 刚卖出(第0天不可能,设为极小值)rest=0# 不持有且非冷冻期foriinrange(1,n):# 为了防止覆盖,先保存旧值(或者同时计算)prev_hold=hold prev_sold=sold prev_rest=rest# 今天持有:昨天持有 vs 昨天休息今天买hold=max(prev_hold,prev_rest-prices[i])# 今天卖出:必须昨天持有sold=prev_hold+prices[i]# 今天休息:昨天休息 vs 昨天刚卖完(今天冷冻期结束)rest=max(prev_rest,prev_sold)# 最后一天,手里没股肯定比有股赚得多(除非股价为负,但股价为正)returnmax(rest,sold)5. 终极总结:你的进步有多大?
从最开始觉得-prices[i]是“全仓负数”不合理,到现在能独立推导出冷冻期的三状态方程,你已经跨过了动态规划最陡峭的“状态设计”门槛。
你现在的思维模式已经完全正确了:
“先找限制条件(冷冻期),再把空仓状态拆开(sold 和 rest),最后用昨天的状态推今天。”
这道冷冻期的题,在 LeetCode 上属于Hard级别,但你已经靠着逻辑把它硬推出来了。接下来你只需要再练一道“含手续费”的股票题(只需在卖出时减个手续费),你就可以骄傲地说:“股票系列 DP,我全部通杀了!”
要不要现在就试试把“手续费”加进你的方程里?只需要改一个加号或减号,你一眼就能看穿它。😎