引言
今天,我们将深入探讨一个经典的算法问题:最多完成两笔交易的股票买卖问题。这个问题不仅考验我们对动态规划的理解,更体现了分治思想在算法设计中的巧妙应用。
问题描述
给定一个整数数组 prices,其中 prices[i] 表示第 i 天的股票价格。你最多可以完成两笔交易(即买入和卖出各两次),但必须遵守以下规则:
- 不能同时参与多笔交易(必须在再次购买前出售掉之前的股票)
- 卖出股票后,无法在第二天买入股票(有1天的冷却期)
我们的目标是设计一个算法,计算能获得的最大利润。
暴力解法与优化思路
暴力法的局限性
最直观的解法是枚举所有可能的买卖组合。对于n天的价格数据,我们需要考虑:
- 第一次买入时间:n种选择
- 第一次卖出时间:最多n种选择
- 第二次买入时间:最多n种选择
- 第二次卖出时间:最多n种选择
这导致时间复杂度高达O(n⁴),显然不可行。
关键洞察:交易拆分
观察问题的本质,我们可以发现一个重要特性:两次交易是独立的。虽然有时间顺序的约束,但我们可以将整个时间段划分为两个独立的区间:
- 第一次交易发生在区间 [0, i]
- 第二次交易发生在区间 [i, n-1]
这样,问题就转化为:寻找一个分割点i,使得左区间的最大利润加上右区间的最大利润最大化。
算法详解:分治预处理法
核心思想
我们通过预处理计算两个数组:
left[i]:表示在第i天或之前完成一次交易能获得的最大利润right[i]:表示在第i天或之后完成一次交易能获得的最大利润
然后遍历所有可能的分割点i,计算 left[i] + right[i] 的最大值。
算法步骤
1. 计算左数组 left
int[] left = new int[n];
int minPrice = Integer.MAX_VALUE;
for (int i = 0; i < n; i++) {
minPrice = Math.min(minPrice, prices[i]);
if (i > 0) {
left[i] = Math.max(left[i - 1], prices[i] - minPrice);
}
}
逻辑解释:
-
minPrice记录到第i天为止的最低价格 -
left[i]有两种选择:- 不在第i天卖出:利润等于
left[i-1] - 在第i天卖出:利润为
prices[i] - minPrice
- 不在第i天卖出:利润等于
-
取两者中的较大值
2. 计算右数组 right
int[] right = new int[n];
int maxPrice = Integer.MIN_VALUE;
for (int i = n - 1; i >= 0; i--) {
maxPrice = Math.max(maxPrice, prices[i]);
if (i < n - 1) {
right[i] = Math.max(right[i + 1], maxPrice - prices[i]);
}
}
逻辑解释:
-
maxPrice记录从第i天开始的最高价格 -
right[i]有两种选择:- 不在第i天买入:利润等于
right[i+1] - 在第i天买入:利润为
maxPrice - prices[i]
- 不在第i天买入:利润等于
-
取两者中的较大值
3. 合并结果
int maxProfit = 0;
for (int i = 0; i < n; i++) {
maxProfit = Math.max(maxProfit, left[i] + right[i]);
}
return maxProfit;
完整代码实现
class Solution {
public int maxProfit(int[] prices) {
int n = prices.length;
if (n < 2) return 0;
// 左数组:记录到第i天为止的最大利润
int[] left = new int[n];
int minPrice = prices[0];
for (int i = 1; i < n; i++) {
minPrice = Math.min(minPrice, prices[i]);
left[i] = Math.max(left[i - 1], prices[i] - minPrice);
}
// 右数组:记录从第i天开始的最大利润
int[] right = new int[n];
int maxPrice = prices[n - 1];
for (int i = n - 2; i >= 0; i--) {
maxPrice = Math.max(maxPrice, prices[i]);
right[i] = Math.max(right[i + 1], maxPrice - prices[i]);
}
// 合并结果
int maxProfit = 0;
for (int i = 0; i < n; i++) {
maxProfit = Math.max(maxProfit, left[i] + right[i]);
}
return maxProfit;
}
}
复杂度分析
时间复杂度:O(n)
- 计算左数组:一次遍历,O(n)
- 计算右数组:一次遍历,O(n)
- 合并结果:一次遍历,O(n)
- 总时间复杂度:O(3n) = O(n)
空间复杂度:O(n)
- 需要两个辅助数组
left和right,每个大小为n - 总空间复杂度:O(2n) = O(n)
示例演示
让我们通过一个具体例子来理解算法的执行过程:
输入:prices = [3, 3, 5, 0, 0, 3, 1, 4]
步骤1:计算左数组
天数: 0 1 2 3 4 5 6 7
价格: 3 3 5 0 0 3 1 4
left: 0 0 2 2 2 3 3 4
步骤2:计算右数组
天数: 0 1 2 3 4 5 6 7
价格: 3 3 5 0 0 3 1 4
right: 4 4 4 4 4 3 3 0
步骤3:合并结果
i=0: left[0]+right[0] = 0+4 = 4
i=1: 0+4 = 4
i=2: 2+4 = 6 ← 最大值
i=3: 2+4 = 6
i=4: 2+4 = 6
i=5: 3+3 = 6
i=6: 3+3 = 6
i=7: 4+0 = 4
最大利润:6
交易策略:
- 第一次交易:第3天买入(0),第5天卖出(3),利润3
- 第二次交易:第6天买入(1),第7天卖出(4),利润3
- 总利润:3+3=6
算法优化:空间复杂度优化
虽然上述算法的空间复杂度为O(n),但我们还可以进一步优化到O(1):
class Solution {
public int maxProfit(int[] prices) {
int n = prices.length;
if (n < 2) return 0;
// 第一次交易的状态
int buy1 = Integer.MAX_VALUE;
int profit1 = 0;
// 第二次交易的状态
int buy2 = Integer.MAX_VALUE;
int profit2 = 0;
for (int price : prices) {
// 更新第一次交易
buy1 = Math.min(buy1, price);
profit1 = Math.max(profit1, price - buy1);
// 更新第二次交易(考虑第一次交易的利润)
buy2 = Math.min(buy2, price - profit1);
profit2 = Math.max(profit2, price - buy2);
}
return profit2;
}
}
优化原理:
buy1:第一次买入的最低成本profit1:第一次交易的最大利润buy2:第二次买入的实际成本(已扣除第一次利润)profit2:两次交易的总最大利润
这种方法将空间复杂度降低到O(1),同时保持了O(n)的时间复杂度。
扩展思考
1. 最多k次交易
对于最多k次交易的情况,我们可以使用动态规划:
dp[i][j] = max(dp[i][j-1], prices[j] + maxDiff)
其中 maxDiff = max(maxDiff, dp[i-1][j] - prices[j])
2. 包含冷却期
如果卖出后需要等待一天才能再次买入,状态转移方程需要调整:
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])
3. 包含交易手续费
每次交易需要支付固定手续费fee:
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] - fee)
总结
通过本文的分析,我们深入探讨了最多完成两笔交易的股票买卖问题。关键要点包括:
- 分治思想:将复杂问题拆分为两个独立的子问题
- 预处理技巧:通过计算左右数组避免重复计算
- 空间优化:进一步将空间复杂度从O(n)优化到O(1)
- 扩展性:算法思想可以扩展到更多交易次数和更复杂的约束条件