栈与队列这两个词,在计算机科班课程里永远是排在最前面的那几章。当年学的时候觉得简单得不能再简单,不就是“后进先出”和“先进先出”嘛。可真到写项目、做全栈开发、甚至面试造轮子的时候才发现,这两个基础结构几乎是无处不在的——函数调用要用栈回溯,任务调度要用队列排队,浏览器的前进后退是栈,线程池的任务缓冲是队列,就连全栈项目里用的消息队列、Redis的列表结构,骨子里也还是这两个东西。
这篇博文就围绕“栈与队列简单practice”这个项目展开,我会从底层实现讲到典型题目,再延伸到真实项目里那些你绕不开的应用场景。不管你是刚学数据结构的新手,还是写了几年业务代码回头补基础的老手,这篇都能给你一些值得收藏的东西。
1. 先搞清楚:栈和队列到底在解决什么问题
1.1 栈:一种“后进先出”的约束
栈的核心特性就一句话:只能在栈顶操作元素。插入叫 push,删除叫 pop,看一眼栈顶叫 top 或 peek。这个约束看起来像是把数据结构“阉割”了——它不让你随便访问中间元素,不让你从底部删东西,一切操作都被限制在一端。
但这恰恰是它强大的地方。因为只允许在一端操作,所以状态的变化路径非常清晰。你想想日常生活中的场景:一叠盘子,你总是拿最上面那个;一摞书,总是先抽最上面那本。这种“后进先出”的约束天然适合处理需要“撤销”或“回溯”的场景。
我举个例子,你肯定用过编辑器的 Ctrl+Z 撤销功能。每次操作就相当于往栈里 push 一个状态,撤销就是 pop。如果你能从中间随便抽走一个状态,那撤销逻辑马上就乱套了。
1.2 队列:一种“先进先出”的公平规则
队列的特性同样简单:一端入队,一端出队。插入叫 enqueue,删除叫 dequeue,队头是 next 要处理的元素,队尾是刚进来的元素。生活中最典型的类比就是排队买奶茶:先来的先服务,后来的老老实实排后面。
这个“公平性”在计算机世界里价值巨大。操作系统的进程调度、网络请求的任务缓冲、消息队列的消息投递,本质上都是想让事情按照“到达顺序”来处理,保证先来的任务不被后来的饿死。
你可能会觉得:这不就是数组加两个指针的事吗?确实,最简单的队列用数组就能实现。但难点在于空间的循环利用、并发环境下的线程安全、阻塞和非阻塞语义的选择。这些细节,后面我会展开讲。
1.3 为什么“简单练习”反而值得认真做
我见过不少人刷题时跳过栈和队列的基础题,觉得“这太简单了,直接上困难题”。但实际面试和工作中,考的最多的恰恰是这些基础结构的变体和组合。比如“用两个栈实现队列”、“循环队列的设计”、“单调栈求下一个更大元素”,还有“线程池的阻塞队列选择”,这些都是从基础生长出来的。
“栈与队列简单practice”这个项目,表面上是练习几个基本操作,实际上是在帮你建立两个思维模型:一个是“回溯与撤销”思维,一个是“排队与缓冲”思维。这两种思维方式一旦建立,你会发现看很多系统的设计都会豁然开朗。
2. 动手实现:从数组到链表,从理论到代码
2.1 基于数组实现:顺序栈与循环队列
先来最简单的:顺序栈。如果你用 Python,其实list自带append()和pop(),天然就是一个栈。但为了训练思维,我建议还是自己封装一层,哪怕只是加个size上限和is_empty方法。
class ArrayStack: def __init__(self, capacity): 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 < 0: raise IndexError("栈空") value = self.data[self.top] self.data[self.top] = None # 释放引用,避免内存泄漏 self.top -= 1 return value def peek(self): if self.top < 0: raise IndexError("栈空") return self.data[self.top] def is_empty(self): return self.top == -1这里有两个细节值得注意。第一,栈顶指针初始化为 -1,而不是 0,这样空栈判断就统一成top < 0,不需要额外用一个size字段。第二,pop时把data[self.top]置为None,这是 Python 里防内存泄漏的习惯——如果列表中存的是大对象,不释放引用就会一直占着内存。
队列的数组实现就没这么简单了。如果用 Python 的list.pop(0)来出队,效果虽然对,但时间复杂度是 O(n),因为每次出队都要把后面的元素整体前移。所以正经的数组队列都是“循环队列”:用两个指针 head 和 tail,元素放满后绕回数组开头继续用。
class CircularQueue: def __init__(self, capacity): self.capacity = capacity self.data = [None] * capacity self.head = 0 # 队头指针 self.tail = 0 # 队尾指针 self.count = 0 # 当前元素个数 def enqueue(self, value): if self.count == self.capacity: raise OverflowError("队列满") self.data[self.tail] = value self.tail = (self.tail + 1) % self.capacity self.count += 1 def dequeue(self): if self.count == 0: raise IndexError("队列空") value = self.data[self.head] self.data[self.head] = None self.head = (self.head + 1) % self.capacity self.count -= 1 return value def is_empty(self): return self.count == 0 def is_full(self): return self.count == self.capacity循环队列的实现有一个经典的设计选择:队满和队空的判断。常见有两种方案,一种是“牺牲一个存储单元”,让tail + 1 == head表示队满;另一种是像我这样加一个count字段。我更推荐count方案,原因有两点:第一,判断逻辑直观,不需要绕弯子;第二,head和tail指向的语义更清晰,不容易在边界条件下出错。代价只是多了一个整型字段,几乎可以忽略。
2.2 基于链表实现:链式栈与链式队列
数组实现的缺点是容量固定,满了就不能再插。如果数据规模不确定,更灵活的做法是用链表。链式栈很简单:每次 push 就是在头节点前插入,每次 pop 就是删除头节点,时间复杂度 O(1)。
class ListNode: def __init__(self, value=0, next=None): self.value = value self.next = next class LinkedListStack: def __init__(self): self._head = None self._size = 0 def push(self, value): node = ListNode(value, self._head) self._head = node self._size += 1 def pop(self): if self._head is None: raise IndexError("栈空") value = self._head.value self._head = self._head.next self._size -= 1 return value def peek(self): if self._head is None: raise IndexError("栈空") return self._head.value def is_empty(self): return self._head is None def size(self): return self._size链式队列的细节略微多一点。要同时维护 head 和 tail 两个指针,入队操作是tail.next = new_node; tail = tail.next,出队操作是head = head.next。tail一定要记得实时更新,否则队列会越加越长但读到的还是旧队尾。
链式实现和数组实现的取舍核心是看场景:数组实现缓存友好、内存紧凑、随机访问快,但容量固定需要扩容策略;链表实现动态伸缩轻松,但每个节点要多一个 next 指针的内存开销,而且在大量高频操作时节点创建销毁的代价也不小。在日常项目中,如果元素数量可预估,优先选数组;如果是高频插入删除且数量波动大,链表更合适。
2.3 Python 的 collections.deque:一个被低估的宝贝
如果你在 Python 里做栈和队列练习,最不该忽略的就是标准库的collections.deque。它是双端队列,两边都能插入和删除,而且都是 O(1) 的时间复杂度。
from collections import deque # 当作栈用 d = deque() d.append("a") d.append("b") d.pop() # 'b' # 当作队列用 q = deque() q.append("task1") q.append("task2") q.popleft() # 'task1'deque是 C 语言实现的双向链表加数组混合结构(block 链表),性能非常可靠。但需要提醒一句:deque不是线程安全的。如果你在多线程环境下需要队列,应该用queue.Queue,后者的内部本质是deque加锁加条件变量,提供了get()的阻塞语义和task_done()的协作机制。
很多网帖说“python队列queue不堵塞”,准确说法是queue模块里有Queue、LifoQueue、PriorityQueue三种类,它们默认行为是阻塞的,比如q.get()在队列为空时会一直等待。如果你不想阻塞,可以用q.get_nowait()或q.get(timeout=0.1)。这个区分非常重要,后面我讲线程池阻塞队列选择时会再提到。
3. 从练习到实战:栈和队列在真实项目中的落点
3.1 函数调用、调用栈与 backtrace 栈回溯
你有没有想过,程序执行时是怎么知道当前应该回到哪个函数的哪一行的?答案就是调用栈。每调用一个函数,系统就把返回地址、参数、局部变量打包成一个栈帧压入调用栈;函数返回时,弹出栈帧,恢复现场。
这也是为什么递归太深会报“栈溢出”——因为每层递归都要压一个栈帧,栈空间是有限的。理解这一点,你就能明白为什么许多后端服务的日志里,错误排查要依赖 backtrace 栈回溯:backtrace 就是把你当前调用路径上所有未返回的栈帧打印出来。栈回溯是排查线上故障最重要的工具之一,它能告诉你“我是怎么走到这一行代码的”。
3.2 消息队列、阻塞队列与线程池
把范围放大一点:分布式系统里的消息队列(比如 Kafka、RabbitMQ、RocketMQ),本质上是把“生产者-消费者”模式解耦开。生产者把消息发到队列,消费者从队列拉取处理。这里就有一个核心问题:消息队列的重复消费问题。消费者在处理完消息后,网络抖动导致 ack 丢失,消息被重新投递,就会重复执行。解决思路通常是消费幂等——在数据库里用唯一键约束、用 Redis setNX 做去重,或者用业务状态机保证重复执行的结果和一次执行一致。
回到单机层面,线程池的阻塞队列同样是个高频考点。线程池的 worker 线程从阻塞队列里取任务,队列空时就阻塞在那,直到新任务到来。Java 的ThreadPoolExecutor支持多种阻塞队列:无界队列LinkedBlockingQueue、有界队列ArrayBlockingQueue、优先级队列PriorityBlockingQueue和直接交接的SynchronousQueue。
选择阻塞队列背后有一个重要的 trade-off:无界队列虽然不会因为任务太多拒绝提交,但如果生产速度长期大于消费速度,任务会积压成山,内存迟早被耗尽;有界队列则会在队列满时触发拒绝策略,但能倒逼你思考任务饱和度。我自己的经验是,线上生产环境不推荐默认用无界队列,宁可设一个合理的上限,让问题尽早暴露。
3.3 堆和栈:不只是数据结构,更是内存区域
“堆和栈”这个词其实有两层含义。数据结构层面的堆(heap)是优先队列的底层实现,栈(stack)就是我前面讲的“后进先出”结构。内存布局层面的“堆”和“栈”则是指运行时内存中的两个区域:栈区存放局部变量和函数调用信息,生命周期严格按调用规则走;堆区存放动态分配的对象,由程序员或垃圾回收器管理。
这两层含义经常把人搞混。面试里如果聊到“堆和栈的区别”,通常指的是内存层面。动态分配走堆,局部变量走栈,栈变量自动释放但容量有限,堆变量容量大但需要手动释放或依赖 GC。搞清楚了这一点,很多内存问题——比如 Go 里的逃逸分析、C++ 里栈上对象和堆上对象的生命周期——就都有了解释框架。
3.4 全栈开发中技术栈里的“栈”是什么
现在热词里经常出现“全栈项目”“技术栈”这些说法。这里的“栈”其实是从“调用栈”引申出来的——一个项目从 UI 层到数据层用到的全部技术组合,像一摞组件叠起来。前端 React + 后端 Node + 数据库 PostgreSQL,这个组合就叫你的技术栈。这是“栈”这个概念在更广阔领域最自然的延伸。
支撑一个全栈项目稳定运行的背后,少不了刚才提到的各类队列。比如 Web 服务接到的请求,通常先落到负载均衡器,再经由进程内部的线程池队列缓冲,才最终到达业务代码。理解队列原理,你排查“为什么响应变慢”“为什么任务堆积”时就不会抓瞎。
4. 典型练习题目与解题思路
4.1 经典题一:括号匹配
给定一个只包含()[]{}的字符串,判断字符串是否有效。这道题是栈的“Hello World”,思路很直接:遍历字符,遇到左括号就压栈,遇到右括号就和栈顶匹配,能匹配就弹出,否则直接返回 false。最后检查栈是否为空。
def is_valid(s: str) -> bool: pairs = {')': '(', ']': '[', '}': '{'} stack = [] for ch in s: if ch in pairs: if not stack or stack.pop() != pairs[ch]: return False else: stack.append(ch) return not stack这个题看起来简单,但最容易犯的错误是:右括号来临时忘记检查栈是否为空,导致stack.pop()直接抛异常。我在刷题网站见过无数次这个解法在空栈时报错,所以每次都会先判断if not stack。这个习惯后面写解析器、写计算器时也通用。
4.2 经典题二:用两个栈实现队列 / 用两个队列实现栈
这道题的考点是“不同结构的组合能否产生新语义”。用两个栈实现队列的核心思路是:入队时直接 push 到in_stack,出队时如果out_stack为空,就把in_stack全部倒入out_stack,然后从out_stackpop。因为两次“后进先出”叠加会变成“先进先出”。
class MyQueue: def __init__(self): self.in_stack = [] self.out_stack = [] def push(self, x): self.in_stack.append(x) def pop(self): if not self.out_stack: while self.in_stack: self.out_stack.append(self.in_stack.pop()) return self.out_stack.pop() def peek(self): if not self.out_stack: while self.in_stack: self.out_stack.append(self.in_stack.pop()) return self.out_stack[-1] def empty(self): return not self.in_stack and not self.out_stack反过来,用两个队列实现栈就稍微绕一点:入栈时把新元素直接入到queue1,然后把queue2里的所有元素依次搬到queue1,再交换两个队列的引用,保证队头永远是最新元素。出栈时直接从queue1出队即可。
这类题目对我的启发是:结构的组合可以改变语义,这也解释了为什么很多中间件设计里会叠加多层结构来获得更复杂的保证。
4.3 进阶题三:单调栈
热词里“单调栈揭秘”出现很频繁。单调栈的典型应用是求“下一个更大元素”。它的核心思想是:维护一个栈,栈内元素保持单调(递增或递减)。遍历数组时,遇到破坏单调性的元素,就触发栈内元素的出栈并计算答案。
以“每日温度”为例:给你每天的气温列表,要返回一个数组,表示每天需要等多少天才能等到更高温度。用单调递减栈,栈内存的是下标。遍历时,如果当前温度大于栈顶下标对应的温度,就弹出栈顶,答案就是当前下标减去弹出的下标。
def daily_temperatures(temperatures): n = len(temperatures) answer = [0] * n stack = [] for i in range(n): while stack and temperatures[i] > temperatures[stack[-1]]: idx = stack.pop() answer[idx] = i - idx stack.append(i) return answer单调栈的精髓在于:每个元素最多入栈一次、出栈一次,所以整体时间复杂度是 O(n),而不是 O(n^2) 的暴力双层循环。这种“空间换时间”的思路,在刷编程题和实际处理流式数据时都非常有价值。
4.4 队列的实战应用:二叉树层序遍历
栈和队列在树与图的算法里出场率极高。尤其是层序遍历二叉树,本质上就是“根节点入队,每次出队一个节点,把它的左右孩子入队”。因为队列的“先进先出”特性,每一层会按顺序依次被访问,天然形成层次结构。
from collections import deque def level_order(root): if not root: return [] result = [] queue = deque([root]) while queue: level_size = len(queue) level = [] for _ in range(level_size): node = queue.popleft() level.append(node.val) if node.left: queue.append(node.left) if node.right: queue.append(node.right) result.append(level) return result这段代码里有个很细但很重要的点:level_size = len(queue)必须放在内层循环之前。因为进入循环前要先把“一层”完整的节点数记录下来,否则内层循环里队列的长度一直在变,层的边界就乱了。这个 bug 我见过好多人调了半天,其实就是少存了一个变量。
5. 常见错误与排查技巧实录
5.1 栈溢出:不只是递归的锅
提到“栈溢出”,大多数程序员第一反应是递归太深。但实际项目里还有一种很隐蔽的栈溢出:大对象局部变量在栈上分配。比如某些语言里把大数组直接定义为局部变量,一个数组几 MB,多嵌套几层函数调用,栈空间瞬间就爆了。
排查栈溢出最有用的手段还是看 backtrace 栈回溯:日志里会打出调用链,你能看到是哪个函数、哪个调用路径把栈空间撑爆的。如果是递归路径,检查递归终止条件;如果是大局部变量,改成堆分配或减小缓冲区。
5.2 循环队列的边界条件:满与空判错
手动实现循环队列时最常见的两个 bug:第一,入队前没有判满,导致尾部覆盖头部数据;第二,出队后指针绕回,但忘记count减一,导致is_empty永远为 false。解决的办法就是我在前面代码里展示的那样:把判断逻辑统一写进enqueue和dequeue里,而不是依赖外部调用者自觉检查。
另外,Python 里用列表模拟队列最坑的一点是list.pop(0)。它虽然可读性好,但时间复杂度是 O(n),大量数据下性能衰减非常明显。我测过一个简单实验:10 万元素用pop(0)消耗的时间比deque.popleft()慢了几十倍。所以只要确定要用“先进先出”的语义,就老老实实导入collections.deque。
5.3 并发环境下的队列坑:线程安全不等于安全
很多人以为用了queue.Queue线程安全就万事大吉,但线程安全和逻辑正确是两码事。Queue只能保证单次操作不会出错,但你的业务逻辑如果包含“先 get 再处理再 task_done”的多个步骤,这整个过程依然需要自己保证状态一致性。
关于 Python 里“queue 不堵塞”的误解,我觉得有必要再澄清一下:queue.Queue.get()的默认行为确实是阻塞的,这在消费者线程里恰恰是合理的——线程要等任务来,而不是空轮询浪费 CPU。如果你希望“不阻塞”,正确工具是get_nowait(),或者一步到位的get(timeout=0.5)加超时处理。这样既能立刻感知队列状态,又不会在空队列时空转。
5.4 消息队列重复消费的排查套路
业务系统接入消息队列后,最常遇到的诡异 bug 就是“数据重复了”。排查时先不要怀疑是队列投递的问题,先看消费逻辑是否有幂等保护。我见过的各类 MQ 重复消费场景,最后大部分都定位到“消费后 ack 超时,导致消息重新投递”。
排查思路我整理成了一张速查表:
| 现象 | 优先排查项 | 常用对策 |
|---|---|---|
| 同一条消息被多次执行 | 消费逻辑是否幂等 | 数据库唯一键、Redis setNX、业务状态机 |
| 消息处理成功但一直重投 | ack 是否在超时前返回 | 调整 ack 超时时间,或异步确认 |
| 消费堆积但 CPU 不高 | 消费者数量不足,还是单条处理太慢 | 增加消费者并发,或批量聚合处理 |
| 队列时而有消息时而没有 | 是否误用了非阻塞读 | 明确使用阻塞 get 或带轮询间隔的非阻塞读 |
这张表后面接的最重要一条经验:任何消费逻辑都应该默认“消息可能重复”,所以幂等设计要从第一天就做,而不是等出了问题再补。
6. 继续往前:从“简单 practice”到进阶方向
6.1 双端队列、优先队列与前 K 大问题
栈和队列的变体很多。双端队列deque是两边都能操作,适合滑动窗口类问题。优先队列(PriorityQueue)的底层是堆,能让你在 O(log n) 时间拿到最大或最小元素,“前 K 大”这类问题就是它的主场。
Python 的heapq模块提供的只是最小堆。如果你要最大堆,常见做法是取负数入堆,取出时再取反。这个技巧很简单但非常好用,刷 LeetCode 的“数组中前 K 个高频元素”时就是标准解法。
6.2 队列在系统设计中的高级应用:削峰、限流与缓冲区
如果你继续深入系统设计层面,队列还有一个重要作用是削峰填谷。瞬时流量高峰打进来,如果不用缓冲区直接打数据库,数据库基本必挂。但你可以把请求先放到队列里,后端消费者按自己最大的处理速度匀速消费,这就是“削峰”。
配合削峰的就是限流。常见的令牌桶算法、漏桶算法,运行时也都依赖队列或计数器来维护令牌的发放节奏。理解了这些,你再回头看线程池的阻塞队列选择,就会意识到一个简单的数据结构选择背后,其实是对整个系统吞吐率和稳定性的把控。
6.3 跨场景迁移:从数据结构题目到业务代码
学习栈和队列最大的收获,不是会写那几道题,而是形成“约束简化思维”。栈用“只能在一端操作”换来回溯能力的清晰;队列用“先进先出”换来任务处理的公平。你在设计自己的系统时,如果能识别出“这个场景本质上是一个括号匹配”“这堆任务本质上需要排队顺序处理”,很多抽象问题就化成了具体的代码。
推而广之,很多消息中间件、分布式事务方案,都是建立在队列这个最朴素概念之上的。你花一晚上把基础实现练扎实,后续看 Kafka 分区、看 Redis 列表阻塞弹出、看 Go 的 channel 调度,都会有“原来如此”的感觉。
我在实际做这个“简单 practice”项目的过程中,最大的体会是:越是基础的东西,越值得亲手多敲几遍。第一遍写数组栈,第二遍写循环队列,第三遍写两栈队列,每一遍都会在边界条件和容量判断上发现新的理解盲区。建议大家拿到任何数据结构题目时,都先问自己三个问题:它的操作限制是什么?每个操作的时间复杂度是多少?边界条件是空还是满?带着这三个问题去练习,基础就能打得非常扎实。