LeetCode 188. 买卖股票的最佳时机 IV(困难)

题目描述 

给你一个整数数组 prices 和一个整数 k ,其中 prices[i] 是某支给定的股票在第 i 天的价格。

设计一个算法来计算你所能获取的最大利润。你最多可以完成 k 笔交易。也就是说,你最多可以买 k 次,卖 k 次。

注意:你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。

示例 1:

输入:k = 2, prices = [2,4,1]
输出:2
解释:在第 1 天 (股票价格 = 2) 的时候买入,在第 2 天 (股票价格 = 4) 的时候卖出,这笔交易所能获得利润 = 4-2 = 2 。

示例 2:

输入:k = 2, prices = [3,2,6,5,0,3]
输出:7
解释:在第 2 天 (股票价格 = 2) 的时候买入,在第 3 天 (股票价格 = 6) 的时候卖出, 这笔交易所能获得利润 = 6-2 = 4 。
     随后,在第 5 天 (股票价格 = 0) 的时候买入,在第 6 天 (股票价格 = 3) 的时候卖出, 这笔交易所能获得利润 = 3-0 = 3 。

提示:

  • 1 <= k <= 100
  • 1 <= prices.length <= 1000
  • 0 <= prices[i] <= 1000

问题分析

这道题是"买卖股票的最佳时机"系列的第四题,是前面问题的一般化:

  1. 最多可以完成 k 笔交易
  1. 每次买入前必须先卖出手中的股票

这是一个典型的动态规划问题,但比前面的题目更复杂,因为交易次数成为了一个可变参数。关键在于如何表示状态和定义状态转移方程。


解题思路

我们可以使用二维动态规划来解决这个问题。定义状态 dp[i][j] 表示在第 i 天进行了 j 次交易后的最大利润,其中 j 的取值范围是 0 到 k。

但实际上,我们需要知道当前是持有股票状态还是未持有股票状态,因此可以进一步细分为:

  • dp[i][j][0]:表示第 i 天结束时,已进行 j 次交易,手上没有股票的最大利润
  • dp[i][j][1]:表示第 i 天结束时,已进行 j 次交易,手上持有股票的最大利润

注意:一次完整的交易指买入然后卖出。我们认为交易次数的增加是在卖出股票时发生的。

状态转移

每天的状态会根据前一天的状态更新,分为两种情况:

  1. 不持有股票 (dp[i][j][0]):
    • 前一天就不持有股票,今天什么都不做:dp[i-1][j][0]。
    • 前一天持有股票,今天卖出,完成一次交易:dp[i-1][j-1][1] + prices[i](如果 j > 0)。
    • 取两者的最大值:
      dp[i][j][0] = max(dp[i-1][j][0], dp[i-1][j-1][1] + prices[i])
  2. 持有股票 (dp[i][j][1]):
    • 前一天就持有股票,今天什么都不做:dp[i-1][j][1]。
    • 前一天不持有股票,今天买入:dp[i-1][j][0] - prices[i]。
    • 取两者的最大值:
      dp[i][j][1] = max(dp[i-1][j][1], dp[i-1][j][0] - prices[i])

状态转移方程为:

  • dp[i][j][0] = max(dp[i-1][j][0], dp[i-1][j][1] + prices[i])
  • 前一天未持有股票,今天仍未持有;或前一天持有股票,今天卖出
  • dp[i][j][1] = max(dp[i-1][j][1], dp[i-1][j-1][0] - prices[i])
  • 前一天持有股票,今天仍持有;或前一天未持有股票,今天买入(此时已进行的交易次数应减1)

初始化

  • 第一天 (i = 0)
    • dp[0][j][0] = 0:不持有股票,利润为 0。
    • dp[0][j][1] = -prices[0]:持有股票,花费了第一天的股价。
  • 边界情况
    • 如果 j = 0(没有交易),dp[i][0][1] 不应有正利润,通常设为负无穷(代码中会处理)。
    • 如果天数少于 2 或 k = 0,直接返回 0。

最终答案

最后一天不持有股票时的最大利润,即 max(dp[n-1][0][0], dp[n-1][1][0], ..., dp[n-1][k][0])。通常 dp[n-1][k][0] 是最大值。

注意

由于在实际编码时,三维数组不好处理,我们可以用两个二维数组分别表示持有和未持有状态:

  • dpSell[i][j]:表示第 i 天结束时,已进行 j 次交易,手上没有股票的最大利润
  • dpBuy[i][j]:表示第 i 天结束时,已进行 j 次交易,手上持有股票的最大利润

特殊情况处理

我们的解法包含一个重要的优化:当 k >= n/2 时,问题等价于不限制交易次数的情况。因为在 n 天内,最多只能进行 n/2 次完整的交易(买入和卖出各占一天)。

当不限制交易次数时,我们可以使用一个更简单的贪心算法:只要价格上涨,就进行买卖,累计所有的正利润。

这个优化可以大大提高算法在 k 值较大时的效率,避免了不必要的计算和可能的内存溢出。


详细执行过程图解

