栈:后进先出结构与典型应用

学完顺序表和链表之后,一个很自然的问题就是:这些基础结构本身能不能进一步抽象成更有明确访问约束的模型?答案当然可以,而栈就是最经典的第一类。它不再允许你像线性表那样在任意位置自由访问和修改,而是主动收紧操作范围,只允许在同一端进行插入和删除。也正因为这种限制,栈反而变得更有力量:规则简单、行为稳定、适合表达一类非常典型的问题。

栈最核心的特征通常只需要一句话就能概括:后进先出,也就是 Last In First Out,常写作 LIFO。最后放进去的元素,会最先被取出来;越早进去的元素,越靠近底部,越晚才能被访问到。这个规律听起来很朴素,但它在程序世界里出现得非常频繁。函数调用栈、表达式求值、括号匹配、浏览器返回、撤销操作、深度优先搜索,都和栈的思想有非常直接的关系。

所以栈这一篇最重要的,不只是知道它能 pushpop,而是理解:

  • 为什么“只能在一端操作”反而是一种强约束下的高效抽象;
  • 为什么这么简单的结构会在系统和算法里反复出现;
  • 为什么很多看似不同的问题,背后其实都符合“最后进入的状态,最先需要处理”这一模式。

一、什么是栈

栈可以先从抽象数据类型的角度来理解:

栈是一种只允许在表的一端进行插入和删除操作的线性结构。

这一端通常被称为栈顶,另一端称为栈底

于是栈中最重要的规则就出来了:

  • 新元素只能从栈顶进入;
  • 删除元素也只能从栈顶移除;
  • 因此最后进入栈顶的元素,会最先离开。

这就是“后进先出”的本质。

如果把它想象成一摞盘子,会非常直观:

  • 你往上面放一个新盘子;
  • 取的时候也总是先拿最上面那个;
  • 最早放进去的盘子会被压在下面,必须等上面的都拿掉之后才能取到。

这种模型虽然简单,但非常适合描述“最近进入的内容应该最先处理”的问题。

二、栈通常支持哪些操作

如果从 ADT 视角看,栈最常见的操作通常只有这几个:

  • push(x):把元素压入栈顶;
  • pop():弹出栈顶元素;
  • peek()top():查看栈顶元素但不删除;
  • isEmpty():判断栈是否为空;
  • size():获取栈中元素个数。

你会发现,和线性表相比,栈的接口非常克制。它故意不鼓励你去中间访问、随机修改,而是把操作集中在一个端点上。

这种限制并不是“功能变少了”,而是让结构的语义更清晰,也让很多操作的复杂度更稳定。

三、为什么栈是线性表的受限版本

前面我们说过,栈不是凭空出现的,它可以看成是线性表在访问规则上的一种特化。

线性表允许你:

  • 按位置访问;
  • 在任意位置插入删除;
  • 查找任意元素。

而栈把这些自由度收紧成:

  • 只能在栈顶插入;
  • 只能在栈顶删除;
  • 最常关注的只有栈顶元素。

所以你可以把栈理解成一种“操作受限,但行为模式更明确”的线性结构。

这种受限设计非常重要,因为它带来了两个直接好处:

  • 实现通常会更简单;
  • 某些场景的建模会特别自然。

数据结构里有一个很常见的思想就是:

不是所有时候都追求“越自由越好”,很多时候恰恰是通过限制操作,换来更清晰的语义和更稳定的效率。

栈就是这类思想最典型的例子。

四、栈为什么容易实现

因为栈只需要关心一个端点,也就是栈顶。

不管你用什么底层结构实现,只要能高效维护栈顶附近的操作,栈的核心能力就能成立。

这也是为什么栈通常有两种最经典的实现方式:

  • 顺序栈,也就是用顺序表或动态数组实现;
  • 链式栈,也就是用链表实现。

由于操作范围很集中,栈的 pushpop 都很容易做到常数级,这一点比一般线性表要稳定得多。

五、顺序栈是什么

顺序栈可以理解成:

用一段连续存储空间来保存栈中的元素,并用一个变量记录当前栈顶位置。

它的典型特点是:

  • 底层通常是数组或动态数组;
  • 栈顶一般放在数组末尾;
  • 入栈相当于尾部追加;
  • 出栈相当于尾部删除。

