跳转至

第 37 章 单调栈

本章配套 6 道例题,见 §例题。 前置:栈

单调栈只做一件事:及时扔掉那些已经被证明永远不可能成为答案的候选。 栈里始终保持单调,每个元素最多进出一次,于是一大批 \(O(n^2)\) 的「向左/向右找第一个 比它大/小的元素」被压到 \(O(n)\),实现只有几行。

「左右第一个更大/更小」「以每个元素为最值的最大区间」「直方图最大矩形」「接雨水」 都是同一个模型的变体。把这套思想搬到滑动窗口上就是 单调队列——那是它的另一种形态。


1 它解决什么问题

给定长为 \(n\) 的序列,求每个元素右边第一个比它小的元素。没有则输出 0。 \(n \le 10^6\)。

序列: 7 2 1 4 5 1 3 2
答案: 2 1 0 1 1 0 2 0

暴力是 \(O(n^2)\)。单调栈的思路:

维护一个单调递增的栈,从左至右遍历序列。 考虑当前处理到元素 \(x\),栈顶元素为 \(y\)。 如果 \(x < y\),那么可以知道 \(y\) 右边第一个比 \(y\) 小的元素就是 \(x\)。 所以 \(y\) 的答案就计算出来了,可以将 \(y\) 从栈中弹出。 如此处理之后,要么栈已经空了,要么栈顶元素 \(y \le x\)。 将 \(x\) 压入栈中,栈中元素依然保持单调递增。 遍历完毕后,栈中剩下的元素均无答案。时间复杂度 \(\Theta(n)\)。

复杂度为什么是 \(O(n)\)? 每个元素最多入栈一次、出栈一次, 所以内层的 while 循环总共只会执行 \(O(n)\) 次——这是均摊分析, 和 list.append 的均摊 \(O(1)\) 是同一类论证(序列与数组)。


2 四种方向的对照表

这是最容易记混的地方。记住两条规则:

  1. 求「更大」用递减栈,求「更小」用递增栈(栈的单调性和目标相反);
  2. 求右边就正序扫,求左边就倒序扫。
目标 扫描方向 栈的单调性 弹栈条件(a[st[-1]] vs a[i])
右边第一个更大 正序 递减 <= a[i] 弹
右边第一个更小 正序 递增 >= a[i] 弹
左边第一个更大 倒序 递减 <= a[i] 弹
左边第一个更小 倒序 递增 >= a[i] 弹

严格 vs 非严格:弹栈条件用 <= 还是 <,决定了「相等元素」怎么处理。 - 用 <=(弹掉相等的):得到的是「第一个严格更大的」; - 用 <(保留相等的):得到的是「第一个大于等于的」。

有重复元素的题目一定要想清楚这一位,这是单调栈最常见的 WA 来源。


3 模板

def next_greater(a):
    """对每个 i,求右边第一个严格大于 a[i] 的下标;不存在则为 -1。O(n)。"""
    n = len(a)
    res = [-1] * n                           # 默认 -1,表示右边没有更大的元素
    # 栈里存下标不存值:答案要写回 res[下标],宽度、距离一类的量也只能靠下标算
    st = []                                  # 存下标,对应的值单调递减
    for i in range(n):
        ai = a[i]
        # 条件是 < 而不是 <=:相等时不弹,于是求出的是「第一个严格更大的」
        while st and a[st[-1]] < ai:         # 栈顶被 a[i] 顶掉,答案就是 i
            res[st.pop()] = i                # 弹出即定案,每个下标只结算这一次
        st.append(i)                         # 压入后栈内自底向上不增(相等元素并存)
    return res


def prev_smaller(a):
    """对每个 i,求左边第一个严格小于 a[i] 的下标;不存在则为 -1。O(n)。"""
    n = len(a)
    res = [-1] * n
    st = []                                  # 存下标,对应的值单调递增
    for i in range(n):
        ai = a[i]
        # 弹掉所有不小于 a[i] 的栈顶:它们不满足「严格小于」;
        # 而对 i 右边的元素来说 a[i] 又更近又不更大,这些栈顶再也轮不到
        while st and a[st[-1]] >= ai:
            st.pop()
        # 栈空说明左边所有元素都不小于 a[i],答案保持 -1
        res[i] = st[-1] if st else -1        # ★ 弹完之后栈顶就是答案
        st.append(i)                         # i 成为后续元素的候选前驱
    return res

