跳到内容

第 2 卷 / 第 3 章 / 第 1 课

3.1 栈:后进先出 ​

前置:数组与链表的选择。这一节先钉牢 LIFO 契约,再比较数组栈与链表栈,最后把同一条规则用到括号匹配和深度优先搜索。

LIFO 只从一端进出 ​

依次把 key、brick、book 压入同一个容器,再连续弹出三次,观察顺序会变成 book、brick、key。这条接口规定:

后放入的元素先取出,先放入的元素后取出。

这种规则叫后进先出,英文是 Last In, First Out,缩写 LIFO。遵守 LIFO 的抽象数据类型就是栈。我们只关心“顶部”这一端,不要求底层一定使用数组或链表。

先记住操作契约 ​

操作含义空栈时的约定抽象模型中的目标成本
push(x)把 x 放到栈顶总能插入,除非资源耗尽Θ(1)
pop()移除并返回栈顶抛出异常或返回失败状态Θ(1)
peek()查看栈顶,不移除抛出异常或返回失败状态Θ(1)
is_empty()判断是否为空返回 TrueΘ(1)
len(stack)返回元素数返回 0Θ(1)

对空栈执行 pop 或 peek 叫下溢(underflow)。库之间的失败方式不同:有的抛异常,有的返回空值或布尔状态。写接口时要选定一种契约,调用方也必须按同一契约处理。

同一座栈,两种存法 ​

第一种把栈顶放在动态数组的右端。CPython list 的末尾追加和弹出均摊 Θ(1)。一次追加可能扩容;一次弹出也可能触发缩容。末尾弹出虽然不移动后缀,仍不能把每次实际分配或复制都当成常数成本。可在 CPython 3.14 源码的 list_pop_impl 和 list_resize 中核对这条路径。

第二种复用上一章的单链表:头节点就是栈顶。push 新建一个节点并接到头部,pop 让头指针越过旧节点。在链表抽象模型里,两项操作都是 Θ(1);实际运行还要承担每次分配节点和保存链接的开销。

下面的程序用两种实现处理同一批物品,并检查结果一致:

python
class ArrayStack:
    def __init__(self):
        self._data = []

    def push(self, item):
        self._data.append(item)

    def pop(self):
        if self.is_empty():
            raise IndexError("pop from empty stack")
        return self._data.pop()

    def peek(self):
        if self.is_empty():
            raise IndexError("peek from empty stack")
        return self._data[-1]

    def is_empty(self):
        return len(self._data) == 0

    def to_list(self):
        return self._data.copy()

    def __len__(self):
        return len(self._data)

class Node:
    def __init__(self, value, next_node=None):
        self.value = value
        self.next = next_node

class LinkedStack:
    def __init__(self):
        self._head = None
        self._size = 0

    def push(self, item):
        self._head = Node(item, self._head)
        self._size += 1

    def pop(self):
        if self.is_empty():
            raise IndexError("pop from empty stack")
        value = self._head.value
        self._head = self._head.next
        self._size -= 1
        return value

    def peek(self):
        if self.is_empty():
            raise IndexError("peek from empty stack")
        return self._head.value

    def is_empty(self):
        return self._head is None

    def to_list(self):
        values = []
        current = self._head
        while current is not None:
            values.append(current.value)
            current = current.next
        return list(reversed(values))

    def __len__(self):
        return self._size

def is_valid_brackets(text):
    matching = {")": "(", "]": "[", "}": "{"}
    stack = []
    for char in text:
        if char in matching.values():
            stack.append(char)
        elif char in matching:
            if not stack or stack.pop() != matching[char]:
                return False
    return not stack

if __name__ == "__main__":
    array_stack = ArrayStack()
    linked_stack = LinkedStack()
    for item in ["alpha", "beta", "gamma"]:
        array_stack.push(item)
        linked_stack.push(item)

    print(f"栈顶:{array_stack.peek()}")
    print(f"弹出:{array_stack.pop()}")
    linked_stack.pop()
    print(f"剩余:{array_stack.to_list()}")
    print(
        "括号匹配:"
        f"{is_valid_brackets('map[{key}]')} "
        f"{is_valid_brackets('map[(])')}"
    )

    assert array_stack.to_list() == linked_stack.to_list()
    assert len(array_stack) == len(linked_stack) == 2
    print("栈检查通过")
text
栈顶:gamma
弹出:gamma
剩余:['alpha', 'beta']
括号匹配:True False
栈检查通过

逐步读数组栈 ​

  1. _data[-1] 是栈顶。栈的使用者不应该绕过接口修改 _data。
  2. push 把元素加到右端;pop 从右端移除。
  3. peek 只读栈顶,所以不会改变长度。
  4. pop 和 peek 先检查空栈,避免把底层容器的偶然错误当成公开契约。
  5. to_list 返回副本,示例才能查看状态而不泄露内部列表。

逐步读链表栈 ​

假设头部依次是 gamma -> beta -> alpha:

  • push("delta") 创建新节点,让它指向旧头部,再把 _head 改为新节点。
  • pop() 保存旧头部的值,让 _head 指向第二个节点。
  • 两个操作都没有沿链表遍历。

