1. 刷题前的思路梳理:为什么我选择集中刷栈和队列
说实话,栈和队列这俩数据结构,在OJ上属于"看着简单、做起来百转千回"的类型。很多人觉得它们不就是"先进后出"和"先进先出"吗?但真正刷起来才发现,从括号匹配到表达式求值,从单调栈到滑动窗口,题目的变体多到让人怀疑人生。我最近集中刷了一批栈和队列的OJ题目,从简单的链式队列入队出队,到复杂的合法出栈序列判定、循环队列设计,再到单调栈压轴题,前前后后做了几十道。这篇文章就是一份完整的做题报告,把题型的套路、代码实现的取舍、以及那些不写进教科书里的坑,一次性讲清楚。
先说说这次刷题的整体思路。栈和队列的OJ题,其实可以按照"底层操作"和"算法思想"两个维度来划分。底层操作就是基本的push、pop、入队、出队,通常考察的是实现细节,比如循环队列怎么判满、链式队列的指针怎么指、C语言里用数组模拟栈时栈顶指针的初始值到底是-1还是0。算法思想层面则更上一层楼,比如用栈实现表达式求值、用队列实现BFS、用单调栈解决"下一个更大元素"、用单调队列解决滑动窗口最大值,这些题的难点不在数据结构本身,而在于"什么时候用栈、什么时候用队列、怎么维护单调性"。
我的选题策略也很明确:先覆盖基础操作题,再挑战经典算法题,最后刷几道综合题来串联知识点。具体来说,基础操作题大概占了四分之一,包括栈的基本操作、链式队列入队与出队、循环队列设计;算法题占了一半多,包括合法出栈序列判定、中缀转后缀、单调栈类的柱状图最大矩形和接雨水、单调队列类的滑动窗口最大值;剩下的是一些变体题,比如两个栈实现队列、两个队列实现栈、最小栈、用栈模拟递归等。
这个结构的合理性在于,栈和队列本质上是"受限的线性表",它们的核心考察点就是"限制带来的特性"以及"如何利用这些特性解决特定问题"。所以刷题时如果你只盯着API调用,那基本学不到东西;必须从底层实现到上层应用全部过一遍,才能真正理解为什么递归要用系统栈、为什么消息队列能削峰填谷、为什么线程池的阻塞队列要选有界队列。这也是我在做题报告里一直强调的思路:每道题做完,都要反问一句"这个场景在真实工程里对应什么"。
另外我特别想说一点:栈和队列在OJ里经常被放到"线性表"这一章来考,但各大OJ的出题风格差异很大。比如杭电OJ(HDU)和华为OJ(OD机试)更偏向输入输出格式的坑,洛谷和力扣更注重算法思维的深度,而东方博宜这类学校的OJ则常常拿基础操作题来考代码熟练度。所以如果是新手上路,建议先在一个固定平台上把基础题刷透,不要反复横跳。我在这次刷题过程中就深有体会,同一道"循环队列设计",力扣上只需要实现类方法,而在某些OJ上还要自己处理多组输入输出,细节完全不同。
2. 栈的OJ核心题型:从基本操作到合法出栈序列判定
2.1 栈的基本操作:数组模拟与链表模拟的取舍
栈的基本操作题,说白了就是考你push、pop、top、isEmpty这几个动作怎么实现。很多OJ上的第一道栈题都是这个套路。用数组模拟栈是最常见的做法,一个一维数组加一个栈顶指针就搞定了。但这里有个特别容易被忽视的细节:栈顶指针的初始值。
如果你用C语言写,栈顶指针top初始化为-1,那么push的时候要先top++再赋值;如果初始化为0,那就要先赋值再top++。这两种写法在OJ上都能过,但很容易因为混淆而出bug。我个人习惯用top = -1的写法,因为这样"栈空"的判断条件就是top == -1,逻辑上更直观,而且后续如果要实现"获取栈中元素个数",直接返回top + 1就行,不用再额外处理偏移。
还有一个选择是用链表模拟栈。链表栈的好处是没有大小限制,不会出现"栈满"的情况,但代价是每个节点要额外存储一个next指针。OJ做题时我一般优先用数组模拟,因为数组的随机访问效率高,而且不会因为频繁malloc/free带来性能抖动。但如果是遇到那种"内存限制特别严格"的题,数组模拟反而更可控,因为链表节点本身还有内存对齐的开销。
不过,数组模拟栈有一个致命问题:栈满溢出。在OJ上,题目通常会给一个相对宽松的栈大小上限,但如果你在解题时没有预先估算最大深度,很容易出现数组越界。比如某些模拟题,数据范围是10^5,但你只开了10^4的数组,提交后就是Runtime Error(RE)。我踩过这个坑,后来养成了习惯:凡是模拟栈,数组大小至少开数据范围上限加5,宁可浪费一点空间,也不要越界。
2.2 合法出栈序列判定:不光会模拟,还得会推理
合法出栈序列判定是我认为栈这章里最有意思的一道题。题目描述很直接:给定入栈序列1到n,再给一个出栈序列,判断这个出栈序列是否合法。最经典的解法就是用一个辅助栈模拟入栈和出栈的过程:用一个指针指向出栈序列的当前位置,遍历入栈序列,每次把一个元素压入辅助栈,然后检查辅助栈栈顶是不是等于当前出栈元素,如果相等就弹出,并且出栈指针后移,注意这里要用while循环,因为可能连续弹出多个元素。最后检查辅助栈是否为空且出栈指针是否已经走完整个出栈序列。
我当时做这道题的时候,一开始想的是"直接判断是否存在逆序对"之类的数学性质,后来发现模拟法才是最稳妥的。不过模拟法的时间复杂度是O(n),空间复杂度是O(n)(存储辅助栈),对于10^5规模的数据完全没问题。但在OJ上提交时,我发现有一个隐藏考点:如果出栈序列中有重复元素,还能不能简单地用上面的模拟法?答案是:如果题目保证入栈序列和出栈序列都是排列(即1到n各出现一次),那模拟法没问题;但如果允许重复元素,就需要更复杂的判定方法了。好在绝大多数OJ上这道题都是排列版本,模拟法就够用了。
这道题还有一个常见的变体:"已知入栈序列,求所有可能的出栈序列个数",这其实是个卡特兰数问题,n个元素的出栈序列总数是卡特兰数C(2n, n)/(n+1)。如果不理解这个公式,可以想象成"每个元素必须先进后出,且出栈序列必须满足某种括号匹配的约束"。这个变体虽然不常直接考代码,但很多"判断合法出栈序列"的证明题都会用到这个思想。
我当时在做完这道题之后,顺手做了一道同类型的题:给定一个字符串,判断它是否是某个合法出栈序列的结果,比如"abc"入栈后合法出栈序列有"abc"、"acb"、"bac"、"bca"、"cba"但不包括"cab"。这种题其实就是同一个模拟思路,但出题人把背景包装成了"字符串操作",一开始很容易被绕晕。我给的建议是:不管题目怎么包装,看到"入栈出栈序列"相关字样,第一反应就应该是辅助栈模拟。
2.3 中缀表达式转后缀与表达式求值:栈在计算器里的经典应用
表达式求值是栈的又一个经典考题。常见的有两种考法:一种是让你把中缀表达式转成后缀表达式(逆波兰式),另一种是直接给一个后缀表达式让你求值。两个过程都依赖栈的核心性质:操作符的优先级和括号匹配。
中缀转后缀的规则不复杂但细节多。它的核心思路是:用一个栈保存操作符,遍历中缀表达式,遇到操作数直接输出;遇到操作符时,如果栈顶操作符的优先级大于等于当前操作符,就把栈顶弹出并输出,然后重复这个过程,直到栈顶优先级小于当前操作符或栈为空,再把当前操作符压入栈;遇到左括号直接压栈;遇到右括号则不停弹出栈顶并输出,直到遇到左括号,再把左括号弹出(不输出)。最后把栈里剩下的操作符全部弹出。
这个算法我在书上看过无数次,但真正在OJ上动手写的时候还是踩了不少坑。第一个坑是负数问题,比如"-3+5"这种表达式,如果直接把"-"当作减号处理,会出问题。处理办法有两种:一是在转换之前先判断当前字符是不是负号并且它的前一个字符是操作符或左括号,如果是就把负号和后面的数字一起当作一个操作数;二是把中缀表达式做预处理,在负号前补一个0,把"-3"变成"0-3"。我推荐第二种,因为实现起来不容易漏。
第二个坑是数字可能是多位数,甚至带小数点。OJ题里如果只给单数字(0-9),那处理起来很简单;但如果是"123+45"这种,就要注意解析连续数字。我的做法是用一个循环读取连续的数字字符或小数点,组成完整的操作数之后再进行后续处理。这看起来简单,但忘记处理的话,写出来的代码会错误地输出"1 2 3 + 4 5"而不是"123 45 +"。
后缀表达式求值就简单多了:遇到操作数压栈,遇到操作符就弹出两个操作数,然后根据是加减乘除还是乘方进行运算,最后把结果压回去。这里有个特别容易出错的地方:减法和除法的顺序问题。弹出的是b和a(b先弹出、a后弹出),那么做减法时应该计算a - b,做除法时应该计算a / b。我一开始没有注意,直接把b - a了,结果怎么调都错,最后把两个出栈变量的名字改成a和b、再往算式里一填才恍然大悟。
2.4 单调栈:从"下一个更大元素"到接雨水、柱状图最大矩形
单调栈是栈这个数据结构在算法层面的高光时刻。所谓单调栈,就是栈内元素从栈底到栈顶保持严格单调(递增或递减)。它最经典的应用场景是:找到数组中每个元素左边或右边第一个比它大(或小)的元素,时间复杂度是O(n)。
我想用一个热词里提到的"每日温度"题来说明。给定一个数组temperatures,返回一个数组answer,answer[i]是指对于第i天,下一个更高温度出现在几天后。如果不存在,就为0。用单调栈的做法是:维护一个从栈底到栈顶递减的栈,遍历数组的时候,如果当前元素大于栈顶元素,就说明栈顶元素找到了右边第一个比它大的元素,此时答案就是当前索引减栈顶索引,然后弹出栈顶继续比较;如果当前元素小于等于栈顶元素,就把当前索引入栈。这个"等号不入栈"的细节很关键,因为题目问的是"严格更高温度",所以相等的天数不能算。
做单调栈题目时,我建议先在纸上演算一遍,再写代码。比如"柱状图中最大的矩形"这道题,思路是用单调栈维护一个递增序列,对于每个柱子,以它作为高度能延伸到的最左和最右边界,分别由左边第一个比它矮的柱子和右边第一个比它矮的柱子决定。如果不用单调栈,暴力法是O(n^2),n是10^5就直接TLE了;用了单调栈,时间复杂度降到O(n),空间复杂度O(n)。
"接雨水"也是同样的套路,只是换了个方向:每个位置能接的雨水量,由左边最高的柱子和右边最高的柱子中较小的那个减去当前柱子的高度决定。用单调递减栈来维护,当遇到一个比栈顶高的柱子时,就可以计算栈顶位置能接的雨水了。这道题如果在OJ上看到,我建议先别急着看题解,自己用纸笔模拟一遍"4,2,0,3,2,5"这个数组,你会明白单调栈为什么能在弹出时同时确定左右边界。
2.5 最小栈与双栈实现队列:设计题里藏着编程思维
最小栈是一道很有意思的设计题。题目要求实现一个栈,除了常规操作外,还要能O(1)的时间获取栈中最小元素。最经典的解法是用两个栈:一个正常存数据,另一个栈存"当前的最小值"。每次入栈时,把当前元素与辅助栈的栈顶比较,如果当前元素更小,就把当前元素压入辅助栈,否则就把辅助栈栈顶再压一遍。这样两个栈的高度始终一致,pop的时候同步弹出即可。
但也有一种空间优化方案:辅助栈中不需要存重复的"当前最小值",只有在遇到比当前最小值更小的元素时才压入;pop的时候,如果弹出的元素等于辅助栈栈顶,就把辅助栈也弹出一个。这个优化能把空间复杂度从最坏O(n)降到最好O(1)(如果数据都是递增的)。OJ上两种写法都能过,但后者如果处理"相等元素"不好,会出现bug——比如连续入栈两个相同的最小值,第二次pop时辅助栈栈顶已经被弹掉了,再pop时就会出错。所以我建议新手先用最朴素的同步栈写法,稳。
"两个栈实现队列"这道题基本是面试必考题。思路是用一个输入栈和一个输出栈,入队时直接push进输入栈;出队时,如果输出栈为空,就把输入栈的所有元素依次弹出并压入输出栈,然后从输出栈弹出栈顶。这样做的原理是:两次栈的"后进先出"操作抵消之后,数据的顺序就变回了"先进先出"。这里有个容易忽略的性能细节:只有当输出栈为空时才做"搬运"操作,可以均摊时间复杂度达到O(1)。我试过如果不做这个优化、每次出队都先把输入栈全清空再搬回来,那性能会退化到O(n)。
3. 队列的OJ核心题型:从链式队列入队出队到滑动窗口最大值
3.1 链式队列入队与出队:指针操作是重灾区
链式队列是许多学校OJ的基础题,特别是那些强调C语言指针的OJ。题目会让你定义一个链表节点结构体,然后实现初始化队列、入队、出队、判断队列是否为空等函数。看起来简单,但很多人死在"尾指针"上。
链式队列的经典结构是:一个头指针front指向队头节点,一个尾指针rear指向队尾节点。入队时,需要创建一个新节点,然后把当前尾节点的next指向新节点,再让rear指向新节点。这里有一个非常容易漏掉的细节:如果队列原本为空(front和rear都指向NULL),入队时front也要指向新节点,否则后续出队时front仍然为空,程序就会崩溃。
出队时就更麻烦了。首先要把队头节点保存下来,怎么保存?很多新手会写成free(front),然后把front = front->next。问题是front->next在free之后已经变成野指针了,你怎么还能访问它?正确做法是:先用一个临时指针temp保存要出队的节点,让front向后移,再free(temp)。另外,出队后如果队列变为空,要把rear也置为NULL,否则rear会变成一个悬空指针,后续再次入队时会出现"队尾指针指向已释放内存"的问题。
我做一个形象的比喻:链式队列的front和rear,就像是两个一前一后的游标,front负责"消费"(出队),rear负责"生产"(入队),两者必须时刻保持同步。如果rear没有在队列为空时跟front一起移动,就好比生产线的入口标记还停留在已经被拿走的空箱子上,下一批货物进来时工艺就会错乱。
3.2 循环队列:判空判满的四种哲学
循环队列是为了解决顺序队列"假溢出"问题而设计的。所谓假溢出,就是数组前面还有空间,但因为rear已经指向数组末尾,导致无法继续入队。循环队列通过取模操作让rear和front在数组范围内"绕圈",把数组当作一个环形缓冲区。
实现循环队列时,最大的坑是如何判断队列是"空"还是"满"。因为环形结构里,front == rear既可以是空,也可以是满。常见的解法有四种:
第一种:牺牲一个存储单元。让rear指向最后一个元素的下一个位置,当(rear + 1) % capacity == front时判定为满,当front == rear时判定为空。这是最经典的写法,缺点是浪费了一个数组空间。第二种:增加一个size变量,记录当前队列中元素个数。入队时size++,出队时size--,判断空就是size == 0,判断满就是size == capacity。这种办法最直观,也不浪费空间,但需要额外维护一个变量。第三种:增加一个flag标记,记录"上一次操作是入队还是出队"。当front == rear时,如果上一次是入队,则为满;如果上一次是出队,则为空。第四种:使用计数器或时间戳。
我在OJ上写循环队列的时候,默认选择第一种"浪费一个空间"的写法,因为代码最简洁,而且我可以提前把数组开大一位来抵消浪费。但如果是那种"严格限制容量"的题目,就要用第二种size变量法。还有一点需要注意:取模运算在C/C++里对于正负数的行为,如果数组下标可能为负,记得先加capacity再取模,不然很容易出现负数下标越界。
3.3 单调队列与滑动窗口最大值:单调栈的孪生兄弟
队列的算法题里,最高频的应该是"滑动窗口最大值"(经典题Sliding Window Maximum)。给定一个数组和一个窗口大小k,窗口每次向右滑动一位,要求输出每个窗口内的最大值。暴力法是O(n*k),在OJ上10^5的数据就直接超时。标准解法是用单调递减队列(队头到队尾递减)。
实现思路是:用一个双端队列(deque)存数组的下标(不是值)。遍历数组时,每到一个新元素,先检查队头是否已经滑出窗口范围,如果滑出就弹出;然后从队尾开始,把所有小于等于当前元素的下标全部弹出,因为那些元素不仅比当前元素旧,值还比当前元素小,它们在后续窗口里永远不可能成为最大值;最后把当前下标压入队尾。这样,队头始终是当前窗口最大值的下标,直接取对应值即可。
这道题和单调栈很像,但有一个本质区别:单调栈是"永不回头"的,处理完一个元素之后就再也不会用到;而单调队列是有"过期"概念的,队头元素会因为窗口滑动而失效。所以我在做这道题时,特别容易忘记"判断队头是否还在窗口内"这一步,导致用过期的最大值输出。排查的方法很直接:看答案是逐位右移的,如果某一步答案突然变小了,十有八九就是队头过期没清理。
这里我推荐一下双端队列deque。虽然也可以用数组模拟一个deque(head指针加tail指针),但要同时支持头尾的弹出,比较麻烦。C++的std::deque或者Python的collections.deque都能直接完成任务。
3.4 两个队列实现栈与层序遍历:队列的另类应用
"两个队列实现栈"和"两个栈实现队列"是一对镜像题目。两个队列实现栈的思路是:入栈时,把元素压入非空的那个队列;出栈时,把非空队列的前n-1个元素依次出队并入队到另一个队列,然后把最后一个元素出队。也就是说,每次出栈都要"倒腾"一次队列。如果题目额外要求"top"操作,就需要注意:用一个变量记录最后一个入队的元素,top操作直接返回这个变量即可,不必倒腾队列。
队列在二叉树层序遍历(BFS)里的应用,也是OJ高频题。用队列做层序遍历的思路特别朴素:先把根节点入队,然后循环处理:取出队头节点,访问它,把它的左孩子和右孩子依次入队。这样每一层的节点都是按顺序被访问的,而且队列天然起到了"分层缓冲区"的作用。如果题目要求按层输出,通常会在循环里记录当前队列的大小size,然后连续出队size次,这样就能把每层的节点一次处理完。
我在做题报告里把"两个队列实现栈"和"BFS层序遍历"放在一起,是因为它们的共同点在于:用队列的"先进先出"特性来改变或维持数据顺序。前者是利用队列的FIFO来模拟LIFO,后者是利用FIFO来保证同层节点的相对顺序。理解了这个本质,后续遇到生产者消费者模型、消息队列削峰填谷之类的工程问题时,思路会通畅很多。
4. 实战过程:从读题到AC的完整流程拆解
4.1 审题与数据规模评估
拿到一道栈或队列的OJ题,我从来不直接上手写代码。我会先把题读三遍,把输入输出样例在纸上亲手跑一遍,然后判断数据规模。这一步,在很大程度上决定了这道题的解法到底是"模拟"还是"上算法"。
举个例子,如果题目说n <= 1000,那O(n^2)的暴力法完全可行;如果n <= 10^5,你就必须考虑O(n)或O(n log n)的解法,否则必然TLE。栈与队列的题目,通常n在10^5上下,所以单调栈、单调队列这种O(n)算法基本是默认解。另外还要注意输入输出方式:如果数据量很大,C++的cin/cout记得关闭同步(ios::sync_with_stdio(false); cin.tie(0);),否则输入就卡掉很多性能。
还有一点容易被忽略:栈和队列的题,内存限制往往比较严格。比如链式队列如果每个节点都malloc一次,在循环里频繁分配释放,容易造成内存碎片,在极端情况下可能导致超出内存限制。所以我在OJ上如果可以选择数组模拟,就优先用数组模拟。实测下来,数组模拟比链表实现不仅代码短,而且Bug少。
4.2 核心代码实现:以"滑动窗口最大值"为例的完整解析
我拿滑动窗口最大值这道题来完整走一遍代码实现。以C++为例,使用deque存储下标:
#include <bits/stdc++.h> using namespace std; vector<int> maxSlidingWindow(vector<int>& nums, int k) { deque<int> dq; // 存下标,维护队头到队尾递减 vector<int> ans; for (int i = 0; i < nums.size(); i++) { // 弹出滑出窗口的下标 if (!dq.empty() && dq.front() <= i - k) { dq.pop_front(); } // 从队尾弹出所有 <= 当前值的下标 while (!dq.empty() && nums[dq.back()] <= nums[i]) { dq.pop_back(); } dq.push_back(i); // 从第k-1个位置开始,窗口形成 if (i >= k - 1) { ans.push_back(nums[dq.front()]); } } return ans; }这里有几个地方值得说明。首先是if (!dq.empty() && dq.front() <= i - k)这一步,队头元素如果已经不在窗口内(也就是下标小于等于i-k),必须弹出。注意这里用的是<=而不是<,因为下标为i-k的元素恰好是窗口左边界前一个元素,已经滑出。其次是后面的while循环,把所有小于等于当前值的下标全部弹出,确保"严格递减",这样队头必然是当前窗口最大值。最后,i >= k-1时才输出,因为前k-1个元素还没凑够一个窗口。
我当时的代码一开始没写<=而写了<,导致窗口左边界元素没被清理干净,提交后WA了两次。排查的过程也很有意思:我打印出deque的所有内容,发现里面出现了窗口外的下标,这才意识到是边界条件的问题。所以刷这种含下标计算的题,强烈建议在本地手动模拟一遍,把下标写出来逐一验证。
4.3 空间换时间的思维:辅助栈、辅助队列的正确打开方式
栈和队列题目中,许多优化都建立在"空间换时间"的基础上。比如最小栈里那个辅助栈,比如队列实现栈时的两个队列,比如单调栈里的存储下标——本质上都是用一个额外的空间结构来记录"历史信息"。
我在做题过程中观察到,很多初学者会陷入一个误区:总想着能不能不用额外空间,结果把代码写得极其复杂,最后还是没有AC。实际上,OJ做题卡的是时间复杂度和空间复杂度上限,在大多数题目里,O(n)的额外空间完全在允许范围内,不用刻意节省。真正应该在意的是:你的解法能不能在限制内跑完。空间换时间是一种非常成熟的工程思路,栈和队列本身就是数组或链表的"限制版",再加一个辅助结构是很自然的事情。
但我也会区分"纯冗余空间"和"算法必要空间"的区别。比如用数组模拟栈时,数组的大小通常需要开满数据规模,那是必要的;但如果你的辅助栈里存了一堆永远用不到的元素,那就需要考虑优化。以"最小栈"为例,同步辅助栈写起来简单,但数据如果全递增,辅助栈里全是重复的最小值,空间白白浪费;优化成"只在更小值出现时入栈、只在弹出元素等于栈顶时出栈",空间效率会好很多。这类"看起来简单但其实有优化空间"的题目,正是OJ题目的魅力所在。
4.4 递归转栈:系统栈的替代方案
在栈的OJ题里,偶尔会遇到一种"用栈模拟递归"的题,比如用非递归方式实现二叉树的前序/中序/后序遍历。这类题的本质是:把系统为递归调用维护的调用栈,手动用栈来模拟。
以前序遍历为例,非递归写法非常简单:先压入根节点,然后循环:弹出栈顶并访问,接着先压入右孩子、再压入左孩子(因为栈是LIFO,先压右孩子出栈时左孩子先被处理)。这个顺序背后的原理,是"访问根节点后,下一步想要访问左子树,所以左孩子必须后进栈但先出栈"。
中序遍历就稍微复杂一点,因为要先访问左子树再访问根再访问右子树。常见做法是:用一个指针cur沿着左子树一路走下去,沿途把节点入栈;当cur为空时,弹出栈顶并访问,然后cur指向弹出节点的右孩子。这个过程,本质上就是在模拟"递归函数压栈后继续深入,到底后再逐层返回并访问"的完整流程。如果对递归的调用栈有深刻理解,写这个非递归版本会非常顺畅;反过来,写多了非递归版本,也会对"系统栈"这个概念有更深入的认识。
我印象很深的一道题是"二叉树的后序遍历非递归版",它是最容易写错的。因为在后序遍历中,必须等到左右子树都访问完之后才能访问根节点,所以你需要在节点里标记"是否已经访问过右子树"。我当时的做法是:用两个栈实现,或者用一个栈加一个lastVisited指针。如果只是想AC,两个栈的做法最简单:第一个栈按"根、右、左"的顺序压入(和先序遍历镜像),再把第一个栈的内容依次弹出压入第二个栈,最后从第二个栈弹出,得到"左右根"的后序遍历顺序。这个方法虽然多用了空间,但逻辑极清晰,不容易错。
5. 做题中的高频问题:编译错误、运行错误与超时全排查
5.1 Runtime Error(RE):数组越界和栈溢出
在栈和队列题目中,RE最常见的原因就是数组越界,尤其是数组模拟栈和队列时。比如栈顶指针在pop时没有判空,直接top++或top--,就很可能越界。另一个常见原因是递归深度过大导致的系统栈溢出,比如某些树上递归DFS题目,如果递归深度超过系统限制,就会爆栈。
我记得有一道"树的中序遍历"要求非递归实现,我一开始偷懒用了递归,结果n是10^5的链状树,直接把系统栈压爆了,OJ返回RE而不是TLE。从那以后,我凡是遇到深度不确定的递归,都先考虑改用栈模拟。在OJ提交时如果出现RE,最快的排查方式是:在本地用同样的数据规模跑一遍,加入断言或打印,定位是哪一行越界。但更推荐的做法是,在写代码的时候就把"判空""边界"写在前面,防患于未然。
5.2 Time Limit Exceeded(TLE):为什么暴力过不了
TLE是栈和队列题里最常见的惩罚。如果你写的是两层循环遍历,而数据规模是10^5,那几乎是必挂的。手感很重要:一般OJ的时间限制是1秒,约等于10^8次简单运算。O(n^2)在n=10^5时是10^10次运算,妥妥超时;O(n)在n=10^6时只有10^6次,稳过。
我做的"接雨水"那道题,第一时间想到的就是对每个位置向左右分别扫描找最大高度,这是O(n^2)。本地测试用n=1000的数据没有问题,但OJ后台的测试点直接给了10^5,提交就TLE了。后来我切换到双指针解法,时间降到了O(n),秒过。TLE之后不要急着优化常数,而是先想清楚复杂度是不是量级上的问题。如果复杂度已经最优了,才考虑IO优化、减少无用的vector拷贝等技术细节。
5.3 Wrong Answer(WA):边界条件和相等元素怎么处理
WA的原因多种多样,但栈和队列题出错最多的地方是边界条件。比如"合法出栈序列判定"中,如果出栈序列还没遍历完但入栈序列已经全部压完,此时栈顶不等于出栈指针所指元素,就应直接判定为非法。这个"提前返回"的逻辑,如果漏写,就会导致死循环或错误输出。
另一个经典WA来源是"相等元素"的处理。比如单调栈里如果允许"小于等于"与"小于"的区别没注意,答案就会偏差。还是以"每日温度"为例,如果题目问的是"下一个温度更高",那么相等的温度就不能算数,所以while循环里要严格使用temperatures[i] > temperatures[st.top()];如果你写成了>=,相等温度的日子就会被错误地计入答案。解决这个问题的办法很简单:做题时把题目中的"严格大于""大于等于""小于"这些词全部圈出来,代码里对应写清楚。
5.4 编译错误:OJ平台与本地环境的差异
有时候本地编译通过,复制到OJ上却编译错误。最常见的原因是头文件和语法标准不同。老OJ平台用C++98,不支持C++11的新特性,比如auto、unordered_map、std::stoi等;新OJ平台普遍支持C++17,反而要注意某些老式写法(如gets)已经移除。另外,如果你用bits/stdc++.h,在部分OJ上可能无法编译,因为它不是标准C++头文件。我到外地某个学校的OJ刷题时,就遇到过这个问题,最后老老实实改成独立头文件才过。
还有一个小坑是变量名。在本地用left、right、data这些作为变量名没问题,但有些OJ的全局变量或者系统头文件里可能已经定义了同名符号,就会编译冲突。我的经验是:做题时变量名尽量加前缀,比如stk、que、pCur,既清楚又安全。
5.5 栈和队列OJ常见错误速查表
| 问题类型 | 常见原因 | 排查与解决建议 |
|---|---|---|
| RE | 数组模拟栈/队列越界 | push/pop前先判满/判空,数组大小开上限+5 |
| RE | 递归深度过大爆栈 | 改用栈模拟递归,或显式增大栈空间(若OJ允许) |
| TLE | O(n^2)暴力过大数据 | 改用单调栈/单调队列/双指针等O(n)算法 |
| TLE | 输入输出太慢 | C++关闭同步、用scanf/printf代替cin/cout |
| WA | 边界条件没处理 | 检查队列空/满、栈空/满、窗口未形成时的情况 |
| WA | 比较符号用错 | 严格大于/大于等于/小于等于,按题目原文字面写 |
| WA | 相等元素处理不一致 | 单调栈/队列的出栈条件需明确写清楚 |
| CE | 使用了不兼容的头文件或语法 | 换成标准头文件,避免bits/stdc++.h,注意C++版本 |
| CE | 变量名与系统宏冲突 | 用有前缀的变量名,如stkTop、queFront |
说实话,这张表里我最想强调的还是那一行"比较符号用错"。我后来统计过自己刷的五十多道栈和队列题,WA的原因里有三分之一都是这种"差一个符号"的问题。不是不会,是真的不细心。后来我养成了一个习惯:写完代码之后,把题目里的比较条件用中文写在注释里,再对着代码逐一核对,错题率立刻降了不少。
6. 做题心得与实践建议
栈和队列在数据结构里看起来是最简单的部分,但它就像是大楼的地基,任何复杂的算法最终都可能在某个环节用到这两个"受限的线性表"。刷完这一批OJ题,我有几点体会想分享给后来者。
第一,别只看题解视频,一定要动手敲。我做"合法出栈序列判定"的时候,以为看懂了模拟过程就会了,结果在OJ上写的时候还是卡了很久,尤其是"while循环连续弹出"这一步,代码里少写一个while根本意识不到。只有自己写出了AC代码,这个题的思维才算真正建立起来。
第二,重视时间复杂度和空间复杂度的分析。栈和队列题特别容易让初学者产生"我能用数组和链表做出来就行"的想法,但OJ判题器不会惯着你。每做一道题,都问自己三个问题:这个解法最坏情况下复杂度是多少?数据规模巅峰时跑得动吗?有没有更优的解法?这三个问题能帮你从"写出代码"进步到"高效解题"。
第三,画图永远是排错的第一利器。栈的弹出顺序、队列的入队出队、单调栈的逐步收敛,在纸上画一遍比盯着代码发呆高效十倍。我到现在遇到复杂一点的单调栈题目,还是先在草稿纸上把下标和值写成一列,再模拟入栈和出栈,差不多能一次AC。
第四,刷题要有总结。我在做题报告里会把每道题的解法思路、代码模板、常犯错误整理成笔记。比如"单调栈有两种写法:找左边更大/更小的用递增栈,找右边更大/更小的用递减栈,注意方向"这样的模板句,关键时候能救命。做OJ题目到底是什么?它不只是刷题,而是在锤炼你建模、分析和优化的能力。栈与队列这两块,恰恰是这些能力的绝佳训练场。把这些基础打扎实,后面再看AVL树、跳表、哈希表,甚至工程里的消息队列和线程池,思路都会顺很多。