刚接触数据结构的同学,最先碰到的往往就是栈和队列。这两个结构听起来简单,一个是“后进先出”,一个是“先进先出”,但真要说清楚它们为什么存在、用在哪些场景、怎么写才算写对了,很多人会卡壳。这篇内容我从实际使用角度把栈和队列拆开讲,包含手写实现的完整思路、常见坑点、以及刷题和工作里真正用得上的场景,适合刚入门的人,也适合学完一遍想查漏补缺的人。
1. 先说人话:栈和队列到底在模拟什么
1.1 什么是栈:一摞盘子的游戏规则
栈(Stack)的规则就一句话:后进先出,英文缩写 LIFO(Last In First Out)。
你可以想象食堂里的一摞盘子,保洁阿姨总是把洗好的盘子叠在最上面,取盘子的师傅也是从最上面拿。最后放上去的盘子第一个被拿走,这就是后进先出。
计算机里的栈和这摞盘子一模一样。你向栈里放一个元素,叫入栈(push);从栈顶拿一个元素,叫出栈(pop);只看不拿,叫取栈顶元素(peek)。新元素永远压在旧元素上面,能操作的位置有且只有一个,就是栈顶。
这个“只有一个出口”的限制看起来很死板,但正是这种约束带来了极佳的性能和极高的确定性。你用栈的时候不用担心从中间删元素、不用维护复杂的顺序,所有操作都在顶部完成,时间复杂度天然是 O(1)。
很多初学者会觉得栈“太简单了,没什么好学的”。但恰恰是这种最简单的结构,在系统底层无处不在。函数调用时的参数保存、递归时的返回地址、表达式的求值、浏览器的后退按钮,背后都是栈在兜底。说它是计算机系统最底层的“铁布衫”,一点都不夸张。
1.2 什么是队列:奶茶店排队的游戏规则
队列(Queue)的规则也一句话:先进先出,英文缩写 FIFO(First In First Out)。
想象奶茶店门口排的队伍,先来的人先买到奶茶,后来的人只能排在后面。新来的同学站队尾(入队,enqueue),柜台接过队首顾客的点单(出队,dequeue)。这就叫先进先出。
队列的核心在于“公平”。先请求的资源先被处理,先到达的任务先被执行,这种特性让它成为系统调度、消息通信、流量缓冲等场景的基础设施。你在食堂排队、在车站买票、在系统里发消息,用的都是队列模型。
和栈一样,队列也有一个明确的限制:只能从一端进、另一端出。这个限制保证了“先来后到”,也让队首和队尾的位置有明确的职责分工。
1.3 为什么要单独学这两种结构
很多人会问:数组、链表就已经能装数据了,为什么还要抽象出栈和队列?
我的理解是:数据结构不只是“存数据的方式”,更是“操作数据的约定”。数组允许你随机访问任意位置,这在带来灵活性的同时也带来成本——你需要管理下标、防止越界、考虑中间插入删除的复杂度。但栈和队列主动放弃了随机访问能力,换来的是逻辑简单、边界清晰、操作效率恒定。
这种“放弃换来确定性”的思路,是工程师做系统设计的重要思维。在业务开发中,如果某个模块只需要“先进先出”的处理顺序,你就不该拿数组手动维护一个队列指针,更不该拿链表反复遍历找尾部。直接用现成的队列结构,出错概率低,代码可读性也高。
从学习角度讲,栈和队列是理解“限制如何创造价值”的第一课。后面学到树、图、动态规划时,你会发现很多算法都是在这些基础结构上做文章。把栈和队列吃透,再去看二叉树的前中后序遍历、图的深度优先和广度优先搜索,会顺畅很多。
2. 手写实现:数组路线和链表路线怎么选
2.1 用数组实现栈:为什么栈顶指针这么设计
用数组实现栈是最直观的方式。你需要一个数组、一个变量记录当前栈顶位置,然后围绕这个变量做push、pop、peek。
class ArrayStack: def __init__(self, capacity=10): self.capacity = capacity self.data = [None] * capacity self.top = -1 # 栈顶下标,-1表示空栈 def push(self, value): if self.top == self.capacity - 1: raise OverflowError("栈已满") self.top += 1 self.data[self.top] = value def pop(self): if self.top == -1: raise IndexError("空栈") value = self.data[self.top] self.data[self.top] = None # 释放引用 self.top -= 1 return value def peek(self): if self.top == -1: raise IndexError("空栈") return self.data[self.top] def is_empty(self): return self.top == -1关于栈顶指针的初始值,我见过不少新手写top = 0,然后入栈直接data[top++] = value。这样写不能说错,但会让“空栈”的判断变得别扭,因为top = 0又代表栈底有元素,又可能代表空栈,容易混淆。更稳的做法是让top = -1代表空栈,入栈先加一再赋值,出栈先取值再减一。
这个设计的本质是把“栈顶位置”和“元素个数”合二为一。当top指向 -1 时没有元素;当top指向 0 时有 1 个元素;当top指向 k 时有 k+1 个元素。判断满栈用top == capacity - 1,判断空栈用top == -1,非常干净。
数组实现栈最大的优点是缓存友好,元素在内存里是连续排列的,访问速度快。缺点是容量固定,一旦满了要么报错,要么扩容。实际工程里我更推荐动态扩容:当数组满了,新开一个两倍大小的数组,把旧数据拷贝过去。虽然拷贝是 O(n),但均摊下来每个元素的成本还是 O(1)。
2.2 用链表实现栈:头插头删为什么比尾插尾删更优
链表实现栈不需要考虑容量,但也别踩“尾插尾删”的坑。
如果拿单链表做栈,入栈操作有两种选择:在头部插入新节点,或在尾部追加新节点。出栈也一样。很多人直觉觉得“尾部更像栈顶”,于是选择维护一个 tail 指针做尾插尾删。问题来了:单链表找尾部需要遍历,除非你额外维护 tail 指针,否则每一步操作都是 O(n)。就算维护了 tail 指针,尾删时想找到倒数第二个节点,仍然需要遍历 O(n)。这个效率是灾难性的。
正确做法是只用头结点作为栈顶。入栈就是头插法,出栈就是删除头结点,全程 O(1),不需要 tail 指针,边界判断也简单:
class Node: def __init__(self, value): self.value = value self.next = None class LinkedStack: def __init__(self): self.head = None def push(self, value): node = Node(value) node.next = self.head self.head = node def pop(self): if self.head is None: raise IndexError("空栈") value = self.head.value self.head = self.head.next return value这段代码里最妙的地方是入栈的三行赋值:新节点的 next 指向旧头,然后 head 指向新节点。第一次写的时候很容易漏掉node.next = self.head这一行,结果头结点永远覆盖旧节点,栈变成了只能存一个元素的“假栈”。
链表实现的好处是容量天然动态,内存不连续但胜在灵活。缺点是每个节点多存一个 next 指针,内存开销更大,而且在极端情况下频繁新建节点会让内存碎片化。实际系统里,如果栈的容量跨度很大,用链表或动态数组都可以;如果容量基本稳定且追求极致性能,动态数组通常更合适。
2.3 循环队列:为什么 rear 指针要浪费一格空间
用数组实现队列,最经典的问题就是“假溢出”。假设数组长度是 5,front 指向队首前面那个位置,rear 指向队尾元素,入队时 rear 向后移动,出队时 front 也向后移动。当 rear 走到数组末尾时,即使 front 前面已经空出大量空间,你还是会误以为队满了。这就是假溢出。
解决假溢出的标准方案是循环队列,让 rear 走到末尾后回到下标 0。判断队列空、队列满的方式需要仔细设计。
我常用的是“牺牲一个存储单元”的方案:队列满的条件是(rear + 1) % capacity == front,而不是rear == front。因为如果 rear 和 front 相等,既可以表示空队列,也可以表示满队列,会引发歧义。浪费一个格子的目的就是把这两种状态区分开。
class CircularQueue: def __init__(self, capacity=10): self.capacity = capacity self.data = [None] * capacity self.front = 0 # 指向队首元素 self.rear = 0 # 指向队尾元素的下一个位置 def enqueue(self, value): if (self.rear + 1) % self.capacity == self.front: raise OverflowError("队列已满") self.data[self.rear] = value self.rear = (self.rear + 1) % self.capacity def dequeue(self): if self.front == self.rear: raise IndexError("空队列") value = self.data[self.front] self.front = (self.front + 1) % self.capacity return value这里有一个容易搞混的点:到底让 rear 指向队尾元素本身,还是指向队尾的下一个空位。我推荐前者,也就是“rear 指向下一个要写入的位置”。这样入队的代码是“先写数据,再移动 rear”,出队的代码是“先取数据,再移动 front”,逻辑统一、不容易写错。
判断队列中元素个数也有固定公式:(rear - front + capacity) % capacity。为什么要加 capacity 再取模?因为 rear 可能已经绕回数组开头,直接相减得到负数,加上 capacity 之后才能转成正数。这个 +capacity 再取模的小技巧,在循环队列的所有运算里都会反复出现,建议记牢。
2.4 链表队列:tail 指针才是核心
链表实现队列,很多人会拿单链表的头尾来类比。入队在尾部操作,出队在头部操作,各干各的,互不干扰。
要实现 O(1) 入队,必须额外维护一个 tail 指针,否则每次入队都要遍历到尾部。这个 tail 指针是整个实现的灵魂:
class LinkedQueue: def __init__(self): self.head = None self.tail = None def enqueue(self, value): node = Node(value) if self.tail is None: self.head = node self.tail = node else: self.tail.next = node self.tail = node def dequeue(self): if self.head is None: raise IndexError("空队列") value = self.head.value self.head = self.head.next if self.head is None: self.tail = None return value这段代码里有两个细节值得注意。第一,入队时如果队列为空,要同时更新 head 和 tail;第二,出队时如果删掉了最后一个元素,tail 要记得置空,否则 tail 会变成一个悬空引用,下次入队时会出现“向一个已删除节点的 next 赋值”的诡异问题。
我在给同事评审代码时经常发现,链式队列漏写if self.head is None: self.tail = None是很常见的错误。这个 bug 不会立刻报错,但会在下一次入队时破坏链表结构,导致遍历时丢失节点。调试起来很费劲,建议写完后专门测一遍“入队一个元素,再出队一个元素,再入队一个元素”的序列。
3. 实战场景:栈和队列在系统里到底藏在哪里
3.1 函数调用与递归:系统栈是怎么工作的
你写的每一行代码,最终都跑在操作系统维护的调用栈上。当一个函数被调用时,系统会把参数、局部变量、返回地址压入栈帧(Stack Frame)。函数执行完毕,系统把栈帧弹出,程序跳回返回地址继续执行。
递归为什么容易栈溢出?因为每层递归都会生成一个栈帧,如果递归深度是几万层,栈空间自然不够用。你平时看到“RecursionError”或者“Stack Overflow”,本质上就是系统栈满了。
理解了系统栈的工作方式,你就能解释很多现象:为什么递归层数太多会崩,为什么局部变量在函数返回后就消失了,为什么调试时看到的“调用堆栈”是反着的。这些都是栈的基本规则在真实系统中的体现。
没有系统栈,函数调用无法正确返回,程序根本跑不起来。这也是为什么几乎所有编程语言和操作系统的运行时里,栈都是最基础的存在。
3.2 浏览器的前进后退:双栈的设计魅力
浏览器的前进、后退功能,是一个非常漂亮的“双栈”应用。
当你从页面 A 访问页面 B,再访问页面 C,浏览器维护一个“后退栈”:A 压入栈底,B 压入栈中,C 在栈顶。点击后退时,C 出栈并进入“前进栈”,你看到页面 B。再点后退,B 出栈进入前进栈,看到页面 A。如果此时你点击一个新链接打开页面 D,浏览器会清空前进栈,因为你已经走出了原来的历史分支。
这个双栈模型把“回退”和“分支”处理得特别自然。你在文本编辑器里撤销和重做,也用同样的模型:撤销栈记录操作历史,重做栈记录被撤销的操作。只要不产生新操作,重做栈里的内容一直保留;一旦产生新操作,重做栈直接清空。
很多业务系统里的“操作历史”“版本回退”功能,都是这个双栈思想的变体。所以下次面试被问到“如何设计撤销重做功能”时,不要只会说“用栈”,要能说出“双栈 + 清空重做栈”这个关键细节。
3.3 括号匹配与表达式求值:算法题里的栈
栈在编写编译器、解释器时是绝对的主力。判断一段代码里的括号是否匹配,就是经典的栈应用。
遍历字符串,遇到左括号就入栈,遇到右括号就取出栈顶元素检查是否配对。如果栈为空却遇到右括号,说明右括号多了;如果遍历结束后栈里还有左括号,说明左括号多了。整个过程一遍扫描,O(n) 解决问题。
表达式求值也离不开栈。中缀表达式1 + 2 * 3要转成后缀表达式1 2 3 * +,再用另一个栈计算结果。操作符优先级需要两个栈协作,一个存数字、一个存符号。很多初学者在理解逆波兰表达式时卡住,其实就是没有把“栈的延迟能力”想清楚——遇到乘号时不能立刻计算,要等右侧的数字先入栈,这种延迟计算正好由栈的先进后出特性承接。
在实际开发中,你可能不会经常手写表达式解析,但一旦遇到模板引擎、规则引擎、公式计算之类的需求,栈就是最趁手的工具。
3.4 消息队列与任务调度:队列扛起了高并发的大旗
队列在工程系统里出镜率极高。最典型的是消息队列,生产者把消息发到队列,消费者从队列里取消息处理,两边速度不一致也没关系,队列充当缓冲。
为什么用队列而不是数组或链表直接传递?因为队列天然实现了“解耦”和“削峰”。系统 A 只需要把消息丢进队列,不需要关心系统 B 是否准备好;系统 B 只需要从队列里取消息,不需要关心上游的处理速度。这种异步模型是分布式系统的基础设施。
操作系统层面的任务调度也大量使用队列。CPU 的进程调度、线程池的任务队列、打印机的任务排列,本质上都是“先来先服务”。你来一个任务就排队,处理完再叫下一个。这种公平顺序在业务系统里也很常见,比如秒杀系统的排队提示、银行窗口的取号叫号,都是队列思想。
广度优先搜索(BFS)的代码结构同样依赖队列。树的层序遍历、迷宫的最短路径问题,算法核心都是“先进队列的节点先扩展”,这保证了搜索是从内向外一层层推进的。队列的先进先出特性,正好和 BFS 的层级扩展逻辑严丝合缝。
4. 进阶变形:单调栈、双端队列与递归转迭代
4.1 单调栈:为什么它能高效解决“下一个更大元素”
单调栈是栈结构的进阶玩法。普通栈只保证先进后出,而单调栈额外保证栈内元素按某种顺序排列,比如从栈底到栈顶单调递增或单调递减。这个特性让它可以快速找到“距离当前元素最近的更大/更小元素”。
经典题目“下一个更大元素”的思路是:遍历数组,维护一个单调递减栈。元素入栈前,如果栈顶元素小于当前元素,就把栈顶元素弹出,并记录“当前元素是栈顶元素的下一个更大元素”。因为栈是单调递减的,弹出的元素必然在栈中较大,而当前遍历到的元素又是它右边第一个比它大的值。
这种做法的复杂度是 O(n)。如果不用单调栈,两重循环暴力求解,复杂度是 O(n^2)。差距就是这么明显。
单调栈的核心技巧是弄清楚“什么时候入栈、什么时候出栈、出栈时能确认什么信息”。多画几遍图比背代码更有效。我建议初学者不要直接背模板,而是手写几个测试用例,观察每次 push 和 pop 的变化。
4.2 双端队列:滑动窗口问题的神器
双端队列(Deque)同时具备队首和队尾的进出能力,是队列的高级形态。
滑动窗口最大值问题里,需要维护一个“队首到队尾单调递减”的双端队列。窗口右移时,新元素从队尾加入,如果队尾元素比新元素小,就弹出;窗口左移时,如果队首元素已经滑出窗口,也弹出。这样队首永远是当前窗口的最大值。
这里要特别注意:队列里存的是数组下标,不是元素值。因为判断队首元素是否滑出窗口,需要用下标和当前窗口左边界比较。存值的话,你无法知道这个元素是否还在窗口内。很多人在写滑动窗口时踩坑,就是因为存了值而不是下标。
双端队列把“从两端操作”的能力发挥到极致,但你也不要滥用。普通场景用栈或队列就够,强行上双端队列只会增加代码复杂度。
4.3 递归转迭代:显式栈代替系统栈
递归代码简洁,但深度受限。当你要把递归改成非递归时,显式栈就是替代系统栈的工具。
以二叉树的前序遍历为例,递归写法可以很方便地完成,但改成迭代写法时,你需要自己维护一个栈。先访问根节点,再把右孩子入栈,最后把左孩子入栈。因为栈是后进先出,左孩子后入栈反而先出栈,正好符合前序遍历“根-左-右”的顺序。
这个过程其实就是在手动模拟系统调用栈的行为。你压入的不只是节点本身,还包括“当前节点处理到哪一步了”。很多复杂递归改迭代时,需要额外用一个状态标记表示“回到这个节点时需要继续执行哪个分支”,这种技术叫“栈帧模拟”。
实操中,如果递归深度不大,直接用递归没问题;如果深度可能很大,或者需要手动控制搜索路径,显式栈是更好的选择。判断标准很简单:这个递归是否可能在数据量增大时栈溢出,如果是,就考虑迭代实现。
5. 常见问题与排查技巧实录
5.1 栈溢出:不只是递归才会触发的崩溃
很多人以为只有递归写深了才会栈溢出,但实际开发中,无限循环的相互调用、过深的函数链调用同样会触发。
我见过一个线上问题:代码里 A 方法调用 B 方法,B 方法在某种异常情况下又回调 A 方法,形成死循环调用。日志里没有任何报错,直到宿主进程直接崩溃。排查时从线程栈里看到的是一长串完全相同的调用记录,这就是典型的系统栈被打爆。
避免这类问题的经验:第一,递归和循环调用务必设置深度上限或次数上限;第二,大数组尽量放在堆里而不是函数局部变量里,因为局部大数组会占用栈空间;第三,交付前用极限参数跑一遍压力测试,确认最深调用链是可控的。
5.2 假溢出:循环队列为什么不能只看 rear 到末尾
数组队列的假溢出问题非常隐蔽,尤其在 C 系语言里写死数组容量时特别容易翻车。
假设数组长度是 4,你入队了 4 个元素,rear 走到了数组末尾,此时你出队 2 个元素,front 也往前走了 2 格。此时逻辑上队列还剩 2 个元素,但 rear 已经在数组尾部,无法再插入。很多新手在数组尾部判断rear == capacity就报“队列已满”,可实际上队列前面空着两个位置。
解决假溢出的唯一彻底方案是循环队列,让 rear 从尾部绕回头部。写代码时一定要用% capacity取模,并且在判断满时用“(rear + 1) % capacity == front”。如果你不想浪费一格空间,也可以加一个 size 变量记录元素个数,用size == capacity判断满,用size == 0判断空,这样就不需要牺牲存储单元。
5.3 空栈空队操作:边界条件为什么必须抛异常
操作空栈或空队,是最容易被忽略的边界情况。程序运行时,如果队列恰好被消费完了,任何一次 pop 或 dequeue 都应该有明确的行为。
我见过的项目里,最常见的错误是“返回 null”。表面上看调用者拿到 null 后可以自己判断,但很容易漏判,一旦对 null 继续调方法,就是空指针异常。更合理的做法是:如果在业务上“空栈/空队”属于正常状态,可以设计一个返回值约定或抛出特定异常;如果空栈是一个意外状态,直接抛出异常让上层感知才是正确的。
从 API 设计的角度讲,明确约定“空栈时 pop 抛出异常”比“返回 null”更安全。调用者在写代码时会被迫处理异常,而不是默默吞掉 null。这也是很多标准库选择在非法操作时抛异常的原因。
5.4 怎么练才能真正掌握:一条可复制的路径
光看不练,栈和队列永远只是课本上的概念。我建议的学习路径分三步。
第一步,先把数组栈、链表栈、循环队列、链表队列四个实现全部手写一遍,不要看源码,写完再对比标准写法。重点是理解“为什么栈顶指针初始值是 -1”“为什么循环队列要留一个空位”“为什么链式队列要维护 tail”。
第二步,刷典型题目。括号匹配、栈实现队列、队列实现栈、最小栈、下一个更大元素、滑动窗口最大值,这 6 题覆盖了栈和队列最常见的考点。每道题都要能独立在白板上写出来,并解释复杂度。
第三步,联系实际项目。在你的业务代码里找一找有没有“后进先出”或“先进先出”的场景。比如最近的搜索结果记录、操作日志、消息推送缓存,尝试用栈或队列重新实现一遍。把理论落到工程里,才算真正消化。
最后再分享一个实操小技巧
我自己在调试栈和队列相关的代码时,习惯写一个非常短的辅助函数,把当前栈或队列的所有元素打印出来。
def print_queue(q): cur = q.head while cur: print(cur.value, end=" -> ") cur = cur.next print("None")每次 push、pop、enqueue、dequeue 之后都调一次这个函数,能看到结构的变化是否符合预期。这个习惯帮我抓出了不少“忘更新 tail”“循环取模位置不对”的隐藏 bug。别嫌打印日志土,亲手跟踪一遍数据结构内部的运动轨迹,比盯着代码干想快得多。栈和队列只是起点,把这种“亲手跟踪状态”的方法用到树、图、动态规划上,你会发现数据结构的学习会顺很多。