如果底层是动态数组,那么:

  • 查看栈顶通常是 O(1)
  • 入栈通常是均摊 O(1)
  • 出栈通常是 O(1)

顺序栈的优点非常明显:

  • 实现简单;
  • 内存局部性好;
  • 栈顶操作很快;
  • 常数开销通常较小。

但它也保留了顺序存储的一些特性,比如依赖连续空间,以及在静态实现下可能会遇到容量限制。

六、链式栈是什么

链式栈通常是:

用链表来表示栈,并把链表头部作为栈顶。

这样设计很自然,因为:

  • 头插相当于入栈;
  • 删除头节点相当于出栈;
  • 查看头节点相当于查看栈顶。

链式栈的特点通常是:

  • 不要求连续空间;
  • 理论上更灵活,不容易受固定容量限制;
  • 栈顶插入删除也能做到 O(1)
  • 但每个节点会有额外指针开销。

所以顺序栈和链式栈并不是谁绝对更好,而是两种不同的实现取舍:

  • 顺序栈通常更紧凑、更快;
  • 链式栈通常更灵活、扩展性更自然。

七、栈的复杂度为什么通常很稳定

这是栈很重要的一个特点。

由于栈所有核心操作都集中在栈顶,所以:

  • push 通常是 O(1)
  • pop 通常是 O(1)
  • peek 通常是 O(1)
  • isEmpty 通常也是 O(1)

如果是基于动态数组的顺序栈,入栈偶尔会因为扩容出现一次较贵操作,但从均摊角度看仍然通常是 O(1)

这说明栈非常适合那种“只关心最近状态”的场景,因为它既能清晰表达语义,又能提供稳定的局部操作效率。

八、为什么说函数调用本身就是一个栈过程

这是栈最经典、也最重要的现实应用之一。

程序在执行函数调用时,通常会为每次调用创建一份调用记录,用来保存:

  • 参数;
  • 局部变量;
  • 返回地址;
  • 调用状态。

这些调用记录通常放在调用栈里。

于是执行过程就非常符合栈的规律:

  • 调用一个新函数,相当于把一个新的调用帧压入栈顶;
  • 当前函数执行结束后,相当于从栈顶弹出这一帧;
  • 总是最后调用的函数先返回。

这和我们前面学递归时看到的执行过程完全一致。所以从某种意义上说,递归之所以能成立,背后正是因为系统用栈维护了层层调用关系。

也正因为这样,递归太深时才会出现栈溢出问题。这不是“递归神秘”,而只是调用栈空间有限。

九、括号匹配为什么适合用栈

这是栈在算法题里最经典的入门应用。

例如一个表达式里有 ()[]{},我们要判断括号是否匹配。直觉上,后出现但尚未闭合的左括号,应该优先等待匹配它后面最近出现的右括号。

这件事和栈非常吻合:

  • 遇到左括号就压栈;
  • 遇到右括号就检查栈顶是否是对应类型的左括号;
  • 如果匹配就弹栈;
  • 最后栈为空,则匹配成功。

这里背后的本质就是:

最近打开但还没关闭的结构,必须最先被关闭。

这正是一个典型的后进先出过程。

十、表达式求值和栈有什么关系

中缀表达式、后缀表达式、运算符优先级处理,也都和栈高度相关。

例如处理中缀表达式时,常常需要解决这些问题:

  • 运算符什么时候参与计算;
  • 括号如何改变优先级;
  • 某个运算符应该先处理还是后处理。

这时,栈就非常适合作为“暂存最近尚未处理运算符或操作数”的结构。

很多经典算法都会用:

  • 一个栈存运算符;
  • 一个栈存操作数;

或者直接把表达式转换成更容易计算的后缀表达式,再借助栈求值。

所以你会发现,栈特别适合那些“当前还不能立刻处理,需要暂时挂起,等后面时机成熟再处理”的场景。

十一、浏览器返回、撤销操作为什么也符合栈模型

这类例子在日常使用中最容易感受到。

1. 浏览器返回

你访问页面的顺序通常是:

  • A 页面;
  • B 页面;
  • C 页面;

