跳转至

第 32 章 栈

本章配套 11 道例题,见 §例题。 前置:序列与数组

栈是后进先出(LIFO)的容器。一个很到位的比喻:

栈中的元素先入后出,就像摆在桌面上的一堆书,先堆上去的书垫在底下。 从顶上一本一本取出来时,最先放上去的书最后取出来。

好消息是:Python 的 list 天然就是一个完美的栈,不需要任何封装。 这一章的重点因此不在「怎么实现栈」,而在「怎么认出一道题是栈题」。


1 list 就是栈

栈操作 C++ std::stack Python list 复杂度
入栈 stk.push(x) st.append(x) \(O(1)\) 均摊
出栈 stk.pop() st.pop() \(O(1)\)
取栈顶 stk.top() st[-1] \(O(1)\)
判空 stk.empty() not st \(O(1)\)
大小 stk.size() len(st) \(O(1)\)

经典的伪代码是数组 + top 指针:

int top = 0
int stk[n]
function push(int x): stk[top++] = x
function get_top():   return stk[top - 1]
function pop():       return stk[--top]

Python 的 list.append / list.pop 内部做的正是这件事(ob_size 就是 top), 所以不要自己维护 top 指针——那只会更慢,因为多了 Python 层的算术。

注意 pop() 的两个语义差异: - C++ 的 stk.pop() 不返回值,取值要先 top(); - Python 的 st.pop() 返回被弹出的值。

写题时若只是想丢弃栈顶,直接 st.pop() 即可,不要写 _ = st.pop()。

空栈保护

if st:              # ✅ 唯一正确的判空写法
    x = st.pop()    # 进了这个分支才 pop,栈一定非空

x = st.pop()        # ❌ 空栈时抛 IndexError: pop from empty list
top = st[-1]        # ❌ 空栈时抛 IndexError: list index out of range

竞赛里绝大多数栈题的 WA 都来自忘了判空,尤其是「弹到栈空为止」的循环:

while st and st[-1] < x:      # ✅ st 为假时不再求值 st[-1],短路保证不越界
    st.pop()

2 栈的四大用途

用途 典型题 讲解位置
括号 / 配对匹配 括号序列合法性、HTML 标签匹配 本章 §3
表达式求值 中缀转后缀、逆波兰求值 本章 §4
单调栈 左右第一个更大/更小元素 单调栈
递归 / DFS 手动模拟调用栈避免爆栈 DFS深度优先搜索

第四点特别值得说:

回溯、函数递归和深度优先搜索(DFS)都需要使用栈,只不过系统提供了函数调用栈。 递归调用函数时,当前的函数所处的状态被压栈,直到其执行完毕才弹栈恢复。

在 C++ 里可以 ulimit -s 524288 把系统栈开到 512MB; Python 里没有这个选项(sys.setrecursionlimit 只改计数器), 所以深度超过约 \(10^4\) 的递归必须手动改写成显式栈。 这就是「栈」这个数据结构在 Python 竞赛里比在 C++ 里更重要的原因。


3 括号匹配

模板一:单一括号,只判合法

def is_balanced(s):
    """只有一种括号时,栈退化成一个计数器。O(n)。"""
    d = 0                  # d 是尚未闭合的左括号数,等价于「栈的当前高度」
    for c in s:
        if c == "(":
            d += 1         # 左括号入栈
        else:
            d -= 1         # 右括号配掉一个左括号
            if d < 0:      # 高度变负说明这个右括号无左括号可配,后面补也无用
                return False
    return d == 0          # 扫完必须恰好清零,剩下没闭合的左括号同样不合法

优化意识:只有一种括号时不需要栈,一个整数就够。 这个「深度」变量还能顺带回答很多问题:最大嵌套深度就是 \(\max d\), 每个位置的括号深度就是当时的 \(d\)。

模板二:多种括号

PAIR = {")": "(", "]": "[", "}": "{"}    # 由右括号反查它该配的左括号


def is_balanced_multi(s):
    st = []                 # 栈里存尚未闭合的左括号
    for c in s:
        if c in "([{":      # 三种左括号一律入栈
            st.append(c)
        elif c in PAIR:     # 只有右括号触发匹配,其余字符直接跳过
            # 栈空说明没有左括号可配;类型对不上说明嵌套交叉。
            # 两个条件的顺序不能反,否则空栈时 pop 会抛 IndexError
            if not st or st.pop() != PAIR[c]:
                return False
    return not st           # 收尾时栈必须空,否则有左括号一直没闭合

