1. 问题背景与定义
今天我想和大家分享一个非常有意思的算法问题 - LintCode 3866 "有效子数组的数量"。这个问题看似简单,但蕴含着单调栈这个重要数据结构的精妙应用。作为一名经常刷题的开发者,我发现这个问题是理解单调栈的绝佳案例。
首先让我们明确什么是"有效子数组":对于一个数组nums中的子数组nums[i..j](i ≤ j),如果该子数组的第一个元素nums[i]是整个子数组中的最小值,那么这个子数组就是有效的。换句话说,从i开始向右扩展,只要nums[i]始终保持是当前子数组的最小值,这些子数组都是有效的。
2. 问题分析与暴力解法
2.1 直观理解
让我们从一个简单的例子开始理解这个问题。假设有数组[1,4,2,5,3],那么它的有效子数组包括:
- 以0开始的:[1], [1,4], [1,4,2], [1,4,2,5], [1,4,2,5,3]
- 以1开始的:[4]
- 以2开始的:[2], [2,5], [2,5,3]
- 以3开始的:[5]
- 以4开始的:[3]
总共有11个有效子数组。可以看到,对于每个起始位置i,有效子数组的数量取决于从i开始向右,第一个比nums[i]小的元素的位置。
2.2 暴力解法实现
最直观的解法就是暴力枚举所有可能的子数组,然后检查每个子数组是否满足条件:
public int validSubarrays(int[] nums) { if (nums == null || nums.length == 0) { return 0; } int n = nums.length; int count = 0; for (int i = 0; i < n; i++) { count++; // 单个元素子数组 for (int j = i + 1; j < n; j++) { if (nums[j] >= nums[i]) { count++; } else { break; } } } return count; }这个解法的时间复杂度是O(n²),在最坏情况下(比如数组是递增的)会达到平方级别。对于大规模数据显然不够高效。
注意:虽然暴力解法效率不高,但在面试中可以先提出这个解法,展示你对问题的理解,然后再优化。这是一个很好的解题策略。
3. 单调栈解法详解
3.1 单调栈的核心思想
为了提高效率,我们需要找到一种方法,能够快速确定对于每个元素nums[i],右边第一个比它小的元素的位置。这正是单调栈的用武之地。
单调栈是一种特殊的栈,它保持栈内元素的单调性(递增或递减)。在这个问题中,我们使用单调递增栈(从栈底到栈顶递增)来维护待处理的元素索引。
3.2 算法步骤解析
初始化一个空栈和一个nextSmaller数组,nextSmaller[i]记录nums[i]右边第一个比它小的元素的位置,默认设为n(表示右边没有更小的元素)。
遍历数组:
- 当栈不为空且当前元素nums[i]小于栈顶元素nums[stack.peek()]时,说明找到了栈顶元素的下一个更小元素,更新nextSmaller[stack.pop()] = i
- 将当前索引入栈
计算总数:对于每个i,有效子数组数量为nextSmaller[i] - i
3.3 完整代码实现
public int validSubarrays(int[] nums) { if (nums == null || nums.length == 0) { return 0; } int n = nums.length; int[] nextSmaller = new int[n]; Arrays.fill(nextSmaller, n); // 初始化为n Deque<Integer> stack = new ArrayDeque<>(); for (int i = 0; i < n; i++) { while (!stack.isEmpty() && nums[i] < nums[stack.peek()]) { nextSmaller[stack.pop()] = i; } stack.push(i); } int count = 0; for (int i = 0; i < n; i++) { count += nextSmaller[i] - i; } return count; }3.4 优化版本
我们可以进一步优化,在单调栈处理的过程中直接计算总数,而不需要额外的nextSmaller数组:
public int validSubarrays(int[] nums) { if (nums == null || nums.length == 0) { return 0; } int n = nums.length; int count = 0; Deque<Integer> stack = new ArrayDeque<>(); for (int i = 0; i <= n; i++) { while (!stack.isEmpty() && (i == n || nums[i] < nums[stack.peek()])) { int index = stack.pop(); count += i - index; } if (i < n) { stack.push(i); } } return count; }这个版本更加简洁,空间复杂度也更优,因为不需要存储nextSmaller数组。
4. 算法复杂度分析
4.1 时间复杂度
单调栈解法的时间复杂度是O(n),因为每个元素最多入栈和出栈一次。相比暴力解法的O(n²),效率有了显著提升。
4.2 空间复杂度
空间复杂度取决于栈的大小,最坏情况下所有元素都会入栈,所以是O(n)。
5. 实际应用与变种
5.1 类似问题
单调栈的这种应用不仅限于这个问题,还可以解决许多类似的问题,比如:
- 柱状图中最大的矩形
- 接雨水问题
- 每日温度问题(找下一个更高温度)
5.2 变种思考
这个问题可以有几种有趣的变种:
- 如果定义改为子数组的第一个元素是最大值,如何解决?
- 如果要求子数组的第一个元素是最小值且最后一个元素是最大值,如何解决?
- 如果数组是循环数组(即首尾相连),如何解决?
这些变种都可以基于单调栈的思想进行扩展解决。
6. 常见错误与调试技巧
6.1 常见错误
- 栈中存储元素值而非索引:这样无法计算子数组长度
- 忽略边界条件:如空数组或单元素数组
- 单调性方向错误:应该使用单调递增栈而非递减栈
- 未正确处理数组末尾的情况
6.2 调试技巧
当你的代码出现问题时,可以:
- 在小样例上手动模拟算法执行过程
- 打印栈的状态和nextSmaller数组的变化
- 比较暴力解法和优化解法的结果是否一致
例如对于输入[1,4,2,5,3],可以这样调试:
System.out.println("Processing i=" + i + ", nums[i]=" + nums[i]); System.out.println("Stack: " + stack); System.out.println("nextSmaller: " + Arrays.toString(nextSmaller));7. 性能优化与实践建议
7.1 性能优化
- 使用ArrayDeque而非Stack类,因为Stack是线程安全的,性能较差
- 在知道数组长度的情况下,可以预分配栈的容量
- 对于特别大的数组,可以考虑并行处理(虽然这个问题不太适合)
7.2 实践建议
- 在面试中,可以先讨论暴力解法,再引出单调栈优化
- 解释清楚为什么单调栈能解决这个问题
- 画图辅助说明算法的执行过程
- 注意代码的整洁性和变量命名
8. 总结与个人心得
通过这个问题,我深刻体会到单调栈在解决"下一个更大/更小元素"这类问题上的威力。在实际编码中,有几点特别重要:
- 明确栈中存储的是索引而非值,这样才能计算距离
- 处理好边界条件,特别是当右边没有更小元素时
- 理解单调栈维护的是什么性质(这里是递增)
我在最初解决这个问题时,曾犯过将元素值而非索引入栈的错误,导致无法正确计算子数组长度。后来通过手动模拟一个小例子,才发现了这个问题。这也让我明白,对于不熟悉的算法,从小例子入手是很好的学习方法。
单调栈是一个需要练习才能掌握的工具,建议多做一些相关的题目来加深理解。一旦掌握了它的核心思想,你会发现它能优雅地解决许多看似复杂的问题。