团灭股票系列!一套动态规划模板搞定 LeetCode 所有买卖股票问题

5 阅读7分钟

大家好,我是你们的爱刷题的朋友。👋

提到动态规划(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];
};

🏆 为什么这是神技?

  1. 一维数组:节省空间。
  2. 倒序遍历:因为 dp[j] 依赖 dp[j-1](前一天的状态)。如果正序遍历,dp[j-1] 可能已经被今天更新过了,那就变成“当天买当天卖”了,逻辑就乱了。倒序能保证拿到的都是昨天的数据。

第四关:特殊规则(冷冻期 & 手续费)

👉 对应题目:309. 最佳买卖股票时机含冷冻期 & 714. 含手续费

这时候,单纯的“持有/不持有”两个状态不够用了,我们需要细化状态机

1. 含冷冻期 (Cooldown)

卖出后,第二天不能买。这意味着“不持有”分成了两种情况:

  1. 刚卖出(明天不能买,进入冷冻)
  2. 冷冻期结束/早就卖了(明天可以买)

我们需要 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); 

看,只需要改一个字符!


📝 一张表总结所有股票问题

为了方便大家记忆,我整理了一个“通关秘籍”:

题目交易次数特殊规则状态定义关键点
股票 I1 次买入只能是 -price,不能累加利润
股票 II无限买入可以是 dp[1] - price,利润累加
股票 III2 次状态扩展为 4 维 (买 1, 卖 1, 买 2, 卖 2)
股票 IVK 次循环扩展状态,一维数组 + 倒序遍历
冷冻期无限卖后停 1 天细化“不持有”状态,增加“冷冻”维度
手续费无限每次扣费卖出状态转移时 - fee

🚀 结语

动态规划并不可怕,可怕的是我们把它当成了黑盒去死记硬背。

股票系列问题的本质,就是状态机的转移

  1. 确定你有几个状态(持有、不持有、冷冻、第几次交易...)。
  2. 确定状态之间如何转移(买、卖、休息)。
  3. 确定初始值(第一天买了多少钱)。

当你掌握了这套思维,哪怕 LeetCode 出了个“股票 V:含随机事件”,你也能从容应对!

💪 觉得有用吗? 如果觉得这篇文章帮你理清了思路,欢迎点赞 👍、收藏 ⭐、关注 📢! 你的支持是我继续输出高质量技术文章的动力!

💬 互动话题: 你在刷股票系列问题时,哪个变种让你最头疼?欢迎在评论区留言,我们一起讨论!


(本文代码基于 JavaScript,逻辑通用于 Java/C++/Python 等语言)