精通单调栈(Monotonic Stacks):优化数组和序列问题的算法效率

245 阅读6分钟

什么是单调栈?

为什么栈很重要

栈 (Stack) 是一种基础数据结构,遵循先进后出(LIFO)原则。它们允许高效的压栈(添加)和弹栈(移除)操作,使其在算法设计中具有不可或缺的作用。对于涉及序列或需要维护部分元素视图的问题,栈是一种自然的选择。

单调栈定义

单调栈 (Monotonic Stack) 是一种特殊的变体,其中元素按照特定顺序(递增或递减)排列,具体顺序取决于问题的需求。这个顺序通过确保栈中只保留相关元素来简化某些操作。

主要特点:

  • 效率:减少了查找元素之间关系的复杂度。
  • 目的明确:专为处理涉及序列比较的问题而设计,如寻找下一个更大或更小的元素、范围问题或基于窗口的操作。

与普通栈不同,单调栈强制执行一个额外的约束:栈的内容必须始终遵循指定的顺序,从而加快许多问题的求解速度。

单调递增栈与递减栈

  • 递增栈:将元素按升序排列(栈顶是最小的元素)。
    • 适用于寻找下一个更小元素或维护最小值的问题。
  • 递减栈:将元素按降序排列(栈顶是最大的元素)。
    • 适用于寻找下一个更大元素的问题。

示例:

  • 对于数组 [4, 3, 6, 5]
    • 单调递增栈(升序):[4] -> [3] -> [3, 6] -> [3, 5]
    • 单调递减栈(降序):[4] -> [4, 3] -> [6] -> [6, 5]

这些特性允许我们以一种减少冗余比较的方式处理元素,从而节省大量时间。


C# 实现与代码示例

下一个更大元素问题

问题定义
给定一个数组 nums,对于每个元素,找到下一个大于当前元素的元素。如果没有这样的元素,返回 -1

示例
输入:[4, 3, 6, 5]
输出:[6, 6, -1, -1]

解决思路
我们使用一个单调递减栈来高效地解决这个问题:

  1. 从右到左遍历数组。
  2. 维护一个栈,栈顶元素是当前索引的最小“更大元素”。
  3. 弹出栈中小于当前数组元素的元素(因为它们在后续的比较中无关)。
  4. 将当前元素压入栈中。

代码示例

public int[] NextGreaterElement(int[] nums) {
    int n = nums.Length;
    int[] result = new int[n];
    Stack<int> stack = new Stack<int>();

    // 从右到左遍历数组
    for (int i = n - 1; i >= 0; i--) {
        // 移除栈中不大于 nums[i] 的元素
        while (stack.Count > 0 && stack.Peek() <= nums[i]) {
            stack.Pop();
        }

        // 如果栈为空,说明没有更大的元素
        result[i] = stack.Count == 0 ? -1 : stack.Peek();

        // 将当前元素压入栈中
        stack.Push(nums[i]);
    }

    return result;
}

逐步解释

  1. 初始化一个空栈和一个值为 -1 的结果数组。
  2. 从数组的最后一个元素开始处理:
    • 对于元素 nums[i]
      • 移除栈中所有小于 nums[i] 的元素(它们不能作为未来任何元素的“下一个更大”)。
      • 如果栈不为空,栈顶元素就是 nums[i] 的“下一个更大”元素。
    • nums[i] 压入栈中,以便未来的元素比较。
  3. 返回结果数组。

示例演示
输入:[4, 3, 6, 5]

  • 初始化空栈和结果数组 [-1, -1, -1, -1]
  • 从右到左遍历:
    • i = 3:栈为空,压入 5
    • i = 2:弹出 5(小于 6),栈为空,压入 6
    • i = 1:栈顶为 6(大于 3),设置 result[1] = 6,压入 3
    • i = 0:栈顶为 6(大于 4),设置 result[0] = 6,压入 4

最终结果:[6, 6, -1, -1]

此解决方案的时间复杂度为 (O(n)),因为每个元素最多会被压入和弹出栈一次。


更多 LeetCode 问题

LeetCode 问题:在线股票交易计划

问题定义
给定一个连续 n 天的股票价格,对于每一天,找出在当天购买股票后,能卖出的最大股票价格。

