平时写业务代码,很少会有人拎着你的耳朵说“这里请用栈”。但等你接触过表达式引擎、撤销重做、任务调度、消息队列这类系统之后,会发现栈和队列几乎是无处不在的“底层语法”。面试时它们也是最容易翻车的基础题:背过的概念,一追问底层扩容,一写循环队列,就露馅了。这篇文章想把栈和队列放到一起,从内存视角、工程视角、算法视角系统梳理一遍,适合刚学完基本语法、想真正吃透数据结构的同学,也适合准备技术面试的开发者回炉。
1. 命名背后的对称性:一个后进先出,一个先进先出
先别急着看代码。我对栈和队列的理解,是靠这两个名字本身建立起来的。英文里 stack 本意是一摞叠放的盘子,你只能从顶上拿盘子,也只能把新盘子放到顶上;queue 是排队买饭的队伍,先到的人先拿到餐。所以栈的行为规则叫 LIFO(Last In First Out,后进先出),队列的行为规则叫 FIFO(First In First Out,先进先出)。名字一出来,整个数据结构的核心语义其实已经定义完了。
1.1 操作规则是天然互逆的两兄弟
栈支持 push(入栈)和 pop(出栈),都发生在栈顶这一端;队列支持 enqueue(入队)和 dequeue(出队),但入队和出队发生在两端:入队发生在队尾,出队发生在队头。用一个很好记的说法,栈是“同端进出”,队列是“异端进出”。
这个差异带来一个非常有意思的镜像关系:同样一组数据,依次入栈再依次出栈,顺序会被反转;依次入队再依次出队,顺序保持不变。大量算法题考的就是这个性质,比如借助栈反转一个序列,或者借助队列保持一个顺序。我给新人梳理这块时,常让他们在白纸上画一条横线,左端是队头,右端是队尾,模拟入队出队;再画一个叠罗汉,模拟入栈出栈。模拟三遍,基本就不会再混淆了。
1.2 “只操作端点”看似受限,其实是效率的保证
很多初学者第一次接触栈和队列时会觉得:这不就是一个数组吗?为什么还要发明两个新名词?关键差别在于“操作权限”。
数组或链表允许在任意位置读写,但栈和队列只允许在端点操作。这个限制带来两个好处。第一,时间复杂度稳定,每次操作都是 O(1),不会因为数据规模增长而退化;第二,语义清晰,使用者不需要关心内部其他元素,出错的面积小了很多。用一个生活化的类比:食堂打饭窗口,如果所有人都能随意走到窗口前插队,队伍就乱了;规定只能从队尾加入、从队头离开,整个系统的行为就变得可预测。栈和队列的价值,正在于用规则约束了自由度,换来了确定性和效率。
1.3 系统认识的第一步,是看懂这对伙伴的互补性
如果只盯着单点看,栈和队列都太简单;把它们放到一起看,互补性立刻出来了。栈擅长处理“和最近状态相关”的问题,比如回溯、嵌套、撤销;队列擅长处理“按顺序推进”的问题,比如排队、广播、缓冲。
我记得在某个网关服务里做请求链路追踪,前端请求进来要按时间顺序记录日志,这是队列;后端一次调用链要记录嵌套的调用层级,这是栈。一套系统里,这两种结构从来不是互斥的,而是配合使用。理解到这一层,“系统认识”这个说法才算成立:你不是在记两个孤立的数据结构,而是在建立一张以“顺序”为轴心的认知地图。
2. 从内存布局看数组实现和链表实现的根本区别
概念层面的“后进先出”“先进先出”好懂,但真要写一个能跑的栈,或者一个不浪费空间的队列,就绕不开存储方案。这一节回到内存视角,看看数组和链表两条实现路线到底差在哪。
2.1 顺序栈:top 指针的语义必须想清楚
顺序栈用一块连续内存保存数据,用一个整数 top 标记栈顶。初始 top = -1 表示栈空;入栈时先把 top 加 1,再把元素写进数组;出栈时先取数组[top] 位置的元素,再把 top 减 1。逻辑非常简单,但有两个容易写错的地方:满栈判断和扩容。
静态数组大小固定时,top 等于 capacity - 1 就满了。继续入栈要么报错,要么触发扩容。动态扩容的常见做法是申请一块两倍大小的新数组,把老数据拷贝过去,再释放老数组。均摊下来,每次入栈仍然是 O(1) 的时间复杂度——这个“均摊分析”在面试里经常被追问,要能说清楚为什么一次性 O(n) 的拷贝,摊到 n 次插入里仍算 O(1)。简单理解就是:n 次插入中,只有最后一次触发了一次 O(n) 拷贝,总成本 O(n) 摊到 n 次操作,每次均摊 O(1)。
2.2 链式栈:用头插头删模拟栈顶
链式栈用单链表实现,头节点天然就是栈顶。入栈等于头插,O(1);出栈等于删除头节点,O(1)。链表实现的优点是容量天然动态,不需要预留空间、不需要扩容;缺点是每个节点要额外存一个指针,而且链表节点在内存中往往分散排列,遍历时 CPU 缓存命中率很低。
数组是连续内存,顺序访问时预取效果非常好。在现代处理器上,同样的数据量,数组实现通常比链表实现快一个数量级。这个差异在数据量小的时候不明显,但一旦涉及高频入栈出栈,差别会立刻体现出来。
2.3 顺序队列的“假溢出”,靠循环队列解决
顺序队列比顺序栈麻烦一点。用连续数组时,front 指向队头,rear 指向队尾下一个空位。入队在 data[rear] 写入,rear 加一;出队取 data[front],front 加一。问题来了:front 和 rear 只增不减,等 rear 走到数组末尾,哪怕数组前段已经空出一大片,也无法再入队。这就是教科书上说的“假溢出”。
解决办法是把数组在逻辑上首尾相接:rear 和 front 更新时都对容量取模。入队时执行 data[rear] = x; rear = (rear + 1) % capacity;出队时执行 x = data[front]; front = (front + 1) % capacity。队空和队满的判断是关键:front == rear 表示空;(rear + 1) % capacity == front 表示满。
很多人会问,为什么判断队满要浪费一个存储位置?因为 front 和 rear 相等时无法区分“空”和“满”,通过让 rear 永远不追上 front 来强制区分,代价只是少用一个槽位,换来的是判断逻辑统一、状态变量少。如果实在不想浪费,加一个 size 字段记录元素个数也能实现,但所有入队出队操作都要同步维护它,状态变量一多,出错概率就上去了。
2.4 一张表看清各实现的取舍
| 实现方式 | 入队/入栈 | 出队/出栈 | 空间特点 | 缓存友好度 | 适用场景 |
|---|---|---|---|---|---|
| 顺序栈 | O(1) | O(1) | 需扩容,预留连续内存 | 高 | 通用首选 |
| 链式栈 | O(1) | O(1) | 节点分散,无上限 | 低 | 无法预估容量上限 |
| 顺序队列 | O(1) | O(1) | 假溢出,需重排或搬移 | 高 | 不推荐直接用 |
| 循环队列 | O(1) | O(1) | 定长或需扩容 | 高 | 有容量上限的缓冲 |
| 链式队列 | O(1) | O(1) | 随节点分配 | 低 | 无界任务队列 |
我个人的倾向很简单:没有特殊理由时,默认选顺序实现。数组的缓存局部性和随机访问能力,让它更适合现代处理器。只有两种情况我会主动选链式:一是确实无法预估容量上限,又不想频繁扩容;二是需要一个“无界”任务队列,允许节点长期积压、逐个处理。
3. 从经典定义里长出来的变体:循环队列、双端队列、优先队列、单调结构
系统认识不能只停在教科书那四个基本操作上。实际工程和算法题里,栈和队列各自演化出一批变体,理解了它们,才算摸到这套工具组合的全貌。
3.1 双端队列:栈和队列的统一封装
双端队列(deque)允许在两端插入、两端删除。它既能当栈用,也能当队列用,还能在两端自由操作。很多语言标准库都内置了双端队列,Python 的 collections.deque、Java 的 ArrayDeque 都是典型代表。双端队列最大的价值是“一条结构覆盖多种场景”,而且通常用循环数组实现,性能并不差。
值得一提的是,Java 的 Deque 接口提供了 push/pop 方法,把它们和栈语义对上之后,代码可读性会好很多。不要只看到它“能当数组用”就随意随机访问,双端队列的定位是两端高效增删,随机访问并不是它的设计目标。
3.2 优先队列:以优先级而非时间顺序出队
优先队列不是严格意义上的队列,因为它不保证 FIFO。它通常用二叉堆实现,每个元素带一个优先级,出队时永远取优先级最高的那个。典型场景是任务调度、图的最短路径算法、TopK 问题。理解优先队列的关键,是明白它跳出了“时间顺序”这个队列核心,换成了“值的大小顺序”。所以它不是队列的退化,而是对“顺序”语义的一次升级。
Python 里可以用 heapq 包装普通列表,堆顶永远是最小元素:
import heapq heap = [] heapq.heappush(heap, (priority, task)) while heap: _, task = heapq.heappop(heap) print(task)用的时候要小心:如果你存的是自定义对象,必须能比较大小,否则入堆时会报错。
3.3 单调栈与单调队列:把 O(n^2) 降成 O(n) 的高频套路
技术面试里最常用的高级用法,一是单调栈,二是单调队列。单调栈指栈内元素保持单调递增或递减。以“下一个更大元素”为例:给定一个数组,求每个元素右边第一个比它大的元素。暴力写法是双重循环 O(n^2);单调栈的写法是遍历数组,遇到比栈顶大的元素就弹出栈顶,同时记录结果,然后当前元素入栈。每个元素最多入栈一次、出栈一次,整体 O(n)。
单调队列常配合滑动窗口使用,比如“滑动窗口最大值”:维护一个双端队列,队头永远是当前窗口最大值;新元素入队时,把队尾所有小于它的元素弹出;窗口左移时,把离开窗口的元素从队头移除。这里最难的地界判断是:什么时候该从队头弹,什么时候该从队尾弹。必须先画图推演,再加入窗口移动的条件,直接背模板非常容易写错。
这两类结构不需要背模板,真正需要理解的是单调性在维护什么信息——栈顶或队头保存的是“当前最优候选”,被弹出的元素都是被新元素淘汰掉的。想通这一点,遇到变种题也能举一反三。
4. 放回真实系统:五个一看就懂的应用场景
概念和实现讲完,很多人还是会问:这些到底在哪用?下面挑五个典型场景,说明栈和队列是怎么嵌入真实系统的。
4.1 浏览器前进后退,是两个栈在配合
浏览器要支持后退和前进,就需要两个栈。后退栈存访问历史,前进栈存被后退弹出的页面。当用户访问一个新页面时,前进栈必须清空——因为新的访问路径已经取代了原来的后续分支。这个模型和编辑器里的撤销重做完全一致:撤销栈保存操作历史,重做栈保存被撤销的操作;一旦有新的编辑动作,重做栈清空。
这个模型让我想到一个很常见的业务例子:配置中心的版本管理。管理员修改配置后,可以逐级回滚;但如果回滚之后又做了新修改,之前被回滚掉的版本就再也找不回来了。这正是双栈语义在真实系统里的体现。
4.2 表达式求值里的运算符栈
编译器或计算器把中缀表达式转成后缀表达式,经典做法是维护一个运算符栈。以1 + 2 * 3为例:数字 1、2、3 直接输出,遇到*时直接入栈,遇到+时发现栈顶是*,优先级更高,于是先把*弹出,再把+入栈。遇到左括号入栈,遇到右括号就一路弹出到左括号。这里栈的作用是保存“还没轮到处理的运算符”,等优先级条件满足后再处理。没有栈,这个流程很难用线性扫描完成。
括号匹配看似更简单,本质也是用一个栈记录“打开的括号”,遇到右括号时检查栈顶是否匹配。遇到不匹配或栈提前为空,就说明括号不合法。这套逻辑小到脚本解析器,大到编程语言的语法分析器,都在用。
4.3 BFS 用队列,DFS 用栈(递归栈也是栈)
图或树的广度优先搜索天然适合队列:先访问的节点先被扩展,一层一层向外推进。深度优先搜索则天然适合栈:一条路走到黑,走不动了再回溯。递归版本的深度优先搜索其实没有显式栈,但函数调用栈承担了同样的职责。每次递归调用都把现场信息压栈,返回时再弹栈,这正是“后进先出”的经典体现。
我常让新人做一个实验:用递归写一个二叉树前序遍历,再用显式栈改写一版。做完之后,你会对“递归等于隐式栈”这句话有切身体会,以后看到递归,第一反应就能知道它背后藏着一个栈。
4.4 消息队列和线程池任务队列
分布式系统里的消息队列并不完全等于数据结构教科书里的队列,但它最基本的语义仍然是 FIFO:发送方把消息放到队尾,消费方从队头取消息。它解决的问题是解耦和削峰——上下游不直接调用,而是通过队列缓冲流量。线程池内部同样维护一个任务队列,工作线程从队列中取任务执行,生产者向队列提交任务。这些都是队列在工程中最典型的应用。
从这个角度看,队列不只是“一种数据结构”,更是一种系统设计思想:把生产和消费的节奏解耦开。控制系统的人说“缓冲”,做业务的人说“排队”,本质都是队列。
4.5 从场景反推数据结构:观察数据是怎么流动的
如果你发现自己写的代码里,某个列表永远只在一端添加、另一端删除,那它就该被改成队列;如果永远只在同一端添加和删除,那就该改成栈。这个反推思路比背场景清单更实用。我在做代码评审时,常问一个问题:“这个列表的读写方向是什么?”答案往往能直接决定该用哪种结构。
很多“滥用数组”的代码,问题的根源不是性能,而是语义不清。一个列表既允许头部插入,又允许尾部删除,使用者就得记一套潜规则。换成栈或队列之后,接口自我解释,代码可读性立刻上一个台阶。
5. 不同语言里写栈和队列的隐藏细节
上一节讲的是“用哪种结构”,这一节讲“用哪种实现”——同一个数据结构,在不同语言里要避开的坑完全不同。
5.1 Python:list 适合当栈,不适合当队列
Python 的 list 是动态数组,append 和 pop 都发生在尾部,所以天然能当栈用,性能很好。但很多人会把 list 当队列,用 pop(0) 模拟出队。这个操作要把后面所有元素前移一位,复杂度 O(n),数据量一大就明显卡顿。正确做法是使用 collections.deque,它能在两端高效增删:
from collections import deque q = deque() q.append("task1") q.append("task2") while q: task = q.popleft() print(task)一个比较隐蔽的细节是,deque 也支持下标访问,但中间下标的访问是 O(n)。如果你需要随机访问,应该用 list,而不是 deque。
5.2 Java:Stack 是历史遗留类,ArrayDeque 更合适
Java 的 java.util.Stack 继承自 Vector,方法加了 synchronized,有额外的同步开销,设计上更接近“可扩展数组”而不是纯栈。官方文档也建议,需要栈语义时用 Deque 接口的实现类,比如 ArrayDeque。有一个细节要注意:ArrayDeque 不支持 null 元素,如果业务里可能出现 null,就得提前处理。
另外,ArrayDeque 作为队列使用时,推荐 addLast 和 removeFirst 组合;作为栈使用时,直接 push 和 pop。不要把两套方法混着用,否则代码读起来会非常拧巴。
5.3 C++:std::stack 和 std::queue 是容器适配器
C++ 标准库里的 stack 和 queue 都不是独立容器,而是容器适配器:它们包装了底层容器,只对外暴露栈或队列的接口。默认底层容器是 deque,但可以显式指定为 vector 或 list。例如std::stack<int, std::vector<int>>就把底层换成了 vector。这个设计的好处是行为可定制,坏处是新手容易把底层容器和栈的语义混在一起。
写 C++ 的时候还要注意,queue 的 front 和 back 都能访问,但只有 pop 才会移除队头。如果你同时需要遍历队列内部,queue 做不到,得换成 deque 自己实现。
5.4 Go:用切片模拟栈,要小心底层数组
Go 语言没有提供现成的泛型栈或队列容器,最常见的做法是用切片模拟栈:push 用 append,pop 用 s[:len(s)-1]。这里有个被忽视的坑:切片只截断长度,底层数组仍保留着已弹出的元素,如果元素类型是指针,会造成对象无法被回收,等价于内存泄漏。所以弹出后,最好把最后一个元素显式置为零值再截断:
func pop(s []*Task) ([]*Task, *Task) { if len(s) == 0 { return s, nil } v := s[len(s)-1] s[len(s)-1] = nil // 避免底层数组持有引用 return s[:len(s)-1], v }队列场景更简单,可以用切片加头尾指针,但要留意头部索引越过切片容量后需要重建底层数组,否则内存会持续增长。想省心一点,就直接用标准库里的 list 包,只是性能会比切片方案差一些。
5.5 无论如何都要小心的边界条件
不管用哪种语言,栈和队列的边界条件都是 bug 重灾区:从空栈里 pop、从空队列里 dequeue、循环队列里队空和队满的区分、扩容后索引是否仍然有效。我见过太多线上故障,最后定位到头就是一个 if 判断写反了。建议所有入口操作先做空判断或满判断,并把判断逻辑封装成私有方法,避免调用方每次自己判断。
6. 判断该用栈还是该用队列:一套可以直接抄走的标准
讲了这么多,核心问题终于浮出来:给定一个需求,凭什么决定用栈还是队列?
6.1 先用一句话对照
栈天然处理嵌套、回溯、逆序、最近相关;队列天然处理排队、顺序、广度、缓冲解耦。遇到具体问题,先问自己:数据的读取方向是什么?如果“后来的反而先处理”,用栈;如果“先来的先处理”,用队列。
6.2 再往深一层看三个信号
第一,是否需要撤销最近一次操作?需要,用栈。第二,是否需要按到达顺序逐个消费?需要,用队列。第三,是否需要在两端操作,或者既当栈又当队列?用双端队列。这三个信号覆盖了绝大多数业务场景。
6.3 一个值得反复写的经典题:用两个栈实现队列
如果用两个栈实现队列,入队时直接压入栈 A;出队时,如果栈 B 非空,直接从栈 B 弹栈;如果栈 B 为空,把栈 A 的元素全部倒入栈 B,再弹栈。后进先出经过两次反转,反而变成了先进先出:
class QueueWithTwoStacks<T> { Deque<T> inStack = new ArrayDeque<>(); Deque<T> outStack = new ArrayDeque<>(); void enqueue(T item) { inStack.push(item); } T dequeue() { if (outStack.isEmpty()) { while (!inStack.isEmpty()) { outStack.push(inStack.pop()); } } return outStack.pop(); } }这个题目考的不只是代码,而是对“反转”这一核心性质的理解。反过来,用两个队列实现栈也是一道对称的经典题,核心思想是留一个空队列作为临时中转。
6.4 把“系统认识”真正落地
我自己的经验是:别只刷题,也别只会做题,把栈和队列放回真实项目的调用链里再看一遍。比如写一个简单的命令行解析器,输入里的花括号嵌套要用栈判断合法性;再写一个批量任务提交工具,任务按提交顺序执行,用队列。当你能从“业务需要的顺序”直接推出“该用栈还是队列”的时候,这套知识才算真正长在你身上了。