☰
栈与队列算法题复盘:从括号匹配到逆波兰表达式,吃透数据结构核心应用
2026/9/29 16:13:27 网站建设 项目流程

刷完了代码随想录day11,栈与队列part2,整个人松快了不少。说实话,栈和队列这两兄弟,刚开始学的时候都以为就是“先进后出、先进先出”,两个口诀背完就以为会了。可真正开始用栈实现队列、用队列实现栈的时候,才发现自己离“理解”还差着十万八千里。day11当天的题量不大,核心就三题:有效的括号、删除字符串中的所有相邻重复项、逆波兰表达式求值,全是栈和队列在字符串处理和表达式计算里的经典应用。如果你正在追代码随想录,或者单纯想把数据结构的地基打牢固,这篇可以当伴读笔记看,我会把每道题的思路、代码、踩坑点全部摊开,再顺着栈帧、backtrace、阻塞队列、消息队列这些工程里的“亲戚”一并聊透。

1. 先把栈和队列的“底”摸清楚:栈帧、回溯与排队模型

1.1 函数调用是怎么用栈的?栈帧与backtrace的秘密

在程序设计的世界里,栈最经典的骨架就是函数调用栈。你在IDE里打断点、打开Call Stack窗口,或者用gdb敲一个bt(backtrace)命令,看到的每一层调用记录,本质上都是栈帧的串联。所谓栈帧(stack frame),就是一次函数调用的完整上下文:参数、返回地址、局部变量、保存的寄存器状态,统统被系统压在栈内存里。

当函数A调用函数B的时候,程序会先把B需要的参数按调用约定压栈,再把当前指令的返回地址压栈,然后跳转到B的入口继续执行。B运行的时候,再把自己的局部变量压进去,形成一个新的栈帧。这个“压栈—执行—弹栈”的动作,完完整整对应数据结构教科书里栈的push和pop。函数B一旦return,系统就把当初保存的返回地址读出来,跳回A接着干活,同时把B的栈帧整体弹掉。backtrace栈回溯的原理就是基于此:栈帧里存着返回到哪去的信息,调试器沿着这些返回地址一路往上找,整条调用链就全都捞出来了。

理解了这一层,再看“递归为什么会爆栈”就特别清晰:每递归一层,系统都要新建一个栈帧。栈空间是有限的(Linux主线程默认8MB,可以用ulimit -s查看),递归深度一大,栈帧一层层叠上去,直接把栈底撑穿,程序就抛stack overflow。所以工程里常用循环加显式栈来替代深递归,本质是把系统栈的活揽到自己手里,可控性反而更高。顺带提一句,“局部变量越少,所占栈空间越小”这个说法基本成立。栈帧的大小主要由局部变量、参数和返回地址撑起来,尤其是嵌入式环境里,例如用pico-sdk做开发时主栈默认很小,函数里少放几个大数组、把大对象挪到堆上,都是非常实在的优化手段。

1.2 队列的“排队”模型和阻塞队列

队列就更好理解了——排队。你在食堂打饭、在银行叫号,都是队尾入、队头出,先来先服务。计算机里的队列就是给数据排队:入队(enqueue)把元素放到队尾,出队(dequeue)从队头取走。

用数组实现队列有一个初学者经常翻车的细节:如果单纯用数组尾部追加、头部弹出,每次弹出都要把后面所有元素往前搬,复杂度瞬间变为O(n)。所以实际实现里普遍用循环队列——头尾指针在数组里转圈,队尾满了就回绕到开头继续用。判空判满也是经典考点:初始时head == tail表示空,而“满了”的判断有几种实现方式,最常见的是牺牲一个格子,用(tail + 1) % size == head表示队满;也有人额外用一个size变量做计数。

队列在实际工程里最典型的形态就是阻塞队列:放不进去就等着,取不到也等着。这不就是生产者-消费者模型吗?生产者往队列里塞任务,消费者从队列里取任务,队列天然承担了异步缓冲和削峰的作用。秒杀场景、异步订单处理、线程池的任务队列,底下全是阻塞队列在兜底。所以这一节可以先建立一个直觉:栈是“操作的现场记录”,队列是“任务的排队缓冲”。带着这两个直觉去刷后面的题,会顺很多。

1.3 “栈”在不同工程语境下的含义

