最近有学弟问我:“栈和队列这章很简单吧,一个先进后出、一个先进先出,考试前背两个小时定义就行了吧?”类似的问题我一年能被问到很多次。栈和队列的概念确实不难,难的地方在于它们能解决的问题极其广泛——函数调用、浏览器后退、括号匹配、消息队列、线程池、树的遍历、图的搜索,底层全是它们在支撑。刚学程序设计的时候没感觉,等真正去写代码或者面试刷题,你会突然发现这两个结构几乎无处不在。
这篇内容我从三个层面展开:先用最直白的方式说清楚栈和队列的本质,再带手写一遍代码避开常见实现坑,最后把考试、求职和工程里最常遇到的场景和变种题过一遍。无论你是期末复习、准备实验报告,还是做面试冲刺,都能拿来直接用。
1. 栈和队列的本质:不是两种容器,是两种操作约定
1.1 线性表的一个分支,两种玩法
教科书里把栈和队列归为“操作受限的线性表”。线性表大家都熟悉,就是一组数据排成一条线,可以在任意位置插入、删除、查找。当给线性表加上限制,只允许在某一端操作元素,就出现了栈;只允许在一端插入、另一端删除,就出现了队列。
栈的特点是后进先出(Last In First Out,LIFO)。你可以想象成自助餐厅里摞盘子:新洗好的盘子总是放在最上面,取用的时候也必须先从最上面拿走。往里放叫入栈(push),往外拿叫出栈(pop),能操作的那一端叫栈顶。这也是为什么很多教材画图时,栈都是一根竖着的容器,元素从顶部进出。
队列的特点是先进先出(First In First Out,FIFO)。生活中的排队打饭、医院叫号,都是这个逻辑。先到的人先被服务,后来的排在队尾等待。在队列里,新增元素的操作叫入队(enqueue),发生在队尾;移除元素的操作叫出队(dequeue),发生在队头。
很多人初学时会陷入一个误区:把栈和队列当成两种“存储容器”去背。实际更准确的理解是——它们是在一组线性数据上约定了操作方式的抽象结构。同样一份数据,用不同的操作规则去组织,呈现出来的行为就是栈或队列。这个认知会直接影响你后续如何选择合适的结构。
1.2 限制操作的好处,比你想的多
既然线性表本身就能在任意位置操作,那为什么还要特意限制成只能在栈顶、队头、队尾操作?直接操作不是更自由吗?我最早学的时候也有这个疑问,后来在真实场景里才慢慢理解:自由是有代价的,限制反而带来确定性。
举个例子,函数调用时系统需要记录每个函数执行完之后的返回地址。函数 A 调用函数 B,B 的返回地址必须先记住;B 调用 C,C 的返回地址又记在 B 的上面。当 C 执行完,只能先回到 B,B 执行完再回到 A。这个顺序天然就是后进先出,只有用栈才能保证“最后一次调用最先返回”这个规则不会乱。
再比如打印任务。你在办公室给同一台打印机提交了多个文件,打印机不可能按提交顺序倒着来,那样先提交的人会等到崩溃。队列存在的意义,就是保证公平和有序,把无需确定顺序的请求排成一条可预期的通道。
我并不觉得“限制”是坏事。恰恰是因为栈和队列只暴露少数几个操作,理解的人更容易判断在什么场景下用它们,代码也会更可控。很多人学二叉树遍历时觉得递归难懂,本质就是对“调用栈”这个概念不够熟悉;很多人做消息队列架构时设计得不顺手,本质是没把“排队”的语义想透。
1.3 先把最容易混淆的几个概念理清
栈这章概念不多,但有一部分同学会在细节上栽跟头:
- 栈顶和栈底。栈底是固定的一端,栈顶是元素进出的一端。判断栈是否为空,看的是栈顶位置;遍历栈时往往也是从栈顶往栈底走。
- 队头与队尾。队头是删除元素的一端,也就是最早进来的位置;队尾是插入元素的位置。有些教材把队头写成 front,把队尾对应 rear。可别在写代码时把指针搞反。
- 出入顺序。栈的进出都在同一端,所以“后进”的反而“先出”;队列进和出是两端分隔的,所以“先进”的“先出”。
- 判断长度时,栈只需要看栈顶指针或存储元素数量;循环队列则要考虑取模,稍后我会详细展开。
还有一个容易忽略的知识点:给定入栈序列 1 到 n,可能的出栈序列并不是随便排列都行。n 个元素的合法出栈序列数量是卡特兰数,公式为 C(2n,n)/(n+1)。当你刷到“判断某序列是否可能是合法出栈序列”这种题时,背后就是这个问题。很多学校的数据结构实验或期末考试都爱在这里做文章。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 栈的代码实现:顺序栈扩容和链式栈边界要抠细
2.1 数组栈与链式栈怎么选
实现栈有两种经典方式:用数组实现(顺序栈)和用链表实现(链式栈)。两者各有特点,考试和面试常被问到,需要能说出差异和取舍。
| 对比项 | 顺序栈(数组实现) | 链式栈(链表实现) |
|---|---|---|
| 存储空间 | 连续内存,空间利用率高 | 每个节点额外存放 next 指针,有结构开销 |
| 扩容 | 数组满时需要扩容并搬运数据 | 天然可增长,只需要申请新节点 |
| 访问性能 | 缓存局部性好,压栈出栈快 | 节点分散,可能产生更多 cache miss |
| 栈顶位置 | 通过索引直接定位 | 需要确保 top 指向链表头节点 |
| 实现难度 | 简单直观 | 稍复杂,但能强化指针理解 |
实际工程中,动态数组实现的栈更常见。因为栈的操作只在尾部进行,数组尾部插入、删除都是 O(1),而且连续内存对缓存更友好。课程实验里让你用链表再写一遍,主要目的不是让你在项目里用链式栈,而是通过手写节点、指针的指向关系,把链表基本功打牢。
2.2 用数组实现一个会自动扩容的栈
这里给一个用 Python 手写顺序栈的例子。很多教材直接用 C 语言固定数组,便于讲清“上溢”“下溢”概念,但真实代码里不可能一点余量都不留。我写的这版模拟了动态扩容,逻辑上更接近 Java 的 ArrayList、Python 的 list 这类动态数组:
python复制class Stack:
def __in
