1. 问题分析与动态规划思路
买卖股票的最佳时机III是LeetCode上一道经典的动态规划问题。与基础版本不同,这道题限制了我们最多只能进行两次交易。我们先来理解题目要求:
- 给定一个数组prices,其中prices[i]表示第i天的股票价格
- 最多可以完成两笔交易(买入和卖出算一次完整交易)
- 不能同时参与多笔交易(必须在再次买入前卖出当前持有的股票)
1.1 动态规划状态定义
对于这类股票交易问题,动态规划是最常用的解法。我们需要定义合适的状态来表示不同阶段的交易情况。在这个问题中,我们可以将每天的状态分为五种:
- 未进行任何操作
- 第一次买入股票
- 第一次卖出股票
- 第二次买入股票
- 第二次卖出股票
但实际上,由于初始状态就是未操作状态且利润为0,我们可以只关注后面四种状态。因此定义dp[i][j]表示第i天处于状态j时的最大利润,其中j的取值范围是0到3:
- dp[i][0]:第一次买入后的最大利润
- dp[i][1]:第一次卖出后的最大利润
- dp[i][2]:第二次买入后的最大利润
- dp[i][3]:第二次卖出后的最大利润
1.2 状态转移方程推导
根据状态定义,我们可以推导出状态转移方程:
-
dp[i][0] = max(dp[i-1][0], -prices[i])
- 保持前一天第一次买入的状态,或者当天第一次买入(花费prices[i])
-
dp[i][1] = max(dp[i-1][1], dp[i-1][0] + prices[i])
- 保持前一天第一次卖出的状态,或者当天第一次卖出(获得prices[i])
-
dp[i][2] = max(dp[i-1][2], dp[i-1][1] - prices[i])
- 保持前一天第二次买入的状态,或者当天第二次买入(花费prices[i],使用第一次卖出后的资金)
-
dp[i][3] = max(dp[i-1][3], dp[i-1][2] + prices[i])
- 保持前一天第二次卖出的状态,或者当天第二次卖出(获得prices[i])
1.3 初始状态设置
初始状态需要特别注意:
- dp[0][0] = -prices[0]:第一天买入
- dp[0][1] = 0:第一天卖出(不可能,所以利润为0)
- dp[0][2] = -prices[0]:第一天买入卖出再买入
- dp[0][3] = 0:第一天买入卖出买入卖出(利润为0)
2. Java实现与代码解析
2.1 完整代码实现
基于上述分析,我们可以写出完整的Java实现:
java复制public class Solution {
public int maxProfit(int[] prices) {
if (prices == null || prices.length == 0) {
return 0;
}
int n = prices.length;
int[][] dp = new int[n][4];
// 初始化状态
dp[0][0] = -prices[0]; // 第一次买入
dp[0][1] = 0; // 第一次卖出
dp[0][2] = -prices[0]; // 第二次买入
dp[0][3] = 0; // 第二次卖出
for (int i = 1; i < n; i++) {
// 第一次买入状态转移
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][2] = Math.max(dp[i-1][2], dp[i-1][1] - prices[i]);
// 第二次卖出状态转移
dp[i][3] = Math.max(dp[i-1][3], dp[i-1][2] + prices[i]);
}
return dp[n-1][3];
}
}
2.2 代码优化:空间压缩
观察状态转移方程,我们发现每一天的状态只依赖于前一天的状态,因此可以将空间复杂度从O(n)优化到O(1):
java复制public int maxProfit(int[] prices) {
if (prices == null || prices.length == 0) {
return 0;
}
int buy1 = -prices[0], sell1 = 0;
int buy2 = -prices[0], sell2 = 0;
for (int i = 1; i < prices.length; i++) {
buy1 = Math.max(buy1, -prices[i]);
sell1 = Math.max(sell1, buy1 + prices[i]);
buy2 = Math.max(buy2, sell1 - prices[i]);
sell2 = Math.max(sell2, buy2 + prices[i]);
}
return sell2;
}
2.3 测试用例验证
让我们用题目中的示例来验证我们的代码:
java复制public static void main(String[] args) {
Solution solution = new Solution();
// 示例1
int[] prices1 = {3,3,5,0,0,3,1,4};
System.out.println(solution.maxProfit(prices1)); // 输出6
// 示例2
int[] prices2 = {1,2,3,4,5};
System.out.println(solution.maxProfit(prices2)); // 输出4
// 示例3
int[] prices3 = {7,6,4,3,1};
System.out.println(solution.maxProfit(prices3)); // 输出0
// 示例4
int[] prices4 = {1};
System.out.println(solution.maxProfit(prices4)); // 输出0
}
3. 算法复杂度分析
3.1 时间复杂度
无论是原始实现还是优化后的版本,我们都只遍历了一次价格数组,因此时间复杂度为O(n),其中n是价格数组的长度。
3.2 空间复杂度
- 原始实现使用了二维数组存储状态,空间复杂度为O(n)
- 优化后的版本只使用了常数个变量,空间复杂度为O(1)
4. 边界条件与注意事项
4.1 特殊输入处理
在实际编码中,我们需要考虑以下边界情况:
- 空数组输入:直接返回0
- 单元素数组:无法完成交易,返回0
- 单调递减数组:无法获得利润,返回0
4.2 常见错误分析
在实现这类动态规划问题时,容易犯以下错误:
-
初始状态设置不正确:
- 忘记初始化第二次买入的状态
- 错误地将所有初始状态设为0
-
状态转移顺序错误:
- 应该按照第一次买入→第一次卖出→第二次买入→第二次卖出的顺序更新状态
- 如果顺序颠倒会导致状态依赖错误
-
空间优化时的变量更新顺序:
- 在优化版本中,必须按顺序更新buy1、sell1、buy2、sell2
- 如果先更新sell2再更新buy2,会导致使用当天的buy2值
5. 扩展思考与变种问题
5.1 最多k次交易的一般情况
这个问题可以扩展到最多进行k次交易的情况。思路类似,但需要管理2k个状态:
java复制public int maxProfit(int k, int[] prices) {
if (prices == null || prices.length == 0 || k <= 0) {
return 0;
}
if (k >= prices.length / 2) {
// 相当于可以无限次交易
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;
}
int[] buy = new int[k];
int[] sell = new int[k];
Arrays.fill(buy, Integer.MIN_VALUE);
for (int price : prices) {
for (int i = 0; i < k; i++) {
buy[i] = Math.max(buy[i], (i == 0 ? 0 : sell[i-1]) - price);
sell[i] = Math.max(sell[i], buy[i] + price);
}
}
return sell[k-1];
}
5.2 含交易手续费的情况
如果每次交易需要支付固定手续费fee,我们可以在卖出时扣除手续费:
java复制public int maxProfit(int[] prices, int fee) {
if (prices == null || prices.length == 0) {
return 0;
}
int buy = -prices[0], sell = 0;
for (int i = 1; i < prices.length; i++) {
buy = Math.max(buy, sell - prices[i]);
sell = Math.max(sell, buy + prices[i] - fee);
}
return sell;
}
5.3 含冷冻期的情况
如果卖出后需要等待一天才能再次买入(冷冻期),状态转移需要调整:
java复制public int maxProfit(int[] prices) {
if (prices == null || prices.length == 0) {
return 0;
}
int n = prices.length;
int[] buy = new int[n];
int[] sell = new int[n];
int[] cooldown = new int[n];
buy[0] = -prices[0];
for (int i = 1; i < n; i++) {
buy[i] = Math.max(buy[i-1], cooldown[i-1] - prices[i]);
sell[i] = buy[i-1] + prices[i];
cooldown[i] = Math.max(cooldown[i-1], sell[i-1]);
}
return Math.max(sell[n-1], cooldown[n-1]);
}
6. 实际应用与经验分享
6.1 股票交易策略的实际考虑
虽然这个算法可以计算最大利润,但在实际股票交易中还需要考虑:
- 交易成本:每次买卖都有手续费,会影响实际收益
- 价格滑点:实际成交价格可能与预期有偏差
- 市场流动性:大额交易可能影响市场价格
- 交易限制:实际交易平台可能有频率限制
6.2 动态规划问题的解题技巧
通过这道题,我总结了以下解决动态规划问题的经验:
- 明确状态定义:清晰地定义每个状态表示的含义
- 理清状态转移:分析各个状态之间的转移关系和条件
- 注意初始状态:初始条件的设置往往很关键
- 考虑空间优化:观察状态依赖关系,减少空间使用
- 验证边界条件:测试各种特殊情况确保鲁棒性
6.3 调试技巧
在实现这类算法时,可以采用以下调试方法:
- 打印状态表:像示例代码中那样打印dp数组,观察状态变化
- 小规模测试:先用小的测试用例手动计算预期结果
- 逐步验证:检查每个状态转移是否正确执行
- 边界测试:特别测试空数组、单元素数组等情况
这道买卖股票的最佳时机III问题很好地展示了如何将现实问题抽象为动态规划模型,并通过状态转移方程来求解。掌握这类问题的解法不仅对算法面试有帮助,也能培养我们分析复杂问题的能力。