当你面对LeetCode上的六道股票买卖问题时,第一反应可能是:每道题都要单独学一种解法?这正是无数开发者的误区。实际上,这六道题可以用同一套状态转移方程统一解决——区别只在于边界条件的细微差异。

股票买卖问题系列是动态规划中"状态机DP"的教科书级案例。它的核心思想是将问题抽象为有限状态的转换:每一天,你只有两种可能的状态——持有股票或不持有股票。而在这两种状态之间转换的动作只有三种:买入、卖出、或什么都不做。这个简洁的状态模型,能够覆盖从单次交易到无限次交易、从冷冻期到手续费的所有变体。

一个框架,六道题目

先看这六道题的约束差异:

题目 难度 交易次数限制 额外约束
121. 买卖股票的最佳时机 简单 最多1次
122. 买卖股票的最佳时机 II 简单 无限次
123. 买卖股票的最佳时机 III 困难 最多2次
188. 买卖股票的最佳时机 IV 困难 最多k次
309. 买卖股票的最佳时机含冷冻期 中等 无限次 卖出后隔一天才能买
714. 买卖股票的最佳时机含手续费 中等 无限次 每次交易收取fee

看似差异巨大,但它们共享同一个状态定义:dp[i][k][0/1] 表示第i天结束时,最多允许进行k次交易,当前不持有/持有股票时的最大利润。

状态转移的核心方程

状态机的精髓在于明确每个状态可以转换到哪些状态。对于股票问题,状态转移图如下:

     买入           卖出
  ┌─────────→ 持有 ─────────→
  │            (1)            │
未持有                         未持有
  (0)                          (0)
  │                            ↑
  └────── rest ──── rest ──────┘

基于这个状态图,通用状态转移方程为:

$$dp[i][k][0] = \max(dp[i-1][k][0], dp[i-1][k][1] + prices[i])$$$$dp[i][k][1] = \max(dp[i-1][k][1], dp[i-1][k-1][0] - prices[i])$$

第一个方程的含义:今天不持有股票,要么昨天就不持有(rest),要么昨天持有今天卖出(sell)。第二个方程的含义:今天持有股票,要么昨天就持有(rest),要么昨天不持有今天买入(buy)——注意买入会消耗一次交易额度,所以是从 k-1 转移过来。

边界条件是整个框架的基石:

  • $dp[-1][k][0] = 0$:还没开始时,利润为0
  • $dp[-1][k][1] = -\infty$:还没开始时不可能持有股票
  • $dp[i][0][0] = 0$:不允许交易时,利润为0
  • $dp[i][0][1] = -\infty$:不允许交易时不可能持有股票

LeetCode 121:只允许一次交易

k = 1 时,状态维度可以简化。因为 k 始终为1,不再需要跟踪交易次数:

$$dp[i][0] = \max(dp[i-1][0], dp[i-1][1] + prices[i])$$$$dp[i][1] = \max(dp[i-1][1], -prices[i])$$
public int maxProfit(int[] prices) {
    int n = prices.length;
    // dp[i][0] = 第i天不持有股票的最大利润
    // dp[i][1] = 第i天持有股票的最大利润
    int[][] dp = new int[n][2];
    dp[0][0] = 0;
    dp[0][1] = -prices[0];
    
    for (int i = 1; i < n; i++) {
        dp[i][0] = Math.max(dp[i-1][0], dp[i-1][1] + prices[i]);
        dp[i][1] = Math.max(dp[i-1][1], -prices[i]);
    }
    return dp[n-1][0];
}

空间优化版本——只保留前一个状态:

public int maxProfit(int[] prices) {
    int dp0 = 0;           // 不持有股票
    int dp1 = Integer.MIN_VALUE;  // 持有股票
    
    for (int price : prices) {
        dp0 = Math.max(dp0, dp1 + price);
        dp1 = Math.max(dp1, -price);
    }
    return dp0;
}

时间复杂度 $O(n)$,空间复杂度 $O(1)$。

这道题还有一个直观的贪心解法:遍历数组,维护最小买入价格和最大利润:

public int maxProfit(int[] prices) {
    int minPrice = Integer.MAX_VALUE;
    int maxProfit = 0;
    
    for (int price : prices) {
        minPrice = Math.min(minPrice, price);
        maxProfit = Math.max(maxProfit, price - minPrice);
    }
    return maxProfit;
}

LeetCode 122:无限次交易

当交易次数无限制时,kk-1 等价,状态方程简化为:

$$dp[i][0] = \max(dp[i-1][0], dp[i-1][1] + prices[i])$$$$dp[i][1] = \max(dp[i-1][1], dp[i-1][0] - prices[i])$$
public int maxProfit(int[] prices) {
    int dp0 = 0;
    int dp1 = Integer.MIN_VALUE;
    
    for (int price : prices) {
        int temp = dp0;
        dp0 = Math.max(dp0, dp1 + price);
        dp1 = Math.max(dp1, temp - price);
    }
    return dp0;
}

