什么是单调栈?
为什么栈很重要
栈 (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]
解决思路:
我们使用一个单调递减栈来高效地解决这个问题:
- 从右到左遍历数组。
- 维护一个栈,栈顶元素是当前索引的最小“更大元素”。
- 弹出栈中小于当前数组元素的元素(因为它们在后续的比较中无关)。
- 将当前元素压入栈中。
代码示例:
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的结果数组。 - 从数组的最后一个元素开始处理:
- 对于元素
nums[i]:- 移除栈中所有小于
nums[i]的元素(它们不能作为未来任何元素的“下一个更大”)。 - 如果栈不为空,栈顶元素就是
nums[i]的“下一个更大”元素。
- 移除栈中所有小于
- 将
nums[i]压入栈中,以便未来的元素比较。
- 对于元素
- 返回结果数组。
示例演示:
输入:[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 等平台的练习题,重点关注其实现与优化。这将增强你对何时以及如何有效应用单调栈的理解。