大家好,我是你们的爱刷题的朋友。👋
提到动态规划(DP),很多人的第一反应是:“头大”。 提到 LeetCode 的**“买卖股票”系列**,很多人的反应是:“这题我做过,但换个条件我又不会了”。
股票 I、股票 II、股票 III、含冷冻期、含手续费、最多 K 次交易……条件五花八门,代码看起来也各不相同。难道我们要背 6 套代码吗?
当然不! 🙅♂️
今天,我就带大家透过现象看本质,用一套核心逻辑,打通这 6 道股票难题。看完这篇文章,保证你以后再遇到股票问题,能像条件反射一样写出状态转移方程!
🎯 核心心法:状态机与“钱包”
在开始之前,我们先统一一下“语言”。所有的股票问题,本质上都是在问:在第 i 天,我手里的“钱包”状态是什么?
我们定义两个核心状态(以二维数组 dp[i][j] 为例):
dp[i][0]:持有股票时的最大现金。(注意:买入意味着花钱,所以通常是负数,代表成本)dp[i][1]:不持有股票时的最大现金。(卖出意味着赚钱,通常是正数,代表利润)
记住这个公式,它是所有股票问题的基石:
今天持有 = max(昨天就持有,今天刚买入) 今天不持有 = max(昨天就不持有,今天刚卖出)
是不是很简单?接下来,我们看看不同的“游戏规则”是如何修改这个公式的。
第一关:新手村(只能买卖一次)
👉 对应题目:121. 买卖股票的最佳时机
这是最基础的模式。规则很简单:一生只能爱一个人(一次交易)。
// 核心逻辑
dp[i][0] = Math.max(dp[i - 1][0], -prices[i]); // 要么昨天就买了,要么今天第一次买
dp[i][1] = Math.max(dp[i - 1][1], dp[i - 1][0] + prices[i]); // 要么昨天卖了,要么今天卖
💡 关键点:
注意 dp[i][0] 的初始化。因为只能买一次,所以今天买入的成本就是 -prices[i],不能加上之前的利润(因为之前没利润)。这代表我们在寻找全局最低点作为买入时机。
第二关:无限火力(可以买卖无数次)
👉 对应题目:122. 买卖股票的最佳时机 II
规则升级:只要有钱,可以反复横跳。但手上始终只能有一股。
// 核心逻辑
dp[i][0] = Math.max(dp[i - 1][0], dp[i - 1][1] - prices[i]); // ⚠️ 注意这里
dp[i][1] = Math.max(dp[i - 1][1], dp[i - 1][0] + prices[i]);
🤔 区别在哪?
看 dp[i][0] 的买入逻辑!
- 股票一:
-prices[i](孤注一掷,之前没赚过钱) - 股票二:
dp[i - 1][1] - prices[i](利滚利!今天买入的钱,包含了昨天卖出赚到的利润)
🌰 举个栗子:
股价 [1, 2, 3]
- 第 1 天买 (1),第 2 天卖 (2),赚 1 块。
- 第 2 天再买 (2),此时你的本金其实是
1 (利润) - 2 (股价) = -1。 - 第 3 天卖 (3),最终
3 + (-1) = 2。 这就是累积利润的魅力!
第三关:限制次数(最多买卖两次 / K 次)
👉 对应题目:123. 买卖股票的最佳时机 III & 188. 买卖股票的最佳时机 IV
规则变难了:机会有限,且用且珍惜。
1. 最多两次 (Stock III)
既然只能两次,那我们就把状态拆开!
- 第一次买入
buy1 - 第一次卖出
sell1 - 第二次买入
buy2 - 第二次卖出
sell2
代码直接展开为 4 个状态:
dp[i] = [
Math.max(dp[i-1][0], -prices[i]), // 第一次持有
Math.max(dp[i-1][1], dp[i-1][0] + prices[i]), // 第一次不持有
Math.max(dp[i-1][2], dp[i-1][1] - prices[i]), // 第二次持有 (用第一次的利润买!)
Math.max(dp[i-1][3], dp[i-1][2] + prices[i]) // 第二次不持有
];
2. 最多 K 次 (Stock IV) —— 终极模板
如果 K 是 2,我们写 4 个状态;如果 K 是 100,难道写 200 个变量?当然不,用循环!
这里有一个空间优化的高级技巧:将二维 DP 压缩为一维,并且从后向前遍历。
// 股票四:通用模板
var maxProfit = function(k, prices) {
// 创建 2*k + 1 个状态 (0 是初始,1 买 1 卖,2 买 2 卖...)
const dp = new Array(k * 2 + 1).fill(0);
// 初始化:所有买入状态初始化为 -prices[0]
for(let i = 1; i < k * 2 + 1; i += 2){
dp[i] = -prices[0];
}
for(let i = 1; i < prices.length; i++){
// ⚠️ 关键:从后向前遍历,防止使用当天更新过的数据
for(let j = k * 2 + 1; j > 0; j--){
if(j % 2 === 1){
// 奇数:买入状态
dp[j] = Math.max(dp[j], dp[j - 1] - prices[i]);
}else{
// 偶数:卖出状态
dp[j] = Math.max(dp[j], dp[j - 1] + prices[i])
}
}
}
return dp[k * 2];
};
🏆 为什么这是神技?
- 一维数组:节省空间。
- 倒序遍历:因为
dp[j]依赖dp[j-1](前一天的状态)。如果正序遍历,dp[j-1]可能已经被今天更新过了,那就变成“当天买当天卖”了,逻辑就乱了。倒序能保证拿到的都是昨天的数据。
第四关:特殊规则(冷冻期 & 手续费)
👉 对应题目:309. 最佳买卖股票时机含冷冻期 & 714. 含手续费
这时候,单纯的“持有/不持有”两个状态不够用了,我们需要细化状态机。
1. 含冷冻期 (Cooldown)
卖出后,第二天不能买。这意味着“不持有”分成了两种情况:
- 刚卖出(明天不能买,进入冷冻)
- 冷冻期结束/早就卖了(明天可以买)
我们需要 4 个状态:
0: 持有股票1: 不持有,且不在冷冻期(可以买)2: 今天卖出了(明天变冷冻)3: 冷冻期(今天不能操作)
// 状态转移逻辑
dp[i][0] = Math.max(dp[i-1][0], dp[i-1][1] - prices[i], dp[i-1][3] - prices[i]); // 买入只能来自状态 1 或 3
dp[i][1] = Math.max(dp[i-1][1], dp[i-1][3]); // 保持可买状态
dp[i][2] = dp[i-1][0] + prices[i]; // 今天卖出
dp[i][3] = dp[i-1][2]; // 昨天卖出,今天冷冻
💡 理解难点: 为什么买入可以来自 dp[i-1][3]?因为 dp[3] 是冷冻期,过完这一天(到了第 i 天),冷冻就结束了,第 i 天就可以买入了!
2. 含手续费 (Fee)
这个最简单,相当于每次交易要交“税”。 在哪里减? 买入时减或卖出时减都可以,只要统一就行。通常在卖出时减去手续费更符合直觉。
// 股票二的基础上,卖出时扣费
dp[1] = Math.max(dp[1], dp[0] + prices[i] - fee);
看,只需要改一个字符!
📝 一张表总结所有股票问题
为了方便大家记忆,我整理了一个“通关秘籍”:
| 题目 | 交易次数 | 特殊规则 | 状态定义关键点 |
|---|---|---|---|
| 股票 I | 1 次 | 无 | 买入只能是 -price,不能累加利润 |
| 股票 II | 无限 | 无 | 买入可以是 dp[1] - price,利润累加 |
| 股票 III | 2 次 | 无 | 状态扩展为 4 维 (买 1, 卖 1, 买 2, 卖 2) |
| 股票 IV | K 次 | 无 | 循环扩展状态,一维数组 + 倒序遍历 |
| 冷冻期 | 无限 | 卖后停 1 天 | 细化“不持有”状态,增加“冷冻”维度 |
| 手续费 | 无限 | 每次扣费 | 卖出状态转移时 - fee |
🚀 结语
动态规划并不可怕,可怕的是我们把它当成了黑盒去死记硬背。
股票系列问题的本质,就是状态机的转移。
- 确定你有几个状态(持有、不持有、冷冻、第几次交易...)。
- 确定状态之间如何转移(买、卖、休息)。
- 确定初始值(第一天买了多少钱)。
当你掌握了这套思维,哪怕 LeetCode 出了个“股票 V:含随机事件”,你也能从容应对!
💪 觉得有用吗? 如果觉得这篇文章帮你理清了思路,欢迎点赞 👍、收藏 ⭐、关注 📢! 你的支持是我继续输出高质量技术文章的动力!
💬 互动话题: 你在刷股票系列问题时,哪个变种让你最头疼?欢迎在评论区留言,我们一起讨论!
(本文代码基于 JavaScript,逻辑通用于 Java/C++/Python 等语言)