目录

题目描述

188. 买卖股票的最佳时机 IV

题意分析

给一个按天排列的价格数组,最多做 k 笔交易,求最大总收益。一笔交易 = 一次买入加上之后的一次卖出,并且手上任何时刻最多持有一股,也就是必须先卖掉才能再买,交易之间不能重叠。可以一笔都不做,所以答案至少是 0。

约束信号有两个。其一,题目限制的是「次数」,说明除了「当前是否持股」之外,还必须把「已经用掉几笔」也记进状态里,否则无法判断还能不能再买。其二,k 的取值范围与天数无关,k 可以给到 100 甚至更大,而 n 可能只有几十。

第二个信号非常关键:一笔交易至少要占掉一买一卖两天,所以 n 天里最多能做 $\lfloor n/2 \rfloor$ 笔有意义的交易。当 $k \ge \lfloor n/2 \rfloor$ 时,次数限制形同虚设,本题就退化成了 122 题(不限交易次数),直接把所有上涨段的差值加起来即可,不必开 k 那么大的状态数组。

边界方面:k = 0、价格数组为空、或者只有一天,都返回 0;价格全程单调下降时一笔都不该做,答案也是 0,而不是负数。

解法:交易次数状态动态规划

核心思路

交易次数受限时,仅记录“持股 / 不持股”还不够,还要记录最多允许做几笔交易。对每个交易上限 t = 1...k 定义:

  • buy[t]:处理完当前价格后,最多进行 t 次买入且手中持股的最大利润;
  • sell[t]:处理完当前价格后,最多完成 t 次卖出且手中不持股的最大利润。

当天只有“保持原状态”或“执行一次操作”两种选择:

\[buy[t] = \max(buy[t],\ sell[t-1] - price)\] \[sell[t] = \max(sell[t],\ buy[t] + price)\]

buy[t]sell[t-1] 转移,保证第 t 次买入前,前 t-1 笔交易已经结束;sell[t] 则在已有持仓上卖出。循环不变量是:处理完每一天后,两组状态都覆盖该价格前缀内、相应交易上限下的最优利润。因此最后的 sell[k] 就是最多做 k 笔交易的答案。

还有一个必须先处理的退化情形:n 天最多完成 n / 2 笔交易(整数除法)。当 k >= n / 2 时,次数限制已经无效,问题等价于不限次数交易;累加所有正的相邻差值即可。这个分支也避免了 k 很大时无意义地申请 $O(k)$ 空间。

解题步骤

  1. k = 0 或价格不足两天,直接返回 0。
  2. k >= n / 2,累加所有 prices[i] - prices[i-1] > 0 的差值并返回。
  3. 初始化 sell[t] = 0buy[t] = -prices[0]。初始时可以不交易;若持股,只可能按首日价格买入。
  4. 从第二天开始遍历价格,对 t = 1...k 更新 buy[t]sell[t]。同一天卖出后再买入或买入后再卖出的收益为 0,不会制造更优解,所以一维原地更新安全。
  5. 返回 sell[k]。“最多 k 笔”允许少做交易,因此不需要强行完成第 k 笔。

例如 k = 2prices = [3,2,6,5,0,3]:第一笔在 2 买、6 卖得到 4;随后 buy[2] 在价格 0 时更新为 4-0=4,最后价格 3 时 sell[2]=4+3=7

代码实现

class Solution {
    public int maxProfit(int k, int[] prices) {
        int n = prices.length;
        if (k == 0 || n < 2) {
            return 0;
        }

        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;
        }

        int[] buy = new int[k + 1];
        int[] sell = new int[k + 1];
        for (int t = 1; t <= k; t++) {
            buy[t] = -prices[0];
        }

        for (int i = 1; i < n; i++) {
            for (int t = 1; t <= k; t++) {
                buy[t] = Math.max(buy[t], sell[t - 1] - prices[i]);
                sell[t] = Math.max(sell[t], buy[t] + prices[i]);
            }
        }
        return sell[k];
    }
}
func maxProfit(k int, prices []int) int {
    n := len(prices)
    if k == 0 || n < 2 {
        return 0
    }

    if k >= n/2 {
        profit := 0
        for i := 1; i < n; i++ {
            if prices[i] > prices[i-1] {
                profit += prices[i] - prices[i-1]
            }
        }
        return profit
    }

    buy := make([]int, k+1)
    sell := make([]int, k+1)
    for t := 1; t <= k; t++ {
        buy[t] = -prices[0]
    }

    for i := 1; i < n; i++ {
        for t := 1; t <= k; t++ {
            buy[t] = max(buy[t], sell[t-1]-prices[i])
            sell[t] = max(sell[t], buy[t]+prices[i])
        }
    }
    return sell[k]
}

func max(a, b int) int {
    if a > b {
        return a
    }
    return b
}

复杂度分析

  • 时间复杂度:DP 分支为 $O(nk)$;当 k >= n / 2 时,贪心分支为 $O(n)$。
  • 空间复杂度:DP 分支为 $O(k)$;贪心分支为 $O(1)$。

关键点总结

  • buy[t]sell[t] 表示“最多 t 次”,所以 sell[k] 自然包含少做交易的方案。
  • 买入必须从 sell[t-1] 转移,这是“同一时刻最多持有一股、交易不能重叠”的状态化表达。
  • k >= n / 2 时要切换到不限次数的贪心,既降复杂度,也避免超大状态数组。
  • 一维滚动可行的原因是同日买卖只产生 0 收益,不会改善最优值。

易错点总结

  • 买入依赖写错:若从 sell[t] 买入,会把交易上限放松;k=1, [1,3,1,3] 会错误地得到 4,而正确答案是 2。
  • 漏掉大 k 分支:当 k 远大于 n 时,直接创建长度 k+1 的数组可能内存溢出。
  • 只写操作、不保留旧状态:转移必须取 max,否则后面的低收益操作会覆盖此前的最优利润。
  • 贪心累加所有差值:只累加正差值;下降段不参与收益。

相似题目

题目 难度 考察点
121. 买卖股票的最佳时机 简单 单次交易维护历史最低价
122. 买卖股票的最佳时机 II 中等 不限次数贪心累加上涨段
123. 买卖股票的最佳时机 III 困难 固定两笔交易的四状态机
309. 买卖股票的最佳时机含冷冻期 中等 卖出后加一天冷冻期的状态机
714. 买卖股票的最佳时机含手续费 中等 每笔交易扣手续费的两状态机
剑指 Offer 63. 股票的最大利润 中等 单次交易的最大差值