以示例 k = 2, prices = [3,2,6,5,0,3] 为例,让我们详细跟踪算法的执行过程:

初始状态:

  • 价格数组:[3,2,6,5,0,3]
  • k = 2

第1天(i=0):

  • 价格 = 3
  • buy[1] = -3
  • sell[1] = 0
  • buy[2] = -3
  • sell[2] = 0

第2天(i=1):

  • 价格 = 2
  • buy[1] = max(-3, 0-2) = -2(今天买入)
  • sell[1] = max(0, -3+2) = 0(不操作)
  • buy[2] = max(-3, 0-2) = -2(今天买入)
  • sell[2] = max(0, -3+2) = 0(不操作)

第3天(i=2):

  • 价格 = 6
  • buy[1] = max(-2, 0-6) = -2(保持持有)
  • sell[1] = max(0, -2+6) = 4(今天卖出)
  • buy[2] = max(-2, 0-6) = -2(保持持有)
  • sell[2] = max(0, -2+6) = 4(今天卖出)

第4天(i=3):

  • 价格 = 5
  • buy[1] = max(-2, 4-5) = -1(今天买入)
  • sell[1] = max(4, -2+5) = 4(保持未持有)
  • buy[2] = max(-2, 4-5) = -1(今天买入)
  • sell[2] = max(4, -2+5) = 4(保持未持有)

第5天(i=4):

  • 价格 = 0
  • buy[1] = max(-1, 4-0) = 4(今天买入)
  • sell[1] = max(4, -1+0) = 4(保持未持有)
  • buy[2] = max(-1, 4-0) = 4(今天买入)
  • sell[2] = max(4, -1+0) = 4(保持未持有)

第6天(i=5):

  • 价格 = 3
  • buy[1] = max(4, 4-3) = 4(保持持有)
  • sell[1] = max(4, 4+3) = 7(今天卖出)
  • buy[2] = max(4, 4-3) = 4(保持持有)
  • sell[2] = max(4, 4+3) = 7(今天卖出)

最终结果 = sell[2] = 7

这个结果对应的交易是:

  1. 在第2天(价格=2)买入,在第3天(价格=6)卖出,利润=4
  1. 在第5天(价格=0)买入,在第6天(价格=3)卖出,利润=3

总利润 = 4 + 3 = 7


   价格
    ^
6   |     *
    |
5   |         *
    |
4   |              
    |
3   |*              *
    |
2   |   *           
    |
1   |               
    |
0   |            *    
    +---------------> 时间
     1  2  3  4  5  6
 
交易策略:
第2天: 买入 (价格=2)
第3天: 卖出 (价格=6, 利润=4)
第5天: 买入 (价格=0)
第6天: 卖出 (价格=3, 利润=3)
总利润: 4+3=7

动态规划解法

Java 实现

class Solution {
    public int maxProfit(int k, int[] prices) {
        // 边界条件检查
        if (prices == null || prices.length <= 1 || k <= 0) {
            return 0;
        }
        
        int n = prices.length;
        
        // 特殊情况:当 k 大于 n/2 时,问题等价于不限制交易次数
        if (k >= n / 2) {
            return maxProfitUnlimited(prices);
        }
        
        // dpBuy[i][j]:第i天结束时,已进行j次交易,手上有股票的最大利润
        // dpSell[i][j]:第i天结束时,已进行j次交易,手上没有股票的最大利润
        int[][] dpBuy = new int[n][k + 1];
        int[][] dpSell = new int[n][k + 1];
        
        // 初始化第一天的状态
        for (int j = 0; j <= k; j++) {
            dpBuy[0][j] = -prices[0];  // 第一天买入股票
            dpSell[0][j] = 0;          // 第一天不操作
        }
        
        // 动态规划过程
        for (int i = 1; i < n; i++) {
            for (int j = 1; j <= k; j++) {
                // 第i天可以选择买入或不操作
                dpBuy[i][j] = Math.max(dpBuy[i-1][j], dpSell[i-1][j-1] - prices[i]);
                
                // 第i天可以选择卖出或不操作
                dpSell[i][j] = Math.max(dpSell[i-1][j], dpBuy[i-1][j] + prices[i]);
            }
        }
        
        // 返回最后一天进行k次交易,手上没有股票的最大利润
        return dpSell[n-1][k];
    }
    
    // 处理不限制交易次数的情况
    private int maxProfitUnlimited(int[] prices) {
        int maxProfit = 0;
        for (int i = 1; i < prices.length; i++) {
            if (prices[i] > prices[i - 1]) {
                maxProfit += prices[i] - prices[i - 1];
            }
        }
        return maxProfit;
    }
}

C# 实现

