跳转至

第 32 章 栈

配套例题:BISHI2 【模板】栈的操作(题解已通过官方样例)、BISHI117 小苯的 IDE 括号问题(easy) 来源:S3 day1《栈 队列》 前置30-序列与数组

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

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

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


32.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)\)

S3 day1 给的伪代码是数组 + 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()

32.2 栈的四大用途

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

S3 day1 特别指出第四点:

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

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


32.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] 直接算出区间长度。 这个套路和单调栈算矩形面积是同一个(37 章)。


32.4 表达式求值

S3 day1 讲得很完整,这里给 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(向零)。 题目说「整数除法」时要看清是哪一种,详见 03-运算符与位运算

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

S3 day1 给出的规则:

  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 行代码的应急手段。


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

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

思路:用两个栈 leftright 背靠背,光标就在两个栈顶之间

原串:  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)\)

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


32.6 例题

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

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

算法上零难度,考的是读题精度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/BISHI2.py

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

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

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

标准的对顶栈题。把 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)\)

手动验证样例 1n=10, k=3s = ((()(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/BISHI117.py(已通过牛客判题机验证)

32.7 本章速查

要点 结论
Python 的栈 直接用 listappend / 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,深度大就必须显式栈
看到什么 → 想到栈
括号 / 配对 / 嵌套
「上一个还没被处理的」「最近的一个」
表达式、运算优先级
「左边第一个比它大的」→ 单调栈(37 章
光标、撤销、回溯
递归深度太大要手动展开