两种 to_list 都是演示用的浅拷贝,需要 Θ(n) 时间和输出空间;链表版本还要遍历节点。复制不会隔离元素内部的修改,不能把这项操作的成本算到每次 push 或 pop 上。

括号匹配:保存最近的未完成工作 ​

扫描 map[{key}] 时,左括号代表尚未完成的嵌套结构,先把它压栈。右括号必须匹配最近一个尚未闭合的左括号;隔着内层去找外层,括号就会交叉。

以 [(]) 为例:

读到的字符栈(底 → 顶)处理
[[左括号入栈
([ (左括号入栈
][ (栈顶是 (,与 ] 不匹配,失败

这个算法有三种失败路径:

  • 右括号出现时栈已经空,说明缺少左括号;
  • 栈不空,但栈顶括号类型不匹配,例如上面的 [(]);
  • 扫描结束后栈仍有元素,说明缺少右括号。

示例函数忽略非括号字符,这是它明确选择的输入契约。真实语言的词法和语法规则更复杂;解析器常用显式栈、递归调用形成的隐式栈,或两者结合来处理嵌套结构。这里的程序只验证三类括号的配对和嵌套次序,不识别字符串和注释里的括号。对长度 n 的输入,最坏时间 Θ(n),辅助空间 Θ(n),例如全部字符都是左括号时。

显式栈与调用栈 ​

递归函数把“稍后还要继续做什么”交给调用栈保存。迭代版本则可以把同样的状态放进显式容器。两者都消耗空间,差别在于状态由运行时管理还是由程序管理。

python
def depth_first_order(graph, start):
    order = []
    seen = set()
    stack = [start]
    while stack:
        node = stack.pop()
        if node in seen:
            continue
        seen.add(node)
        order.append(node)
        stack.extend(reversed(graph[node]))
    return order

graph = {"A": ["B", "C"], "B": ["D"], "C": [], "D": []}
assert depth_first_order(graph, "A") == ["A", "B", "D", "C"]

这里要求每个可达顶点都在 graph 中有邻接列表,reversed 让列表左边的邻居先弹出。节点在弹出时才标记,因此同一节点可能重复入栈,但只展开一次。令 V、E 为可达部分的顶点数和邻接项数,在哈希操作为常数的模型下,时间 Θ(V+E),这个写法的显式栈最坏可存 O(V+E) 项;不能直接照抄递归 DFS 的 O(V) 栈空间。

空操作也可以采用返回状态的接口,而不是抛异常。关键是别让调用方猜:

python
def try_pop(stack):
    if not stack:
        return False, None
    return True, stack.pop()

assert try_pop([]) == (False, None)
assert try_pop([None]) == (True, None)
assert try_pop(["钥匙"]) == (True, "钥匙")

Python 的递归上限不是固定的“约 1000”。当前解释器的值可通过 sys.getrecursionlimit() 读取,也可在允许范围内调整。提高上限并不会消除 C 栈或进程内存限制。处理可能很深的树和图时,显式栈通常让失败条件与内存使用更容易控制。

调用栈由语言运行时、ABI、编译器生成的调用约定,以及机器提供的寄存器和指令共同实现。本章只借用 LIFO 关系,不展开这些系统细节。

常见语言的栈接口 ​

语言建议容器压入 / 弹出 / 查看需要留意
Pythonlistappend(x) / pop() / stack[-1]末尾追加/弹出均摊 O(1);空弹出或读取抛 IndexError
Java 17Deque<E> + ArrayDeque<E>push / pop / peekStack 是旧 API,并未标注 @Deprecated;ArrayDeque 禁止 null,非线程安全;空 pop 抛异常,空 peek 返回 null
C++std::stack<T>push / pop / top默认适配 std::deque;先确认非空,再读 top 并 pop;pop 不返回元素

参考契约:

常见错误怎么定位 ​

症状先检查什么
弹出顺序像 FIFOpush 与 pop 是否在同一端
空栈崩溃位置难懂公开方法是否先执行空检查
括号交错仍返回真右括号是否与“栈顶”比较,而不是与任意左括号比较
扫描结束仍误判为真返回前是否检查栈已经清空
链表栈长度错误每次 push 和成功的 pop 是否各更新一次 _size

动手验证 LIFO 与下溢 ​

  1. 依次压入 A、B、C,两次 pop 会得到什么?栈里还剩什么?
  2. 给 peek 写一个空栈测试。它应该抛出哪种异常?
  3. 手算 {a[(b+c)]} 和 {a[(b+c])} 的栈状态。
  4. 给链表栈增加 clear。更新入口和计数只需常数次赋值,为什么回收 n 个旧节点仍可能需要 Θ(n) 工作?
  5. 进阶:用两个栈实现一个队列。每个元素为什么只会在两个栈之间转移一次?

把栈的边界说清楚 ​

push 与 pop 在同一端操作,peek 不改变长度,空栈按约定失败。用同一组输入验证数组栈和链表栈,再说明扩缩容和 to_list 各自花在哪里。括号匹配还要检查类型错配和扫描结束时的剩余内容。

下一节进入队列,看“先到先走”如何改变容器两端的分工。

Built with VitePress | Software Systems Atlas