Skip to content

模型Model

栈

施工中 · 内容逐步修订

Stack · LIFO stack

以栈顶为唯一更新端、按后进先出规则组织元素的抽象数据类型;接口语义与具体表示的成本分别规定。

形式陈述 ​

用状态把接口说清楚 ​

把栈的抽象状态写成有限序列 S=[a1,…,ak],左端是栈底,右端是栈顶。初始状态为空序列 []。

操作 返回值 操作后的状态
push(x) 无 [a1,…,ak,x]
top(),要求 k>0 ak 原状态
pop(),要求 k>0 ak [a1,…,ak−1]
isEmpty() k=0 的真假 原状态

这里采用 pop 返回被移除元素的约定;有些接口把“查看”和“删除”完全分开,使用时应以该接口为准。对空栈执行 pop、top 是下溢情形,需要规定为报错、返回失败标记,或违反前置条件,不能把未定义的值当作合法元素。固定容量的栈还需要规定满栈时的行为。

例如,从空栈依次执行 push(A)、push(B)、pop()、push(C),状态变化为

text
[]  →  [A]  →  [A, B]  →  [A]  →  [A, C]
                         返回 B           栈顶 C

随后两次弹出依次得到 C、A。LIFO 约束的是每次操作时仍在栈中的元素,并不是说所有输入必须先结束,才能开始输出。由状态定义还能直接得到一条检验规律:在任意合法栈上先 push(x) 再 pop(),会返回 x 并恢复原状态。

直觉

先处理最近留下的任务 ​

读到一对嵌套括号的右半边时,应该先结束哪一层?答案是最近开启、尚未结束的那一层。栈把这种规则变成一个接口:元素只从同一端进入和离开,最后放入的元素最先取出,称为后进先出(LIFO)。这一端叫栈顶,另一端叫栈底。

栈是一种抽象数据类型:它规定允许怎样操作、操作返回什么,不规定元素怎样存放。动态数组和链表都能实现栈,但扩容、节点分配以及内存布局属于实现层。[1][2]

栈的 push、pop 与 top 示意图
例子与边界

括号匹配为什么只需看栈顶 ​

扫描括号串时,栈保存已经遇到、还没有匹配的左括号,按开启次序排列。遇到左括号就压入;遇到右括号,它只能关闭栈顶对应的那一层。因此必须同时检查“栈非空”和“括号类型相同”,检查通过才弹出。

对于 ([)],读到 ) 时栈底到栈顶依次为 (、[,栈顶是 [,所以立即判定不匹配。左右括号数量相等并不能保证结构正确;错误恰好发生在跨层关闭。对于 ([]),两个右括号依次关闭 [ 和 (,最后栈空,才说明所有层都已结束。

python
def brackets_match(text: str) -> bool:
    """检查三类括号;忽略其他字符。"""
    stack: list[str] = []
    partner = {')': '(', ']': '[', '}': '{'}
    for ch in text:
        if ch in '([{':
            stack.append(ch)
        elif ch in partner:
            if not stack or stack.pop() != partner[ch]:
                return False
    return not stack

算法的不变量是:每次扫描后,栈中恰好是当前前缀尚未关闭的左括号。左括号保持这一事实,合法右括号删除最近的一项;遇到失配时,任何后续字符也不能补救已经发生的交叉。长度为 n 的文本只扫描一次,时间为 O(n);空间为最大嵌套深度,最坏为 O(n)。

相同接口,成本保证可以不同 ​

以下成本以顺序 RAM 模型、常数大小元素和常数成本的局部内存操作为前提;链式实现还把单个节点的分配、释放按常数成本计算。

表示方法 push pop、top 主要条件
固定容量数组 最坏 O(1) 最坏 O(1) 容量是契约的一部分
几何扩容的动态数组 摊还 O(1) top 最坏 O(1);pop 取决于缩容策略 扩容的单次 push 可为 Θ(k)
表头作为栈顶的单链表 最坏 O(1) 最坏 O(1) 不包含非恒定的分配器开销

动态数组翻倍扩容时,连续压入 n 个元素所产生的复制量受几何和控制:1+2+4+⋯<2n。加上每次写入新元素的成本,总时间仍是 O(n)。这说明平均分摊到每次操作为常数,并没有让触发扩容的那一次操作变成常数时间。若允许缩容,通常在占用降到容量四分之一时才减半,避免在容量边界反复复制。[1]

使用双端队列时,只使用同一端的一组操作,也能实现栈接口。但若把“删除任意中间元素”暴露给客户端,提供的就是比纯栈更强的接口;这与内部偶尔移动元素是两回事。

推论与应用

栈怎样组织算法的控制流 ​

函数调用形成嵌套关系时,调用栈保存尚未完成的活动记录,其中可以包含返回位置、局部变量等信息。内层调用结束后,恢复最近暂停的外层调用,正好符合 LIFO。具体运行时可能优化尾调用,所以不应把每个源代码层面的调用都机械等同于新增一个物理栈帧。

深度优先搜索使用栈保存待继续探索的分支。递归写法让运行时维护这份状态;显式写法把它放进程序可管理的栈。若要精确复现递归访问次序,还需保存当前邻接表位置,或相应调整邻居的压栈顺序。

单调栈则在普通 LIFO 之上增加一个不变量:栈内元素按某种大小关系排列。新元素到来时,持续弹出已经失去候选资格的元素。线性时间通常来自每个元素至多入栈、出栈各一次,而不是因为一轮循环只能弹出一次。

与队列的先进先出规则相比,栈优先处理最近加入的元素。

参考资料

[1] Robert Sedgewick、Kevin Wayne,Algorithms, 4th ed., Addison-Wesley, 2011,§1.3: Bags, Queues, and Stacks:接口、链式实现、动态数组与缩容策略。

[2] MIT 6.031,2021 年春季课程讲义,Abstract Data Types:抽象行为与具体表示的分离。

关系图谱28 个相邻概念 · 5 类关系

拖动节点调整位置。

显示关系

显示:依赖

  1. 前置三跳
  2. 前置二跳
  3. 前置一跳
  4. 当前条目
  5. 后续一跳
  6. 后续二跳
  7. 后续三跳
文字版关系按与当前条目的最短距离分组
分类位置

上位 / 更一般

下位 / 直接特例

暂未标注直接特例。

类型化关系