1. 单调栈:算法世界里的"守门人"
第一次接触单调栈是在LeetCode第496题"下一个更大元素",当时我的暴力解法超时了。翻看题解时看到有人用单调栈在O(n)时间内搞定,那种感觉就像发现了一个新大陆。单调栈(Monotonic Stack)本质上是一种维护栈内元素单调性的数据结构,它像一位严格的守门人,只允许符合特定顺序的元素进入。
在实际刷题中,单调栈特别擅长解决"寻找下一个更大/更小元素"这类问题。比如在柱状图最大矩形问题中,我们需要快速找到每根柱子左右两边第一个比它矮的柱子,这正是单调栈的拿手好戏。与普通栈相比,单调栈在入栈时会主动淘汰破坏单调性的元素,这种"自私"的特性反而让它成为了解决特定问题的高效工具。
提示:单调栈的"单调"指的是栈内元素始终保持递增或递减的顺序,这种约束条件看似严格,却恰恰是它高效解决问题的关键。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 单调栈的两种基本形态
2.1 递增栈:寻找更小元素的利器
递增栈(从栈底到栈顶元素值递增)是处理"下一个更小元素"问题的标准配置。以LeetCode 84题"柱状图中最大的矩形"为例:
python复制def largestRectangleArea(heights):
stack = []
max_area = 0
heights.append(0) # 哨兵节点
for i in range(len(heights)):
while stack and heights[i] < heights[stack[-1]]:
h = heights[stack.pop()]
w = i if not stack else i - stack[-1] - 1
max_area = max(max_area, h * w)
stack.append(i)
return max_area
这段代码的精妙之处在于:
- 遇到比栈顶小的元素时开始"清算"栈内元素
- 宽度计算分为两种情况:栈为空时宽度就是当前索引i,否则用i与新的栈顶索引之差减1
- 末尾添加的0作为哨兵节点,确保所有柱子都能被处理
2.2 递减栈:捕捉更大元素的猎手
递减栈(从栈底到栈顶元素值递减)则擅长解决"下一个更大元素"问题。以经典的LeetCode 739题"每日温度"为例:
python复制def dailyTemperatures(T):
stack = []
res = [0] * len(T)
for i in range(len(T)):
while stack and T[i] > T[stack[-1]]:
prev = stack.pop()
res[prev] = i - prev
stack.append(i)
return res
这个实现有几个关键点:
- 只有当当前温度高于栈顶日期温度时才触发计算
- 结果数组中存储的是天数差而非温度值
- 未处理的日期会留在栈中,默认结果为0(符合题目要求)
3. 单调栈的四大经典应用场景
3.1 边界探测问题
在柱状图问题中,单调栈能高效找到每根柱子的左右边界。这类问题的共同特点是需要同时考虑元素的左右邻居。例如LeetCode 85题"最大矩形",可以看作是柱状图问题的二维扩展:
python复制def maximalRectangle(matrix):
if not matrix: return 0
m, n = len(matrix), len(matrix[0])
heights = [0] * n
max_area = 0
for i in range(m):
for j in range(n):
heights[j] = heights[j] + 1 if matrix[i][j] == '1' else 0
max_area = max(max_area, largestRectangleArea(heights.copy()))
return max_area
这里巧妙地将二维问题转化为多个一维柱状图问题,复用之前的单调栈解法。
3.2 滑动窗口极值问题
虽然滑动窗口问题通常用双端队列解决,但单调栈也能派上用场。比如LeetCode 239题"滑动窗口最大值",可以用单调栈思想预处理数组:
python复制def maxSlidingWindow(nums, k):
if not nums: return []
n = len(nums)
left_max = [0] * n
right_max = [0] * n
left_max[0] = nums[0]
right_max[-1] = nums[-1]
for i in range(1, n):
left_max[i] = nums[i] if i % k == 0 else max(left_max[i-1], nums[i])
for i in range(n-2, -1, -1):
right_max[i] = nums[i] if (i+1) % k == 0 else max(right_max[i+1], nums[i])
return [max(right_max[i], left_max[i+k-1]) for i in range(n-k+1)]
这种解法虽然不如双端队列直观,但展示了单调栈思想的灵活性。
3.3 雨水收集问题
LeetCode 42题"接雨水"是单调栈的另一个经典应用。不同于双指针解法,单调栈可以逐层计算雨水面积:
python复制def trap(height):
stack = []
res = 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]
res += (min(left_bound, right_bound) - bottom) * (i - stack[-1] - 1)
stack.append(i)
return res
这个解法中:
- 栈存储的是索引而非高度值
- 每次形成凹槽时才计算积水量
- 积水高度由左右边界的最小值决定
3.4 子数组极值问题
LeetCode 907题"子数组的最小值之和"展示了单调栈在统计问题中的应用。我们需要计算所有子数组最小值的和:
python复制def sumSubarrayMins(arr):
MOD = 10**9 + 7
stack = []
res = 0
arr = [0] + arr + [0]
for i in range(len(arr)):
while stack and arr[i] < arr[stack[-1]]:
j = stack.pop()
k = stack[-1]
res += arr[j] * (i - j) * (j - k)
stack.append(i)
return res % MOD
这个解法利用单调栈快速确定每个元素作为最小值的影响范围,避免了暴力枚举所有子数组。
4. 单调栈的五个进阶技巧
4.1 哨兵节点的妙用
在前面的柱状图问题中,我们在数组末尾添加了0作为哨兵节点。这个技巧可以简化代码逻辑,避免处理栈内剩余元素。例如在接雨水问题中,可以在数组两端添加最大值作为哨兵:
python复制def trapWithSentinel(height):
if not height: return 0
height = [float('inf')] + height + [float('inf')]
stack = [0] # 存储索引
res = 0
for i in range(1, len(height)):
while height[i] > height[stack[-1]]:
mid = stack.pop()
if stack[-1] == 0: break # 左哨兵不参与计算
h = min(height[stack[-1]], height[i]) - height[mid]
w = i - stack[-1] - 1
res += h * w
stack.append(i)
return res
4.2 索引与值的同时存储
有时我们需要同时访问元素的索引和值。比如LeetCode 962题"最大宽度坡",我们需要找到满足A[i] <= A[j]且i < j的最大(j - i):
python复制def maxWidthRamp(A):
stack = []
for i, num in enumerate(A):
if not stack or num < A[stack[-1]]:
stack.append(i)
res = 0
for j in range(len(A)-1, -1, -1):
while stack and A[j] >= A[stack[-1]]:
res = max(res, j - stack.pop())
return res
这里栈中存储的是索引,但比较的是对应的值,展示了索引与值的协同使用。
4.3 前驱与后继的同时处理
有些问题需要同时计算元素的前驱和后继。比如LeetCode 901题"股票价格跨度",我们需要找到当前价格之前连续小于或等于它的天数:
python复制class StockSpanner:
def __init__(self):
self.stack = [] # (price, span)
def next(self, price):
span = 1
while self.stack and self.stack[-1][0] <= price:
span += self.stack.pop()[1]
self.stack.append((price, span))
return span
这个实现中,栈内存储的是价格和对应的跨度,每次新增价格时合并之前的跨度。
4.4 二维问题的降维处理
对于二维问题,常常可以通过降维转化为一维问题。比如LeetCode 1504题"统计全1子矩形",我们可以逐行构建高度数组:
python复制def numSubmat(mat):
m, n = len(mat), len(mat[0])
heights = [0] * n
res = 0
for i in range(m):
for j in range(n):
heights[j] = heights[j] + 1 if mat[i][j] == 1 else 0
stack = []
dp = [0] * n
for j in range(n):
while stack and heights[stack[-1]] >= heights[j]:
stack.pop()
if stack:
prev = stack[-1]
dp[j] = dp[prev] + heights[j] * (j - prev)
else:
dp[j] = heights[j] * (j + 1)
stack.append(j)
res += dp[j]
return res
这种降维思想是解决复杂空间问题的有效手段。
4.5 单调栈与动态规划的结合
有时单调栈可以优化动态规划中的状态转移。比如LeetCode 1776题"车队II",我们需要计算每辆车与下一辆车相遇的时间:
python复制def getCollisionTimes(cars):
n = len(cars)
res = [-1.0] * n
stack = []
for i in range(n-1, -1, -1):
position, speed = cars[i]
while stack:
j = stack[-1]
prev_pos, prev_speed = cars[j]
if speed <= prev_speed:
stack.pop()
else:
collision_time = (prev_pos - position) / (speed - prev_speed)
if res[j] > 0 and collision_time >= res[j]:
stack.pop()
else:
res[i] = collision_time
break
stack.append(i)
return res
这个解法从右往左遍历,利用单调栈维护可能发生碰撞的车辆。
5. 单调栈的常见误区与调试技巧
5.1 边界条件的处理
单调栈问题最容易出错的就是边界条件。比如在柱状图问题中,当栈弹出最后一个元素时,宽度的计算方式会发生变化。我建议在纸上画出几个简单测试用例,手动模拟栈的变化:
code复制测试用例1:[2,1,2]
测试用例2:[1,2,3,4,5]
测试用例3:[5,4,3,2,1]
5.2 相等元素的处理
当遇到相等元素时,是弹出还是保留?这取决于具体问题。在"下一个更大元素"问题中,通常保留;而在"接雨水"问题中,可能需要弹出。例如:
python复制# 处理相等元素的两种方式
while stack and nums[i] >= nums[stack[-1]]: # 弹出相等元素
while stack and nums[i] > nums[stack[-1]]: # 保留相等元素
5.3 循环数组的处理
对于循环数组问题(如LeetCode 503题),可以通过扩展数组或取模运算来处理:
python复制def nextGreaterElements(nums):
n = len(nums)
res = [-1] * n
stack = []
for i in range(2 * n):
while stack and nums[i % n] > nums[stack[-1]]:
res[stack.pop()] = nums[i % n]
if i < n:
stack.append(i)
return res
5.4 调试输出技巧
在调试单调栈问题时,可以打印出每次操作后的栈状态:
python复制def debugPrint(stack, nums, i):
print(f"i={i}, num={nums[i]}, stack={stack}", end=" ")
if stack:
print("stack values=", [nums[x] for x in stack])
else:
print()
5.5 时间复杂度分析
虽然单调栈有嵌套循环,但每个元素最多入栈出栈一次,因此时间复杂度通常是O(n)。可以通过计算操作次数来验证:
python复制operation_count = 0
for i in range(n):
while stack and condition:
operation_count += 1
stack.pop()
stack.append(i)
print(f"Total operations: {operation_count + n}")
6. 单调栈的刷题路线建议
6.1 入门阶段(3-5题)
建议从这些经典题目开始:
-
- 下一个更大元素 I
-
- 每日温度
-
- 柱状图中最大的矩形
6.2 巩固阶段(5-8题)
掌握基础后可以尝试:
-
- 接雨水
-
- 股票价格跨度
-
- 子数组的最小值之和
6.3 进阶挑战(8-10题)
最后挑战这些难题:
-
- 最大矩形
-
- 车队 II
-
- 队列中可以看到的人数
我个人的刷题经验是,每做完一道题后,尝试用不同的测试用例手动模拟栈的变化,直到能准确预测每一步的栈状态。这种深度理解比单纯追求刷题数量更有价值。