多说一句,栈这个名词在工程里不止一种用法。除了数据结构里的栈,还有网络协议栈(TCP/IP协议栈)、安卓的网络请求栈(比如OkHttp、Retrofit处理HTTP请求的那套管线),甚至iOS Safari里用uniapp的canvas时如果导出白图,本质也是绘制操作和异步队列之间没协调好。凡是“先进后出”或“先进先出”的任务组织方式,都可以拿栈和队列的思维去理解。这也是为什么算法题刷到后面,你会发现栈和队列无处不在。

2. part2核心三题逐题复盘

2.1 part1先复盘:栈和队列互相模拟的底层逻辑

进入当天题目之前,先把part1的两道题快速过一遍,因为后面会反复用到它们的思维。

232 用栈实现队列的核心是“两次后进先出等于先进先出”。维护一个输入栈in和一个输出栈out,push直接进in;pop的时候先检查out是不是空的,空就把in里所有元素全部倒进out,再取out的栈顶。为什么可以倒?因为倒一次之后,压在栈底的元素到了栈顶,顺序正好被纠正过来。最关键的是均摊复杂度:一个元素最多进栈两次、出栈两次,均摊O(1),实际跑起来非常快。

225 用队列实现栈更好玩。队列的先进先出不会自然翻转顺序,所以pop时需要把前size-1个元素重新塞到队尾,让队首变成最后一个进来的元素,充当栈顶。优化之后甚至只需要一个队列:每次pop,把除了最后一个以外的所有元素重新入队,队首就是要弹出的元素。top操作同理,只是把队首取出来后再放回队尾,保持队列原状。

这两道题本质上是考你“两种数据结构能否互相模拟”,面试出现频率很高,建议多手写几遍,直到闭着眼能写出为止。

2.2 20. 有效的括号:最经典的栈匹配应用

题目:给定一个只包含()[]{}的字符串,判断括号是否有效。

为什么这道题必须想到栈?因为括号匹配天然是“最近优先”的:最内层的左括号,一定匹配它后面的第一个右括号。你手动判断的时候,也是从中间往两边消,这就是后进先出。代码可以写得很简洁:

bool isValid(string s) { stack<char> st; for (char c : s) { if (c == '(') st.push(')'); else if (c == '[') st.push(']'); else if (c == '{') st.push('}'); else { if (st.empty() || st.top() != c) return false; st.pop(); } } return st.empty(); }

这版代码是代码随想录里很常用的写法:遇到左括号,把对应的右括号压栈;遇到右括号,就看栈顶是不是自己。这种“反向压栈”的好处是不用额外记左右怎么对应,代码分支最少。

但是有三种不匹配的情况必须想全。一是右括号多了,遍历过程中栈已经为空,直接false;二是左右括号类型对不上,栈顶不等于当前字符;三是左括号多了,遍历完了栈还不为空。我见过不少提交挂掉就挂在第三种——循环结束直接return true,忘了检查st.empty()。多写一行判断,稳得很。

2.3 1047. 删除字符串中的所有相邻重复项:把字符串当栈用

题目:给出一个小写字母字符串,反复删除两个相邻且相同的字母,直到没有相邻重复项,返回最终字符串。

这题直观解法就是“遍历加消消乐”。新来的字符如果和“已保留字符串”的最后一个相同,就把那个字符废掉;否则保留当前的字符。仔细一看,“已保留字符串的最后一个”,这不就是标准的栈顶访问吗?于是可以直接拿一个字符串模拟栈:

string removeDuplicates(string s) { string st; for (char c : s) { if (!st.empty() && st.back() == c) st.pop_back(); else st.push_back(c); } return st; }

为什么一定要用栈?因为消除相邻重复项是一个动态过程:消掉一对之后,新露出来的相邻关系只可能发生在栈顶和下一个待处理字符之间,之前的字符不会“复燃”参与新的匹配。所以用一个栈维护“还没被消掉的字符序列”,每一步只需要比较栈顶和当前字符就够了。

这里有个小技巧值得记:如果题目要求输出处理后的字符串,用string当栈比用std::stack舒服得多。省掉最后把栈元素倒出来再反转的一次遍历,时间和空间都更省。这个思路在很多字符串处理题里都能复用,比如一些“保留最后一个出现字符”的变体。

2.4 150. 逆波兰表达式求值:计算机天生爱后缀表达式

