LeetCode(十)—— 栈与队列
栈和队列在数据结构里看起来很不起眼,可一旦真做起算法题,会发现它们出现的频率高得惊人。原因其实只有一句:大部分题目本质上都是在问访问顺序——栈是后进先出(LIFO),队列是先进先出(FIFO),再加上单调栈、双端队列、优先队列这几个变体,括号匹配、下一个更大元素、滑动窗口最值、前 K 大、BFS、还有一票"用 X 实现 Y"的题,几乎全在它们的射程之内。
这一篇会把这几样东西一次讲完。先把基础数据结构本身讲清楚,再用六道有代表性的 LeetCode 题完整跑一遍流程,最后给一张复杂度对照表,外加一个集中回答常见疑问的 Q&A 部分。

系列导航
LeetCode 算法刷题手册(共 10 篇):
- 哈希表 —— Two Sum、最长连续序列、字母异位词
- 双指针 —— 对撞指针、快慢指针、滑动窗口
- 链表操作 —— 反转、判环、合并
- 二叉树遍历与递归 —— 前/中/后序、最近公共祖先
- 动态规划入门 —— 一维/二维 DP,状态转移
- 回溯算法 —— 排列、组合、剪枝
- 二分查找进阶 —— 整数/实数/答案二分
- 滑动窗口 —— 定长与变长窗口
- 栈与队列 —— 单调栈、优先队列、双端队列 ← 本篇
- 贪心与位运算 —— 贪心策略、位运算技巧
一、栈与队列基础
1.1 栈:后进先出
栈(Stack)是一种线性容器,最后进去的元素最先出来。直观的类比就是叠盘子:只能从顶上放,也只能从顶上拿。接口很小:
push(x):把x放到栈顶pop():弹出并返回栈顶peek()/top():只看栈顶,不弹出empty()、size():状态查询
这五个操作都是 O(1) 的最坏复杂度。栈之所以好用,关键在于它的访问模式恰好对上某一类问题——任何"刚才最近一次发生的 X 是什么"的问题都能被它表达。
Python 里几乎没人专门写一个栈类,直接用 list 就行,因为 append 和末尾 pop 都是均摊 O(1):
| |
Java 里推荐用 ArrayDeque,它比早期的 Stack 类更快、行为也更规范:
| |
C++ 直接用标准库的 stack:
| |
1.2 队列:先进先出
队列(Queue)正好反过来:最早进去的最早出来。排队买咖啡就是这个意思。标准接口:
enqueue(x)/offer(x):从队尾加入dequeue()/poll():从队首取出peek()/front():只看队首empty()、size()
如果直接用 Python 的 list 模拟队列,会发现 pop(0) 是 O(n)——因为后面的元素都要往前挪一格。BFS 一旦数据稍大就会超时。正确做法是 collections.deque,两端都是 O(1):
| |
Java 一般也用 ArrayDeque(通过 offer / poll 当队列用),C++ 用 std::queue<int>。
一句话的记忆模型:栈是函数调用历史,队列是待办任务列表。BFS、DFS、撤销/重做、任务调度、括号匹配——它们各自该用哪个,凭这一句就能判断个八九不离十。
二、栈的经典题
2.1 LeetCode 20:有效的括号
给定一个只含
()[]{}的字符串s,判断它是否合法。
合法的核心规则是嵌套:遇到一个右括号,它必须匹配最近一次没被关闭的那个左括号。“最近"两个字就是栈的语义。
算法:扫一遍字符串。遇到左括号就 push;遇到右括号则要求栈非空、且栈顶恰好是匹配的左括号——否则直接判错。扫完之后还要再验一次:栈必须为空,否则说明有左括号没关掉。
| |
下图把 ({[]}) 的执行过程一格一格画出来了。每一列是一步:上面是当前读入的字符,下面是这一步之后的栈。可以看到栈先涨到深度 3,再对称地退完。
![有效括号:扫描 “({[]})” 的栈轨迹](./10-%e6%a0%88%e4%b8%8e%e9%98%9f%e5%88%97/fig2_valid_parentheses.png)
复杂度:每个字符最多入栈、出栈各一次,时间 O(n),空间最坏 O(n)(比如全是左括号)。
踩坑点:
- pop 之前必须先判
not stack,否则空栈 pop 会报错。 - 不要因为某一步匹配成功就提前返回,要把整串都扫完。
- 循环结束后必须再判一次栈是否为空,否则像
(((这种就会被误判为合法。
2.2 LeetCode 155:最小栈
设计一个栈,除了常规的
push、pop、top,还要能用 O(1) 时间拿到当前最小值。
如果每次都遍历找最小,那是 O(n);要做到 O(1),就必须把"每个深度对应的最小值"都记下来。最简洁的写法是把每个元素和"截至此时的最小值"打包成一个二元组:
| |
四个操作全是 O(1),多花的空间也只是每个元素多存一个整数。“双栈写法"也能做,但出现重复最小值时容易写错——push 时要写成 <= 而不是 <,pop 时也只在弹出值等于当前最小值时才 pop 辅助栈,否则数据会对不上。
2.3 LeetCode 150:逆波兰表达式求值
后缀表达式把运算符放在两个操作数之后,因此完全不需要括号:((2 + 1) * 3) 写成 2 1 + 3 *。求值时只要记住一句话——栈里随时存着等待下一个运算符的那些操作数。
| |
两个最容易踩的点:
- 操作数顺序:第一个 pop 出来的是右操作数,
a - b和b - a完全不同。 - 除法:题目要求向零截断,C 风格除法(
-7 / 2 == -3)。但 Python 的//是向下取整(-7 // 2 == -4),结果会差一。所以写int(a / b),让浮点除法+int做截断。
三、单调栈
单调栈就是在普通栈上加了一条规矩:栈里的元素必须保持单调(要么严格递增,要么严格递减)。一旦新元素会破坏这条不变量,就先把破坏者们都 pop 掉。这一条小小的纪律就能把"对每个 i,向后/向前找第一个满足某条件的 j"这类题,从看起来 O(n²) 的暴力降到摊还 O(n)——因为每个下标最多入栈、出栈各一次。
它能套住的题型主要有四类:
- 右侧第一个比自己大的元素 → 维护递减栈
- 右侧第一个比自己小的元素 → 维护递增栈
- 左侧第一个更大/更小的元素 → 同样的逻辑,反向扫一遍即可
3.1 LeetCode 739:每日温度
给定每天的温度数组
temperatures,对每个i,输出"还要等多少天才能遇到更暖的一天”。如果之后再也没有更暖的,输出0。
最直白的写法是双层循环 O(n²)。改用单调栈之后,栈里存的是下标,对应的温度从栈底到栈顶递减。每读到新一天的温度,就把所有比它低的栈顶都解决掉——这些栈顶等的就是这一天,弹出后填 answer[idx] = i - idx。
| |
下图把这个解析过程画出来了。每条绿色弧线代表"这一天最终被那一天解决了”;下面那一行展示了每一步之后的栈,可以清楚看到温度始终是递减的。

复杂度:每个下标最多进栈一次、出栈一次,所以即便外层和内层都有循环,总操作数仍是 O(n),空间 O(n)。
注意:栈里存的是下标而不是温度,因为算答案时既要值(用来比较)也要位置(用来求差)。
四、队列与 BFS
队列最有名的工作就是广度优先搜索:先访问所有距离为 1 的邻居,再访问距离为 2 的,依此类推。骨架就八行:
| |
写 BFS 有两条铁律,几乎所有题都通用:
- 入队就标记 visited,不要等到出队才标。 否则同一个节点可能被多次入队,时间和内存都会爆。
- 用
deque,不要用list。list.pop(0)是 O(n),一不小心就把 O(V+E) 的 BFS 拖成 O(V·(V+E))。
层序遍历(LeetCode 102)只是 BFS 多了一个小技巧:每一轮开始前先把 len(q) 记下来,这样就知道这一层有多少个节点,一层处理完就 append 一组结果。
| |
五、优先队列与堆
优先队列(Priority Queue)故意打破 FIFO:每次出队的是优先级最高(或最低)的那个。标准实现是二叉堆——一棵完全二叉树存在数组里,最小堆要求每个父节点都不大于自己的两个孩子。

数组布局让父子之间的关系完全靠下标计算,根本不需要指针:
- 下标
i的父节点是(i - 1) // 2 - 两个孩子是
2*i + 1和2*i + 2
正是这一点让 heappush 和 heappop 都是 O(log n),peek(取根)是 O(1),从一个现成数组建堆更是 O(n)。
Python 的 heapq 永远是最小堆,要做最大堆就把 key 取反塞进去。
5.1 LeetCode 347:前 K 个高频元素
返回数组
nums中出现次数前k多的元素。
按照"巧到啥程度"列三种解法:
| |
| |
最小堆这个套路远不止于此:合并 K 个升序链表(每条链表的头进堆)、数据流中的第 K 大、Dijkstra 最短路,全是同一招。
六、双端队列:滑动窗口最大值
双端队列(Deque)允许在两端做 O(1) 的插入和删除。当一道滑动窗口题问"当前窗口的极值是多少"时,它就是最优解。
6.1 LeetCode 239:滑动窗口最大值
长度为
k的窗口在nums上从左滑到右,输出每个窗口的最大值。
诀窍是维护一个单调递减的双端队列,存的是下标:
- 当队首下标已经离开窗口(
dq[0] <= i - k),从前面 pop 掉; - 在 push 当前下标
i之前,先从后面 pop 掉所有值小于nums[i]的下标——它们在i还在窗口里时永远轮不到当最大值; - 当前窗口的最大值始终是
nums[dq[0]]。
| |
每个下标最多入队、出队各一次,所以总时间 O(n)。如果用最朴素的"每个窗口扫一遍"是 O(n·k)。为什么不用堆?因为堆没办法便宜地把"刚刚滑出窗口"的元素干掉——只能延迟删除,复杂度退化成 O(n log n)。单调队列前后都是 O(1) 弹,才能把它压回真正的 O(n)。
七、互相实现:栈与队列的桥
下面这两道题在系统设计面试里经常作为热身出现,因为它们考的是你对访问模式到底有没有真正想清楚。
7.1 LeetCode 232:用栈实现队列
队列要 FIFO,栈是 LIFO。把数据反转两次就回到了原始顺序,于是用两个栈:一个输入栈专门接收 push,一个输出栈专门服务 pop。当 pop 发现输出栈是空的,就把输入栈整体倒灌进来;否则直接 pop 输出栈顶。

| |
push 是 O(1);pop 和 peek 是摊还 O(1):每个元素从入到出最多被搬运一次,n 次操作的总成本就是 O(n),平均下来每次 O(1)。某一次 pop 可能很贵,但平均仍然便宜。
7.2 LeetCode 225:用队列实现栈
反方向就丑得多,因为旋转的代价摆在那。最简单的写法是只用一个队列:每次 push 之后,把队列里前 n - 1 个元素都从前面 pop 出来再 append 到后面,这样新元素就被旋转到了队首。
| |
push 是 O(n),pop 和 top 是 O(1)。这种不对称其实在告诉我们一件事:队列不是“功能更弱的栈”,LIFO 的纪律本质上要靠不一样的机器才能高效维持。
八、复杂度速查表
| 容器 | push / enqueue | pop / dequeue | peek | search |
|---|---|---|---|---|
| 栈 | O(1) | O(1) | O(1) | O(n) |
| 队列(基于 deque) | O(1) | O(1) | O(1) | O(n) |
| 双端队列 | 两端 O(1) | 两端 O(1) | O(1) | O(n) |
| 最小/最大堆 | O(log n) | O(log n) | O(1) | O(n) |
由数组 heapify 建堆 | O(n) 一次性 | — | — | — |
按算法分类的总结:
- 括号匹配 / 逆波兰求值:时间 O(n),空间 O(n)
- 单调栈(下一个更大/更小元素):时间 O(n),空间 O(n)
- BFS(V 个点,E 条边):时间 O(V + E),空间 O(V)
- 单调队列做滑动窗口最值:时间 O(n),空间 O(k)
- 大小为 K 的最小堆求 Top-K:时间 O(n log k),空间 O(n)
九、Q&A
Q1:Python 写队列为什么必须用 deque 而不是 list?
list.pop(0) 是 O(n),因为后面所有元素都要往前挪一格。deque.popleft() 是 O(1)。BFS 这类队列重度操作的场景,这不是常数差距,是渐进复杂度的差距。
Q2:什么时候要换成单调栈?
题面长得像"对每个下标 i,向左/向右找第一个比它大/小的下标"。把这种暴力 O(n²) 的扫法用单调栈写一遍就能压到 O(n)。
Q3:Python 的 heapq 怎么做最大堆?
push 时取负号,pop 完再取一次负号还原。如果是带优先级的元组,写 (-priority, value),最大优先级就变成最小负数。
Q4:滑动窗口最大值为什么用单调队列而不是堆?
堆能 O(log n) 给你当前最大值,但它没法便宜地删除"刚刚滑出窗口"的那个元素——只能延迟删除,复杂度被拖到 O(n log n)。单调队列前面 O(1) 弹掉过期下标,后面 O(1) 弹掉无用下标,整体真正 O(n)。
Q5:Top K 大用最小堆还是最大堆?
大小为 K 的最小堆。堆里凑够 K 个之后,每来一个比堆顶大的就替换堆顶;堆顶始终是当前 K 个最佳里最小的那个,新来的只要"比这个垫底的还小"就直接淘汰。反过来,“Top K 小"用大小为 K 的最大堆。
Q6:为什么"用两个栈实现队列"是摊还 O(1)?
每个元素从入到出最多被从输入栈搬到输出栈一次。n 次操作的总搬运量是 O(n),所以平均每次 O(1)。虽然单次 pop 可能突然搬一大堆,但平均下来仍然便宜。
Q7:树/图遍历该用栈还是队列?
栈(或者递归)对应 DFS——一条路走到黑,再回溯。队列对应 BFS——按和起点的距离一层一层访问。两种顺序探索的是同一张图,但回答的问题不一样:要"任意一条路径"通常 DFS 顺手;要"最短路径"必须 BFS。
十、小结
把上面这些东西压成几句话就是:
- 栈:你需要"最近一次还没解决的那个东西”。
- 队列:你需要按到达顺序处理。
- 单调栈:“最近"还要满足某种比较不变量,把 O(n²) 压成 O(n)。
- 双端队列:两端都要操作;滑动窗口最值、限长撤销/重做。
- 优先队列:顺序无关,优先级才重要;Top-K、调度、Dijkstra。
题目一旦能映射到上面五句话之一,写代码基本就是机械的事。面试时大部分功夫都花在"识别该用哪个"上,数据结构本身只是把活干掉而已。