1. 单调数据结构入门:当算法遇上排队问题
第一次听说"单调队列"这个概念时,我正在解决一个滑动窗口最大值问题。当时我用了暴力解法,时间复杂度O(nk)的代码在LeetCode上直接超时。直到看到讨论区有人用4行代码就解决了问题,我才意识到这种数据结构的神奇之处。单调队列和单调栈本质上都是维护数据单调性的特殊结构,它们能高效解决那些需要快速获取区间极值的问题。
想象你在超市排队结账,突然有个新规定:队伍必须按身高从矮到高排列。每当有新人加入时,比他矮的人都要离开队伍——这就是单调队列的生动比喻。而单调栈则像是叠盘子,新盘子必须比下面的盘子小才能放上去,否则就要把不符合条件的盘子拿走。
2. 单调栈:寻找最近的更大元素
2.1 单调栈的基本原理
单调栈维护一个栈内元素单调递增或递减的顺序。以经典的"每日温度"问题为例:给定每天的温度,要求返回每一天需要等待多少天才能遇到更暖和的天气。
python复制def dailyTemperatures(T):
stack = []
res = [0] * len(T)
for i, t in enumerate(T):
while stack and T[stack[-1]] < t:
prev = stack.pop()
res[prev] = i - prev
stack.append(i)
return res
这个实现中,栈里保存的是日期索引,且对应的温度是单调递减的。当遇到更高温度时,就可以确定之前那些日子的答案。
2.2 单调栈的典型应用场景
- 下一个更大元素:找出数组中每个元素右边第一个比它大的元素
- 柱状图最大矩形:利用单调栈在O(n)时间内求解
- 接雨水问题:计算凹槽能接多少雨水
关键技巧:当问题涉及到"最近的更大/更小元素"时,优先考虑单调栈解法。栈内通常存储索引而非值,方便计算距离。
3. 单调队列:滑动窗口的极值专家
3.1 单调队列的实现机制
单调队列通常使用双端队列实现,以维护队列元素的单调性。经典的滑动窗口最大值问题:
python复制def maxSlidingWindow(nums, k):
from collections import deque
q = deque()
res = []
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:
res.append(nums[q[0]])
return res
队列中存储的是可能成为窗口最大值的候选元素索引。每次新元素加入时,会淘汰所有比它小的元素,确保队列头部始终是当前窗口的最大值。
3.2 单调队列优化DP的魔法
在动态规划中,当状态转移方程形如:
dp[i] = min/max(dp[j] + cost) for j in [i-k, i-1]
单调队列可以将时间复杂度从O(nk)优化到O(n)。例如"跳跃游戏VI"问题:
python复制def maxResult(nums, k):
n = len(nums)
dp = [0] * n
dp[0] = nums[0]
q = deque([0])
for i in range(1, n):
while q and q[0] < i - k:
q.popleft()
dp[i] = dp[q[0]] + nums[i]
while q and dp[i] >= dp[q[-1]]:
q.pop()
q.append(i)
return dp[-1]
4. 实战对比:何时选择哪种结构
4.1 单调栈 vs 单调队列的选择标准
| 特征 | 单调栈 | 单调队列 |
|---|---|---|
| 数据结构 | 栈 (LIFO) | 双端队列 (FIFO+LIFO) |
| 典型问题 | 最近更大/小元素 | 滑动窗口极值 |
| 维护方向 | 单向处理 | 双向处理 |
| 时间复杂度 | O(n) | O(n) |
| 空间复杂度 | O(n) | O(k) |
4.2 常见错误与调试技巧
- 索引越界:特别是在处理环形数组时,忘记取模运算
- 相等元素处理:根据题意决定是否保留相等元素
- 窗口大小判断:在单调队列中容易忽略窗口左边界移动的条件
- 初始化问题:DP问题中初始状态设置错误
调试时可以打印出每一步的栈/队列状态,可视化处理过程。例如:
python复制# 调试单调队列
print(f"Step {i}: val={nums[i]}, queue={list(q)}, res={res}")
5. 进阶应用:从LeetCode到真实系统
5.1 多重背包问题的优化
在背包问题中,当每种物品有数量限制时,单调队列可以将复杂度从O(NVS)优化到O(NV):
python复制def boundedKnapsack(W, wt, val, cnt):
dp = [0] * (W + 1)
for i in range(len(wt)):
for r in range(wt[i]):
q = deque()
for k in range(r, W + 1, wt[i]):
while q and (k - q[0][0]) // wt[i] > cnt[i]:
q.popleft()
curr = dp[k] - k // wt[i] * val[i]
while q and curr >= q[-1][1]:
q.pop()
q.append((k, curr))
dp[k] = q[0][1] + k // wt[i] * val[i]
return dp[W]
5.2 时间序列数据分析
在金融领域,分析股票价格时常用滑动窗口统计指标。使用单调队列可以高效计算:
- 过去N天的最高价/最低价
- 突破布林带边界的点位
- 移动平均线的极值点
python复制def stock_analysis(prices, window):
max_prices = []
q = deque()
for i, p in enumerate(prices):
while q and prices[q[-1]] <= p:
q.pop()
q.append(i)
if q[0] == i - window:
q.popleft()
if i >= window - 1:
max_prices.append(prices[q[0]])
return max_prices
6. 性能优化与边界情况
6.1 空间优化技巧
对于某些问题,可以复用输入数组作为输出,减少空间使用:
python复制def nextGreaterElements(nums):
n = len(nums)
res = [-1] * n
stack = []
for i in range(2 * n):
while stack and nums[stack[-1] % n] < nums[i % n]:
res[stack.pop() % n] = nums[i % n]
stack.append(i)
return res
6.2 处理环形数组
环形问题通常的解法是将数组复制一份拼接在原数组后面,然后用单调栈处理:
python复制def nextGreaterElementsCircular(nums):
n = len(nums)
res = [-1] * n
stack = []
for i in range(2 * n - 1):
while stack and nums[stack[-1] % n] < nums[i % n]:
res[stack.pop() % n] = nums[i % n]
stack.append(i)
return res
7. 从理论到实践:编码细节决定成败
在实际实现时,有几个容易忽略但至关重要的细节:
- 相等元素的处理:在单调递增栈中,遇到相等元素时是否弹出会影响结果
- 哨兵节点的使用:在栈/队列开始放入一个极值,可以简化边界判断
- 存储内容的选择:有时存储值更方便,有时存储索引更实用
- 循环变量的范围:特别是在处理环形数组时,循环次数容易算错
例如,在实现"接雨水"问题时,使用单调栈的完整实现:
python复制def trap(height):
stack = []
water = 0
for i, h in enumerate(height):
while stack and h > height[stack[-1]]:
bottom = height[stack.pop()]
if not stack:
break
left_bound = height[stack[-1]]
distance = i - stack[-1] - 1
water += (min(left_bound, h) - bottom) * distance
stack.append(i)
return water
这个实现中,我们存储的是索引,通过比较当前高度和栈顶高度来决定是否形成凹槽。
