栈和队列这两个词,凡是写过代码的人多少都听过,但说句实在话,能把它们讲透彻、用明白的人真不多。尤其是刷OJ题的时候,很多人一看题目涉及栈或者队列,第一反应是“哦,用个stack或者queue不就完了”,结果一写就卡壳,要么边界条件漏了,要么复杂度超了。这篇文章我就拿3道经典OJ题开刀,把栈和队列的底层逻辑、实战思路、还有那些不写进题解里的细节一次性聊透,适合正在学数据结构的学生、准备面试的开发者,以及想“回头补基础”的全栈工程师。
1. 为什么栈和队列总是被当成OJ题的“钉子户”
很多人不理解,栈和队列的实现这么简单,一个数组加几个指针就搞定了,为什么各大OJ平台、面试环节都爱拿它们出题?我的看法是,正因为结构简单,才能把人的思维逼到“约束”里去思考,这才是考察的重点。
1.1 读懂这两个结构,先看懂它们的“脾气”
先聊栈。栈的本质是后进先出,你可以把它想象成往弹夹里压子弹,最后压进去的子弹最先打出去。操作上只有几个动作:push(压入)、pop(弹出)、top(看栈顶),有些实现叫peek。这个约束极其严格,你只能动栈顶,不能随机读取中间元素。正是这种“限制”,决定了栈最擅长处理需要回退、配对、递归展开的场景。
队列则是先进先出,就像排队买奶茶,先来的先拿到。操作对应是push(入队)、pop(出队)、front(看队首)。队列的核心价值在于维持顺序、削峰填谷、异步缓冲,生产者和消费者之间解不开的纠缠,到了队列这儿就清爽了。
这里我想多说一句堆和栈的区别,很多初学者把这两个概念混在一起。栈是程序运行时的内存区域,用来存局部变量、函数调用信息,由编译器自动管理;而数据结构里的栈是一种抽象模型,你自己在代码里控制它的push、pop。两者不是一个维度的概念,但底层实现确实会借用到栈空间。弄混这两层意思,刷题和看底层源码都容易懵。
1.2 栈和队列的核心差异与选型切入点
做OJ题选型时,怎么判断该用哪个?我总结了一个经验:凡是“最新状态优先处理”的,用栈;凡是“先来先处理”的,用队列。
举几个场景你就明白了。
括号匹配、表达式求值、函数调用栈、浏览器的后退按钮,这些全是栈的典型应用。它们的共性是:需要和最近出现的那个状态对比、撤销、回滚。而BFS层序遍历、CPU任务调度、打印机任务队列、消息队列,这些全是队列的典型应用。它们的共性是:必须保证公平性、顺序性,或者需要缓冲区来应对突发流量。
另外还有一种变形结构值得留意:单调栈和单调队列。它们不是新结构,而是在栈/队列的基础上,维护内部元素的单调性,让最大值、最小值这类查询变成O(1)操作。后面讲第3道题时我会展开。
| 维度 | 栈 | 队列 | 单调栈 | 单调队列 |
|---|---|---|---|---|
| 进出顺序 | 后进先出 | 先进先出 | 后进先出,元素有序 | 先进先出,元素有序 |
| 典型操作 | push/pop/top | push/pop/front | push/pop/top | push/pop/front |
| 核心应用 | 括号匹配、表达式求值 | BFS、任务调度 | 下一个更大元素 | 滑动窗口最值 |
| 底层实现 | 数组、链表均可 | 数组、链表均可 | 数组、链表 | 双端队列 |
2. 第1题:有效的括号——栈的“配对验证”绝活
先来一道最入门的经典题:给定一个只包含'('、')'、'{'、'}'、'['、']'的字符串,判断括号是否有效。有效需要满足左括号必须用相同类型的右括号闭合,并且按正确顺序闭合。
这道题从难度上说很简单,但包含了栈最核心的“配对验证”思想,值得认真拆解一遍。
2.1 为什么不能用简单计数器
我第一次见到这道题时,第一反应是:统计左括号和右括号的数量,相等不就完了?这想法对纯()情况成立,但遇到多种括号混合就废了。比如字符串([)],各种括号数量都是匹配的,但它明显不有效,因为[和)交叉了。
这就是栈起作用的地方。栈天然适合处理“最近出现的左括号必须先被关闭”这种嵌套结构。因为当遇到一个右括号时,必须和“最近一个未匹配的左括号”配对,这不就是后进先出吗?
2.2 栈解法完整拆解
算法思路非常清晰:
- 初始化一个空栈。
- 遍历字符串的每个字符。
- 如果是左括号,入栈。
- 如果是右括号,先看栈是否为空,为空说明没有左括号可以和它配对,直接判无效。
- 如果栈不为空,弹出栈顶元素,检查是否匹配。如果不匹配,判无效。
- 遍历结束后,栈必须为空,否则说明还有左括号没被关闭。
这里有个写法上的小优化,网上很多题解用一堆if-else去判断括号类型,代码特别长。我的做法是用一张映射表把右括号映射到对应的左括号,判断时直接用。示例代码如下:
#include <stack> #include <unordered_map> #include <string> class Solution { public: bool isValid(std::string s) { std::stack<char> st; std::unordered_map<char, char> match = { {')', '('}, {']', '['}, {'}', '{'} }; for (char c : s) { // 如果是右括号 if (match.count(c)) { // 栈为空,或栈顶不匹配,直接失败 if (st.empty() || st.top() != match[c]) { return false; } st.pop(); } else { // 左括号入栈 st.push(c); } } return st.empty(); } };这段代码的关键细节有几点。match.count(c)用来判断当前字符是不是右括号,比写一串||判断要清爽得多。st.top() != match[c]一步完成类型匹配和顺序校验,非常干脆。最后返回st.empty()时,不需要再写if分支,因为empty()本身就是布尔值。
2.3 这道题的边界与易错点
这道题看似简单,但刷题群里还是经常有人栽跟头。我把常见的错误列出来,你可对照自查。
栈初始化问题。如果字符串是"}",第一个字符就是右括号,此时栈为空。如果你没检查st.empty()就直接st.top(),程序直接崩溃或者行为未定义。处理顺序必须是:先判空,再取栈顶。
遍历完栈不为空的情况。字符串是"((("这种全左括号时,循环结束后栈里还剩三个左括号。如果只判断“过程中有没有匹配失败”,会误判为有效。所以最后return st.empty()这一趴不能省。
左右括号匹配但顺序错误的交叉。字符串是"([)]"时,遍历到)时栈顶是[,不匹配直接返回 false。这就是为什么计数器和字符串替换方案都会失效,而栈能一次遍历解决。
刷完这道题后我建议你做一道变体:给定只包含'('和')'的字符串,求最长有效括号子串的长度。这道题从“判断是否有效”升级到了“找最长有效片段”,还能训练DP和栈的混合应用,帮助会更大。
3. 第2题:用队列实现栈——两种结构互相“扮演”的思考题
有效的括号是栈的入门必刷题,但如果只做这类题,你对栈的理解会停留在“能用API”的层面。第2题我选了LeetCode 225“用队列实现栈”,这道题的精髓在于逼你去思考:能不能用一个先进先出的结构,模拟出后进先出的行为?
题目要求很简单:使用队列实现栈的push、pop、top、empty操作。你可以使用多个队列,但必须只使用队列的标准操作,也就是只能操作队首元素。
3.1 核心难点与两种攻防思路
队列是先进先出,栈是后进先出,两者方向相反。要拿队列实现栈,本质上就是解决一个问题:如何让最后入队的元素,反而能被最先操作?
这是一个典型的“结构约束”问题。你不能破坏队列的FIFO性质,只能在操作方式上做文章。常见的方案有两类:push时调整和pop时调整。前者让新元素入队后直接“浮”到队首,后者让队首取到旧元素时再腾挪。两种思路都能解决问题,复杂度恰好相反,这里面的取舍很有意思。
3.2 两个队列的“倒腾”解法
我先讲两个队列的方案。核心思路是:保持一个队列始终为空,作为辅助缓冲区。
push(x)时,先把x入队到空队列q2,然后把q1里的所有元素依次出队并入队到q2。最后交换q1和q2。这样入队操作完成后,新元素就在q1的队首。
pop()和top()就变得很简单:直接操作q1的队首即可。pop()是弹出队首,top()只看不弹。
这个方案的代价是:push操作的时间复杂度是O(n),因为每次入队都要把旧元素全部搬一次。但pop和top是O(1)。
3.3 一个队列的巧妙优化写法
两个队列能过题,但还有一个更妙的方案:只用一个队列,照样能实现。
做法是,push(x)时先把x入队,然后从队首开始,把队列里之前的每个元素依次弹出并重新入队。这个操作完成后,原来的元素全部到了新元素的后面,新元素自然就“浮”到了队首。
举个例子。队列是[a, b, c],现在要 push 一个d。先把d入队变成[a, b, c, d],然后依次做三次操作:弹出a入队、弹出b入队、弹出c入队。最终队列变成[d, a, b, c]。你看,d是不是就到了队首?
代码写出来也非常干净:
#include <queue> class MyStack { private: std::queue<int> q; public: void push(int x) { int size = q.size(); q.push(x); // 将前 size 个元素依次移到队尾 for (int i = 0; i < size; i++) { q.push(q.front()); q.pop(); } } int pop() { int val = q.front(); q.pop(); return val; } int top() { return q.front(); } bool empty() { return q.empty(); } };这段代码里int size = q.size();这一步很关键。如果你在循环里直接用q.size()作为循环上限,它会随着你不断入队而变化,导致循环次数失控。先把size固定下来,才能保证只搬运原来的元素。
3.4 复杂度分析与做题心得
| 实现方式 | push | pop | top | 空间 |
|---|---|---|---|---|
| 两个队列(push调整) | O(n) | O(1) | O(1) | O(n) |
| 两个队列(pop调整) | O(1) | O(n) | O(n) | O(n) |
| 一个队列(push调整) | O(n) | O(1) | O(1) | O(n) |
做完这道题后,你可能会有一个疑问:既然一个队列就能实现,为什么题解还总爱讲两个队列的版本?我的理解是,两个队列的版本更符合“辅助空间”的直觉,也更容易迁移到其他场景。而单队列方案虽然代码简洁,但对“队列操作会改变长度”这一点理解不深的人,很容易写出死循环。
我实际做题时还有一个习惯:实现pop()时想办法复用top()。虽然代码里是两行,但思路上的提炼可以帮你少写很多重复逻辑。这在小项目里无所谓,但在笔试写代码时,逻辑越清晰,越不容易犯低级错误。
4. 第3题:滑动窗口最大值——单调队列的进阶打法
前两题是栈和队列的基础应用,第3题我选了LeetCode 239“滑动窗口最大值”。这道题比前两道高出一个段位,因为它在队列之上引入了“单调性”这个概念,对你理解栈与队列的极限能力非常有帮助。
题目是这样的:给一个整数数组nums和一个窗口大小k,窗口从数组最左端滑到最右端,每次只向右移动一位,要求输出每个窗口内的最大值。
4.1 为什么暴力法和堆优化都差点意思
最直观的暴力法:对每个窗口扫描一遍找最大值,时间复杂度O(nk)。数据量小的时候没问题,但一旦n和k都是万级别,直接超时。
有人会想用最大堆优化,滑入一个元素就push,滑出一个元素就pop,堆顶就是最大值。这个思路方向是对的,但有个致命问题:堆只能删除堆顶元素,没法快速删除任意元素。窗口滑动时被移出的那个元素,可能根本不在堆顶,你没办法精准删除它。
当然,可以引入“延迟删除”的技巧,记录每个值的出现次数,堆顶如果已不在窗口内就弹出。这个方案能过,复杂度是O(n log k)。但既然有O(n)的解法,我建议直接学透最优方案。
4.2 单调队列是怎么把复杂度降到O(n)的
单调队列的核心思想是:维护一个候选集合,把不可能成为答案的元素提前淘汰掉。
对滑动窗口最大值来说,如果队列里有两个元素i < j,且nums[i] <= nums[j],那么当窗口滑到包含j的时候,i永远不可能成为最大值。因为j比i更新(晚过期)、更大(更值得选)。所以每次新元素入队前,可以把队尾所有比当前元素小的下标全部弹出。
我强调一点:队列里存的是下标,不是值。什么意思呢?因为窗口滑动时要判断某个元素是否滑出了窗口,如果存值就没法判断,所以必须存下标,需要用下标去算窗口边界。很多初学者就是这一步没想明白,导致后面写错。
整个算法的流程是:
- 遍历数组,对每个元素
nums[i]: - 如果队首下标已经滑出窗口(
q.front() <= i - k),弹出队首。 - 从队尾开始,把所有值小于等于
nums[i]的下标弹出。 - 将
i入队。 - 如果
i >= k-1,说明窗口已经成形,队首对应的值就是当前窗口最大值。
为什么循环里先判断队首过期,再判断队尾单调性?因为如果队首过期了,不先清理,后续的单调性判断可能会保留一个已经滑出窗口的下标。
4.3 完整实现与细节说明
#include <vector> #include <deque> class Solution { public: std::vector<int> maxSlidingWindow(std::vector<int>& nums, int k) { std::vector<int> result; std::deque<int> dq; // 存下标,队首到队尾对应的值递减 for (int i = 0; i < nums.size(); i++) { // 1. 清理队首,保证队首在当前窗口内 if (!dq.empty() && dq.front() <= i - k) { dq.pop_front(); } // 2. 维护单调性:弹出所有小于等于当前元素的值 while (!dq.empty() && nums[dq.back()] <= nums[i]) { dq.pop_back(); } // 3. 当前下标入队 dq.push_back(i); // 4. 窗口成形后记录结果 if (i >= k - 1) { result.push_back(nums[dq.front()]); } } return result; } };实现时要特别注意第三步和第四步的顺序。有些写法先push_back再尝试弹出队尾,这容易把刚入队的元素又弹出去,逻辑就全乱了。我建议按照“清队首、清队尾、入队、记录”这个固定顺序来写,不容易出错。
另外,nums[dq.back()] <= nums[i]这里的<=是有讲究的。如果你只写<,那么值相等的元素会留在队列里。对求最大值来说,相等的情况保留旧元素也没问题,但会让队列里残留无效候选,多消耗空间、多点判断。用<=弹出旧元素,新元素保留,队列更干净,性能也略好。
4.4 单调队列的扩展联想
做完滑动窗口最大值,我强烈建议你顺手把“下一个更大元素”这道题也做了。它用的是单调栈——从左到右遍历,维护一个递减栈,遇到比栈顶大的元素就出栈并记录答案。你会发现,单调栈和单调队列本质上是同一个思想的两面:都在维护候选集合的单调性,减少无效比较。
之所以有人觉得这类题难,是因为桥梁没搭好。你不是在背代码,而是在理解“淘汰不可能成为答案的候选”这句话。一旦想通了,接雨水、柱状图中最大矩形、每日温度这些题,都会有豁然开朗的感觉。
5. 从OJ走进工程:栈与队列的真实战场
刷题刷到一定程度,你会开始好奇:这些东西除了过题,到底在真实项目里怎么用?我单独开一节,结合我自己的经验,把栈与队列在工程里的几个典型战场讲透。
5.1 函数调用栈和栈空间
写过递归的人应该都体验过“爆栈”。每一次函数调用,系统都会在栈上分配一块栈帧,用来存局部变量、返回地址、寄存器状态。递归深度一高,栈空间耗尽,程序直接崩溃。
C++在主流平台下默认栈空间一般在1MB到8MB之间,这跟操作系统和编译器设置都有关系。如果你在函数里开一个大数组,或者递归深度达到十万层以上,很容易触发栈溢出。这时候你就要考虑改成迭代写法,或者把数据放到堆上。
我印象很深的一次经历是,有朋友现场手写快排,期望复杂度O(n log n),结果在小数据集上跑得好好的,一上大数据就崩。排查到最后,发现是递归深度在退化情况下达到了数组长度,而数组长度是百万级,栈空间根本兜不住。后来改成用显式栈模拟递归,问题就解决了。这个案例告诉我们:OJ题里的栈,和工程里的调用栈是联动的。理解了栈的容量限制,你就能提前预判哪些代码在什么数据规模下会挂。
5.2 消息队列、阻塞队列与生产消费模型
站到更宏观的视角,队列在分布式系统和并发编程里简直是基础设施级别的存在。消息队列的三大作用,我一句话总结就是解耦、削峰、异步。生产者把消息丢进队列,消费者按自己的节奏消费,两边互不阻塞。你在系统里引入消息队列,本质上就是在两个模块之间加了一个缓冲区,让它们不需要同时在线、不需要同速运转。
线程池里的阻塞队列也走同一个套路。当任务提交速度大于线程处理速度时,任务会被放到阻塞队列里排队。选择哪种阻塞队列,就需要考虑不同场景下的取舍了。
| 队列类型 | 锁机制 | 适用场景 |
|---|---|---|
| ArrayBlockingQueue | 有界,一把锁 | 需要限制任务积压量的场景 |
| LinkedBlockingQueue | 有界/无界,两把锁 | 吞吐量要求高的场景 |
| SynchronousQueue | 不存任务 | 希望任务直接交接给线程,不排队 |
我在项目里见过一个比较典型的坑:无界队列看着很方便,任务随便往里丢,但如果消费者挂了,任务全积压在内存里,最后把整个应用的内存打爆。用有界队列,配合拒绝策略,反而能保护系统不会雪崩式崩溃。这就是结构选型在工程里的分量。
5.3 循环队列与嵌入式场景
在单片机和RTOS环境里,内存资源极其有限,动态分配也很谨慎,所以想用队列时首选是循环队列。它用固定大小的数组加头尾指针,配合取模操作实现“逻辑环形”,避免了频繁申请和释放内存。
举个串口接收的例子。用STM32的串口空闲中断接收不定长数据时,数据是一个字节一个字节进来的,如果边收边处理,主程序很容易被频繁打断。更好的方案是收完数据直接放进环形缓冲,主循环再从缓冲区取出来解析。这样中断服务程序尽量短,主程序按自己的节奏消费,数据不乱不丢。FreerTOS的队列本质上也是类似的思路,只是增加了任务间的同步和阻塞机制。
回头看我在OJ里写的那些循环队列题目,核心就是两块:容量取模运算、队空队满判断。这些基本功在嵌入式和网络协议栈里还真的天天用。
5.4 单调栈在算法题之外的用处
很多人觉得单调栈听起来很难,好像只存在于竞赛题里。其实它在一些“找最近最大/最小”的业务场景里也有用武之地。比如计算股票历史数据中每个交易日往后看第一个价格更高的日子,或者浏览器里解析嵌套HTML标签时的闭合匹配,都可以借助单调栈优雅实现。
我在面试中问到单调栈时,并不要求候选人背出代码,而是希望对方能说清楚“为什么要用单调栈,暴力为什么不行”。能讲明白这个,说明结构理解和复杂度分析都过关了。这一点也建议你在刷题时多去想,代码能跑通,只是第一步;能说清楚,才算真正掌握了。
6. 刷题实战中那些“不写文档”的经验
最后一部分不按题目展开,我想分享一些自己刷题多年总结出来的方法论。这些经验不是某个特定题目的解法,却能覆盖你后面刷所有栈与队列题时踩的坑。
6.1 拿到题目先画图
我见过太多人拿到题目就直接开写,写完一跑测试用例,发现输出不对,再回去看代码,来回折腾一晚上。其实画图是最快的验证方式。
拿“用队列实现栈”举例,光靠脑子想“把一个元素移到队首”很容易绕晕。但在草稿纸上画一个长度4的队列,模拟一次push操作,马上就能发现规律。画图时我会用一串字母表示队列,比如[a, b, c],然后在每一步操作后面写上新队列的状态,最后再对照代码跑一遍,基本一次就能通过。
单调队列的题目更是强烈建议画图。你画一个k=3的窗口,手动执行“清理队首、清理队尾、入队、记录”四个步骤,画三到五个窗口之后,算法逻辑就刻在脑子里了,这辈子都不容易忘。
6.2 边界条件检查清单
刷栈和队列的题目,我最常犯的错误都集中在边界条件上。下面这个清单是我每次提交前的必查项:
- 空输入(空字符串、空数组)。
- 只有一个元素的情况(
n=1、k=1)。 - 窗口大小等于数组长度(
k=n)。 - 全是相同元素的输入。
- 全部左括号、全部右括号的输入。
- 元素值全是负数的情况(滑动窗口最大值不应该是0)。
- 队列操作时先判空还是先取值的顺序问题。
这里特别想提醒的是负数用例。很多人写滑动窗口最大值时,习惯初始化max_value = 0,遇到全是负数的数组,整个答案都是错的。正确做法是用队首对应的元素初始化,或者把初始值设成INT_MIN。
6.3 容易让人栽跟头的代码细节
deque和queue别搞混。单调队列在C++里用的是deque,因为它需要两端操作。如果用queue,就没有pop_back()和push_front()了,代码直接编译不过。
pop和top/ front的区别。栈里pop()是弹出但不返回,top()是返回但不弹出。队列里pop()不返回,front()返回。这就导致写int val = q.front(); q.pop();时不能简化成int val = q.pop()。很多语言新手笔试时为了少写一行,把API给用错了,这种低级错误特别可惜。
用哈希表代替一堆if。括号匹配那道题,如果你在switch或if里写四个分支,代码要长好几倍,还容易漏掉分支。用unordered_map一步映射,既简洁又不容易错,这是我在实际开发里也常用的技巧。
6.4 一套可复用的刷题节奏
最后聊一聊刷题的节奏问题。我发现不少新人刷题有一个共性误区:做一遍、看答案、抄一遍、换题,循环往复,看起来很勤奋,但遇到新题还是不会。
我自己建议的节奏是这样的:先把一种结构吃透,再做组合题,最后限时模拟。比如栈,先做“有效的括号”和“最小栈”,理解栈顶操作的幂等性;再做“用队列实现栈”这种结构互换题,逼自己想清楚操作语义;最后上“滑动窗口最大值”这种需要组合多种技巧的题目,把单调栈、双端队列、下标管理一起练透。每个阶段不超过三天,反复做三遍以上,直到不假思索能写出来为止。
这里要特别说一下,重复做题不是让你背代码。第二遍做的时候,我会刻意留着上一遍的代码不看,自己重新推导一遍。能独立写出来,才说明思路变成了自己的。可能你会觉得这样做题慢,一天只能过一两道。但我的实际体会是,真正吃透一道题,比浮光掠影刷十道题到面试时全忘光,效率高太多了。
另外,做题时养成记录错题的习惯也很有价值。我会在每道错题下面写一两句“错误原因”和“正确思路”,比如“忘记判断栈空”“循环边界没固定”这类。到面试前一周,快速过这些记录,比重新刷几十道题管用得多。
结尾
说实话,栈和队列这两个结构,学的时候总觉得“小儿科”,但真正用好的关键在于你能不能跳出API层面,理解它背后的约束和适应场景。我从第一次写“有效的括号”时只会死记硬背,到后来能在项目中主动设计阻塞队列和解耦方案,用了很长一段时间的反复实践。你现在刷这些OJ题,看似只是在“刷题”,实际上是在训练自己面对约束时寻找最优解的思维方式。
最后再分享一个小技巧:刷完一道题,试着想一想“如果我把需求改一点点,这个解法还成立吗?”比如“有效的括号”改成“判断的时候忽略空格”,或者滑动窗口最大值改成“求最小值”,你会发现自己对结构本身的理解,又深了一层。这种带着问号去刷题的方式,比单纯追求AC数量要有意思得多,也扎实得多。