1. 别急着写代码:先看透“操作受限”这四个字
栈和队列这两个数据结构,难吗?说实话,每个学过编程的人都能说出个大概:栈是“后进先出”,队列是“先进先出”。但真到用的时候,问题就来了——为什么函数调用非要栈?为什么操作系统的消息队列不叫消息栈?为什么同样是线性表,数组实现和链表实现写出来的栈,性能差距能到几倍甚至十几倍?我在一次团队代码评审里见过一个很典型的情况:功能全部跑通,但用了一个list.pop(0)模拟队列,数据量一大,接口直接超时。所以这篇不是给你背定义的,是想把栈与队列里那些“大家默认你会,但你其实没想透”的部分,一次讲完。
1.1 栈和队列本身也是线性表
先回到本质。线性表就是一组数据排成一列,每个元素有前驱和后继。数组和链表都是线性表的底层实现方式。栈和队列有什么特殊?特殊不在存储方式,而在操作规则:栈只允许在同一端(栈顶)插入和删除,队列只允许在一端(队尾)插入、另一端(队头)删除。
所以教科书里把栈和队列叫作“操作受限的线性表”。很多人把这句话当理论背过去了,其实它是整个数据结构的核心。正是因为操作被“锁死”,使用者在写代码时只需要关心顺序规则,不用操心内部数据的排列细节,抽象层级一下就拉上去了。你想想,如果函数调用可以随便从任意位置存取栈帧,那程序没跑两步就全乱套了。
栈遵循“后进先出(LIFO)”,队列遵循“先进先出(FIFO)”。这两个规则不是随便定的,它们分别对应两类不同的问题:一类需要“撤销最近的操作”,一类需要“按到达顺序处理事件”。
1.2 限制操作带来的三个实际好处
限制操作这件事,在工程上至少有三个好处:
第一,便于推理和保证一致性。栈和队列的操作模式固定,行为可预期,写业务逻辑的时候,你不需要担心某个地方被意外修改。好比排队买饭,大家按顺序往前走,队伍不会乱,新来的人永远排在队尾。
第二,便于复用和组合。因为底层只要实现“从一端放入、从一端取出”这组接口,上层就可以构建表达式求值、括号匹配、广度优先搜索、消息队列等大量应用,不需要知道底层是数组还是链表。
第三,便于实现算法的高效性。很多算法的复杂度优势,恰恰来自“只用一端操作”。比如单调栈、深度优先搜索的递归改非递归,如果数据结构没有这种限制,就得额外维护状态,代码复杂度成倍上升。
1.3 基本操作与时间复杂度速览
先列一个表,后面每个章节都会围绕这个表展开:
| 操作 | 栈 | 队列 | 说明 |
|---|---|---|---|
| 插入 | push 栈顶 | enqueue 队尾 | 入栈/入队 |
| 删除 | pop 栈顶 | dequeue 队头 | 出栈/出队 |
| 查看队头/栈顶 | peek/top | front | 不删除,只看 |
| 判空 | isEmpty | isEmpty | 经常被忽略 |
| 平均时间复杂度 | O(1) | O(1) | 数组+链表都是 |
| 空间复杂度 | O(n) | O(n) | 存储数据本身 |
这里需要注意一点:O(1) 说的是“均摊”或“在固定容量场景下”的时间。如果你用 Python 的 list 当栈,append和pop确实是 O(1);但如果拿它当队列用,pop(0)会把后面所有元素往前搬,一次就是 O(n)。这个坑我放到最后一章专门讲,这里先记住一个结论:数据结构选对,代码复杂度直接降一个量级。
2. 两种底层实现:数组方案的快与链表方案的活
很多人觉得栈和队列的实现太简单,不值得认真写。但恰恰是这些“简单结构”的实现,最能暴露对内存、指针、边界条件的理解程度。我用 Python 做演示,因为代码短、可读性强,但思路对所有语言通用。
2.1 顺序栈:用一个游标就能完成的实现
数组实现栈的核心是维护一个top指针,指向当前栈顶元素。插入元素时top加 1,删除时top减 1。下面是带扩容的版本:
class ArrayStack: def __init__(self, capacity=8): self.data = [None] * capacity self.capacity = capacity self.top = -1 # 栈顶元素的索引,空栈时为 -1 def push(self, val): if self.top == self.capacity - 1: # 栈满,扩容 self._resize(self.capacity * 2) self.top += 1 self.data[self.top] = val def pop(self): if self.isEmpty(): raise IndexError("pop from empty stack") val = self.data[self.top] self.data[self.top] = None # 帮助 GC 回收引用 self.top -= 1 return val def peek(self): if self.isEmpty(): raise IndexError("peek from empty stack") return self.data[self.top] def isEmpty(self): return self.top == -1 def _resize(self, new_capacity): old = self.data self.data = [None] * new_capacity for i in range(self.top + 1): self.data[i] = old[i] self.capacity = new_capacity两个细节值得多说。一是self.data[self.top] = None这一行,很多教科书代码里没有。它看起来可有可无,但在长期运行的程序里,如果栈里存的是大对象、大数组,不把弹出的引用清掉,对象就永远不会被回收,内存悄悄涨上去。所谓“内存泄漏”,很多就是这么积累出来的。二是top = -1而不是top = 0,这样空栈的判断和pop之前的判断共用一套逻辑,少一次比较。
2.2 链式栈:该还回去的引用一定要还
链表实现栈不需要提前分配容量,每个元素是一个节点,指向下一个节点。入栈就是在链表头插入节点,出栈就是移除链表头节点。
class LinkedStack: class _Node: __slots__ = ("value", "next") def __init__(self, value, next=None): self.value = value self.next = next def __init__(self): self._head = None self._size = 0 def push(self, val): self._head = self._Node(val, self._head) self._size += 1 def pop(self): if self.isEmpty(): raise IndexError("pop from empty stack") val = self._head.value self._head = self._head.next # 让原头节点失去引用 self._size -= 1 return val def peek(self): if self.isEmpty(): raise IndexError("peek from empty stack") return self._head.value def isEmpty(self): return self._head is None链式栈的pop只需要让_head指向下一个节点,原来那个节点就会被回收,同样要注意的是不要先把头节点的next存到临时变量再改,那样一步到位反而不容易出错。用__slots__声明节点属性,是为了减少每个节点的内存占用,节点数量大的时候能省不少。
2.3 数组与链表的真实选择逻辑
这两个实现哪种更好?要看场景。
数组栈的优势在内存连续。连续内存在 CPU 层面可以被预取到缓存,遍历和存取的速度远高于散落在不同地址的链表节点。如果你要写一个高频入栈、出栈的组件,比如函数调用的调度器,数组方案通常更快。劣势是要扩容,扩容那一次需要把旧数据复制到新数组,最坏情况 O(n),但均摊下来依然是 O(1)。
链表栈的优势在无需扩容、结构简单,每次插入只需分配一块节点内存。劣势是指针占空间,而且节点分散,缓存不友好,在数据量变大后性能反而下降。
所以我的建议是:默认选数组,除非你需要实现“无上限”且“插入删除频繁”的场景,才考虑链表。算法题里没有特殊说明,数组栈足够。工程里如果容量不可控,可以选择动态扩容数组,而不是一上来就上链表。
队列的数组实现比栈麻烦——它需要两个指针,一个指向队头,一个指向队尾。如果只用线性数组,出队时所有元素得往前移,那就 O(n) 了。解决办法就是下一章要讲的循环队列。
3. 循环队列的边界条件:为什么白白空一格反而最省事
我见过不少人手写队列,写完一测,看起来没问题,但把队列填满再全部弹出,程序进入死循环或者直接给出错误结果。几乎所有 bug 都出在同一个地方:判空和判满的条件不清晰。
3.1 不循环会遇到的搬移问题
如果用一个普通数组记录队列,队头指针head指向第一个元素,队尾指针tail指向下一个插入位置。入队时tail += 1,出队时head += 1。问题很明显:出队操作让head越来越大,数组前面的位置空出来却无法复用。当tail到达数组末尾时,即使前面有一大片空位,也没有办法继续入队。
循环队列的思路是:逻辑上把数组头尾相接,tail从n-1再往前走一步就回到下标 0。这样空出来的位置就能被重复利用。
3.2 判空判满与长度计算的统一套路
循环队列最经典的做法是牺牲一个存储单元。tail永远指向下一个入队的位置,队列最多只存capacity - 1个元素。
class CircularQueue: def __init__(self, capacity): self.capacity = capacity self.data = [None] * capacity self.head = 0 # 队头元素下标 self.tail = 0 # 下一个入队位置 def enqueue(self, val): if self.isFull(): raise OverflowError("queue is full") self.data[self.tail] = val self.tail = (self.tail + 1) % self.capacity def dequeue(self): if self.isEmpty(): raise IndexError("dequeue from empty queue") val = self.data[self.head] self.data[self.head] = None self.head = (self.head + 1) % self.capacity return val def peek(self): if self.isEmpty(): raise IndexError("peek from empty queue") return self.data[self.head] def isEmpty(self): return self.head == self.tail def isFull(self): return (self.tail + 1) % self.capacity == self.head def size(self): return (self.tail - self.head + self.capacity) % self.capacity这套代码里有几个点必须理解透:
enqueue里先写数据再移动tail,因为tail指向的是“空位”,不是当前元素。dequeue里先取数据再移动head,因为head指向的就是当前元素。- 判空用
head == tail。刚初始化时两者相等,出队出到两者又相等时,队列一定是空的。 - 判满用
(tail + 1) % capacity == head。因为永远留一个空位,所以当tail的下一个位置是head时,说明只剩一个空位,按约定不能再入队。 - 长度计算
(tail - head + capacity) % capacity,这个公式可能第一次看不直观。举个例子:capacity = 5,head = 3,tail = 1,实际元素在下标 3、4、0 三个位置,长度是 3。(1 - 3 + 5) % 5 = 3,正确。如果不加capacity再取模,1 - 3 = -2,直接当长度就会得到负数。
3.3 另外一种方言:用 size 字段替代空位法
空位法虽然经典,但让容量打了折扣:明明分配了capacity个位置,最多只能存capacity - 1个。有人觉得不爽,就改用size字段记录当前元素个数。
class SizedCircularQueue: def __init__(self, capacity): self.capacity = capacity self.data = [None] * capacity self.head = 0 self.tail = 0 self.size = 0 def enqueue(self, val): if self.size == self.capacity: raise OverflowError("queue is full") self.data[self.tail] = val self.tail = (self.tail + 1) % self.capacity self.size += 1 def dequeue(self): if self.size == 0: raise IndexError("dequeue from empty queue") val = self.data[self.head] self.data[self.head] = None self.head = (self.head + 1) % self.capacity self.size -= 1 return val这样能存满capacity个元素,但代价是多维护一个size变量,每次插入删除都要同步更新。工程上如果队列绝大多数时间不满,空位法的“浪费”其实可以忽略;如果需要精确利用容量,带size的版本也不错。两种写法都见过,关键是别混着用——一会用head == tail判空,一会用size == 0判空,逻辑很快就乱了。
3.4 队列实现里最容易写错的三个点
第一,tail指针更新时需要取模。很多人漏掉% capacity,入队几次后下标就越界。第二,size()公式里的+ capacity不能省。第三,使用引用类型的元素时,出队后一定要把原位置置为None,否则大对象滞留内存。这三个问题我都在实际项目里见过,排查到最后往往就是一行代码的事。
4. 栈和队列在真实系统里的出场频率:比你想象的高得多
栈和队列不只是刷题用的工具。计算机系统里,它们是无处不在的基础设施。这一章从应用场景入手,帮你建立直觉:什么信号出现时,第一反应应该用栈,什么信号出现时,应该用队列。
4.1 函数调用栈:每个程序都在隐形使用栈
写程序的人未必手动写过栈,但每次函数调用都在用系统栈。函数 A 调用函数 B,B 调用 C,系统需要记住“C 结束后回到 B 的哪一行,B 结束后回到 A 的哪一行”。这天然是个后进先出问题:最后被调用的函数最先返回。所以 CPU 用栈帧保存局部变量、参数、返回地址,一层层压栈,返回时一层层弹栈。递归之所以可能栈溢出,就是因为每层递归都会压一帧,层数太多,栈空间不够了。
理解了这一点,你就知道“递归改迭代”的本质是什么——用自己显式维护的栈,替代系统隐式维护的调用栈。举一个实际例子:树的深度优先遍历。教科书写递归很简单,但工程里如果树很深,递归可能溢出。改成非递归,核心就是一个栈,压入根节点后不断弹出并压入子节点。
4.2 表达式求值:一个栈把中缀转后缀讲清楚
表达式求值是栈的经典应用。直观上人来算3 + 4 * 2,知道先乘后加;但程序需要规则明确,于是先转成后缀表达式3 4 2 * +,然后用一个栈逐项计算:碰到数字压栈,碰到运算符弹出两个数字,算出结果再压回去。
中缀转后缀的“调度场算法”同样只用到一个栈:遇到数字直接输出,遇到运算符,如果栈顶运算符优先级不低于当前运算符,就弹出栈顶,最后把当前运算符压栈。规则不难,但能帮你彻底理解栈的“延迟处理”作用——先暂存不急着计算的符号,等优先级更高的符号处理完再弹出。
4.3 队列在系统侧:从任务调度到消息削峰
队列的核心语义是“先来先得”,天然适合任务排队。操作系统里的进程调度、打印机任务队列、Web 服务器的请求队列,本质都是队列。再看消息队列中间件,名字里就带“队列”,它的价值不只是排队,而是削峰填谷:请求峰值时生产者猛往队列里塞消息,消费者按自己的速度慢慢处理,系统不会被打垮。
生产者消费者模型也是围绕队列展开的。我经常用食堂打饭来类比:厨师是生产者,打饭的人是消费者,中间那个餐台就是队列。餐台能缓冲,食堂就不会因为厨师做得慢导致窗口全堵死。工程里用阻塞队列,生产者放不进去就等,消费者取不到也等,解耦效果极其明显。
4.4 DFS 与 BFS:两种遍历只是栈和队列的选择题
深度优先搜索(DFS)和广度优先搜索(BFS)的区别,本质就是选择“下一个访问谁”:DFS 用栈,最近加入的节点优先;BFS 用队列,最早加入的节点优先。同一个迷宫问题,用栈做能一条路走到底再回头,用队列做会像水波纹一样一层层往外扩。
这个视角对解题特别有启发:很多“最短路径”问题用 BFS,因为先进先出保证了第一次到达目标时的步数一定最小;很多“所有方案”问题用 DFS,因为栈能帮我们维护一个完整路径的状态。所以当你拿到一个新问题,先想一想:这题的搜索顺序应该是什么?答案是“按层级扩散”还是“按深度探索”?把这个判断做了,数据结构选型就完成了一半。
5. 从普通容器到解题利器:双端队列、单调栈、单调队列
理解了栈和队列本身,再去看它们的“变体”,很多题目的套路就通了。这一章讲三个经常混在一起的概念:双端队列、单调栈、单调队列。它们不是全新的数据结构,而是基于栈和队列思想改造出来的工具。
5.1 双端队列:把栈和队列合成一个,还能做更多
双端队列(deque,全称 double-ended queue)允许在头部和尾部都能插入、删除,所以它既可以当栈用,也可以当队列用。Python 的collections.deque是工程里最常用的双端队列实现,它内部是一系列的固定大小“块”通过指针连接,而不是简简单单的链表节点。这样设计的好处有两个:头部和尾部插入删除都是 O(1),而且内存是分块连续分配的,比纯链表节点省空间、访问缓存也更友好。
在很多算法题里,“窗口滑动、需要同时从两端淘汰元素”的场景,非 deque 不可。最典型的就是下一节的单调队列。
5.2 单调栈:栈顶存的是“还没找到答案的候选”
单调栈,就是在压栈过程中保持栈内元素单调递增或递减。单调递减栈有个经典用途:找每个元素右边第一个比它大的数。
用一个具体例子说:数组[2, 1, 3, 4],从左到右遍历,维护一个栈底到栈顶递减的栈。规则是:当前元素比栈顶大时,说明栈顶元素的“下一个更大元素”就是当前元素,弹出栈顶并记录答案,直到栈空或栈顶大于等于当前元素,再把当前元素压进去。整个过程:
- 2 入栈,栈
[2] - 1 比
2小,入栈,栈[2,1] - 3 比
1大,弹出1,答案 1 -> 3;3 比2大,弹出2,答案 2 -> 3;栈空,3 入栈,栈[3] - 4 比
3大,弹出3,答案 3 -> 4;4 入栈
所以结果为:2 -> 3,1 -> 3,3 -> 4,4 -> -1。为什么这样能行?因为栈里保存的是“还在等待答案的候选元素”,一旦遇到第一个比它们大的数,它们的答案就定了。这个过程每个元素最多入栈一次、出栈一次,整体 O(n)。
def next_greater(nums): n = len(nums) ans = [-1] * n stack = [] for i, x in enumerate(nums): while stack and nums[stack[-1]] < x: idx = stack.pop() ans[idx] = x stack.append(i) return ans单调栈属于“看着简单,想通很难”的一类。关键在于理解栈内元素代表的是一组“尚未解决”的候选人。一旦新元素比栈顶大,新的元素就是栈顶的答案,栈顶的历史使命完成,自然退场。
5.3 单调队列:滑动窗口最大值的一个高性价比解法
单调队列用来解决“滑动窗口最大值”这类问题。给你一个数组和一个窗口大小 k,窗口每次往右滑一格,要求输出每个窗口的最大值。
如果每次窗口滑动都重新扫描,复杂度 O(nk)。用单调队列可以把每个元素进队出队各一次,整体 O(n)。核心思想是:队列里只保留可能成为窗口最大值的候选元素,并且从队头到队尾单调递减。新元素入队时,把队尾所有比它小的元素全部弹出——因为它们永远不可能成为当前或未来窗口的最大值了。同时,如果队头元素已经滑出窗口,也要从队头弹出。
from collections import deque def max_sliding_window(nums, k): q = deque() result = [] for i, x in enumerate(nums): while q and nums[q[-1]] <= x: q.pop() # 队尾弹出比 x 小的元素 q.append(i) # 当前元素入队 if q[0] <= i - k: # 队头滑出窗口 q.popleft() if i >= k - 1: # 窗口形成后开始记录 result.append(nums[q[0]]) return result为什么用双端队列而不用普通队列?因为既要容纳“未来可能成为答案”的候选,又需要从队尾弹出不合适的元素,还要求能从队头淘汰过期元素。这三个操作要同时支持,普通队列做不到,只有 deque 可以。
这类变体的价值在于,它们让你意识到:数据结构不是死的,理解规则后可以在约束内做改造。单调栈和单调队列就是给基础结构加了一个“单调性约束”,结果威力翻倍。
6. 标准库选型与常见误区排查笔记
最后一个章节,我挑几个大家在日常编码里真的会踩到的坑来聊。很多坑不是栈和队列本身的问题,而是语言标准库的使用姿势问题。
6.1 别拿 list 当队列用:pop(0) 的代价超出想象
Python 的 list 是按连续数组实现的。从头部弹出元素,需要把后面所有元素往前挪一位,时间复杂度 O(n)。如果队列规模是一万,pop(0)一次要挪一万次;十万,就挪十万次。接口在数据量变大后变慢,几乎都是这类“隐藏的 O(n)”引起的。
正确做法是用collections.deque:
from collections import deque q = deque() q.append(1) # 入队 x = q.popleft() # 出队,O(1) x = q.pop() # 当栈用的出栈,O(1)注意 deque 的pop()不带参数,默认从尾部弹出;想要从头部弹出就用popleft()。很多从 list 转过来的人,习惯性写q.pop(0),结果发现 deque 不支持pop(0)——这恰恰是它的设计:不提供 O(n) 的接口,避免你不小心写出低效代码。
6.2 Java 的 Stack 是历史遗留:ArrayDeque 才是正解
Java 里的java.util.Stack在教科书里经常出现,但实际上它是一个历史遗留类,继承了Vector,所有方法都加了同步锁。这意味着你在单线程场景下用 Stack,会白白背上同步开销;而且因为继承关系,你可以对 Stack 做add(0, elem)这种违背栈语义的操作,破坏了栈的“操作受限”特性。
现代 Java 推荐的方案是ArrayDeque:
Deque<Integer> stack = new ArrayDeque<>(); stack.push(1); // 入栈 int top = stack.peek(); int x = stack.pop();ArrayDeque 既支持push/pop当栈用,也支持addLast/removeFirst当队列用,初始容量 16,自动扩容,没有加锁开销,几乎在所有指标上都优于 Stack。如果你看到老代码里还在用 Stack,可以建议改成 ArrayDeque,但要注意push是从头部插入的,和addLast方向不同,混用容易搞乱。
6.3 几道能快速验证理解的真问题
怎么检验自己是不是真懂了?我总结了三道题,不用写完整代码,回答上来就行:
第一,循环队列用空位法时,容量为 5,head是 3,tail是 1,队列里有多少个元素?答案是 3。如果你第一反应是“这不矛盾吗?tail 在 head 后面啊”,说明还得多算几遍取模。
第二,用单调栈找“下一个更大元素”,为什么数组里最后一个元素的答案一定是 -1?因为后面没有元素能成为它的候选。
第三,滑动窗口最大值里,为什么入队前要把队尾所有比当前元素小的都弹出?因为它们不仅不可能是当前窗口的最大值,在它们“退出视线”之前,窗口的最大值已经被当前元素覆盖了——留着它们只会浪费空间和时间。
这三个问题想通了,栈和队列那些“看一眼就会、写一次就错”的边界和思想,基本就都掌握了。
最后再分享一点个人经验:别嫌基础结构“太简单”就在编辑器里敲一遍就完事。我建议每过一段时间就手写一次数组栈、循环队列、链表队列,再把单调栈和单调队列各写一遍。写的时候留意自己在哪一步迟疑了——迟疑的地方,就是理解还没到位的地方。基础结构这东西,不贵在多,贵在每次重新写都有新的体会。