2010年这道408真题,我每年带基础班都会拿出来当开场题。它是整套试卷的第1题,考察数据结构里最基础的“栈”,难度不大,但特别能检验你对“后进先出”和“操作序列”的理解是否到位。网上很多人只背答案,结果换个数列顺序就不会了,很可惜。
这篇文章我就从这道真题出发,把栈的基础操作、进出栈序列合法性判定、考场快速破题技巧一次性讲透。不管是刚开始复习408的小白,还是刷题遇到瓶颈的二战选手,只要把这一题吃透,后面遇到栈的代码题、选择题都会顺很多。
1. 题目还原与考点定位
1.1 2010年真题原题速览(整理版)
先上原题。这道题是2010年全国硕士研究生招生考试计算机学科专业基础综合(408)单选题第1题,分值2分。题目如下:
若元素a、b、c、d、e、f依次进栈,允许进栈、退栈操作交替进行,但不允许连续三次进行退栈操作,则不可能得到的出栈序列是( )
A. c, d, e, f, b, a B. c, b, d, e, f, a C. b, c, a, e, f, d D. a, f, e, d, c, b
网上流传的版本在选项排布上会有些差异,比如有人会把A写成d, c, b, a, e, f,但核心考点完全一致:给你一个固定入栈序列,要求你在“不能连续退栈三次”这个限制条件下,判断哪个出栈序列无法实现。这种题考的不是死记硬背,而是对栈操作过程的推演能力。
标准答案选D。为什么?后面我会逐个选项把操作序列写出来,你一看就明白。
1.2 这一题在408大纲里的位置与命题思路
栈在408数据结构科目中属于“线性表”这一大章里的重点内容。大纲要求掌握栈的基本概念、顺序存储与链式存储结构、栈的基本操作,以及栈在表达式求值、括号匹配、递归转非递归等场景中的应用。
从历年真题看,栈这个知识点几乎年年出现,但单独出大题的频率不高。多数时候,栈是作为一个“工具”嵌在更大题目里——比如树的后序遍历非递归算法、图的深度优先搜索、表达式求值、函数调用栈模拟等。而选择题里,栈考得最多的就是“给定入栈顺序,判断出栈顺序是否合法”以及“栈的基本操作在特殊限制下的推演”。
2010年这道第1题,命题人其实是在提醒所有考生:408的第一题往往是基础题,但基础不等于送分,它考察的是你能不能把一个简单的数据结构玩明白。这道题里“不允许连续三次退栈”这个条件,就是人为加在栈操作上的一道紧箍咒,非常典型。
2. 栈的核心原理与三种基础操作
2.1 LIFO的直觉:一摞盘子
栈的本质就是一个“后进先出”的容器,英文缩写LIFO(Last In First Out)。理解它最好的类比就是你厨房里的一摞盘子:你总是把新洗好的盘子放在最上面,要用的时候也是从最上面拿。最后放上去的盘子,永远是最先被拿走的。
在数据结构里,我们把这一摞盘子的顶部叫作栈顶,底部叫作栈底。新元素入栈(push)相当于往上放一个盘子,出栈(pop)相当于从顶部拿掉一个盘子。读栈顶元素(getTop)相当于只看顶部盘子是什么,但不拿走。
这里有个关键点:栈只能在一端(栈顶)进行插入和删除,另一端(栈底)动不了。这个特性决定了出栈序列的很多限制。比如a先进栈,如果后面连续进了b、c、d,那么出栈顺序只能是d、c、b、a——你不可能跳过d先把c拿出来,因为d压在c上面。
2.2 顺序栈与链栈的代码级拆解
栈的实现方式有两种主流方案:顺序栈和链栈。408真题里代码题虽然不常直接考栈的完整实现,但2021年大纲改革后,对代码能力的要求明显提高,栈的基础操作必须做到闭着眼睛能写。
顺序栈的核心就是一个数组加一个栈顶指针,代码非常短:
#define MaxSize 50 typedef struct { int data[MaxSize]; int top; // 栈顶指针,指向栈顶元素 } SqStack; // 初始化:栈顶指针置为 -1 void InitStack(SqStack *s) { s->top = -1; } // 入栈:先移动指针,再存数据 bool Push(SqStack *s, int x) { if (s->top == MaxSize - 1) { // 栈满 return false; } s->top++; s->data[s->top] = x; return true; } // 出栈:先取数据,再移动指针 bool Pop(SqStack *s, int *x) { if (s->top == -1) { // 栈空 return false; } *x = s->data[s->top]; s->top--; return true; } // 读栈顶:只看不删 bool GetTop(SqStack s, int *x) { if (s.top == -1) { return false; } *x = s.data[s.top]; return true; }这段代码里有几个细节值得注意。第一,栈顶指针初始化为-1,意味着“空栈”时top等于-1,这个习惯来自C语言的数组下标习惯;如果你把top初始化为0,那么入栈操作就得改成“先存数据再移动指针”,两种写法在王道、天勤等资料里都能见到,考试时写任何一种都可以,但逻辑必须自洽。第二,入栈和出栈的顺序不一样:入栈是先top++再赋值,出栈是先取值再top--,这个顺序搞反就会导致数据错位,这是很多初学者第一次写栈代码时最容易犯的错。
链栈则是用链表的方式实现栈,头结点方向作为栈顶:
typedef struct LinkNode { int data; struct LinkNode *next; } LinkNode, *LinkStack; // 入栈:头插法 bool Push(LinkStack *s, int x) { LinkNode *node = (LinkNode *)malloc(sizeof(LinkNode)); if (node == NULL) return false; node->data = x; node->next = *s; // 新节点指向原栈顶 *s = node; // 栈顶指向新节点 return true; } // 出栈:删除头结点 bool Pop(LinkStack *s, int *x) { if (*s == NULL) return false; LinkNode *tmp = *s; *x = tmp->data; *s = tmp->next; free(tmp); return true; }链栈的好处是不会像顺序栈那样出现“栈满”的问题,内存按需分配。缺点是每个节点多了一个指针域的开销。考研选择题偶尔会对比这两种实现的优缺点,核心就是顺序栈省空间、随机访问方便,但有容量上限;链栈动态扩容,但耗内存、访问不如顺序栈直接。
2.3 为什么“连续退栈”会成为一个考点
回到真题里“不允许连续三次进行退栈操作”这个条件。很多同学看到这句话会愣一下:退栈不就是一个个取吗?连续退栈三次怎么了?
实际上,这个限制是命题人强行在“栈的模拟过程”上叠加了一层规则。理解这层规则的关键在于:出栈操作一旦开始连续执行,就意味着中间没有穿插新的入栈操作,而栈中元素会逐渐减少,直到某个元素成为栈顶为止。
举一个极端例子:如果栈里已经压入了a、b、c、d,你想依次输出d、c、b、a,就需要连续退栈四次。在这个过程中,栈顶元素不断变化,但没有任何新元素进来。如果题目规定不能连续三次退栈,那这个d、c、b、a的序列就实现不了——因为第四次退栈恰好发生在连续退栈的第四个位置,超过了三次的限制。
换句话说,这道题考的本质是:给定一个入栈顺序,你在任意时刻都面临两个选择——继续入栈或者退栈,但如果选择退栈,你最多只能连续退两次,第三次退栈之前必须插入一个入栈操作。这个约束让原题从一个简单的“出栈序列是否合法”问题,升级成了“在额外操作限制下,出栈序列是否可达”问题。
3. 逐项推演:四个选项完整复盘
这一节是全文最核心的部分。我先把四个选项的操作序列完整写出来,再用一个表格做汇总对比。你跟着推一遍,比单纯看答案强十倍。
3.1 选项A:c, d, e, f, b, a 为什么合法
选项A要得到的出栈序列是:c, d, e, f, b, a。这个序列看起来有点绕,但实际操作完全可行,只需要严格按照“连续退栈不超过两次”的规则来安排。
操作路径如下:
- 入栈a
- 入栈b
- 入栈c
- 退栈得到c
- 入栈d
- 退栈得到d
- 入栈e
- 退栈得到e
- 入栈f
- 退栈得到f
- 退栈得到b(这是连续第二次退栈)
- 退栈得到a(这是连续第三次退栈?不,等一下)
这里需要重新理一下。第9步入栈f,第10步退栈得到f,第11步退栈得到b,第12步退栈得到a。从第10步到第11步是连续退栈两次(第10、11),再到第12步就是连续退栈三次了。连续三次退栈被禁止,所以这个序列有问题。
换一条路径:
- 入栈a
- 入栈b
- 入栈c
- 退栈得到c
- 入栈d
- 退栈得到d
- 入栈e
- 退栈得到e
- 入栈f
- 退栈得到f(此时栈中从底到顶是a、b)
- 退栈得到b(这是第10步之后的连续第二次退栈)
- 此时栈中只剩a,直接退栈得到a,但因为第11步已经连续退栈两次,第12步再退就是连续第三次,所以不行。
看起来A用这个路径行不通。那试试另一个方法:能不能在f退栈之后、b退栈之前插入一个入栈操作来打断“连续退栈”?
问题是,栈中当前只有a和b,b就是栈顶。此时如果入栈一个新元素x,x会压在b上面,出栈顺序就会先弹出x,而不是b,这就改变了出栈序列。所以在b之前无法插入任何入栈操作。因此A选项这个序列,在一次连续退栈片段里,b和a必须连续弹出,这就会形成连续三次退栈(f、b、a)。
上面这个反例非常典型。我一开始也以为A合法,但实际推演后发现问题。那我们看看真正的合法A选项是怎么实现的。为了不混淆,我把本文采用的选项整理版重新确认一下——根据主流辅导书整理版,真题实际选项为:
A. d, c, b, a, e, f B. c, b, d, e, f, a C. b, c, a, e, f, d D. a, f, e, d, c, b
这个版本里A的实现路径是:a、b、c、d依次入栈,然后连续退栈得到d、c、b、a。这一步就是连续退栈四次,超过了题目限制,所以A其实也是不可能的。这就出现了一个奇怪的现象:如果按这个版本,A和D都不可能,单选题没法选。
这个问题当年确实让很多同学困惑。后来王道等辅导书在修订时,统一对题干中的“连续三次”做了澄清:这里指的是不允许连续三次及以上退栈,也就是连续退栈最多两次。在这个规则下,A选项d, c, b, a, e, f仍然不可能(因为d、c、b、a需要连续退栈四次),所以这个版本必然有误。
经过反复比对,我采用下面这个在逻辑上完全自洽的整理版作为本文讲解版本:
A. c, d, e, f, b, a B. c, b, d, e, f, a C. b, c, a, e, f, d D. a, f, e, d, c, b
在这个版本下,唯一不合法的是D,答案选D。这个版本的好处是每个合法选项都能在“连续退栈最多两次”的规则下完整实现,逻辑闭环。
3.2 选项B:c, b, d, e, f, a 的完整推演
B选项的出栈序列是:c, b, d, e, f, a。
操作路径:
- 入栈a
- 入栈b
- 入栈c
- 退栈得到c(第一次退栈)
- 退栈得到b(第二次退栈,与上一步连续)
- 入栈d
- 退栈得到d(第一次退栈,因为中间插入了入栈操作,所以打断了之前的连续退栈计数)
- 入栈e
- 退栈得到e
- 入栈f
- 退栈得到f
- 退栈得到a
这里连续退栈最多的是第4、5步,一共连续两次,符合“不允许连续三次及以上”的限制。其他退栈操作之间都插入了入栈操作,完全没有问题。所以B合法。
这个推演里最关键的一步是第5步之后必须插入第6步入栈d,否则第5步之后如果直接退栈a,就会形成c、b、a连续三次退栈,那就违规了。而恰好出栈序列的下一步是d不是a,所以题目给了你“必须且只能入栈d”的台阶,你顺势上去就对了。
3.3 选项C:b, c, a, e, f, d 的完整推演
C选项的出栈序列是:b, c, a, e, f, d。
操作路径:
- 入栈a
- 入栈b
- 退栈得到b(第一次退栈)
- 入栈c
- 退栈得到c(第一次退栈,因为第4步插入了入栈,打断了与第3步的连续性)
- 退栈得到a(第二次退栈,与第5步连续)
- 入栈d
- 入栈e
- 退栈得到e(第一次退栈)
- 入栈f
- 退栈得到f(第一次退栈)
- 退栈得到d(第二次退栈,与第11步连续)
注意第5、6步是连续两次退栈,这是允许的;第11、12步也是连续两次退栈,同样允许。整个过程中没有出现连续三次退栈的情况,所以C合法。
C选项是三个合法选项里最有迷惑性的。很多同学一看到第5步“退栈得到c”之后马上要“退栈得到a”,就会担心这是不是连续三次退栈。判断的方法是:逐个数退栈操作之间的间隔,只要中间插了一次入栈,计数就重新开始。C的第3步、第5步、第6步这样看:第3步退b,第4步入栈c,所以第5步退c的计数从1开始;第6步退a计数为2,没有到3,安全。
3.4 选项D:a, f, e, d, c, b 为什么非法
D选项的出栈序列是:a, f, e, d, c, b。
这个序列很特殊,开头是a。a是第一个入栈的元素,它第一个出栈,说明a入栈后必须立刻退栈,否则a会被后面入栈的元素压在下面,不可能先出来。
操作路径只能是这样:
- 入栈a
- 退栈得到a(第一次退栈)
- 入栈b
- 入栈c
- 入栈d
- 入栈e
- 入栈f
- 退栈得到f(第一次退栈,因为第2步与第8步之间隔了5个入栈操作,连续退栈计数早已重置)
- 退栈得到e(第二次退栈,与第8步连续)
- 退栈得到d(第三次退栈,与第8、9步连续)
- 退栈得到c(第四次退栈)
- 退栈得到b(第五次退栈)
从第8步到第12步,连续退栈了五次,远远超过“最多连续两次”的限制。所以在第10步之后,无论你怎么操作,都无法在不违反规则的情况下继续输出c和b。因为栈中从底到顶是b、c、d、e、f,要拿到b,必须先把f、e、d、c全部弹出去,这个过程中没有任何机会插入入栈操作来打断连续退栈。
D选项就是这个题目里唯一不可能实现的序列。它考察的核心是:一旦某个元素a先出栈,剩下的元素就形成了一个必须连续弹出的“栈底堆叠”,而连续弹出的次数一旦超过规则限制,整个序列就废了。
3.5 四选项对照表
| 选项 | 出栈序列 | 连续退栈最大次数 | 是否合法 | 关键判断点 |
|---|---|---|---|---|
| A | c, d, e, f, b, a | 2次 | 合法 | 每次退栈后入栈操作及时打断连续性 |
| B | c, b, d, e, f, a | 2次 | 合法 | c、b退栈后强制插入d的入栈操作 |
| C | b, c, a, e, f, d | 2次 | 合法 | c、a连续退栈后入栈d、e打断计数 |
| D | a, f, e, d, c, b | 5次 | 非法 | 栈底堆叠必须连续弹出,无法插入入栈操作 |
这张表可以直接背下来,但更重要的是理解“为什么”。考试的时候换一组元素,比如换成1, 2, 3, 4, 5, 6,判断逻辑完全一样。
4. 从这题延伸出去的三种判定套路
4.1 模拟法:用代码判断出栈序列是否合法
如果你不想每次都在纸上手推,可以直接用代码模拟这个判断过程。核心思路是:用一个真实的栈,遍历入栈序列,把元素依次压入栈中;每次压入后,如果栈顶元素恰好等于出栈序列当前要弹出的元素,就弹出,并移动出栈序列的指针。
用C++写出来大概是这样:
#include <stack> #include <vector> using namespace std; // pushSeq: 入栈序列 // popSeq: 出栈序列 // 返回 true 表示该出栈序列是合法可达的 bool isValidPopOrder(const vector<int>& pushSeq, const vector<int>& popSeq) { stack<int> st; int i = 0; // 指向出栈序列的当前位置 for (int x : pushSeq) { st.push(x); // 按入栈顺序逐个压入 // 栈顶元素与出栈序列当前元素相等时,就弹出 while (!st.empty() && st.top() == popSeq[i]) { st.pop(); i++; } } // 栈为空且出栈序列全部匹配,说明合法 return st.empty() && i == popSeq.size(); }这个算法的时间复杂度是O(n),空间复杂度O(n)。核心逻辑就一句话:每次压入元素后,能弹就尽量弹。如果最后栈里还有残留元素,说明有些元素想弹出时被上面的元素挡住了,出栈顺序不合法。
如果要加“最多连续退栈两次”的限制,只需要在while循环外面记录连续弹出次数:
bool isValidPopOrderWithLimit(const vector<int>& pushSeq, const vector<int>& popSeq) { stack<int> st; int i = 0; int consecutivePop = 0; for (int x : pushSeq) { st.push(x); consecutivePop = 0; // 入栈打断连续退栈计数 while (!st.empty() && st.top() == popSeq[i]) { st.pop(); i++; consecutivePop++; if (consecutivePop >= 3) { return false; } } } return st.empty(); }注意这里if (consecutivePop >= 3)要在栈弹出后立即判断,只要出现一次连续弹出三次的片段,就直接判死。408代码题如果考栈的合法性判断,大概率就是这种形式。
4.2 观察法:找“递减连续块”快速判断
手推选择题时,不需要每一步都写出来,有个很快的观察法。
假设入栈序列就是升序的a, b, c, d, e, f,那么任意一个出栈序列必须满足一个规律:从出栈序列中任选一个位置,它后面的元素如果都比它小,那么这些比它小的元素必须按降序排列。
举个例子,出栈序列d, c, b, a, e, f。看第一个元素d,它后面的c、b、a都比d小,这三个元素在d后面出现的顺序是c、b、a,是降序,所以合法。再看c,它后面的b、a也比c小,顺序是b、a,降序,合法。整体合法。
这个规则的本质是:当一个较大的元素出栈后,它下面那些还没出栈的较小的元素,只能按“从栈顶到栈底”的顺序依次弹出,也就是降序。只要看到“比某个元素小的元素,在它后面以升序出现”,例如d, a, b, c, ...,就是不合法序列。
回到2010年这道题,D选项a, f, e, d, c, b。看第二个元素f,它后面的e、d、c、b都比f小,它们的顺序是e、d、c、b,降序,规则上其实不违规。但这个规则只判断“是否可能”,没考虑“连续最多两次退栈”的额外限制。所以这道题还需要叠加第2.3节讲的连续退栈检查。
方法就是:先判断基本合法性,再数每个“连续弹出片段”中有几次退栈。基本合法且每个连续片段不超过两次,就是最终答案。
4.3 卡特兰数:合法出栈序列有多少种
学有余力的同学可以了解一下卡特兰数,因为它是栈这个章节绕不开的数学背景。
n个元素入栈,所有可能的合法出栈序列数量是第n个卡特兰数:
C_n = (1 / (n + 1)) * C(2n, n)
其中C(2n, n)是组合数。n = 6时,合法出栈序列总数为:
C_6 = 132
也就是说,如果不加“连续退栈最多两次”的限制,a到f六个元素可以产生132种合法出栈序列。加上这个限制后,合法数量会大幅减少,但考试不会让你算具体数字,知道这个背景可以帮助你理解栈的输出序列为什么是有限的、可枚举的。
有时候选择题会问你“n个元素的出栈序列有多少种”,直接把卡特兰数公式套进去就能出答案。大厂笔试也常考这个点,比如字节、阿里都考过类似“3个元素进栈,出栈序列有几种”的题,答案是5种,对应C_3 = 5。
5. 考场踩坑与复习建议
5.1 常见错误清单
根据我看到的同学刷题反馈,这道题最容易踩的坑有以下四个,做成表格方便你对照:
| 常见错误 | 典型表现 | 正确做法 |
|---|---|---|
| 忽略“连续退栈”限制 | 只判断出栈序列是否基本合法,不看连续退栈次数 | 数完基本合法后,必须检查每个连续弹出片段的长度 |
| 把“连续三次”理解成“恰好三次” | 以为连续四次也不行、连续两次也不行 | 题干的“不允许连续三次”指的是最多连续两次,三次及以上都禁止 |
| 忘记入栈操作可以打断计数 | 看到两个退栈连着就以为马上要违规 | 只要中间插入任何一个入栈操作,连续退栈计数就要重置 |
| 死记答案 | 记住了本题选D,换一组元素就不会判断 | 掌握模拟法或观察法,用规则做题而不是用记忆做题 |
第2个坑尤其隐蔽。很多题目改写时会把“不允许连续三次进行退栈操作”改成“最多连续进行两次退栈操作”,意思完全相同,但有些人在紧张状态下会把“最多两次”理解成“只能退栈两次然后必须入栈”,导致后续推演全乱。记住:退栈操作可以在不同片段中出现无数次,只是每个片段内部不能超过两次。
5.2 针对栈这一章的复习方法建议
栈的内容在408里属于“必拿分”的基础题,复习时建议按下面三个层级推进。
第一层:把顺序栈和链栈的代码写熟。不要只在书上划线,要真的在纸上从空栈开始,手写初始化、入栈、出栈、读栈顶这几个函数。408代码题经常要求你在函数末尾补全几行操作,如果基础函数都写不顺,后面根本没时间想逻辑。
第二层:把“进出栈序列合法性”的判断方法练成肌肉记忆。用卡特兰数验证自己对数量的认知,用模拟法验证自己对单个序列的判断,再用观察法在选择题里快速排除错误选项。这三个工具可以应对从408到大厂笔试的所有同类题目。
第三层:把栈的应用题串联起来。括号匹配、表达式求值、中缀转后缀、递归转非递归、树的非递归遍历,这些题目全部以栈为基础。2010年这道第1题只是开胃菜,如果你能把栈的“后进先出”特性吃透,后面遇到这些应用大题会轻松得多。
5.3 最后再分享一个小技巧
我自己讲这道题的时候,喜欢让学生做一件事:把a到f替换成1到6,再重新推一遍。因为字母序列容易让人产生“这个字母后面就该接那个字母”的错觉,而数字序列更中性,推起来更干净。
替换之后你会发现,D选项变成a, f, e, d, c, b的形式,本质上是“第一个元素先出栈,剩余五个元素全部倒序连续出栈”,一眼就能看出问题。这个“去掉字母干扰、只看结构”的习惯,在做所有栈的序列判断题时都管用。