public class Solution {
    public int MaxProfit(int k, int[] prices) {
        // 边界条件检查
        if (prices == null || prices.Length <= 1 || k <= 0) {
            return 0;
        }
        
        int n = prices.Length;
        
        // 特殊情况:当 k 大于 n/2 时,问题等价于不限制交易次数
        if (k >= n / 2) {
            return MaxProfitUnlimited(prices);
        }
        
        // dpBuy[i][j]:第i天结束时,已进行j次交易,手上有股票的最大利润
        // dpSell[i][j]:第i天结束时,已进行j次交易,手上没有股票的最大利润
        int[][] dpBuy = new int[n][];
        int[][] dpSell = new int[n][];
        
        for (int i = 0; i < n; i++) {
            dpBuy[i] = new int[k + 1];
            dpSell[i] = new int[k + 1];
        }
        
        // 初始化第一天的状态
        for (int j = 0; j <= k; j++) {
            dpBuy[0][j] = -prices[0];  // 第一天买入股票
            dpSell[0][j] = 0;          // 第一天不操作
        }
        
        // 动态规划过程
        for (int i = 1; i < n; i++) {
            for (int j = 1; j <= k; j++) {
                // 第i天可以选择买入或不操作
                dpBuy[i][j] = Math.Max(dpBuy[i-1][j], dpSell[i-1][j-1] - prices[i]);
                
                // 第i天可以选择卖出或不操作
                dpSell[i][j] = Math.Max(dpSell[i-1][j], dpBuy[i-1][j] + prices[i]);
            }
        }
        
        // 返回最后一天进行k次交易,手上没有股票的最大利润
        return dpSell[n-1][k];
    }
    
    // 处理不限制交易次数的情况
    private int MaxProfitUnlimited(int[] prices) {
        int maxProfit = 0;
        for (int i = 1; i < prices.Length; i++) {
            if (prices[i] > prices[i - 1]) {
                maxProfit += prices[i] - prices[i - 1];
            }
        }
        return maxProfit;
    }
}

空间优化解法

观察标准动态规划解法中的状态转移方程,我们可以发现,对于第 i 天的状态,我们只需要第 i-1 天的状态,所以可以将空间复杂度从 O(nk) 降低到 O(k)。

Java 空间优化实现

class Solution {
    public int maxProfit(int k, int[] prices) {
        if (prices == null || prices.length <= 1 || k <= 0) {
            return 0;
        }
        
        int n = prices.length;
        
        // 特殊情况:当 k 大于 n/2 时,问题等价于不限制交易次数
        if (k >= n / 2) {
            return maxProfitUnlimited(prices);
        }
        
        // 定义两个数组来分别记录当前持有股票和未持有股票的最大利润
        int[] buy = new int[k + 1];   // buy[j]: 进行j次交易时,持有股票的最大利润
        int[] sell = new int[k + 1];  // sell[j]: 进行j次交易时,未持有股票的最大利润
        
        // 初始化
        Arrays.fill(buy, -prices[0]);
        Arrays.fill(sell, 0);
        
        // 动态规划过程
        for (int i = 1; i < n; i++) {
            for (int j = 1; j <= k; j++) {
                // 计算第i天的状态
                buy[j] = Math.max(buy[j], sell[j-1] - prices[i]);
                sell[j] = Math.max(sell[j], buy[j] + prices[i]);
            }
        }
        
        return sell[k];
    }
    
    private int maxProfitUnlimited(int[] prices) {
        int maxProfit = 0;
        for (int i = 1; i < prices.length; i++) {
            if (prices[i] > prices[i - 1]) {
                maxProfit += prices[i] - prices[i - 1];
            }
        }
        return maxProfit;
    }
}

C# 空间优化实现

public class Solution {
    public int MaxProfit(int k, int[] prices) {
        if (prices == null || prices.Length <= 1 || k <= 0) {
            return 0;
        }
        
        int n = prices.Length;
        
        // 特殊情况:当 k 大于 n/2 时,问题等价于不限制交易次数
        if (k >= n / 2) {
            return MaxProfitUnlimited(prices);
        }
        
        // 定义两个数组来分别记录当前持有股票和未持有股票的最大利润
        int[] buy = new int[k + 1];   // buy[j]: 进行j次交易时,持有股票的最大利润
        int[] sell = new int[k + 1];  // sell[j]: 进行j次交易时,未持有股票的最大利润
        
        // 初始化
        for (int j = 0; j <= k; j++) {
            buy[j] = -prices[0];
            sell[j] = 0;
        }
        
        // 动态规划过程
        for (int i = 1; i < n; i++) {
            for (int j = 1; j <= k; j++) {
                // 计算第i天的状态
                buy[j] = Math.Max(buy[j], sell[j-1] - prices[i]);
                sell[j] = Math.Max(sell[j], buy[j] + prices[i]);
            }
        }
        
        return sell[k];
    }
    
    private int MaxProfitUnlimited(int[] prices) {
        int maxProfit = 0;
        for (int i = 1; i < prices.Length; i++) {
            if (prices[i] > prices[i - 1]) {
                maxProfit += prices[i] - prices[i - 1];
            }
        }
        return maxProfit;
    }
}

复杂度分析

  • 时间复杂度:
    • 常规情况:O(nk),其中 n 是天数,k 是交易次数限制
    • 特殊情况(k >= n/2):O(n)
  • 空间复杂度:
    • 标准DP版本:O(nk)
    • 空间优化版本:O(k)