此时点击返回,最先离开的应该是最近进入的 C,然后才是 B,最后才轮到 A。这显然符合栈模型。

2. 撤销操作

在编辑器里,你最近做的一次修改,通常会最先被撤销;更早之前的修改,则要等最近操作撤销后才会轮到。

这本质上也是:

  • 最新状态最先回退;
  • 早期状态后回退。

所以很多撤销、回退、历史状态恢复,本质上都非常适合用栈来维护。

十二、深度优先搜索为什么常和栈联系在一起

图和树的深度优先搜索 DFS,无论是显式写栈,还是用递归,本质上都离不开栈思想。

因为深度优先搜索的行为模式是:

  • 先沿着当前路径一直往深处走;
  • 走不通了再回退到最近的分叉点;
  • 再继续尝试新的分支。

这里“最近的分叉点先被回退处理”,本质上就是后进先出。

如果用递归写 DFS,系统调用栈在替你隐式维护这条路径;如果不用递归,就常常要自己显式维护一个栈。

所以栈不仅是一个独立结构,也是一种很强的“过程组织方式”。

十三、什么时候一个问题适合想到栈

这是学栈时最值得培养的判断能力。

一个问题如果具备下面这些特征,往往就可以考虑栈:

  • 最近进入的状态要最先处理;
  • 需要沿着一条路径不断深入,再逐层回退;
  • 某些元素需要暂存,等待未来某个时机再处理;
  • 存在明显的嵌套结构;
  • 需要匹配“最近未完成的部分”。

例如:

  • 括号匹配;
  • 函数调用;
  • 表达式求值;
  • 路径回退;
  • 单调栈类问题;
  • 深度优先遍历。

也就是说,真正学会栈,不是背一个定义,而是看到问题时能识别出“这里是不是最近进入的东西要最先出来”。

十四、学习栈时最容易踩的坑

1. 只记住后进先出,但不会识别应用场景

这是最常见的问题。定义背得很熟,但一到题目里就不知道什么时候该用栈。

2. 忘记判空

无论是顺序栈还是链式栈,在 poppeek 前都要先考虑空栈情况。

3. 把“栈”和“递归”当成两套无关知识

其实递归背后就是调用栈,二者联系非常紧密。

4. 对顺序栈和链式栈的差别没有建立复杂度和实现视角

虽然它们核心操作都很快,但内存组织、扩容方式、空间开销并不一样。

5. 只会写基本栈操作,不理解为什么某些问题天然符合栈模型

真正重要的是模式识别能力,而不是 pushpop 几行代码。

十五、为什么栈是后面很多主题的基础

栈虽然看起来比线性表更“收缩”,但它的影响范围非常广。

后面你会不断遇到和栈相关的内容:

  • 递归和调用栈;
  • 表达式求值;
  • 深度优先搜索;
  • 单调栈;
  • 语法解析中的嵌套结构处理;
  • 编译器和解释器中的部分状态维护。

这说明栈并不是一个“功能单一、只适合入门”的结构。恰恰相反,它是那种规则很简单,但可以稳定迁移到很多复杂问题中的基础模型。

总结

栈的重要性,不只是因为它简单,而是因为它把“最近状态优先处理”这类问题抽象得非常清楚。真正值得先建立起来的,是这些核心认识:

  • 栈是一种只允许在一端插入和删除的线性结构,核心规律是后进先出;
  • 它是线性表的一种受限版本,通过限制操作换来了更清晰的语义和更稳定的局部效率;
  • 顺序栈和链式栈都能高效实现栈,但它们在空间组织和实现细节上有所不同;
  • 函数调用、递归、括号匹配、表达式求值、浏览器返回、撤销操作和深度优先搜索都和栈高度相关;
  • 真正学会栈,不是只会几个基础操作,而是能识别“最近进入的内容应该最先处理”的问题模式;
  • 栈既是一种具体数据结构,也是一种非常重要的过程组织思想。

把这一篇理解透之后,后面再去学队列时,你会更明显地看到另一种完全不同的访问约束:一个强调后进先出,一个强调先进先出。它们共同说明了一件很重要的事:在数据结构里,限制操作方式并不是削弱能力,很多时候恰恰是在为某类问题提炼最合适的表达模型。

参考资源