题目:给你一个逆波兰表达式(后缀表达式),求它的值,例如["2","1","+","3","*"] = 9。

逆波兰表达式就是运算符写在两个操作数之后的表达式:中缀“2 + 1”写成后缀“2 1 +”。人类看中缀顺眼,但计算机处理中缀要考虑优先级、括号,麻烦得很;后缀表达式只需要一个栈,从左到右扫一遍,遇到数字就压栈,遇到运算符就弹出两个数做运算,结果再压回去。等表达式全部扫描完,栈顶就是最终答案。这就是为什么编译器的表达式求值阶段,往往先把中缀表达式转成后缀,再顺序计算。

代码实现:

int evalRPN(vector<string>& tokens) { stack<int> st; for (const string& t : tokens) { if (t == "+" || t == "-" || t == "*" || t == "/") { int b = st.top(); st.pop(); int a = st.top(); st.pop(); if (t == "+") st.push(a + b); else if (t == "-") st.push(a - b); else if (t == "*") st.push(a * b); else st.push(a / b); } else { st.push(stoi(t)); } } return st.top(); }

这题有一个让人印象极其深刻的坑:运算顺序。先弹出栈顶的是后一个操作数,也就是表达式里靠右的那个数。做减法时,先出栈的是b(右操作数),后出栈的是a(左操作数),正确结果是a - b;除法同理是a / b。如果把顺序写成b - a,测试用例里但凡出现减法必然翻车。这事我自己丢过分,后来给自己定了个规矩:碰到二元运算,先默念“先弹右、后弹左”,再动手写。

第二个坑是负数。tokens里可能出现“-11”这种字符串,如果单纯用t[0] == '-'来判断当前字符是否是减号,很容易把数字“-11”当成运算符。正确的做法是判断整个字符串是不是四则运算符,用完整字符串比较,而不是只看首字符。LeetCode这题保证表达式合法有效,但工程里谁都不敢保证输入一定干净,所以这个习惯还是要养成。

3. 别急着收工:单调队列和优先级队列,同类题的长尾

3.1 239. 滑动窗口最大值:单调队列的淘汰逻辑

day11当天没有安排这两道题,它们一般在后面才出现,但我强烈建议跟着一起刷。239 滑动窗口最大值题义:给定一个数组和窗口大小k,每次窗口右移一格,输出当前窗口内的最大值。

暴力做法很简单,每滑一格就在窗口里扫一遍找最大值,复杂度O(nk),数组一长直接超时。优化思路是:我们不需要维护窗口里所有元素,只需要维护“有机会成为窗口最大值的候选集”。这就是单调队列的核心思想。

维护一个双端队列deque,让队列从队首到队尾保持严格递减。新元素入队时,先把队尾所有小于等于它的元素全部弹出,再把新元素从队尾塞进去。为什么可以弹?因为新元素既比它们大,又在窗口里“活”得更久,一个“更大且更新”的元素出现了,旧元素永远没机会当窗口最大值。窗口滑动时,还要检查一下队首元素的下标是不是刚好是移出窗口的那个,如果是,说明它过期了,从队首弹出。

代码骨架大致是这样:

deque<int> dq; // 存下标,方便判断是否过期 for (int i = 0; i < nums.size(); i++) { while (!dq.empty() && nums[dq.back()] <= nums[i]) dq.pop_back(); dq.push_back(i); if (dq.front() <= i - k) dq.pop_front(); // 队首下标离开窗口 if (i >= k - 1) ans.push_back(nums[dq.front()]); // 收集答案 }

提示:队列里存下标,而不是存值,是这道题不超时的关键。如果只存值,窗口滑动时你根本判断不了“队首元素是否已移出窗口”。存下标之后,一个if就能解决过期问题。

整体复杂度O(n),每个元素最多进队一次、出队一次,比暴力快了一个数量级。

3.2 单调队列优化DP:识别套路更重要

除了滑窗最值,单调队列还能优化一类区间DP。典型形式长这样:

dp[i] = max(dp[j]) + cost,其中j的取值范围落在[i-k, i-1]这个滑动窗口内。