模板三:求最长合法括号子串

栈里存下标而不是字符,这是括号题的通用升级技巧:

def longest_valid(s):
    """最长合法括号子串长度,O(n)。栈底放一个「上一个不合法位置」的哨兵。"""
    st = [-1]                       # 哨兵取 -1,使从下标 0 起的合法段算出 i - (-1) = i + 1
    best = 0                        # 一个合法段都没有时答案就是 0
    for i, c in enumerate(s):
        if c == "(":
            st.append(i)            # 存下标而不是字符,弹栈时才能直接算长度
        else:
            st.pop()                # 右括号先无条件弹一个:弹掉的要么是配对的左括号,要么是哨兵
            if st:                  # 还有元素说明刚才配对成功,新栈顶是当前合法段左边界的前一位
                if i - st[-1] > best:   # 栈顶在边界外一格,两下标相减就是段长,不用再 +1
                    best = i - st[-1]
            else:
                st.append(i)        # 这个 ')' 无法匹配,成为新的哨兵
    return best

括号题通法:栈里存下标 → 弹栈时用 i - st[-1] 直接算出区间长度。 这个套路和单调栈算矩形面积是同一个(单调栈)。


4 表达式求值

这里给出完整的 Python 实现。

后缀表达式(逆波兰)求值

8 - (3 + 2 * 6) / 5 + 4 的后缀形式是 8 3 2 6 * + 5 / - 4 +。 从左到右扫:数字入栈,运算符从栈顶取两个数运算后把结果压回。

执行过程:

