ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

栈与队列OJ题实战:从括号匹配到单调队列的精讲

栈与队列OJ题实战:从括号匹配到单调队列的精讲 栈和队列这两个词凡是写过代码的人多少都听过但说句实在话能把它们讲透彻、用明白的人真不多。尤其是刷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/toppush/pop/frontpush/pop/toppush/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::stackchar st; std::unordered_mapchar, 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::queueint 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 复杂度分析与做题心得实现方式pushpoptop空间两个队列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::vectorint maxSlidingWindow(std::vectorint nums, int k) { std::vectorint result; std::dequeint 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]然后在每一步操作后面写上新队列的状态最后再对照代码跑一遍基本一次就能通过。单调队列的题目更是强烈建议画图。你画一个k3的窗口手动执行“清理队首、清理队尾、入队、记录”四个步骤画三到五个窗口之后算法逻辑就刻在脑子里了这辈子都不容易忘。6.2 边界条件检查清单刷栈和队列的题目我最常犯的错误都集中在边界条件上。下面这个清单是我每次提交前的必查项空输入空字符串、空数组。只有一个元素的情况n1、k1。窗口大小等于数组长度kn。全是相同元素的输入。全部左括号、全部右括号的输入。元素值全是负数的情况滑动窗口最大值不应该是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数量要有意思得多也扎实得多。
返回列表