1. 问题概述与核心思路
这道LeetCode经典题目要求我们在给定的股票价格序列中找到一次买入和卖出机会,使得利润最大化。看似简单的问题背后蕴含着深刻的算法思想,也是面试中检验候选人基础能力的常见题目。
问题核心约束条件:
- 只能进行一次完整的交易(一次买入+一次卖出)
- 卖出必须在买入之后(时间不可逆)
- 如果没有获利可能则返回0
在实际操作中,新手最容易犯的错误是试图通过"先找最小值再找最大值"来解决问题。比如给定序列[3,2,6,5,0,3],最小值是0,最大值是6,但6出现在0之前,这种组合是无效的。这就是为什么我们需要更聪明的算法。
2. 暴力解法分析与优化思路
2.1 暴力解法实现
最直观的解法是双重循环枚举所有可能的买卖组合:
java复制public int maxProfitBruteForce(int[] prices) {
int max = 0;
for (int i = 0; i < prices.length; i++) {
for (int j = i + 1; j < prices.length; j++) {
int profit = prices[j] - prices[i];
if (profit > max) {
max = profit;
}
}
}
return max;
}
时间复杂度分析:
- 外层循环执行n次
- 内层循环平均执行n/2次
- 总体时间复杂度为O(n²)
当n=10⁵时,操作次数将达到约5×10⁹次,在现代CPU上需要数秒才能完成,远超算法题目通常要求的1秒时限。
2.2 暴力解法的问题
暴力解法的主要问题在于重复计算。例如,当计算第j天卖出时,我们需要反复比较prices[j]与前j-1天的所有价格。实际上,我们只需要知道前j-1天中的最低价即可。
关键洞察:对于任意一天j,如果在j卖出,最优买入点一定是前j-1天中的最低价。
3. 贪心算法详解
3.1 算法思想
贪心算法的核心是:在遍历过程中维护两个关键变量:
minPrice:迄今为止遇到的最低价格maxProfit:迄今为止能获得的最大利润
对于每一天的价格,我们只需要:
- 更新历史最低价
- 计算"如果在历史最低价买入,今天卖出"的利润
- 更新最大利润
3.2 Java实现
java复制public int maxProfit(int[] prices) {
int minPrice = Integer.MAX_VALUE;
int maxProfit = 0;
for (int price : prices) {
if (price < minPrice) {
minPrice = price;
} else if (price - minPrice > maxProfit) {
maxProfit = price - minPrice;
}
}
return maxProfit;
}
代码解析:
- 初始化minPrice为最大整数值,确保第一个价格会被记录
- 初始化maxProfit为0,符合题目要求
- 使用增强for循环提高代码可读性
- 每次先检查是否需要更新minPrice
- 否则检查是否能更新maxProfit
3.3 算法正确性证明
我们可以用数学归纳法证明这个贪心算法的正确性:
基例:当只有1天时,maxProfit=0,算法正确
归纳假设:假设前k天的解是正确的
归纳步骤:
- 第k+1天时:
- 如果price[k+1] < minPrice:更新minPrice,此时maxProfit不变(因为还没找到新的卖出机会)
- 否则:计算当前利润并可能更新maxProfit
- 两种情况都保持了正确性
因此,算法对所有n都正确。
4. 复杂度分析与优化
4.1 时间复杂度
- 只需要一次遍历数组:O(n)
- 每个元素处理时间是常数:O(1)
- 总体时间复杂度:O(n)
4.2 空间复杂度
- 只使用了两个额外变量:O(1)
4.3 实际性能对比
测试数据规模n=10⁵时:
- 暴力解法:约5秒(超时)
- 贪心算法:<1毫秒
4.4 代码优化技巧
- 使用Math函数简化代码:
java复制minPrice = Math.min(minPrice, price);
maxProfit = Math.max(maxProfit, price - minPrice);
虽然会多计算一次price-minPrice,但现代JVM会优化这种简单操作。
- 边界条件处理:
java复制if (prices == null || prices.length < 2) {
return 0;
}
虽然题目保证输入有效,但实际工程中应该考虑。
- 循环展开:
对于特别大的数组,可以考虑手动循环展开,但JVM的JIT通常会自动优化。
5. 常见问题与解决方案
5.1 如何记录具体买卖日期?
java复制int buyDay = -1, sellDay = -1;
int tempBuyDay = 0;
int minPrice = Integer.MAX_VALUE;
int maxProfit = 0;
for (int i = 0; i < prices.length; i++) {
if (prices[i] < minPrice) {
minPrice = prices[i];
tempBuyDay = i;
} else if (prices[i] - minPrice > maxProfit) {
maxProfit = prices[i] - minPrice;
buyDay = tempBuyDay;
sellDay = i;
}
}
5.2 如果允许不交易怎么办?
算法已经处理了这种情况,因为maxProfit初始化为0,如果没有正利润,结果就是0。
5.3 如何处理多个最大利润的情况?
如果需要找出所有能获得最大利润的买卖组合,可以在第一次遍历确定maxProfit后,第二次遍历找出所有满足条件的组合:
java复制List<int[]> findAllMaxProfits(int[] prices) {
int maxProfit = maxProfit(prices); // 先用原算法计算最大值
List<int[]> results = new ArrayList<>();
int minPrice = Integer.MAX_VALUE;
int minIndex = 0;
for (int i = 0; i < prices.length; i++) {
if (prices[i] < minPrice) {
minPrice = prices[i];
minIndex = i;
} else if (prices[i] - minPrice == maxProfit) {
results.add(new int[]{minIndex, i});
}
}
return results;
}
5.4 为什么不能先找最大值再找最小值?
因为股票交易有时间顺序限制。最大值可能出现在最小值之前,这样组合就是无效的。例如[5,4,3,2,1],最大值5在最前面,最小值1在最后面,但不能在第0天卖出、第4天买入。
6. 实际应用场景
6.1 金融交易系统
java复制class StockMonitor {
private double minPrice = Double.MAX_VALUE;
private double maxProfit = 0;
public void update(double currentPrice) {
if (currentPrice < minPrice) {
minPrice = currentPrice;
} else {
maxProfit = Math.max(maxProfit, currentPrice - minPrice);
}
// 可以添加报警逻辑
if (maxProfit > threshold) {
sendAlert();
}
}
}
6.2 电商价格追踪
监控商品价格变化,找出最佳购买时机:
java复制class PriceTracker {
private Map<String, Integer> itemMinPrices = new HashMap<>();
private Map<String, Integer> itemMaxProfits = new HashMap<>();
public void track(String itemId, int currentPrice) {
int min = itemMinPrices.getOrDefault(itemId, Integer.MAX_VALUE);
int profit = itemMaxProfits.getOrDefault(itemId, 0);
if (currentPrice < min) {
itemMinPrices.put(itemId, currentPrice);
} else {
itemMaxProfits.put(itemId, Math.max(profit, currentPrice - min));
}
}
}
6.3 资源调度优化
在云计算中优化资源购买时机:
java复制class ResourceOptimizer {
public int findBestTimeToBuy(List<InstancePrice> prices) {
int minPrice = Integer.MAX_VALUE;
int maxSaving = 0;
for (InstancePrice price : prices) {
if (price.cost < minPrice) {
minPrice = price.cost;
} else {
maxSaving = Math.max(maxSaving, price.cost - minPrice);
}
}
return maxSaving;
}
}
7. 相关题目扩展
7.1 LeetCode 122. 买卖股票的最佳时机 II
允许无限次交易:
java复制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;
}
7.2 LeetCode 123. 买卖股票的最佳时机 III
最多两次交易,需要使用动态规划:
java复制public int maxProfit(int[] prices) {
int buy1 = Integer.MAX_VALUE, buy2 = Integer.MAX_VALUE;
int sell1 = 0, sell2 = 0;
for (int price : prices) {
buy1 = Math.min(buy1, price);
sell1 = Math.max(sell1, price - buy1);
buy2 = Math.min(buy2, price - sell1);
sell2 = Math.max(sell2, price - buy2);
}
return sell2;
}
7.3 LeetCode 188. 买卖股票的最佳时机 IV
最多k次交易,通用动态规划解法:
java复制public int maxProfit(int k, int[] prices) {
if (prices.length <= 1) 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+1];
int[] sell = new int[k+1];
Arrays.fill(buy, Integer.MIN_VALUE);
for (int price : prices) {
for (int i = 1; i <= k; i++) {
buy[i] = Math.max(buy[i], sell[i-1] - price);
sell[i] = Math.max(sell[i], buy[i] + price);
}
}
return sell[k];
}
8. 面试技巧与注意事项
8.1 面试常见问题
-
如何解释算法思路:
- 先描述暴力解法及其问题
- 然后提出优化思路:维护历史最低价
- 最后说明贪心选择的正确性
-
边界条件:
- 空数组或单元素数组
- 价格持续下跌的情况
- 价格全部相同的情况
-
复杂度分析:
- 明确说明时间复杂度和空间复杂度
- 与暴力解法对比
8.2 白板编程技巧
- 先写出暴力解法
- 分析其问题
- 提出优化思路
- 实现优化后的算法
- 讨论边界条件
- 进行复杂度分析
8.3 代码风格建议
- 使用有意义的变量名(minPrice而不是min)
- 添加必要的注释
- 处理边界条件
- 考虑使用更简洁的写法(如Math函数)
9. 算法思想延伸
9.1 贪心算法适用场景
- 最优子结构:问题的最优解包含子问题的最优解
- 贪心选择性质:局部最优选择能导致全局最优解
- 无后效性:当前选择不影响后续选择
9.2 类似问题模式
- 最大子数组和(Kadane算法)
- 跳跃游戏问题
- 任务调度问题
- 找零钱问题(特定面值情况下)
9.3 与动态规划的关系
贪心算法可以看作是动态规划的特例,当问题具有贪心选择性质时,贪心算法通常更高效。但动态规划适用性更广。
10. 工程实践中的注意事项
-
输入验证:
- 检查null或空数组
- 检查负价格(虽然题目限定非负)
-
数值溢出:
- 对于极大价格差,考虑使用long
- 特别是当价格可能接近Integer极限值时
-
并发环境:
- 如果需要实时更新,考虑线程安全
- 可以使用Atomic变量或同步块
-
日志记录:
- 记录关键决策点
- 特别是实际交易系统中的买卖决策
-
测试用例设计:
- 正常情况
- 边界情况(空数组、单元素、持续涨/跌)
- 极端值(最大/最小价格)
- 随机生成的长序列测试性能
11. 性能优化进阶
11.1 分支预测优化
原始代码中的if-else分支可能影响性能:
java复制// 原始版本
if (price < minPrice) {
minPrice = price;
} else if (price - minPrice > maxProfit) {
maxProfit = price - minPrice;
}
// [优化版本](https://taotoken.net?utm_source=general)(减少分支预测失败)
minPrice = Math.min(minPrice, price);
maxProfit = Math.max(maxProfit, price - minPrice);
11.2 内存访问优化
对于极大数组,顺序访问比随机访问更快:
- 使用增强for循环而非索引访问
- 避免不必要的数组索引计算
11.3 并行化尝试(不适用)
虽然看起来可以分段处理,但由于严格的顺序依赖关系,这个算法无法有效并行化。任何尝试并行化的版本都会破坏算法的正确性。
12. 不同语言实现对比
12.1 Python实现
python复制def maxProfit(prices):
min_price = float('inf')
max_profit = 0
for price in prices:
min_price = min(min_price, price)
max_profit = max(max_profit, price - min_price)
return max_profit
12.2 C++实现
cpp复制int maxProfit(vector<int>& prices) {
int minPrice = INT_MAX;
int maxProfit = 0;
for (int price : prices) {
minPrice = min(minPrice, price);
maxProfit = max(maxProfit, price - minPrice);
}
return maxProfit;
}
12.3 JavaScript实现
javascript复制function maxProfit(prices) {
let minPrice = Infinity;
let maxProfit = 0;
for (let price of prices) {
minPrice = Math.min(minPrice, price);
maxProfit = Math.max(maxProfit, price - minPrice);
}
return maxProfit;
}
13. 测试用例设计
13.1 基础测试用例
-
普通情况:
- 输入:[7,1,5,3,6,4]
- 输出:5(第2天买入,第5天卖出)
-
无利润情况:
- 输入:[7,6,4,3,1]
- 输出:0
-
持续上涨:
- 输入:[1,2,3,4,5]
- 输出:4(第1天买入,第5天卖出)
13.2 边界测试用例
-
空数组:
- 输入:[]
- 输出:0
-
单元素数组:
- 输入:[5]
- 输出:0
-
两元素数组:
- 输入:[3,1] → 0
- 输入:[1,3] → 2
13.3 极端测试用例
-
大数组测试:
- 生成10⁵个元素的随机数组
- 验证算法在合理时间内完成
-
极值测试:
- 输入:[0, Integer.MAX_VALUE]
- 输出:Integer.MAX_VALUE
-
全部相同:
- 输入:[5,5,5,5]
- 输出:0
14. 常见错误与修正
14.1 错误:先找最大值再找最小值
java复制// 错误实现
int min = Arrays.stream(prices).min().getAsInt();
int max = Arrays.stream(prices).max().getAsInt();
return max - min;
修正:必须保证卖出在买入之后,不能简单取全局最大最小值。
14.2 错误:初始化不当
java复制// 错误初始化
int minPrice = prices[0];
int maxProfit = 0;
for (int i = 1; i < prices.length; i++) {
// ...
}
问题:当prices为空时会抛出异常。修正:添加空数组检查。
14.3 错误:更新顺序错误
java复制// 错误顺序
maxProfit = Math.max(maxProfit, price - minPrice);
minPrice = Math.min(minPrice, price);
问题:在同一天先卖出再买入,违反交易规则。修正:必须先更新minPrice。
15. 总结与个人心得
这道题目虽然被标记为"简单",但它很好地展示了算法设计的精髓:从暴力解法出发,通过观察问题特性,找到优化思路。在实际编程练习中,我有以下几点深刻体会:
-
理解问题本质比记忆解法更重要:抓住"历史最低点"这个关键点,问题就迎刃而解。
-
边界条件决定代码鲁棒性:空输入、单元素、极值等情况往往能暴露代码问题。
-
贪心算法需要严格证明:不能仅凭直觉认为算法正确,需要数学归纳等严谨证明。
-
从特例到一般:先考虑小规模例子,再推广到一般情况,这是解决算法问题的有效方法。
-
工程实践与算法竞赛的差异:在实际工程中,除了正确性,还需要考虑代码可读性、异常处理等。
最后,这道题的解法模式可以推广到许多类似的时间序列分析问题,掌握这种思想比记住具体代码更有价值。