stack: 8 3 2 6,读入 *,计算 2 * 6 = 12
stack: 8 3 12,读入 +,计算 3 + 12 = 15
stack: 8 15 5,读入 /,计算 15 / 5 = 3
stack: 8 3,读入 -,计算 8 - 3 = 5
stack: 5 4,读入 +,计算 5 + 4 = 9
def eval_rpn(tokens):
    """求值后缀表达式,tokens 是字符串列表。O(n)。"""
    st = []                     # 栈里只放已经算出的中间结果
    for t in tokens:
        if t == "+":
            # 先弹出的是右操作数,后弹出的才是左操作数,减法除法靠这个顺序
            b = st.pop(); a = st.pop(); st.append(a + b)
        elif t == "-":
            b = st.pop(); a = st.pop(); st.append(a - b)
        elif t == "*":
            b = st.pop(); a = st.pop(); st.append(a * b)
        elif t == "/":
            b = st.pop(); a = st.pop()
            # 竞赛中「整数除法」通常指向零取整,Python 的 // 是向下取整,要区分
            st.append(int(a / b) if a * b < 0 else a // b)
        elif t == "^":
            b = st.pop(); a = st.pop(); st.append(a ** b)
        else:
            st.append(int(t))   # 不是运算符就是数字,转成 int 压栈
    return st[-1]               # 合法后缀式扫完后栈里恰剩一个数,就是答案

陷阱:b = st.pop(); a = st.pop() 的顺序不能反—— 后弹出的是左操作数。- 和 / 不满足交换律,写反了样例都过不了。

陷阱:负数整除。-7 // 2 == -4(向下),而 C++ 的 -7 / 2 == -3(向零)。 题目说「整数除法」时要看清是哪一种,详见 运算符与位运算。

中缀转后缀(调度场算法)

规则如下:

  1. 遇到数字直接输出;
  2. 遇到运算符和左括号压入符号栈;
  3. 遇到右括号则一直弹栈输出直到遇到左括号;
  4. 压入运算符时,如果栈顶符号不为括号且优先级不小于当前运算符,则弹出栈顶运算符并输出, 直到栈空 / 遇到左括号 / 遇到优先级更低的运算符,然后压入当前运算符;
  5. 读入结束后弹出栈内所有运算符。
PRI = {"+": 1, "-": 1, "*": 2, "/": 2, "^": 3}    # 数值越大越先算
RIGHT_ASSOC = {"^"}          # 右结合运算符


def to_rpn(tokens):
    """中缀 -> 后缀(调度场算法)。O(n)。"""
    out = []                            # 输出队列,最终的后缀式
    ops = []                            # 符号栈,只放运算符和左括号
    for t in tokens:
        if t == "(":                    # 左括号无条件入栈,它是后续弹栈的下界
            ops.append(t)
        elif t == ")":                  # 遇右括号:一路弹到左括号为止
            while ops and ops[-1] != "(":
                out.append(ops.pop())
            ops.pop()                       # 弹掉左括号,括号本身不进输出
        elif t in PRI:
            # 栈顶优先级更高就必须先算;优先级相同时左结合要弹(从左往右算),
            # 右结合不弹(从右往左算),差别全在 t not in RIGHT_ASSOC 这一句
            while (ops and ops[-1] != "("
                   and (PRI[ops[-1]] > PRI[t]
                        or (PRI[ops[-1]] == PRI[t] and t not in RIGHT_ASSOC))):
                out.append(ops.pop())
            ops.append(t)               # 该弹的都弹完了,当前运算符才入栈
        else:
            out.append(t)                   # 数字直接进输出,不入符号栈
    while ops:                          # 读完后栈内残余运算符依次输出,栈顶的先算
        out.append(ops.pop())
    return out

一道练习:(8 + (7 - 6) + 5) + 4 * 3 / 2 * (1 + 9) \(\to\) 8 7 6 - + 5 + 4 3 * 2 / 1 9 + * +。可以拿这组数据自测。

右结合的处理:^ 是右结合的(\(2^{3^2} = 2^9\)), 所以遇到同优先级时不弹栈。+ - * / 是左结合,同优先级要弹。 这一行 t not in RIGHT_ASSOC 就是全部区别。

Python 特供:能不能直接 eval?

print(eval("8 - (3 + 2 * 6) / 5 + 4"))     # 8.0,注意 / 是真除法,结果是 float

可以,但有三个致命限制:

问题 说明
除法语义 Python 的 / 是真除法返回 float,题目多半要整除
幂运算符 题目里的 ^ 在 Python 里是异或,含义完全不同
大表达式 eval 要走完整的编译流程,\(10^5\) 次调用会很慢

所以模板题老老实实写栈;只有在「表达式很短、语义恰好和 Python 一致」时, eval 才是一个能省 30 行代码的应急手段。


5 对顶栈:用两个栈模拟光标

这是笔试高频模型:一个可以左右移动的光标,在光标处做插入/删除。

思路:用两个栈 left 和 right 背靠背,光标就在两个栈顶之间。

原串:  a b c I d e f
left  = [a, b, c]           栈顶 c 紧贴光标左边
right = [f, e, d]           栈顶 d 紧贴光标右边(注意是逆序存的)
操作 实现 复杂度
光标左移 right.append(left.pop()) \(O(1)\)
光标右移 left.append(right.pop()) \(O(1)\)
在光标处插入 left.append(c) \(O(1)\)
删除光标左边一个 left.pop() \(O(1)\)
删除光标右边一个 right.pop() \(O(1)\)
还原完整串 left + right[::-1] \(O(n)\)

这就是经典题「仅用两个栈实现一个双端队列」的思路。 对比:用 list 存整串 + 一个下标当光标,每次删除是 \(O(n)\),\(k\) 次操作就是 \(O(nk)\)。


6 例题

题号 题名 难度 题解
BISHI2 【模板】栈的操作 简单 题解
BISHI117 小苯的IDE括号问题(easy) 中等 题解
BM42 用两个栈实现队列 简单 题解
BM43 包含min函数的栈 简单 题解
BM44 有效括号序列 简单 题解
BM49 表达式求值 中等 题解
BM77 最长的括号子串 较难 题解
LC20 有效的括号 简单 题解
LC32 最长有效括号 困难 题解
LC155 最小栈 中等 题解
LC394 字符串解码 中等 题解

BISHI2 【模板】栈的操作(简单)

\(n \le 10^5\) 次操作:push x 入栈;pop 若非空则删栈顶,否则输出 Empty; query 若非空则输出栈顶,否则输出 Empty;size 输出元素数量。 题面见 原题。

算法上零难度,考的是读题精度和 IO 速度。

三个坑:

  1. pop 成功时不输出任何东西,只有空栈才输出 Empty。这一条最容易写反;
  2. query 空栈也输出 Empty,但和 pop 的语义不同(一个删一个不删);
  3. size 永远有输出,空栈时输出 0。

外加一个格式坑:只有 push 后面跟参数,pop/query/size 是单 token, 所以必须用游标按 token 读,不能按「每行两个数」读。

import sys


def main():
    # push 带参数、pop/query/size 不带,行长不固定,只能整读后按 token 消费
    data = sys.stdin.buffer.read().split()
    n = int(data[0])                # 操作条数
    i = 1                           # token 游标,data[0] 已被 n 取走
    st = []                         # list 本身就是栈,进出都在尾部
    out = []                        # 输出先攒着,结尾一次写出
    for _ in range(n):
        op = data[i]                # 操作码保持 bytes,直接与 b"..." 比较,省一次解码
        i += 1                      # 操作码消费掉,游标停在它的参数位上
        if op == b"push":
            st.append(data[i])          # 直接存原始 bytes,省掉 int/str 往返
            i += 1                  # push 的那个参数也消费掉
        elif op == b"pop":
            if st:                  # 判空必须在 pop 之前,空栈 pop 会抛 IndexError
                st.pop()                # 成功时不输出
            else:
                out.append("Empty")     # 只有空栈时才有输出,这一条最容易写反
        elif op == b"query":
            out.append(st[-1].decode() if st else "Empty")  # query 只看栈顶不弹栈,输出前才转 str
        else:                           # size
            out.append(str(len(st)))    # size 永远有输出,空栈时是 0
    sys.stdout.write("\n".join(out) + "\n")     # 一次写出,逐行 print 会被 IO 拖垮


main()

「存 bytes 不转 int」是这类模板题的通用加速手段: 既然入栈的值最后原样输出,就没必要 int(data[i]) 再 str(x) 转回来, 省掉 \(2 \times 10^5\) 次转换。只有需要参与运算时才转。

题解见 solutions/nowcoder/BISHI2/sol.py。

BISHI117 小苯的 IDE 括号问题(easy)(中等)

长度 \(n \le 2\times10^5\) 的串仅含 (、)、I,其中 I 恰好出现一次表示光标。 \(k \le n\) 次操作: - backspace:若光标左边是 ( 且右边紧跟 ),一次性删掉这对括号; 否则若左边还有字符就只删左边一个;左边为空则无效; - delete:若右边存在字符则删掉右边第一个,否则无效。

输出全部操作后的最终串(含光标 I)。 题面见 原题。

标准的对顶栈题。把 I 左边的部分作为左栈(栈顶 = 紧贴光标的字符), 右边的部分逆序存进右栈(栈顶 = 紧贴光标的字符), 于是三条规则全部变成 \(O(1)\) 的栈顶操作:

import sys


def main():
    data = sys.stdin.buffer.read().split()      # token 顺序:n、k、串、k 条操作
    s = data[2].decode()           # 串排第三个 token,下标 2
    i = s.index("I")               # 光标位置;题目保证 I 恰好出现一次
    left = list(s[:i])                 # 切片右端不含 i,正好取到光标左边全部;栈顶 left[-1] 紧贴光标
    right = list(s[i + 1:])[::-1]      # i+1 跳过 I 本身;反转后原来的第一个字符落到栈顶
    k = int(data[1])               # 操作条数
    for j in range(k):
        op = data[3 + j]           # 前 3 个 token 是 n、k、串,操作从下标 3 起
        if op == b"backspace":
            if left and right and left[-1] == "(" and right[-1] == ")":   # 先判空再取 [-1],短路保证不越界
                left.pop()             # 成对删除
                right.pop()
            elif left:
                left.pop()             # 不成对时退化成删左边一个字符
            # 左边为空则什么都不做
        else:                          # delete
            if right:                  # 右边没字符时 delete 无效,什么都不做
                right.pop()
    right.reverse()                # 右栈是逆序存的,输出前转回正序
    sys.stdout.write("".join(left) + "I" + "".join(right) + "\n")   # 光标 I 要原样留在两段之间


main()

复杂度 \(O(n + k)\)。

手动验证样例 1:n=10, k=3,s = ((()(I))((,光标在下标 5。 left = "((()(",right 栈从顶到底是 ) ) ( (。

操作 判断 left right(顶→底)
初始 ((()( ))((
backspace 左 (、右 ) → 成对删 ((() )((
backspace 左是 ) → 只删左一个 ((( )((
delete 删右一个 ((( ((

输出 ((( + I + (( = (((I(( ✓

关键陷阱:

  1. 成对删除的判定是「左边是 ( 并且 右边是 )」,两个条件缺一不可, 而且要先判空(left and right),否则空栈取 [-1] 会 IndexError;
  2. 右栈是逆序存的,最后输出前要 reverse() 回来;
  3. 输出必须保留 I——题目要的是最终的「括号串」,光标仍在里面。

为什么不能用字符串下标模拟? 每次删除都要 s = s[:p] + s[p+1:],是 \(O(n)\),\(k\) 次就是 \(4\times10^{10}\)。 对顶栈把它降到 \(O(1)\)。这就是栈在这题的全部价值。


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

面试题单里的五道栈题

§2 那张「四大用途」表可以拿这五道题逐行验一遍。

用栈换掉另一种容器。 用两个栈实现队列是经典的摊还构造: 入队压 in 栈,出队时若 out 栈为空就把 in 整个倒进 out。 每个元素一生只被搬运一次,所以 \(n\) 次操作总代价 \(O(n)\),均摊 \(O(1)\)—— 单次最坏仍是 \(O(n)\),这两个数不矛盾,看的是不同的东西。 包含min函数的栈要求 min 也是 \(O(1)\),办法是再开一个同步的最小值栈: 主栈压什么,辅助栈就压「当前栈内最小值」。 省空间的写法是辅助栈只在「新值 \(\le\) 当前最小」时才压, 但弹栈时的比较要相应写成 \(\le\),否则重复的最小值会被提前弹掉。

括号与表达式,正好对上 §3 与 §4 两节。 有效括号序列是 §3 模板一的原样形态。 表达式求值是 §4 的完整版:含括号、含优先级、可能有多位数字。 两条路都能走——先中缀转后缀再求值,或者双栈(操作数栈 + 运算符栈)一趟扫完。 无论走哪条,读数字必须一次读完整个多位数,按字符压栈是这题最常见的错法。

最长的括号子串是 §3 的延伸:不只判合法,还要求最长的合法子串长度。 栈里存下标而不是字符,栈底垫一个 \(-1\) 当哨兵; 每次匹配成功后弹栈,当前下标减去新栈顶就是以此处结尾的合法长度。 哨兵那一位省不掉——它代表「上一个不合法位置」, 没有它,从头开始就合法的串会算少 1。 这题也有 \(O(1)\) 空间的解法:从左到右扫一遍数左右括号计数,再从右到左扫一遍, 两遍各自在计数相等时更新答案,见 双指针与滑动窗口 的同类手法。

另一处题库里的四道栈题

LC 20. 有效的括号 是模板二的原样应用:左括号入栈,右括号与栈顶配对, 扫完还要检查栈是否为空——只判「每个右括号都配上了」会漏掉 (( 这种全是左括号的输入。 LC 32. 最长有效括号 对应模板三,栈里压的是下标而不是字符: 栈底始终留一个「最后一个未匹配右括号的位置」当基准, 弹栈后用当前下标减去新栈顶,就是以此处结尾的合法子串长度,一遍扫描 \(O(n)\)。

LC 155. 最小栈:把「查询最小值」也变成栈上的一次推入。 push / pop / top / getMin 四个操作都要求 \(O(1)\), 而最小值会随 pop 回退,所以它必须跟着栈帧一起保存。 辅助栈版本每次推入时同步压入「当前前缀最小值」,两个栈等高; 省一半空间的版本只用一个栈,把 (值, 当前最小) 打成元组一次推入。 把最小值存成一个标量变量是错的——弹出最小元素之后无从恢复上一个最小值。

LC 394. 字符串解码:两个栈分别记「重复次数」与「已拼好的前缀」。 遇到 [ 就把当前累计的数字与已拼好的串各推一个栈、随后清空; 遇到 ] 弹出一对,把括号内的串重复相应次数,再接到弹出的前缀后面。 嵌套深度就是栈深,输出串总长为 \(L\) 时整体 \(O(L)\)。 一个常见的错法是把数字按单个字符处理,100[a] 会被读成 1、0、0 三层嵌套。

7 本章速查

要点 结论
Python 的栈 直接用 list:append / pop / [-1]
不要做的事 自己维护 top 指针(更慢)
判空 if st: / while st and ...(短路保证不越界)
pop() 差异 Python 的 pop() 返回值,C++ 的不返回
单一括号匹配 不需要栈,一个计数器 d 就够
括号题通法 栈里存下标,弹栈时 i - st[-1] 得区间长
后缀求值 b = pop(); a = pop(),顺序不能反
中缀转后缀 同优先级:左结合弹栈,右结合(^)不弹
eval 除法语义、^ 含义、性能三个坑,模板题别用
光标类问题 对顶栈,\(O(1)\) 完成全部操作
Python 递归 没有 ulimit -s,深度大就必须显式栈
看到什么 → 想到栈
括号 / 配对 / 嵌套
「上一个还没被处理的」「最近的一个」
表达式、运算优先级
「左边第一个比它大的」→ 单调栈(单调栈)
光标、撤销、回溯
递归深度太大要手动展开