这类题如果对每个i都去扫一遍窗口,复杂度O(nk);但如果维护一个针对dp数组的单调队列,窗口内最大值在O(1)时间就能拿到,总复杂度直接降到O(n)。识别特征也很简单:转移方程里出现“前面连续k个位置的某种最值”,基本就是单调队列优化的味道。做题的顺序建议是:先写出朴素DP,再看有没有滑窗最值窗口,最后替换成单调队列。这条递进路径在LeetCode里很多“线性DP加滑窗限制”的题都能用,学一次收益很高。

3.3 347. 前K个高频元素:优先级队列的正确姿势

347 前K个高频元素,给定一个整数数组,返回出现频率最高的前K个元素。常规思路:先统计每个元素的频次,再按频次排序,取前K个,复杂度O(n log n)。但排序会把全部元素都排一遍,我们只需要最大的K个,完全可以用最小堆只维护K个元素:

priority_queue<pair<int,int>, vector<pair<int,int>>, greater<>> pq; // 小顶堆 for (auto& [num, freq] : mp) { pq.push({freq, num}); if (pq.size() > k) pq.pop(); // 丢掉当前最小的 }

这里为什么不用大顶堆?因为要的是前K个最大频次的元素,只有堆顶是当前K个里最小的时候,遇到新的更高频元素才能弹掉旧的、换上新的。如果建大顶堆,堆顶永远是最大的,根本不知道该淘汰谁。优先级队列(堆)的“优先级”由比较器决定,这道题正好把概念顺带理清了。

4. 从算法题到工程现场:工程里的栈与队列,远比题目复杂

4.1 线程池的阻塞队列怎么选

回到第1部分提过的阻塞队列。在Java的线程池里,任务队列本质上就是生产者-消费者之间的缓冲。选择不同的阻塞队列,直接决定了线程池的行为:

  • ArrayBlockingQueue:有界数组阻塞队列,容量固定,满了就让提交线程阻塞或走拒绝策略,适合需要控制任务积压量的场景;
  • LinkedBlockingQueue:有界或无界链表阻塞队列,默认无界时可能导致任务无限积压,内存被拖垮,这个坑在大型系统里真实发生过;
  • SynchronousQueue:不存任务的队列,每一个put必须等到一个take,线程池一有任务马上创建线程执行,适合对延迟敏感的短任务;
  • PriorityBlockingQueue:按优先级出队的阻塞队列,适合需要任务分级处理的场景。

选型本质上是“缓冲能力、内存风险、业务需求”三者的权衡。无界队列看起来很省心,其实是把风险藏到了内存里;有界队列配合饱和策略(抛异常、丢弃、调用者自己跑)才是生产环境的常规操作。

4.2 消息队列选型实战对比:kafka、rabbitmq、rocketmq

把阻塞队列再放大一个层级,就到了跨服务的消息队列。很多团队一开始选型很随意,后面踩坑才回头补课。这里把三个主流MQ的差异摊开看:

维度KafkaRabbitMQRocketMQ
核心定位分布式流处理平台通用消息中间件(AMQP)金融级消息中间件
吞吐量百万级/秒,极高万级/秒十万级/秒
消息延迟毫秒级微秒到毫秒级毫秒级
可靠性副本机制,可配置acks镜像队列,防止节点故障支持同步刷盘和事务消息
路由能力弱,基于分区强,topic+exchange+绑定中等,支持tag过滤
顺序消息分区内有序单一队列有序分区队列有序
事务消息官方不支持部分场景可模拟原生支持
大数据生态和Flink/Spark无缝集成一般部分支持

选型建议基本就是“三看”:看吞吐,大数据采集、日志管道、流计算场景,Kafka最稳;看路由复杂度,业务消息需要灵活的绑定关系,RabbitMQ的AMQP模型天然合适;看交易级别可靠性,事务消息、顺序消息、金融风控这类场景,RocketMQ的Java生态最省心。如果你所在团队已经深度绑定某个框架,选型还要考虑运维成本和团队熟悉度,技术指标只是其中一环。

4.3 重复消费问题:幂等是唯一的出路

聊消息队列绕不开的痛点就是重复消费。分布式系统为了不丢消息,普遍采用“至少一次”投递语义:生产者重试投递、消费者消费成功但还没来得及提交offset就宕机,消息都会被再次投递。换句话说,重复消费不是“会不会发生”的问题,而是“什么时候发生”的问题。

