1. 为什么我们需要单调栈?
单调栈(Monotonic Stack)是一种特殊的栈结构,它要求栈中的元素始终保持单调递增或单调递减的顺序。我第一次接触这个概念是在解决LeetCode第739题"每日温度"时,当时暴力解法超时后才意识到这种数据结构的精妙。
单调栈的核心价值在于:它能以O(n)的时间复杂度解决那些看似需要O(n²)的遍历问题。这就像在杂乱无章的衣柜中突然发现一个自动整理衣物的神器——你不再需要反复翻找,所有物品都按预定顺序排列。
实际工程中常见的应用场景包括:
- 寻找下一个更大/更小元素(Next Greater Element)
- 柱状图中的最大矩形面积计算
- 股票价格跨度问题
- 接雨水问题
以电商平台的价格分析为例,当我们需要找出某商品未来第一次出现更低价格的日期时,单调栈就能高效解决这个问题。相比双重循环的暴力解法,时间复杂度从O(n²)降到了O(n)。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 单调栈的工作原理与实现
2.1 基本操作规则
单调栈有两种基本类型:
- 单调递增栈:栈顶到栈底元素递增
- 单调递减栈:栈顶到栈底元素递减
其操作规则可以概括为:
python复制初始化空栈
for 元素 in 序列:
while 栈非空且当前元素破坏单调性:
弹出栈顶元素并处理
当前元素入栈
2.2 代码实现示例
以下是Python实现的单调递增栈模板:
python复制def monotonic_stack(nums):
stack = []
result = [-1] * len(nums) # 默认值为-1
for i, num in enumerate(nums):
while stack and nums[stack[-1]] < num:
popped_index = stack.pop()
result[popped_index] = num # 当前num是popped_index的下一个更大元素
stack.append(i)
return result
这个模板可以解决"下一个更大元素"问题。例如输入[3,1,4,2],输出将是[4,4,-1,-1],表示:
- 3的下一个更大是4
- 1的下一个更大是4
- 4和2后面没有更大元素
2.3 时间复杂度分析
虽然代码中有嵌套循环,但每个元素最多入栈和出栈各一次,所以整体时间复杂度是O(n)。这比暴力解法的O(n²)有了质的提升。
3. 典型问题解析:柱状图最大矩形
3.1 问题描述
给定n个非负整数表示的柱状图,每个柱子的宽度为1,求能勾勒出的最大矩形面积。例如[2,1,5,6,2,3]的最大矩形面积是10(高度5和6组成的矩形)。
3.2 单调栈解法
这个问题的关键在于对每个柱子,找到它左右两边第一个比它矮的柱子。这正是单调递增栈的用武之地:
python复制def largestRectangleArea(heights):
heights.append(0) # 哨兵值,确保最后所有柱子被处理
stack = [-1] # 栈底哨兵
max_area = 0
for i in range(len(heights)):
while stack[-1] != -1 and heights[stack[-1]] > heights[i]:
h = heights[stack.pop()]
w = i - stack[-1] - 1
max_area = max(max_area, h * w)
stack.append(i)
return max_area
3.3 执行过程拆解
以输入[2,1,5,6,2,3]为例:
- 初始状态:stack = [-1], i=0, height=2
- i=0: 2入栈 → stack = [-1,0]
- i=1: height=1 < 2 → 弹出0,计算面积2×1=2 → stack = [-1], 然后1入栈
- i=2: height=5 > 1 → 2入栈 → stack = [-1,1,2]
- i=3: height=6 > 5 → 3入栈 → stack = [-1,1,2,3]
- i=4: height=2 < 6 → 弹出3,计算面积6×1=6 → 弹出2,计算面积5×2=10 → stack = [-1,1], 然后4入栈
- i=5: height=3 > 2 → 5入栈 → stack = [-1,1,4,5]
- i=6: height=0 < 3 → 弹出5,计算面积3×1=3 → 弹出4,计算面积2×3=6 → 弹出1,计算面积1×6=6 → stack = [-1]
- 最终max_area=10
4. 单调栈的变种与应用技巧
4.1 环形数组处理
当处理环形数组时(如503.下一个更大元素II),可以通过将数组复制一份连接在后面,或者使用取模运算来模拟环形:
python复制def nextGreaterElements(nums):
n = len(nums)
result = [-1] * n
stack = []
for i in range(2 * n):
while stack and nums[stack[-1]] < nums[i % n]:
result[stack.pop()] = nums[i % n]
if i < n:
stack.append(i)
return result
4.2 存储索引还是值?
在实际编码中,我建议存储索引而非值,因为:
- 索引可以同时访问原始数组中的值
- 计算宽度等需要位置信息的场景更方便
- 处理重复元素时更准确
4.3 常见错误与调试技巧
新手容易犯的几个错误:
- 忘记处理栈中剩余元素:可以在数组末尾添加哨兵值(如柱状图问题中的0)
- 边界条件处理不当:空数组或全等数组的情况需要特殊考虑
- 单调性方向搞反:递增栈和递减栈要根据问题需求选择
调试时可以:
- 打印每次循环后的栈状态
- 在小规模测试用例上手动模拟执行过程
- 对比暴力解法的结果验证正确性
5. 工程实践中的性能优化
5.1 内存预分配
对于性能敏感的场景,可以预先分配结果数组和栈的空间:
cpp复制vector<int> nextGreaterElement(vector<int>& nums) {
int n = nums.size();
vector<int> res(n, -1);
vector<int> stack;
stack.reserve(n); // 预分配内存
for(int i = 0; i < n; ++i) {
while(!stack.empty() && nums[stack.back()] < nums[i]) {
res[stack.back()] = nums[i];
stack.pop_back();
}
stack.push_back(i);
}
return res;
}
5.2 并行化处理
对于超大规模数据,可以考虑将数组分段后用多个单调栈并行处理。不过需要注意段与段之间的衔接问题,这通常需要额外的合并步骤。
5.3 与其他算法的结合
单调栈常与其他算法配合使用:
- 与动态规划结合:如最大矩形问题
- 与滑动窗口结合:处理子数组相关问题
- 与并查集结合:解决某些特殊约束问题
我在实际项目中处理过一个商品价格波动分析的需求,需要找出所有价格波峰和波谷。通过单调栈与简单状态机的结合,将原本需要几小时的计算优化到了几分钟内完成。