两种写法的区别: - next_greater:在弹栈时给被弹出的元素赋答案(「被谁顶掉」); - prev_smaller:弹完之后看栈顶给当前元素赋答案(「谁挡在前面」)。

求「右边的」用前者,求「左边的」用后者,都只需要正序扫一遍, 不需要真的倒着扫。这比上面那张表更实用。


4 经典应用:直方图中最大矩形

给定柱状图,求其中面积最大的子矩形。

思路:

以每个柱子做高,向左和向右分别找出第一个比它矮的柱子, 这个是能覆盖的极大子矩形。可以使用正反两次单调栈扫描完成。

def largest_rectangle(h):
    """直方图最大矩形面积,O(n)。

    技巧:在两端各加一个高度 -1 的哨兵,
    就不用单独处理「栈没被弹空」和「左边界不存在」两种边界情况。
    """
    # 首哨兵比任何柱子都矮,永远不会被弹出,栈因此永远非空,省掉 if st
    # 尾哨兵同样矮,扫到它时会把栈里剩下的柱子全部弹出结算,省掉收尾循环
    a = [-1] + list(h) + [-1]
    st = []                                  # 存下标,对应高度单调递增
    best = 0
    for i, x in enumerate(a):
        # 栈顶比 x 高,说明它向右扩不过 i,此刻正好能算出它的极大矩形
        # 条件用 > 不用 >=:等高的柱子留给最右边那根一次结算,宽度不会算漏
        while st and a[st[-1]] > x:
            top = st.pop()
            # 左右边界都是「第一个比它矮的」,两端都取不到,所以宽度再减 1
            width = i - st[-1] - 1           # 左边界是新栈顶,右边界是 i
            area = a[top] * width            # 以 a[top] 为高时能覆盖的最大矩形
            if area > best:
                best = area
        st.append(i)
    return best

哨兵是单调栈的标配技巧。开头放 \(-\infty\) 保证栈永远非空(省掉 if st), 结尾放 \(-\infty\) 保证所有元素最终都被弹出(省掉收尾循环)。

这个模型的变体极多:最大全 1 子矩阵、接雨水、柱状图问题, 都是「向左右找第一个不满足条件的位置」。


5 例题

题号 题名 难度 题解
BISHI121 数列后缀极大位置统计 简单 题解
BISHI122 区间后缀极大位置计数 简单 题解
BM94 接雨水问题 较难 题解
LC42 接雨水 困难 题解
LC84 柱状图中最大的矩形 困难 题解
LC739 每日温度 中等 题解

BISHI121 数列后缀极大位置统计(简单)

数列 \(a\) 初始为空,\(n \le 10^5\) 次操作每次在末尾添加正整数 \(x\)。 每次操作后,求当前所有后缀最大值下标(下标从 1 开始)的按位异或和。 下标 \(i\) 是后缀最大值下标当且仅当对所有 \(i < j \le |a|\) 都有 \(a_i > a_j\)(严格)。 题面见 原题。

核心观察:「后缀最大值下标的集合」就是单调递减栈本身。

为什么?栈中保持严格递减,意味着栈里每个元素都严格大于它右边所有还在栈里的元素; 而被弹出的元素恰恰是「存在右边某个元素 \(\ge\) 它」的,正好不满足条件。

新元素 \(x\) 到来时弹掉所有 \(a_{\text{top}} \le x\) 的(注意 <=,因为条件是严格大于), 再把自己压进去。维护一个异或和变量,随进出增量更新:

import sys