注意这里需要用 temp 保存旧的 dp0,因为计算 dp1 时需要用到更新前的 dp0

这道题有一个更优雅的贪心解法:只要价格上涨就卖出。为什么这个策略正确?因为任何上涨区间,都可以分解为若干个相邻上涨:

价格序列: 1, 2, 3, 4, 5
贪心策略: (2-1) + (3-2) + (4-3) + (5-4) = 4
一次买卖: 5 - 1 = 4

两者结果相同,但贪心策略将一次大交易拆成了多次小交易,在无限次交易的条件下完全等价。

public int maxProfit(int[] prices) {
    int profit = 0;
    for (int i = 1; i < prices.length; i++) {
        if (prices[i] > prices[i-1]) {
            profit += prices[i] - prices[i-1];
        }
    }
    return profit;
}

贪心算法的正确性可以用数学归纳法证明:假设最优解在某天持有了股票而没有卖出,而第二天价格上涨。那么在这两天之间卖出再买入,不会减少总利润,但符合贪心策略。通过不断交换,任何最优解都可以转化为贪心解的形式。

LeetCode 123:最多两次交易

k = 2 时,必须完整保留状态维度。但 k 很小,可以直接展开:

public int maxProfit(int[] prices) {
    // 四个状态:第一次买入后、第一次卖出后、第二次买入后、第二次卖出后
    int buy1 = Integer.MIN_VALUE;
    int sell1 = 0;
    int buy2 = Integer.MIN_VALUE;
    int sell2 = 0;
    
    for (int price : prices) {
        // 注意更新顺序:从后往前,避免状态污染
        sell2 = Math.max(sell2, buy2 + price);
        buy2 = Math.max(buy2, sell1 - price);
        sell1 = Math.max(sell1, buy1 + price);
        buy1 = Math.max(buy1, -price);
    }
    return sell2;
}

理解这四个状态的关键是:它们代表的是在第i天结束时处于该状态的最大利润buy1 表示已完成第一次买入(持有第一支股票),sell1 表示已完成第一次卖出,以此类推。

状态转移方程为:

$$buy1[i] = \max(buy1[i-1], -prices[i])$$$$sell1[i] = \max(sell1[i-1], buy1[i-1] + prices[i])$$$$buy2[i] = \max(buy2[i-1], sell1[i-1] - prices[i])$$$$sell2[i] = \max(sell2[i-1], buy2[i-1] + prices[i])$$

LeetCode 188:最多k次交易

这是最一般的情形,121和123都是它的特例。核心代码:

public int maxProfit(int k, int[] prices) {
    int n = prices.length;
    if (n == 0 || k == 0) return 0;
    
    // 关键优化:当k >= n/2时,等价于无限次交易
    if (k >= n / 2) {
        int profit = 0;
        for (int i = 1; i < n; i++) {
            if (prices[i] > prices[i-1]) {
                profit += prices[i] - prices[i-1];
            }
        }
        return profit;
    }
    
    // dp[j][0] = 最多j次交易,不持有股票
    // dp[j][1] = 最多j次交易,持有股票
    int[][] dp = new int[k + 1][2];
    
    // 初始化:第一天买入后的状态
    for (int j = 1; j <= k; j++) {
        dp[j][1] = -prices[0];
    }
    
    for (int i = 1; i < n; i++) {
        // 必须倒序遍历k,避免状态污染
        for (int j = k; j >= 1; j--) {
            dp[j][0] = Math.max(dp[j][0], dp[j][1] + prices[i]);
            dp[j][1] = Math.max(dp[j][1], dp[j-1][0] - prices[i]);
        }
    }
    return dp[k][0];
}

这里有一个重要优化:当 $k \geq n/2$ 时,问题退化为无限次交易。因为一次完整的交易至少需要两天(买入一天,卖出一天),所以最多只能进行 $n/2$ 次交易。当 k 超过这个上限时,交易次数的约束就失去了意义。

为什么要倒序遍历 k?因为 dp[j][1] 依赖于 dp[j-1][0]。如果正序遍历,dp[j-1][0] 已经被更新为当前天的值,导致在同一天既买入又卖出,这是不允许的。

时间复杂度 $O(n \times k)$,空间复杂度 $O(k)$。

LeetCode 309:含冷冻期

冷冻期的约束是:卖出后必须等待一天才能买入。状态转移方程需要调整:

$$dp[i][0] = \max(dp[i-1][0], dp[i-1][1] + prices[i])$$$$dp[i][1] = \max(dp[i-1][1], dp[i-2][0] - prices[i])$$

买入时的转移从 i-1 变成了 i-2,因为买入必须在卖出后的第二天。

public int maxProfit(int[] prices) {
    int n = prices.length;
    if (n < 2) return 0;
    
    int[][] dp = new int[n][2];
    dp[0][0] = 0;
    dp[0][1] = -prices[0];
    dp[1][0] = Math.max(0, prices[1] - prices[0]);
    dp[1][1] = Math.max(-prices[0], -prices[1]);
    
    for (int i = 2; i < n; i++) {
        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-2][0] - prices[i]);
    }
    return dp[n-1][0];
}

