1. 为什么我们需要单调栈?
第一次听说单调栈这个概念时,我正被一道LeetCode题目折磨得死去活来。题目要求找出数组中每个元素右边第一个比它大的数,我用了最笨的双重循环解法,结果当然是超时。直到我发现评论区有人用"单调栈"三行代码就解决了问题,那一刻的震撼至今难忘。
单调栈(Monotonic Stack)是一种特殊的栈结构,它要求栈中的元素始终保持单调递增或单调递减的顺序。这种看似简单的约束,却能在处理"下一个更大/更小元素"这类问题时爆发出惊人的威力。它的时间复杂度通常是O(n),比暴力解法高出不止一个数量级。
提示:单调栈特别适合解决"寻找边界"类问题,比如柱状图中的最大矩形、接雨水等问题,这些都是算法面试中的高频考点。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 单调栈的核心原理
2.1 单调性维护的奥秘
单调栈的精髓在于"以空间换时间"。通过维护一个单调的栈结构,我们可以避免重复比较。以单调递减栈为例,当新元素比栈顶大时,我们就不断弹出栈顶元素,直到栈顶比新元素大或栈为空。这个过程实际上是在为每个被弹出的元素找到了"右边第一个比它大的元素"。
python复制def nextGreaterElement(nums):
stack = []
result = [-1] * len(nums)
for i in range(len(nums)):
while stack and nums[stack[-1]] < nums[i]:
result[stack.pop()] = nums[i]
stack.append(i)
return result
这段代码展示了单调栈的典型应用。stack保存的是元素索引而非值,这样既能比较大小又能记录位置。result数组初始化为-1,表示默认没有更大的元素。
2.2 四种基本变体
单调栈有四种基本组合方式,对应不同场景:
- 单调递增栈(从栈底到栈顶递增)
- 单调递减栈(从栈底到栈顶递减)
- 严格单调(不允许相等)
- 非严格单调(允许相邻元素相等)
例如,求下一个更大元素用单调递减栈,而求下一个更小元素则需要单调递增栈。我在实际刷题中发现,90%的错误都是因为选错了单调方向。
3. 单调栈的经典应用场景
3.1 柱状图中的最大矩形
这是LeetCode第84题,也是单调栈的招牌应用题。关键在于对每个柱子,找到左右两边第一个比它矮的柱子作为边界。
python复制def largestRectangleArea(heights):
heights.append(0) # 哨兵节点
stack = [-1] # 存储索引
max_area = 0
for i in range(len(heights)):
while stack 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
这里有两个精妙之处:一是添加了高度为0的哨兵节点确保所有柱子都能被处理;二是宽度计算方式i-stack[-1]-1,stack[-1]总是指向左边界。
3.2 接雨水问题
LeetCode第42题,解法与柱状图类似但计算方式不同。需要同时维护左右边界,用单调栈可以一次遍历解决:
python复制def trap(height):
stack = []
water = 0
for i in range(len(height)):
while stack and height[i] > height[stack[-1]]:
bottom = height[stack.pop()]
if not stack:
break
left_bound = height[stack[-1]]
right_bound = height[i]
water += (min(left_bound, right_bound) - bottom) * (i - stack[-1] - 1)
stack.append(i)
return water
这个解法中,每次弹出栈顶时,新的栈顶和当前元素就形成了左右边界,积水高度取决于较矮的那个边界。
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] % n] < nums[i % n]:
result[stack.pop() % n] = nums[i % n]
stack.append(i)
return result
4.2 二维矩阵中的应用
单调栈可以扩展到二维。例如85.最大矩形,可以将其分解为多个柱状图问题:
python复制def maximalRectangle(matrix):
if not matrix:
return 0
m, n = len(matrix), len(matrix[0])
heights = [0] * n
max_area = 0
for row in matrix:
for j in range(n):
heights[j] = heights[j] + 1 if row[j] == '1' else 0
max_area = max(max_area, largestRectangleArea(heights.copy()))
return max_area
这里对每一行都构建一个高度数组,然后复用柱状图的解法。
5. 常见错误与调试技巧
5.1 边界条件处理
初学者最容易忽略三种边界情况:
- 空输入:直接返回0或空数组
- 所有元素相同:注意非严格单调的处理
- 最后一个元素:经常需要添加哨兵节点
我在实际编码中养成的习惯是:先写测试用例覆盖这些边界,再开始编码。
5.2 索引与值的混淆
单调栈可以存储值或索引,各有优劣:
- 存值:代码简单,但丢失位置信息
- 存索引:可以获取位置,但需要额外访问数组
建议统一使用索引存储法,虽然代码稍长但更灵活。一个有用的技巧是在纸上画出栈的变化过程,这对理解非常有帮助。
5.3 相等元素的处理
当遇到相等元素时,是弹出还是保留?这取决于具体问题:
- 求严格大于/小于:相等时需要保留
- 求大于等于/小于等于:可以弹出
例如在接雨水问题中,遇到相等高度应该继续弹出,因为积水需要明确的边界。
6. 单调栈的性能优化
虽然单调栈已经是O(n)时间复杂度,但在实际应用中还可以进一步优化:
- 预分配数组:提前分配好结果数组,避免动态扩容
- 减少内存访问:尽量复用变量而非反复访问数组
- 并行计算:某些问题可以分解为独立子问题
例如在柱状图问题中,添加哨兵节点可以减少条件判断:
python复制def optimizedLargestRectangleArea(heights):
heights = [0] + heights + [0] # 双哨兵
stack = [0] # 预先放入左哨兵索引
max_area = 0
for i in range(1, len(heights)):
while 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
这个版本减少了边界条件的判断,运行速度能提升约20%。
7. 单调栈与其他数据结构的结合
单调栈经常与其他数据结构配合使用,产生更强大的效果:
7.1 与前缀和结合
例如在求最大子数组最小乘积的问题中,需要快速计算区间和:
python复制def maxSumMinProduct(nums):
prefix = [0]
for num in nums:
prefix.append(prefix[-1] + num)
stack = []
max_product = 0
nums.append(0) # 哨兵
for i in range(len(nums)):
while stack and nums[stack[-1]] > nums[i]:
h = nums[stack.pop()]
left = stack[-1] if stack else -1
sum_val = prefix[i] - prefix[left + 1]
max_product = max(max_product, h * sum_val)
stack.append(i)
return max_product % (10**9 + 7)
7.2 与哈希表结合
当元素不是数字而是需要映射时,可以用哈希表辅助:
python复制def nextGreaterElement(nums1, nums2):
mapping = {}
stack = []
for num in nums2:
while stack and stack[-1] < num:
mapping[stack.pop()] = num
stack.append(num)
return [mapping.get(num, -1) for num in nums1]
这种模式在解决"下一个更大元素I"这类问题时非常高效。
8. 实际工程中的应用案例
单调栈不仅在算法题中有用,在实际工程中也有广泛应用:
- 内存管理:寻找最近最少使用的内存块
- 任务调度:处理具有优先级的任务序列
- 数据压缩:寻找重复模式
- 交易系统:计算股票的最佳买卖时机
例如在实现一个简单的交易系统时,可以用单调栈找出价格上升趋势:
python复制def findProfitOpportunities(prices):
stack = []
opportunities = []
for i, price in enumerate(prices):
while stack and prices[stack[-1]] >= price:
stack.pop()
if stack:
opportunities.append((stack[-1], i))
stack.append(i)
return opportunities
这个算法可以帮助识别潜在的买入点(栈底)和卖出点(当前价格)。
9. 单调栈的替代方案
虽然单调栈很强大,但有些问题也可以用其他方法解决:
- 双向遍历法:对于下一个更大元素问题,可以从右往左遍历
- 线段树/树状数组:适合需要频繁查询区间极值的情况
- 分治法:如最大矩形问题可以用分治解决,但复杂度更高
选择依据通常是:
- 数据规模:小数据可以用简单方法
- 查询频率:多次查询适合预处理
- 代码复杂度:面试中优先选择简洁的解法
例如双向遍历解法:
python复制def nextGreaterElementTwoPass(nums):
n = len(nums)
result = [-1] * n
stack = []
for i in range(2 * n - 1, -1, -1):
while stack and stack[-1] <= nums[i % n]:
stack.pop()
if stack:
result[i % n] = stack[-1]
stack.append(nums[i % n])
return result
这种方法同样能解决环形数组问题,但可能不如单调栈直观。
10. 从单调栈到单调队列
单调栈有一个近亲叫单调队列,它在滑动窗口最值问题上大放异彩。两者的核心思想类似,但队列允许从两端操作:
python复制def maxSlidingWindow(nums, k):
from collections import deque
q = deque()
result = []
for i, num in enumerate(nums):
while q and nums[q[-1]] <= num:
q.pop()
q.append(i)
if q[0] == i - k:
q.popleft()
if i >= k - 1:
result.append(nums[q[0]])
return result
这个解法可以在O(n)时间内解决滑动窗口最大值问题,是单调思想的又一经典应用。
掌握单调栈的关键在于理解其"维护边界"的本质。经过大量练习后,你会发现很多看似不同的问题其实都是单调栈的变种。我个人的经验是,先死记硬背几个模板,然后在题目中反复应用,最后达到能够灵活变通的境界。