def main():
    data = sys.stdin.buffer.read().split()
    n = int(data[0])
    a = list(map(int, data[1:1 + n]))
    # 栈里存的就是要输出的下标本身,栈的内容即「后缀最大值下标」的集合
    st = []                                  # 存下标(1-indexed),对应值严格递减
    # 增量维护栈内下标的异或和;每次重扫整个栈会退化成 O(n^2)
    xor = 0
    out = []
    push = out.append
    for i in range(1, n + 1):
        # 题目下标从 1 起、数组从 0 起,取值要错开一位
        x = a[i - 1]
        # 弹掉所有值不大于 x 的栈顶:它们右边出现了不小于自己的元素,
        # 「对右边所有 j 都有 a[i] > a[j]」不再成立
        while st and a[st[-1] - 1] <= x:     # 相等也要弹(条件是严格大于)
            # 异或的自反性:同一个下标再异或一次,等同于把它从集合里移除
            xor ^= st.pop()
        # 末尾新元素右边没有任何元素,必是后缀最大值,入栈并计入异或和
        st.append(i)
        xor ^= i
        push(xor)
    sys.stdout.write("\n".join(map(str, out)) + "\n")


main()

复杂度 \(O(n)\)(每个下标最多进出一次)。

手动验证样例(a = 2 1 3 5 4):

\(i\) \(x\) 弹出 栈(下标) 异或和 期望
1 2 — [1] 1 1 ✓
2 1 — (\(2 > 1\),不弹) [1,2] \(1 \oplus 2 = 3\) 3 ✓
3 3 2、1 [3] 3 3 ✓
4 5 3 [4] 4 4 ✓
5 4 — (\(5 > 4\)) [4,5] \(4 \oplus 5 = 1\) 1 ✓

两个坑:

  1. 弹栈条件必须是 <=。写成 < 会保留相等元素, 但相等时「\(a_i > a_j\)」不成立,答案会错。这题唯一的思维点就在这个等号上;
  2. 异或和要增量维护,不能每次 reduce(xor, st)——那是 \(O(n^2)\)。 异或的自反性(\(x \oplus x = 0\))保证了「弹出时异或一次」就等于「移除」, 见 运算符与位运算。

BISHI122 区间后缀极大位置计数(简单)

长度 \(n \le 10^6\) 的数组,对每个长度为 \(k\) 的子区间, 求其后缀极大值位置的个数。输出 \(n-k+1\) 行。 题面见 原题。

BISHI121 是「前缀 + 单调栈」,这题是「滑动窗口 + 单调队列」。 由 BISHI121 的观察可知:答案就是单调递减队列在该窗口下的长度。

所以代码几乎和 sliding_max 一样,只是输出 len(q) 而不是 a[q[0]]。

\(n \le 10^6\) 且时限只有「其他语言 2 秒」,必须上性能写法: list + 双指针(队列长度直接是 t - h + 1,连 len() 调用都省了)。

import sys


def main():
    data = sys.stdin.buffer.read().split()
    n = int(data[0]); k = int(data[1])
    a = list(map(int, data[2:2 + n]))
    # 队列至多装 n 个下标,一次性开满,主循环里不再有扩容开销
    q = [0] * n                              # 存下标
    h = 0                                    # 队首(含)
    t = -1                                   # 队尾(含)
    res = []
    push = res.append
    for i in range(n):
        x = a[i]
        # 队尾弹出:值不超过 x 的旧下标不再是后缀极大位置(要求严格大于);
        # t >= h 同时充当判空,避免读到空队列的队尾
        while t >= h and a[q[t]] <= x:       # 相等也弹(严格大于)
            t -= 1
        t += 1
        q[t] = i
        # 队首下标 <= i-k 说明它已滑出窗口 [i-k+1, i] 的左端
        if q[h] <= i - k:
            h += 1
        # 窗口凑满 k 个元素才输出;队内下标个数就是后缀极大位置的个数
        if i >= k - 1:
            push(t - h + 1)                  # ★ 答案就是队列长度
    sys.stdout.write("\n".join(map(str, res)) + "\n")


main()

手动验证样例(n=5, k=3, a = 2 1 3 5 4):

\(i\)(0-indexed) \(a_i\) 队列(下标) 窗口 输出 期望
0 2 [0] —
1 1 [0,1] —
2 3 [2](弹 1、0) \([0,2]\) 1 1 ✓
3 5 [3](弹 2) \([1,3]\) 1 1 ✓
4 4 [3,4] \([2,4]\) 2 2 ✓

Python 现实性评估:

