想象你是餐厅后厨的总管。洗好的盘子被一只只摞在餐盘架上——新洗的盘子总是压在旧盘子上面,取用时却必须从最上面那一只开始拿。这就是「后进先出」。而取餐窗口的顾客们排成一条长队——先到的人先拿到餐食,没人能插队到最前面,这就是「先进先出」。这两种看似简单的规则,正是计算机世界里最基础、最强大的两种数据结构:栈(Stack)与队列(Queue)。后厨的餐盘架管理着每道菜的出品顺序,计算机的栈与队列则管理着每一段程序、每一个任务、每一份数据的流转节奏。从函数调用到浏览器回退,从打印任务到外卖派单,从AI请求排队到GPU算力调度,处处都是它们的身影。今天,我们就用这家「数字餐厅」的视角,把栈与队列的原理、实现、应用一次看透。
理解栈与队列是掌握一切算法大厦的基石。对AI从业者而言,无论是训练数据的流水线调度、推理服务的请求排队,还是Transformer中层层嵌套的计算追踪,本质上都是在驾驭这两种「顺序的艺术」。
目录
可视化演示
展示栈LIFO压栈弹栈操作与函数调用栈映射、队列FIFO入队出队与循环队列环形空间复用、优先队列最小堆结构与堆顶出队过程。
本节配有交互动效(进入视口后自动加载,约 3KB)。
栈的后进先出(LIFO)模型 PUSH压入栈顶,POP弹出栈顶元素 函数调用栈:f3→f2→f1→main层级展开 push: stack[++top]=x; pop: return stack[top--]; O(1) 栈顶唯一操作端,push/pop均为O(1),递归调用天然使用栈结构。
栈的原理:一摞刚洗好的餐盘
后厨的餐盘架前,洗碗工小张把洗好的盘子一只只往架上摞。他只会把新盘子放在最上面,取用时也只能从最上面拿——最底下那只「陈年餐盘」要等到上面所有盘子都用完才重见天日。这就是「后进先出」(Last In First Out, LIFO)。计算机里的栈就是这样一个餐盘架:只允许在一端(栈顶)放入或取出元素,另一端(栈底)被牢牢封死。
栈的极简规则带来极高的效率。数组实现时,我们只需维护一个 top 指针指向栈顶位置。「入栈」(push)把新元素写到 top 处并让指针向上移动一格,「出栈」(pop)则把指针向下移动一格并返回刚才指向的元素。无论是入栈还是出栈,都只触碰数组的一个位置,不需要移动其他任何元素——这正是栈的灵魂:专一而快速。
出栈操作则反向执行:先把 top 减一,再返回 data[top]。判断栈空只需检查 top == 0,判断栈满则检查 top == capacity。餐盘架的弹簧把每一只新盘子托到最顶,同样地,每一次入栈都把新元素托到「栈顶」这个唯一的入口。
- 定位栈顶位置
data[top]是数组中以 top 为下标的位置,也就是当前最顶上的空位。 - 写入新元素把 x 放到这个栈顶位置,相当于把新盘子摞到最上面。
- 移动栈顶指针top 加一,让它指向下一个空位,随时准备迎接下一个元素。
栈的应用:大厨的进阶菜谱
主厨王师傅要挑战一道「佛跳墙」,步骤多达十几步。他不会一股脑全部记在脑子里,而是把「做完这道菜要回到哪个步骤」写成一张张便签摞在灶台边。做到第5步需要去备料,就把「回到第5步」压到便签堆最上面;备料完成,取走最上面的便签,回到第5步继续。每层嵌套的步骤,恰好构成一个后进先出的小山——这正是计算机中函数调用栈的工作原理。程序调用函数 A,A 又调用 B,B 再调用 C,每个函数的信息像便签一样压栈;C 执行完返回 B,B 返回 A,逐层弹出。
这个机制最经典的应用之一是括号匹配。编译器检查 ([{}]) 是否合法时,遇到左括号就入栈,遇到右括号就弹出栈顶并检查是否配对。遍历结束时若栈为空,说明所有括号都找到了自己的另一半。
同样基于栈的还有表达式求值:将中缀表达式 3 + 4 × 2 转换为后缀表达式 3 4 2 × + 时,运算符要暂存进栈,等优先级更高的运算符先出栈计算。浏览器里的「后退」按钮也是栈——你访问过的每个页面被依次压栈,点后退就是弹出最近访问的那一页。便签山上的规则,从厨房灶台一路延伸到了整个计算机世界。
- 遇到左括号把它压入栈中暂存,等待对应的右括号。
- 遇到右括号弹出栈顶元素,检查两者是否配对成功。
- 最终判定遍历完整个表达式后,如果栈为空则全部匹配,否则说明有括号「落单」。
队列的原理:取餐窗口前的那条长队
正午十二点,取餐窗口前的队伍排到了门口。每个人都自觉站在队尾,拿到餐的人从队首离开。先来的先服务,后来的耐心等待,这就是「先进先出」(First In First Out, FIFO)。队列正是这条长队的精确建模:新元素从队尾(rear)进入,旧元素从队首(front)离开,绝无插队的可能。
队列用数组实现时,同样只需要两个指针:front 指向队首元素,rear 指向队尾的下一个空位。入队时在 rear 位置写入数据然后 rear++,出队时读取 front 位置的数据然后 front++。两队指针像餐厅的「叫号员」,一前一后地管理着整个队伍。
判断队列空的条件是 front == rear,判断队满则是 rear == capacity。生活中的「排队」无处不在:银行叫号、打印机任务、外卖配送顺序……队列用最简单直接的公平规则,保证了先到先得的确定性。
- 定位队尾空位
data[rear]是当前队尾的下一个空位。 - 写入新元素把 x 放到这个位置,新顾客站到队伍最后。
- 移动队尾指针rear 加一,队尾向后延伸一个位置。
顺序与链式:固定窗口与接龙队伍
同样是排队,餐厅有两种组织方式。一种是「固定窗口」:划好一片连续区域,每个位置固定编号,顾客按号码依次往前站(数组实现)。另一种是「接龙队伍」:每位顾客只知道自己后面是谁,整个队伍由一个个「节点 + 指针」串联而成(链表实现)。
数组实现的最大优势是随机访问——你知道第3个位置是谁,直接一步走到那里,复杂度 O(1)。但它的弱点是空间固定:队伍太长时只能整体搬迁到更大的区域(动态扩容),搬迁时要复制所有元素,开销不小。链表实现则没有这个烦恼:新的顾客来了,只需让最后一位顾客的指针指向新节点,队伍随时可以无限延长。但链表无法「一步跳到位」,想找第3个人必须从队首一个个数过去。
实际工程中两者各有拥趸:底层队列常用数组追求吞吐量,而需要频繁插入删除的场合则偏爱链表。固定窗口与接龙队伍各有各的节奏,关键看你的餐厅更在意哪种体验。
- 数组空间n 个元素紧密排列,每个占 size 字节,总空间为 n × size。
- 链表空间每个节点除了元素本身,还要一个指针字段(4或8字节),总空间为 n × (size + ptr)。
- 取舍权衡数组更省空间且缓存友好,链表更灵活但内存开销大。
循环队列:旋转寿司台的智慧
固定窗口式队列有个尴尬的问题:顾客取餐离开后,front 前面的位置就空了出来,但 rear 已经走到了数组末尾——明明前面有空位,新顾客却排不进来。这就是「假溢出」。聪明的餐厅老板改造了布局:把直线队伍弯成一个旋转寿司台。寿司盘转完一圈回到原点继续使用,座位不再分首尾,而是一个环形。
计算机里的解决方案同样优雅:用「取模运算」让 front 和 rear 指针在到达数组末尾后自动绕回开头。(rear + 1) % capacity 使得指针在 0 到 capacity-1 之间循环移动。这样一来,数组的空间被真正循环复用,不再有「前面空着却进不来」的悲剧。
旋转寿司台让每一寸空间都被反复使用,「假溢出」彻底消失。这个设计在操作系统缓冲区、网络数据包队列、音频流播放等场景中无处不在——循环数组是工程中最经典的「空间复用」智慧。
- 取模绕回
(rear + 1) % capacity让队尾指针加一后在数组范围内循环。 - 队满条件当绕回的队尾指针等于队首指针时,环形队伍已经转满一圈,无法再入队。
- 牺牲一格队空条件是
front == rear,为了和队满区分,通常牺牲一个存储单元,让队满时 rear 恰好落后 front 一格。
队列的应用:传菜流水线与叫号系统
后厨与前厅之间有一条传菜履带:厨师把做好的菜放在履带一端,服务员从另一端取走。履带上的菜按照放上去的顺序到达,谁也不能越过前面那份——这就是队列在生产中的角色:缓冲与解耦。厨房出菜速度和前厅上菜速度不必完全同步,中间的履带吸掉了速度波动。
在计算机系统中,这种「履带」无处不在。操作系统用任务队列管理等待 CPU 的进程,新进程排在队尾,CPU 空闲时从队首取一个执行;分布式系统用消息队列(如 Kafka、RabbitMQ)在服务之间传递数据,生产者只管放,消费者按序取,两边互不阻塞;深度学习的数据加载管线同样如此,DataLoader 用一个多进程队列源源不断地把处理好的数据喂给 GPU,让昂贵的算力永不停歇。
从传菜履带到 AI 推理服务中的请求排队,队列始终是系统「削峰填谷」的定海神针。它让生产者和消费者各干各的,彼此放心。
- 出队取点BFS 从队首取出当前节点 u,表示「这一层正在处理的位置」。
- 扩展邻居把 u 的所有未访问邻居 v 入队,每个邻居的深度在 u 的基础上加一。
- 逐层推进队列保证了同一深度的节点先于下一层节点被处理,实现「层层扫描」。
双端与单调:贵宾通道与精英筛选
有的餐厅设置了一条贵宾通道:两头都能进出,VIP 可以从前面直接插队,也可以从后面提前离开。这就是双端队列(deque)。它能从两端插入和删除,是栈和队列的「自由合体」。Python 的 collections.deque 就是它的工业级实现,两端操作都是 O(1)。
双端队列最精彩的应用是单调队列——想象评委筛选「最佳菜品」:候选者排成一队,但规则很残酷——新来的候选者如果比队尾的更强,队尾那个就被当场淘汰(因为他在新候选者离开之前永远不可能是最佳)。这样整条队伍从队首到队尾始终保持「实力递减」的单调性。每轮只需要看一眼队首,就能知道当前窗口的最强者。
单调队列用「弱肉强食」的淘汰策略,把滑动窗口最值问题从 O(nk) 降到 O(n)。这道经典的 LeetCode 题目(滑动窗口最大值),正是每一位算法工程师必须掌握的「精英筛选术」。
- 暴力复杂度n 个窗口,每个窗口内比较 k 次,总时间 O(nk)。
- 单调队列复杂度每个元素最多被入队一次、出队一次,总时间 O(n)。
- 核心思想淘汰掉「永远不可能成为最大值」的弱者,让队首始终是当前窗口的最佳答案。
优先队列:急诊室的叫号系统
普通队列讲先来后到,但急诊室不这么干:心跳骤停的病人即使刚进门,也必须排在感冒病人前面。这里的关键不是「到达顺序」,而是「病情优先级」。优先队列(Priority Queue)正是为这种场景而生——出队的元素永远是当前队列中优先级最高(或最低)的那个,而不是最早进来的那个。
优先队列最常见的底层结构是二叉堆(Binary Heap):一种特殊的完全二叉树,堆顶永远是最大(或最小)元素。插入新元素时把它放到末尾然后向上「上浮」;弹出堆顶后把末尾元素放到堆顶再向下「下沉」。上浮和下沉每步跨越一层,而树的高度是 log n,因此插入和删除的复杂度都是 O(log n)。
基于优先队列的 TopK 问题是海量数据场景的常客:用大小固定的最小堆扫描一遍全量数据,堆里始终保留最大的 K 个,最终堆顶就是第 K 大的元素。从急诊分诊到推荐系统的热门榜单,优先队列让「最重要的事最先被处理」成为一条可计算的规则。
- 插入上浮新元素先放到堆的末尾,然后逐层与父节点比较,必要时交换向上。
\text{bubbleUp}: \text{child} \leftrightarrow \text{parent},\ \text{直到父 \geq 子} - 弹出下沉删除堆顶后,把末尾元素移到堆顶,再逐层与较大的孩子交换向下。
\text{sinkDown}: \text{parent} \leftrightarrow \text{largest child},\ \text{直到父 \geq 子} - 对数时间树高为 log n,每步至多跨越一整个树高,因此单次操作 O(log n)。
AI 应用场景
GPU推理服务的请求调度
阶段:模型部署与在线推理
解决的问题:大语言模型服务上线后,并发请求像潮水般涌来,GPU 算力却有限。如果直接让每个请求独占 GPU,超载时大量请求会排队超时甚至崩溃。用队列接收所有推理请求,按先进先出的顺序分批喂给 GPU 执行,既保证了请求的公平性,又让 GPU 像传菜履带一样保持满负荷运转,避免「厨师忙死、顾客饿死」的失衡。队列的缓冲作用把尖峰流量平滑成匀速水流,系统稳定性大幅提升。
具体案例:某 AI 公司部署 7B 参数对话模型,使用消息队列(Kafka)接收用户请求,后端消费者按序拉取并组 batch 送入 GPU。高峰期每秒 2000 个请求全部进入队列排队,GPU 利用率从 40% 提升到 85%,超时率从 15% 降到 0.5%。
自动求导中的调用栈追踪
阶段:模型训练与反向传播
解决的问题:训练 Transformer 时,损失函数对每个参数的梯度需要从输出层逐层回传。这种「从后往前」的计算顺序天然是一棵调用树——前向传播每执行一个张量操作,自动求导引擎(如 PyTorch 的 autograd)就把该操作的梯度函数压入栈中;反向传播时从栈顶依次弹出并执行,恰好按照「后计算的操作先求梯度」的 LIFO 顺序完成。如果没有栈,框架就无法记住复杂的嵌套计算路径。
具体案例:训练 GPT 模型时,一个 attention 层包含数十个张量操作,autograd 用调用栈记录每一步的操作与依赖。反向传播时逐层弹出梯度函数,从输出层一路计算到嵌入层,最终完成全部参数更新。这正是栈在 AI 框架底层最核心的工程应用。
Python 演示
from collections import deque def max_sliding_window(nums, k): """单调队列求解滑动窗口最大值。 队列中保存下标,且对应数值从队首到队尾严格递减。 """ dq = deque() result = [] for i, num in enumerate(nums): while dq and nums[dq[-1]] <= num: dq.pop() dq.append(i) if dq[0] <= i - k: dq.popleft() if i >= k - 1: result.append(nums[dq[0]]) return result if __name__ == "__main__": nums = [1, 3, -1, -3, 5, 3, 6, 7] k = 3 print("滑动窗口最大值:", max_sliding_window(nums, k))
from collections import deque导入双端队列,两端增删都是 O(1),这是单调队列的物理基础。while dq and nums[dq[-1]] <= num:把队尾所有「打不过新元素」的下标全部弹出,维持队列从队首到队尾单调递减。dq.append(i)当前元素的下标入队,它可能是未来窗口的最大值候选。if dq[0] <= i - k:队首下标已经滑出窗口范围,果断淘汰。result.append(nums[dq[0]])窗口形成后(i >= k-1),队首永远是当前窗口的最大值。
常见误区
误区用数组实现队列时,用 front++ 和 rear++ 移动两个指针就万事大吉了,数组前面空出来的位置不用管。
正解指针只增不减会让 front 和 rear 一直向右移动,当 rear 到达数组末尾时,即使 front 前面空着一大片位置,也无法再入队。这就是「假溢出」。真正的解法是循环队列——用取模运算让指针绕回数组开头,把闲置空间循环利用起来,就像旋转寿司台的轨道。
为何常见:一维数组给人「线性走到头」的直觉,容易忽略指针可以折返的设计空间。
误区循环队列中,front == rear 既可以表示队空也可以表示队满,只需要额外用一个变量记录元素个数就行。
正解如果 front == rear 同时代表两种状态,程序无法区分「空无一人的队伍」和「绕了一圈正好站满」的情况。经典的解决方法是牺牲一个存储单元:让队满条件变成 (rear + 1) % capacity == front,此时 rear 始终比 front 落后一格,两种状态彻底分离。用计数器是另一种方案,但要额外维护变量。
为何常见:认为「首尾相遇就是满了」是符合直觉的,却忽略了空队列也会首尾相等。
误区单调队列和优先队列都是能快速找最值的「队列」,本质是同一回事,可以互换使用。
正解两者机制完全不同。单调队列维护的是「窗口内按下标顺序排列、值单调的候选序列」,它只能访问窗口内的最值,且过期元素会自动滑出;优先队列用二叉堆维护的是「全局按优先级排序」的集合,每次出队的是全局最优,但无法处理滑动窗口的「过期」问题。滑动窗口最值用单调队列 O(n),全局 TopK 用优先队列 O(n log K)。
为何常见:两个名字都带「队列」,又都能求最值,初学者极易混淆其适应场景。
误区Python 的 list 既能 append 又能 pop(0),这不就是现成的队列吗?
正解list.pop(0) 要从队首删除元素,底层需要把后面所有元素整体前移一位,时间复杂度 O(n)。如果有 n 个元素依次入队出队,总时间会退化到 O(n²)。正确做法是用 collections.deque,它的 popleft() 是真正的 O(1) 操作。
为何常见:Python 的 list 用起来太顺滑了,掩盖了它底层是连续数组、头部删除成本极高的本质。
面试题
Q1. 栈最重要的特性是什么?
- A.先进先出
- B.后进先出← 正确答案
- C.支持随机访问
- D.两端都可以插入删除
答案:B。栈像一摞餐盘,后放上去的盘子总是在最上面,取用时也最先被拿走,这就是「后进先出」(LIFO)。队列才是先进先出,链表才支持灵活插入,双端队列才允许两端操作。
Q2. 以下哪种场景最适合使用队列?
- A.函数调用的返回地址追踪
- B.括号匹配检查
- C.打印机任务按提交顺序输出← 正确答案
- D.浏览器后退按钮
答案:C。打印任务讲究先到先打印,是典型「先进先出」场景,和取餐窗口前排队一模一样。函数调用追踪和浏览器后退都要「回到最近的状态」,属于栈;括号匹配是栈顶的配对验证,也是栈。
Q3. 栈的典型应用场景包括哪些?
- A.函数递归调用← 正确答案
- B.表达式求值← 正确答案
- C.消息中间件的广播
- D.浏览器前进后退← 正确答案
答案:ABD。函数递归调用时每层调用的返回地址压入调用栈,表达式求值的运算符需要暂存到栈中等待优先级处理,浏览器前进后退维护的是一份访问历史栈。消息广播是「一对多」的发布订阅模型,与栈的后进先出无关。
Q4. 对于一个容量为 5 的循环队列,当前 front = 2,rear = 1,队列中的元素个数是多少?
- A.2
- B.3
- C.4← 正确答案
- D.1
答案:C。循环队列元素个数公式为 (rear - front + capacity) % capacity,代入得 (1 - 2 + 5) % 5 = 4。就像旋转寿司台上从 front 位置顺时针数到 rear 位置经过的盘子数,答案是 4 个元素。
Q5. 判断循环队列已满的条件是(capacity 为数组长度)?
- A.front == rear
- B.rear == capacity - 1
- C.(rear + 1) % capacity == front← 正确答案
- D.front == (rear + 1) % capacity == 0
答案:C。为了区分队空(front == rear)和队满,循环队列牺牲一个存储单元。当 rear 再向前走一格就追上 front 时,说明整圈已经站满,即 (rear + 1) % capacity == front。选项 D 包含两个等号嵌套,语法都不成立。
Q6. 关于单调队列,下列说法正确的有?
- A.它可以维护滑动窗口内的最大值← 正确答案
- B.内部元素从队首到队尾单调递减时可以求窗口最大值← 正确答案
- C.它利用堆结构保证元素有序
- D.它的时间复杂度和暴力遍历相同
答案:AB。单调队列通过淘汰「永远不可能成为最大值」的弱元素,让队首始终保持当前窗口的最大值,因此 A、B 正确。它依赖的是双端队列而不是堆,C 错;暴力法是 O(nk),单调队列是 O(n),D 错。这就像精英筛选,弱者被当场淘汰,队首永远是最强者。
Q7. 用单调队列求解长度为 n 的数组、窗口大小为 k 的滑动窗口最大值,时间复杂度是?
- A.O(nk)
- B.O(n)← 正确答案
- C.O(nlogn)
- D.O(k)
答案:B。虽然外层循环有 n 次迭代,但每个元素最多被入队一次、出队一次,总操作次数是 O(n)。暴力法对每个窗口重新比较 k 次才是 O(nk)。单调队列的巧妙之处在于淘汰永远不会成为答案的元素,让每一轮都是 O(1) 摊还。
Q8. 优先队列(二叉堆实现)插入一个新元素的平均时间复杂度是?
- A.O(1)
- B.O(logn)← 正确答案
- C.O(n)
- D.O(nlogn)
答案:B。插入新元素时先放到堆末尾,然后不断和父节点比较并「上浮」。二叉堆是完全二叉树,高度为 log n,上浮最多跨越一个树高,因此复杂度 O(logn),与急诊室分诊台每次调整的层级数对应。
Q9. 关于顺序实现(数组)与链式实现,下列说法正确的有?
- A.数组支持 O(1) 的随机访问← 正确答案
- B.链表在头部插入和删除是 O(1)← 正确答案
- C.数组扩容时需要整体搬迁原有元素← 正确答案
- D.链表在任何场景下都比数组快
答案:ABC。数组是一段连续内存,按下标一步定位,随机访问 O(1);链表节点用指针串联,在已知节点的头部增删只需改指针,O(1);数组扩容要申请更大内存并复制全部元素,C 正确。但链表需要额外指针空间且缓存不友好,并非任何场景都快,D 错。
Q10. 用大小为 K 的「最大堆」扫描 n 个元素求 TopK 最小元素,扫描结束后堆顶元素是什么?
- A.第 K 小的元素← 正确答案
- B.第 K 大的元素
- C.全局最小的元素
- D.全局最大的元素
答案:A。维护大小为 K 的最大堆时,堆里始终保留「当前遇到的最小的 K 个元素」,堆顶是这 K 个里最大的那一个,所以堆顶恰是全体数据中第 K 小的元素。就像急诊室只保留病情最重的 K 个病人,分诊台上最轻的那个就是第 K 重。若求 TopK 最大则用最小堆,堆顶就是第 K 大。