深蓝黑背景封面:右侧大标题「单调栈和单调队列,各管哪类最值」,副标题「找边界靠栈,窗口最值靠队列」;左侧示意图分上下两部分——上半是单调栈快照:竖直栈内自下而上为 5、4、3(单调递减),右侧新元素 6 正被压入,箭头指向栈顶,旁注「6 入栈时弹出 3、4、5,它们遇到的下一个更大元素都是 6」;下半是滑动窗口:数组 1、3、-1、-3、5、3 的前三个元素被括号框为窗口(k=3,最大值 3),下方双端队列保存 3、-1 两个递减值,标注「队首即窗口最大值」。左上标注「知识延伸 · 算法模板」,右上胶囊标注「Day 07 · 11-14」 图 1:本文封面,单调栈定边界与单调队列管窗口两个场景的速写。

同批的真题复盘用单调栈解了「区间最小值 × 区间和」那道笔试题,把那道题的推导讲透了。这篇把镜头拉远:单调栈还有个兄弟叫单调队列,两者名字像、都靠「单调性」吃饭,管的却是两类不同的最值问题。搞清楚「哪类问题用哪个结构」,比背任何模板都重要。

两种结构,各维护一条单调不变量。

单调栈维护的是:栈内元素从栈底到栈顶保持单调(递减或递增)。新元素会破坏单调性时,就把栈顶一直弹到不破坏为止——关键在弹出瞬间:被弹出的元素恰好在此刻获得自己的答案。

单调队列维护的是:一个双端队列,队内下标递增、对应的值单调(比如递减),队首永远是当前窗口的最值。它比栈多管一件事——下标滑出窗口的元素要从队首删掉,这是「窗口」语义带来的。

一句话分工:单调栈回答「每个元素的边界在哪」,单调队列回答「每个窗口的最值是谁」。

单调栈的典型问题:下一个更大元素。

以数组 2、1、4、3 为例,维护一个从栈底到栈顶递减的栈:2 入栈得 [2];1 比栈顶小,直接入栈得 [2,1];4 来了,它比栈顶的 1 大、比 2 也大,于是 1 和 2 依次弹出——它们遇到的下一个更大元素都是 4;4 入栈后 3 入栈得 [4,3]。扫描结束,栈里剩下的 4 和 3 右侧没有更大元素,记 -1。最终结果 4、4、-1、-1。

为什么敢把弹出的元素「结案」?因为栈递减时,第一个让栈顶站不住的新元素,就是栈顶元素右侧第一个比它大的值——单调性保证了没有更早的候选被漏掉。复杂度是线性的:每个元素入栈一次、出栈至多一次,总操作不超过 2n 次。

把比较方向反过来,就得到「上一个更小」「右侧第一个更小」等变体——同批真题用它定「以某元素为最小值能覆盖的最远左右边界」,那道题的完整推导在真题复盘里,这里不重复展开。

单调队列的典型问题:滑动窗口最大值。

数组 1、3、-1、-3、5、3、6、7,窗口长度 k=3,要求每个窗口的最大值。规则两条:新元素入队前,把队尾所有比它小的下标删掉;每走一步,检查队首下标是否滑出窗口,滑出就删。删完入队,队首即当前窗口最大值。逐窗口结果是 3、3、5、5、6、7。

为什么队尾的小元素可以永久删除?新元素比它们大,下标又比它们新——旧元素滑出窗口时新元素还在窗口里,所以任何包含旧元素的窗口里都有个更大更新的存在,旧元素永远当不上最大值,留着只会挡路。线性复杂度的理由和单调栈一样:每个下标入队、出队各至多一次。

两种结构的代码实测。

用 Node.js 22 把两个模板跑出来,并各配一个暴力实现做对拍:

// 单调栈:下一个更大元素
function nextGreater(a) {
  const ans = new Array(a.length).fill(-1), st = [];
  for (let i = 0; i < a.length; i++) {
    while (st.length && a[st[st.length - 1]] < a[i]) ans[st.pop()] = a[i];
    st.push(i);
  }
  return ans;
}
// 单调队列:滑动窗口最大值
function windowMax(a, k) {
  const q = [], out = [];
  for (let i = 0; i < a.length; i++) {
    while (q.length && q[0] <= i - k) q.shift();
    while (q.length && a[q[q.length - 1]] <= a[i]) q.pop();
    q.push(i);
    if (i >= k - 1) out.push(a[q[0]]);
  }
  return out;
}
console.log(nextGreater([2, 1, 4, 3]));               // [ 4, 4, -1, -1 ]
console.log(windowMax([1, 3, -1, -3, 5, 3, 6, 7], 3)); // [ 3, 3, 5, 5, 6, 7 ]

输出与上面手工推演一致;再各跑两万组随机小规模用例与暴力解对拍,全部一致,模板才算可靠。

深蓝黑背景概念图,分三个区域。左上卡片「单调栈」:不变量是栈底到栈顶单调递减;示例数组 2、1、4、3,对应「下一个更大元素」结果 4、4、-1、-1;下方逐步演示入栈过程——push 2 得 [2],push 1 得 [2,1],4 入栈弹出 1 和 2(它们的答案都是 4),再 push 3 得 [4,3],4 和 3 右侧无更大元素记 -1;标注适用问题为下一个更大/更小元素与左右边界查找。右上卡片「单调队列」:不变量是队内下标递增、对应值递减,队首即窗口最值;示例数组 1、3、-1、-3、5、3、6、7,窗口 k=3 时各窗口最大值依次为 3、3、5、5、6、7;规则为入队弹出比新元素小的队尾、队首下标滑出窗口即删除。底部对照表三行:不变量(栈递减 vs 队列下标递增且值递减)、弹出时机(新元素更大弹栈顶 vs 滑出窗口删队首、新值更大删队尾)、典型问题(下一个更大元素、直方图边界 vs 滑动窗口最值),两者均为 O(n)。 图 2:两种结构的不变量、弹出时机与适用问题对照。

怎么选:看问题问的是「边界」还是「窗口」。

题目里出现「下一个/上一个更大的元素」「最远能扩展到哪」「以某元素为最值的管辖范围」,选单调栈——它的本质是沿着单调性找每个元素两侧的边界,直方图最大矩形、接雨水都是这一族。题目里出现「每个长度为 k 的区间/窗口的最值」,选单调队列——它的本质是维护一个随窗口滑动而更新的最值候选集。两者都是 O(n),分别把 O(n²) 的边界枚举和 O(nk) 的窗口扫描压到线性。

再往上一层:滑动窗口最值也能用堆做,但堆是 O(n log k),而且懒删除写起来绕;单调队列是均摊 O(1) 的特化解。单调队列还能优化一类「窗口内取最值做转移」的动态规划,这是它超出模板题的价值。

学这类结构,建议的顺序是:先手工推一遍不变量为什么成立,再写模板,最后必须配暴力对拍——单调结构的错误大多藏在相等元素的取舍里。你第一次被单调结构惊艳到是哪道题?遇到窗口最值你会先想到堆还是双端队列?欢迎聊聊。