想在重复消息下不让系统产生脏数据,核心是幂等设计。常见做法:

  • 靠消息唯一ID去重:消费端把已处理的ID存到Redis或数据库,消费前先检查再写入,注意“检查后写”最好是原子操作,否则仍有竞态;
  • 靠业务唯一键兜底:比如订单表里订单号建唯一索引,重复插入被数据库直接拒绝;
  • 靠状态机:处理结果里带上订单状态,只有前置状态匹配才继续流转,天然幂等。

我在项目里的体会是:前两种方案最常用,第三种适合有明确状态流转的业务。消息队列本身不背幂等的锅,真正决定正确性的,永远是消费者的业务逻辑。

5. 常见问题与避坑实录

5.1 栈相关:爆栈、越界与大数组存放

日常开发里栈的高频问题集中在爆栈。Linux默认主线程栈8MB,递归不设节制、函数里声明超大局部数组,都是常见的翻车原因。某个递归函数每层吃几KB栈帧,来个几万层递归,8MB说没就没。

排查时先看backtrace能不能正常打出,如果连bt都输出不全,大概率是栈被写穿或者栈帧指针损坏。常见处置方案:把深递归改成循环加显式栈;把大数组改为new或vector放到堆上;嵌入式环境里,如果用的是pico-sdk这类开发,可以自己调大任务栈或主栈大小,前提是确认整体内存够用。

另外,模拟栈的写法有个小细节:用数组模拟时,top到底指向“栈顶元素”还是“下一个空位”,很多人会搞混。C++的STL中stack基于deque实现,不需要自己管理内存;但手写题里,把top初始化为-1还是0,直接影响后面的压栈代码是++st.top还是st[top++]=x。建议专门写一页笔记,固定住自己习惯的那套写法。

5.2 队列实现:环形缓冲、判空判满和底层容器

循环队列是面试爱考的实现题,核心是判空判满:

  • 队空:head == tail;
  • 队满:牺牲一个存储单元,让(tail + 1) % size == head成立时视为满;
  • 出队入队:head = (head + 1) % size,tail = (tail + 1) % size。

注意这里的“满”不等于数组最后一个格子被占了,而是“再放一个就会和head撞上”。实际使用STL的queue时不需要关心这些,但知道它底层是deque双端队列而不是连续数组,能帮你理解为什么queue的push和pop复杂度都是O(1)均摊——它内部是由分段的连续块拼接而成,扩容时不会像vector那样整体搬移。

5.3 刷题高频错误速查表

把这几天常见的错误整理成一张速查表,刷到相关题目时可以先对一遍:

题目高频错误正确姿势
20. 有效括号遍历完直接return true,漏掉栈非空检查结尾用return st.empty()
1047. 删除相邻重复项用stack存字符,最后忘了反转直接用string当栈,或最后reverse
150. 逆波兰表达式减法除法操作数顺序写反先弹右操作数b,再弹左操作数a
150. 逆波兰表达式把“-11”这类负数当运算符用完整字符串判断运算符
232. 用栈实现队列peek忘记transfer,pop后状态不同步peek复用transfer逻辑
225. 用队列实现栈top弹出元素后没还原队列取到栈顶后重新放回队尾
239. 滑动窗口最大值队列存值不存下标,无法判断过期存下标,用front() <= i-k判断

这些错误大多不是“不会”,而是“写太快”。我的习惯是:每道题提交前,用几个刁钻例子在脑内跑一遍。括号题试一下“([)]”和“(((”,RPN题试一下“3 -4 +”,滑动窗口试一下窗口大小等于数组长度。先把边界情况跑顺了再提交,正确率会明显高很多。

我个人在实际刷题中的体会是:栈和队列难的不是定义,而是建立“什么时候该用它们”的直觉。括号匹配天然是栈的活,因为匹配规则是最近优先;相邻重复项消除是栈的活,因为你只关心“上一个还剩下的字符”;表达式求值是栈的活,因为后缀表达式用栈扫一遍就能完成。而队列那边,削峰、排队、异步解耦,从阻塞队列到消息队列,全是“先进先出”这一条规则在不同规模下的演绎。后来我在线上排查问题,打开backtrace看到一层层调用帧时,脑子里浮现的就是栈帧在栈上一个一个叠起来的样子,那种感觉特别奇妙——原来刷题时建立的心智模型,真的会在工程现场冒出来。希望你也能一边刷题,一边往真实场景里联想,这样记下的东西才不容易忘。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询