项 量级
读入 \(10^6\) 个 token read().split() 约 0.15 s
map(int, ...) 建列表 约 0.25 s
主循环 \(10^6\) 次迭代(含均摊的弹栈) 约 1.0–1.5 s
输出 \(10^6\) 行 "\n".join 约 0.2 s

时限「其他语言 2 秒」——这题在 Python 下是真的险。 能做的优化已经全部用上了:一次性读入、双指针代替 deque、 push = res.append 绑局部名、一次性输出。如果仍然 TLE, 只能说明这道题的数据规模对 Python 不友好,属于语言差而非做法错。

本题给出的判据:\(n = 10^6\) 且时限 2 秒的题,Python 大约只剩 「一遍 \(O(n)\) 的简单循环」的预算。任何每个元素多做几次 Python 层操作的写法 (deque 方法调用、函数封装、对象属性访问)都可能是压垮骆驼的稻草。


题解:solutions/nowcoder/BISHI122/sol.py(已通过判题机验证)


BM94 接雨水问题

给定长度 \(n\) 的高度数组,求柱子之间能接住多少水。 它是 §4「直方图中最大矩形」的近亲:同样是「向左右各找第一个比自己高的柱子」, 只是求的东西从面积换成了水量。

按单调栈做的写法是维护一个递减栈:遇到比栈顶高的柱子就说明找到了右边界, 弹出栈顶当「凹底」,此时新的栈顶就是左边界, 这一层的水量是 \((\min(h_{左}, h_{右}) - h_{凹底}) \times (右 - 左 - 1)\)。 按层横向累加是单调栈解法的特点,与下面那种按列纵向累加的解法算出来是同一个数。

这题另有两条不用栈的路,都值得知道:

解法 思路 复杂度 额外空间
单调栈 横向按层累加 \(O(n)\) \(O(n)\)
预处理前后缀最大值 第 \(i\) 列的水量是 \(\min(\max_{\le i}, \max_{\ge i}) - h_i\) \(O(n)\) \(O(n)\)
相向双指针 哪边的前缀最大值小就先结算哪边 \(O(n)\) \(O(1)\)

三条路同阶,差别在额外空间与思维负担。 前缀最大值那条最容易写对,双指针那条最省空间,见 双指针与滑动窗口。

另一处题库里的三道单调栈题

LC 739. 每日温度 是模板的原样应用:求每个位置右侧第一个更大元素的下标距离, 维护一个自栈底向栈顶递减的下标栈,遇到更大值就连续弹栈并当场结算。 对照那张四种方向的表,它落在「向右找第一个更大」那一格。

LC 84. 柱状图中最大的矩形 与本章「直方图中最大矩形」是同一道题: 每根柱子向左右各找第一个比它矮的柱子,宽度由这两个边界确定,一遍单调栈 \(O(n)\)。 两侧加哨兵(左端补 \(-1\)、右端补一根高度为 \(0\) 的柱)能省掉「栈空」与「扫尾」两处特判。

LC 42. 接雨水 与接雨水问题是同一道题,上面已按单调栈的口径讲过一遍。 值得对照的是它的双指针解法:左右各维护一个前缀最大值, 每次移动较矮的那一侧,该侧当前列的储水量当场结算,\(O(n)\) 时间、\(O(1)\) 空间。 单调栈是按层横着结算,双指针是按列竖着结算,两条路加出来的是同一个总量。

6 本章速查

要点 结论
核心思想 及时扔掉永远不可能成为答案的候选
复杂度 \(O(n)\),每个元素最多进出一次(均摊)
求「更大」 用递减栈
求「更小」 用递增栈
求右边的 弹栈时给被弹元素赋答案
求左边的 弹完后看栈顶给当前元素赋答案
严格 vs 非严格 弹栈用 <= 得「严格更大」,用 < 得「大于等于」
哨兵技巧 两端加 \(\pm\infty\),省掉判空和收尾
识别信号 → 想到什么
「左边/右边第一个比它大/小的」
「以每个元素为最值的最大区间」
直方图矩形、接雨水、最大全 1 子矩阵
「后缀最大值的集合」

窗口相关的信号(固定长度窗口最值、DP 转移是窗口最值)见 单调队列 章末。