空间优化版本:

public int maxProfit(int[] prices) {
    int dp0 = 0;                    // 今天不持有
    int dp1 = Integer.MIN_VALUE;    // 今天持有
    int dpPre0 = 0;                 // 昨天不持有(用于冷冻期)
    
    for (int price : prices) {
        int temp = dp0;
        dp0 = Math.max(dp0, dp1 + price);
        dp1 = Math.max(dp1, dpPre0 - price);
        dpPre0 = temp;
    }
    return dp0;
}

另一种视角是将状态分为三种:持有股票、不持有股票(可买入)、不持有股票(冷冻期)。这种三状态模型更直观:

public int maxProfit(int[] prices) {
    int hold = Integer.MIN_VALUE;   // 持有股票
    int sold = 0;                   // 刚卖出(冷冻期)
    int rest = 0;                   // 不持有,可买入
    
    for (int price : prices) {
        int prevHold = hold;
        int prevSold = sold;
        
        hold = Math.max(hold, rest - price);
        sold = prevHold + price;
        rest = Math.max(rest, prevSold);
    }
    return Math.max(sold, rest);
}

LeetCode 714:含手续费

手续费的约束更简单:每次交易扣除 fee。可以在买入时扣除,也可以在卖出时扣除——两者等价:

// 方案一:买入时扣除
public int maxProfit(int[] prices, int fee) {
    int dp0 = 0;
    int dp1 = Integer.MIN_VALUE;
    
    for (int price : prices) {
        int temp = dp0;
        dp0 = Math.max(dp0, dp1 + price);
        dp1 = Math.max(dp1, temp - price - fee);
    }
    return dp0;
}

// 方案二:卖出时扣除
public int maxProfit(int[] prices, int fee) {
    int dp0 = 0;
    int dp1 = Integer.MIN_VALUE;
    
    for (int price : prices) {
        int temp = dp0;
        dp0 = Math.max(dp0, dp1 + price - fee);
        dp1 = Math.max(dp1, temp - price);
    }
    return dp0;
}

注意使用 long 类型避免整数溢出,特别是当 prices[i]fee 都很大时。

状态机DP的本质

股票问题系列展示了状态机DP的完整方法论:

第一步:定义状态。股票问题的核心状态是"是否持有股票",加上"交易次数"这个维度。状态定义要满足两个条件:一是能够完全描述问题,二是状态之间有明确的转换关系。

第二步:画出状态图。状态图清晰展示了所有可能的转换路径。在股票问题中,从"不持有"可以通过"买入"转换到"持有";从"持有"可以通过"卖出"转换到"不持有"。

第三步:写出转移方程。转移方程是状态图的数学表达。每个状态的值等于所有可能的前驱状态值加上转换代价的最大值。

第四步:处理边界条件。边界条件决定了动态规划的起点。股票问题中,“还没开始"和"不允许交易"是两种边界情况。

第五步:空间优化。当状态转移只依赖于前一个或前几个状态时,可以用滚动变量替代整个数组。

面试中的常见陷阱

陷阱一:混淆交易次数的定义k 指的是最多允许的买入次数(或完整的买卖对数),而不是卖出次数。状态转移时,买入消耗一次额度,卖出不消耗。

陷阱二:状态更新顺序错误。空间优化时,必须保证用到的是上一轮的值。对于股票问题,通常需要倒序遍历 k,或用临时变量保存旧值。

陷阱三:边界条件遗漏dp[0][1] = -prices[0] 还是 dp[0][1] = Integer.MIN_VALUE?这取决于状态定义。如果定义是"在第i天结束时持有股票”,则前者正确;如果定义是"在第i天进行了买入操作",则需要更仔细考虑。

陷阱四:整数溢出。当 prices[i] 达到 $10^4$,fee 达到 $10^4$,且交易次数较多时,累加可能溢出。建议使用 long 类型,或在比较前检查边界。

统一模板总结

public int maxProfit(int[] prices) {
    // 基础状态:不持有/持有
    int dp0 = 0, dp1 = Integer.MIN_VALUE;
    
    for (int price : prices) {
        int temp = dp0;
        // 根据具体问题调整转移方程
        dp0 = Math.max(dp0, dp1 + price);        // 卖出或不操作
        dp1 = Math.max(dp1, temp - price);       // 买入或不操作
    }
    return dp0;
}

这个模板可以通过简单修改适应所有变体:

  • k=1dp1 = Math.max(dp1, -price)
  • k=∞dp1 = Math.max(dp1, temp - price)
  • 冷冻期:引入 dpPre0 变量
  • 手续费:买入或卖出时减去 fee
  • k次交易:扩展为二维数组 dp[k][2]

股票问题系列的魅力在于,它用最简洁的状态模型解释了动态规划的核心思想——状态定义、状态转移、边界处理。掌握这个框架后,类似的状态机DP问题(如打家劫舍系列、编辑距离系列)都将迎刃而解。

参考