☰
单调栈算法解析:有效子数组数量问题
2026/11/3 15:31:53 网站建设 项目流程

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 算法步骤解析

  1. 初始化一个空栈和一个nextSmaller数组,nextSmaller[i]记录nums[i]右边第一个比它小的元素的位置,默认设为n(表示右边没有更小的元素)。

  2. 遍历数组:

    • 当栈不为空且当前元素nums[i]小于栈顶元素nums[stack.peek()]时,说明找到了栈顶元素的下一个更小元素,更新nextSmaller[stack.pop()] = i
    • 将当前索引入栈
  3. 计算总数:对于每个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 变种思考

这个问题可以有几种有趣的变种:

  1. 如果定义改为子数组的第一个元素是最大值,如何解决?
  2. 如果要求子数组的第一个元素是最小值且最后一个元素是最大值,如何解决?
  3. 如果数组是循环数组(即首尾相连),如何解决?

这些变种都可以基于单调栈的思想进行扩展解决。

6. 常见错误与调试技巧

6.1 常见错误

  1. 栈中存储元素值而非索引:这样无法计算子数组长度
  2. 忽略边界条件:如空数组或单元素数组
  3. 单调性方向错误:应该使用单调递增栈而非递减栈
  4. 未正确处理数组末尾的情况

6.2 调试技巧

当你的代码出现问题时,可以:

  1. 在小样例上手动模拟算法执行过程
  2. 打印栈的状态和nextSmaller数组的变化
  3. 比较暴力解法和优化解法的结果是否一致

例如对于输入[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 性能优化

  1. 使用ArrayDeque而非Stack类,因为Stack是线程安全的,性能较差
  2. 在知道数组长度的情况下,可以预分配栈的容量
  3. 对于特别大的数组,可以考虑并行处理(虽然这个问题不太适合)

7.2 实践建议

  1. 在面试中,可以先讨论暴力解法,再引出单调栈优化
  2. 解释清楚为什么单调栈能解决这个问题
  3. 画图辅助说明算法的执行过程
  4. 注意代码的整洁性和变量命名

8. 总结与个人心得

通过这个问题,我深刻体会到单调栈在解决"下一个更大/更小元素"这类问题上的威力。在实际编码中,有几点特别重要:

  1. 明确栈中存储的是索引而非值,这样才能计算距离
  2. 处理好边界条件,特别是当右边没有更小元素时
  3. 理解单调栈维护的是什么性质(这里是递增)

我在最初解决这个问题时,曾犯过将元素值而非索引入栈的错误,导致无法正确计算子数组长度。后来通过手动模拟一个小例子,才发现了这个问题。这也让我明白,对于不熟悉的算法,从小例子入手是很好的学习方法。

单调栈是一个需要练习才能掌握的工具,建议多做一些相关的题目来加深理解。一旦掌握了它的核心思想,你会发现它能优雅地解决许多看似复杂的问题。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询