解决方案
我们可以使用单调递减栈来有效地追踪未来的最大股票价格。

代码示例

public int[] StockSpan(int[] prices) {
    int n = prices.Length;
    int[] result = new int[n];
    Stack<int> stack = new Stack<int>();

    for (int i = n - 1; i >= 0; i--) {
        // 维护一个递减栈,栈中的价格是未来的最大值
        while (stack.Count > 0 && stack.Peek() <= prices[i]) {
            stack.Pop();
        }

        result[i] = stack.Count == 0 ? -1 : stack.Peek();
        stack.Push(prices[i]);
    }

    return result;
}

该方法确保了 (O(n)) 的时间复杂度。


LeetCode 问题:每日温度

问题定义
给定一组每日温度,返回一个列表,其中每个元素表示要等待多少天,直到遇到一个更温暖的温度。如果没有未来的更温暖的温度,返回 0

解决方案
使用单调递减栈来追踪温度的索引。

代码示例

public int[] DailyTemperatures(int[] temperatures) {
    int n = temperatures.Length;
    int[] result = new int[n];
    Stack<int> stack = new Stack<int>();

    for (int i = 0; i < n; i++) {
        while (stack.Count > 0 && temperatures[i] > temperatures[stack.Peek()]) {
            int index = stack.Pop();
            result[index] = i - index;
        }
        stack.Push(i);
    }

    return result;
}

解释

  • 遍历数组。
  • 对于每个温度,检查栈中是否存在温度较低的日期的索引。
  • 计算当前日期与栈顶日期之间的差值,即等待天数。

LeetCode 问题:接雨水

问题定义
给定一个表示高度的数组,找出可以接住的最大雨水量。

解决方案
单调栈可以帮助我们高效地识别雨水的边界。

代码示例

public int Trap(int[] height) {
    int n = height.Length;
    Stack<int> stack = new Stack<int>();
    int water = 0;

    for (int i = 0; i < n; i++) {
        while (stack.Count > 0 && height[i] > height[stack.Peek()]) {
            int top = stack.Pop();
            if (stack.Count == 0) break;

            int distance = i - stack.Peek() - 1;
            int boundedHeight = Math.Min(height[i], height[stack.Peek()]) - height[top];
            water += distance * boundedHeight;
        }
        stack.Push(i);
    }

    return water;
}

关键思路
栈帮助我们找到每个高度的左右边界,从而计算可以接住的水量。


单调栈的实际应用

事件序列分析

在实时分析系统中,单调栈可以帮助处理有序约束的事件序列。例如,查找下一个具有更高优先级或时间戳的事件,这是单调栈的直接应用。

UI/UX 组件渲染

单调栈在 UI 渲染中的可见性约束管理中也非常有用:

  • 动态调整大小:在调整容器大小时,追踪哪些元素是可见的。
  • 虚拟滚动:高效管理哪些元素应该在可滚动视口中可见。

数据处理管道

在数据按照有序约束(例如,排序流)到达的场景中,单调栈可以简化合并、过滤或提取相关数据等操作。


时间复杂度与优化

为何单调栈高效

单调栈通过将许多问题的时间复杂度从 (O(n^2))(暴力解法)降低到 (O(n)),因为每个元素:

  • 仅被推入一次:只有在该元素相关时,才会被加入栈中。
  • 仅被弹出一次:当该元素不再有用时,从栈中移除。

空间复杂度考虑

  • 空间复杂度是 (O(n)),因为使用了栈。
  • 在许多情况下,这种空间换时间的做法是最优的,因为线性空间对于实际输入来说是可以接受的。

结论

关键点总结

单调栈是解决涉及序列、数组或约束问题的强大工具。其有序的特性使得原本可能需要更复杂方法的问题,可以通过简单高效的方式解决。

实际应用意义

虽然在日常开发中不常见,单调栈在编码面试和竞赛编程中是非常重要的。除此之外,它在数据处理和 UI 渲染中的实际应用,使其成为一个多用途的概念。

最后的思考

要掌握单调栈,建议通过 LeetCode 等平台的练习题,重点关注其实现与优化。这将增强你对何时以及如何有效应用单调栈的理解。