GESP八级单选深度解析:快排复杂度、CAS与单调栈
2026/9/11 7:38:08 网站建设 项目流程

2025年12月那场GESP八级考试结束以后,我这边好几个学生第一时间就来发消息说,单选第4到第6题考得“很八级”——不是那种背了结论就能选出来的题,而是要把概念真正吃透才能稳住。这篇文章我把这三道单选题按考场回忆的版本重新整理出来,逐个选项拆开讲,顺便还原它们背后的考察逻辑。内容适合准备GESP八级的人看,也适合刚过七级、打算往八级冲的同学。你要是打算备考2026年的八级,这篇能帮你大概感知单选会从哪个方向出题。

1. 先说说八级单选:它到底想考什么

1.1 八级的定位与单选命题风格

GESP是CCF主办的编程能力等级认证,一年有4次考试机会。八级是这个体系里的最高一级,它在考纲上已经跳出了“学会语法、能写小工具”的阶段,重点转向算法设计、数据结构综合运用和C++语言的高级特性。

具体到单选部分,八级的题目不像一级到四级那样,问你某个函数返回值是多少、某个循环执行几次。到了八级,单选题经常干这样几件事:给你一段代码让你判断复杂度,给你一个并发场景让你选正确描述,给你一个算法变体让你判断能不能在线性时间内完成。换句话说,它考的已经不是“知识点本身”,而是“知识点的边界条件”。

2025年12月这套卷子的单选第4到第6题,正好覆盖了三个典型方向:第4题考排序算法的复杂度分析,第5题考多线程场景下的CAS与ABA问题,第6题考单调栈的原理与复杂度。这三道题放在一起,基本就是在告诉你:八级单选不再考背模板,而是要你理解算法和语言特性为什么这么设计。

1.2 从七级到八级,三个明显的难度变化

很多学生是考完七级接着冲八级的,最容易踩的坑就是拿七级的复习方式去准备八级。七级和八级在单选题上的差异,我用一张表整理过给学生们看,这里也贴出来:

对比维度七级八级
语言考点指针、引用、STL基础容器智能指针、移动语义、并发原语
算法考点经典模板,如二分、DFS/BFS框架复杂度证明、算法变形、边界条件
出题形式直接问“以下哪个说法正确”给代码或场景,追问“哪个判断正确”
备考重心能写出来就行不但能写,还要能说清为什么

从这张表能看出来,八级单选最大的变化是“反套路”。比如快排这道题,七级顶多问你时间复杂度是多少,八级会设置一个选项说“空间复杂度是O(1)”,表面看快排确实是原地排序,但递归调用带来的运行栈开销被刻意忽略掉了。你如果只会背结论,很容易掉进这个坑。

2. 单选题第4题:快速排序的复杂度与递归栈

2.1 题目复现(根据考生回忆整理)

第4题给了一段快速排序的实现,大意是标准的分区加递归:

#include <iostream> using namespace std; int partition(int a[], int l, int r) { int pivot = a[r]; int i = l - 1; for (int j = l; j < r; j++) { if (a[j] < pivot) { i++; swap(a[i], a[j]); } } swap(a[i + 1], a[r]); return i + 1; } void quickSort(int a[], int l, int r) { if (l >= r) return; int p = partition(a, l, r); quickSort(a, l, p - 1); quickSort(a, p + 1, r); }

题目问:关于上述快速排序算法,下列说法正确的是?四个选项分别是:

  • A. 无论输入数据如何,该算法时间复杂度都是 O(n log n)
  • B. 在每次划分都很均匀时,递归深度约为 O(log n)
  • C. 该实现是一种稳定排序
  • D. 该排序算法是原地排序,空间复杂度为 O(1)

这道题严格来说没有超纲,但每个选项都在测试你对快排的理解深度。选B是正确的。

2.2 逐项拆解:A、B、C、D为什么对或错

先说A选项。快排的平均时间复杂度确实是O(n log n),但它不是一个对所有输入都稳定的复杂度。当数组已经有序,而分区又固定取最右边元素作为哨兵时,每次划分只能分出一个元素,递归会退化成n层,时间复杂度变成O(n²)。考场上看到“无论输入数据如何”这种绝对化表述,第一反应就该警惕。算法题目里出现“无论”“一定”“所有”这类词,往往就是错误选项的标配。

B选项说的是递归深度。快排每次递归都会把当前区间分成左右两部分,如果每次划分都比较均匀,左右两边规模接近一半,递归树的深度就是log₂n量级。这里要区分两个概念:时间复杂度描述的是总操作次数,递归深度描述的是同时存在的函数调用层数。前者是O(n log n),后者是O(log n),两者不矛盾。

C选项考查稳定性。所谓稳定排序,指的是相等元素的相对顺序在排序前后保持不变。快排本质上依赖交换操作,swap很容易把相等元素的先后关系打乱。例如数组 [3a, 3b, 2],取最后一个元素2为哨兵,第一次分区时3a和2交换,最终结果中3b会排在3a前面。所以快排不是稳定排序,C错。

D选项是最容易迷惑人的。快排确实是原地排序,因为它不需要额外的数组来存中间结果,但这不代表空间复杂度是O(1)。递归调用本身会占用函数调用栈,递归深度是多少,运行栈空间就是多少。最坏情况下递归深度O(n),栈空间就是O(n)。我在实际备考中见过太多学生把“原地排序”和“空间复杂度O(1)”划等号,这是完全不同的两个概念。某些优化版本可以做到O(log n)栈空间,但那一版代码用了尾递归优化,和题目给出的实现不是一回事。

2.3 这类排序题考场上怎么快速判断

结合这道题,我给准备八级的学生总结了一套快速判断方法。看到排序相关的选择题,先问自己三个问题:第一,这个排序稳定吗?第二,最坏情况和平均情况复杂度分别是什么?第三,它有没有额外的空间开销,包括递归栈?

这三个问题的答案要像背乘法口诀一样熟练。快排:不稳定,平均O(n log n)、最坏O(n²),递归栈空间平均O(log n)、最坏O(n)。归并:稳定,始终O(n log n),需要O(n)辅助数组。堆排:不稳定,始终O(n log n),原地排序但需要O(1)辅助空间。选择排序:不稳定,始终O(n²),O(1)空间。插入排序:稳定,最好O(n)、最坏O(n²),O(1)空间。把这些结论整理成一张表放在手边,考前每天过一遍,单选遇到排序题基本不会丢分。

3. 单选题第5题:CAS、ABA和多线程的工程思维

3.1 题目复现(根据考生回忆整理)

第5题给了这样一个场景,用std::atomic<int>做计数器:

#include <atomic> std::atomic<int> g_count(0); // 线程A执行 int old = g_count.load(); g_count.compare_exchange_strong(old, old + 1); // 线程B执行 int old = g_count.load(); g_count.compare_exchange_strong(old, old + 1);

题目问:关于多线程编程中的CAS操作可能出现的ABA问题,下列说法正确的是?选项是:

  • A. ABA问题只会在多个线程同时写入同一个共享变量时出现
  • B. 即使线程A在读取和CAS之间,变量被线程B从A改为B再改回A,线程A的CAS也会失败
  • C. 使用计数器作为版本号和指针一起打包,通过CAS操作检测版本号变化,可以有效避免经典的ABA问题
  • D. CAS操作本身必须依赖加锁才能实现

这道题在考C++多线程里的经典问题,答案选C。

3.2 什么是ABA问题,为什么它隐蔽

先解释ABA问题。假设有一个共享变量,线程A读取它的值,发现是A;在A准备CAS修改它的过程中,线程B介入,先把值从A改成B,又改回A;等到线程A执行CAS时,它发现当前值仍然是A,和自己之前读到的旧值一致,于是CAS成功。看起来一切正常,但问题恰恰出在这里:这个值和原来的值相同,并不代表它背后的状态没有变化。

C++里最典型的例子是无锁栈。栈顶指针指向节点A,线程A想弹出A,先读了栈顶指针;线程B此时弹出A,又推入一个节点B,再推入一个节点A。这个“新A”可能是从内存池里重新分配出来的,地址恰好和原来的A相同。线程A接着做CAS,发现栈顶还是A,于是把栈顶更新为A的下一个节点。但此时A的next已经被线程B改过了,整个栈结构就乱了。

很多八级考生第一次接触ABA问题时,会本能地觉得“只要值没变,操作就是安全的”。这个直觉在单线程下成立,在多线程下不成立。数值相同不代表状态相同,这是并发编程里最容易忽视的暗坑。

3.3 从选项到代码:避免ABA的常用手段

B选项说“线程A的CAS会失败”,这正好和ABA问题的定义相反。ABA问题的危害恰恰在于CAS不会失败,它会把“被修改过又改回来”的值当成“从未变过”,然后照常继续执行。

C选项提到的是最常见的解决方案之一:给数据附加一个版本号。也就是说,不单独比较指针值,而是把指针和计数器打包成一个更大的原子变量,每次修改指针之前先把计数器加一。CAS比较的时候,同时比较指针和计数器,只要计数器变了,即使指针地址相同,也能识别出这个节点被操作过。在64位系统上,常见做法是把指针放到低48位,版本号放到高16位,拼成一个uint64_t做CAS。C++的std::atomic直接支持对uint64_t的原子比较交换,因此这种方案在工程上是可行的。

D选项是一个典型的认知误区。CAS本身是硬件指令级别的原子操作,比如x86架构上的cmpxchg,它不需要加锁就能保证比较和交换的原子性。恰恰相反,很多无锁数据结构的设计目标就是避免使用锁,因为锁会引发线程阻塞和上下文切换,而CAS可以让线程在不阻塞的情况下自旋重试。”锁性能一定更差“这种说法也不绝对,在小规模竞争场景下锁可能更简单高效,但CAS的设计初衷确实是不依赖锁。

3.4 这道题背后的备考启示

为什么GESP八级会考并发编程?因为最近几年编程等级认证明显在往“工程素养”方向靠拢。八级证书对应的应该是具备独立设计中等复杂度程序的能力,而多线程、原子操作、竞态条件这些概念,恰好是大学计算机课程里操作系统和并发编程才会涉及的内容。

备考建议是不要只刷选择题。我让学生们用一个月时间写一个小型多线程程序,比如多线程累加、生产者消费者队列,实际跑一下,看看数据错乱是什么样子,再看看加锁和原子操作各能解决什么问题。踩过真实的坑之后,再回来看这道ABD选项,就能从原理层面识别错误,而不是靠猜。

4. 单选题第6题:单调栈的正确打开方式

4.1 题目复现(根据考生回忆整理)

第6题给了这样一个场景:给定整数数组 h = [2, 1, 5, 6, 2, 3],要求用单调栈求每个元素右边第一个比它大的元素下标,不存在则记为 -1。题目问:关于这个问题的解法,下列说法正确的是?

  • A. 使用单调递增栈,可以在 O(n) 时间内完成
  • B. 使用单调递减栈,可以在 O(n) 时间内完成
  • C. 无论用递增栈还是递减栈,最坏情况时间复杂度都是 O(n²)
  • D. 单调栈无法解决此类找左右两边第一个更大或更小元素的问题

正确答案是B。

4.2 单调栈的原理,以及为什么是O(n)

如果题目是求“右边第一个比当前元素大的元素”,处理方式是:从右往左遍历数组,维护一个栈底到栈顶严格递减的栈。每处理一个新的元素,就把栈中所有小于等于它的元素弹出,弹完之后栈顶元素(如果存在)就是当前元素右边第一个更大的元素,然后把当前元素下标压入栈。

我们拿题目里的 h = [2, 1, 5, 6, 2, 3] 实际走一遍:

下标当前元素栈操作右侧第一个更大元素处理完后的栈(存下标)
53栈空,压入5-1[5]
42栈顶5对应元素3 > 2,直接取栈顶5[5, 4]
36弹出4、5,栈空-1[3]
25栈顶3对应元素6 > 5,取栈顶3[3, 2]
11栈顶2对应元素5 > 1,取栈顶2[3, 2, 1]
02弹出1(因为1 < 2),栈顶2对应元素5 > 2,取栈顶2[3, 2, 0]

最终答案:下标0对应2,下标1对应2,下标2对应3,下标3对应-1,下标4对应5,下标5对应-1。

为什么复杂度是O(n)?因为每个元素最多被压入栈一次、弹出栈一次。虽然弹出的时候可能连着弹好几个,但整体看来每个元素只参与两次栈操作,均摊到n个元素上,总操作次数就是线性级别。这就是单调栈最迷人的地方:它用空间换时间,把原本可能O(n²)的嵌套循环压缩成了O(n)。

这里要特别提醒一下单调性的方向。很多人背结论时容易把递增栈和递减栈记混。核心判断标准是:你想保留哪些元素作为候选答案。本题找“右边第一个更大元素”,那么在从右往左扫描时,如果一个元素比较小,它左边更大的元素会被它挡住,不再有机会成为更左边元素的答案,所以它一旦被更大的元素挡住,就可以直接弹出。这个逻辑对应的是递减栈。反过来,如果求“右边第一个更小元素”,维护的就是递增栈。把方向和大小的对应关系搞清楚,比背结论可靠得多。

4.3 把这道单选题改成编程题,代码怎么落地

八级考试里,同一个知识点可能在单选、判断、编程三种题型中反复出现。我习惯让学生看完选择题之后立刻手写一遍完整代码,因为这能验证你到底懂没懂。这道题的C++实现可以这样写:

#include <iostream> #include <vector> #include <stack> using namespace std; vector<int> nextGreaterElement(const vector<int>& nums) { int n = nums.size(); vector<int> ans(n, -1); stack<int> st; // 栈中存下标 for (int i = n - 1; i >= 0; i--) { while (!st.empty() && nums[st.top()] <= nums[i]) { st.pop(); } if (!st.empty()) { ans[i] = st.top(); } st.push(i); } return ans; } int main() { vector<int> h = {2, 1, 5, 6, 2, 3}; vector<int> ans = nextGreaterElement(h); for (int x : ans) { cout << x << " "; } return 0; }

代码里有两个细节值得单独拎出来讲。第一,栈里存的是下标而不是值,因为题目要求返回下标,而且通过下标可以随时访问到对应值。第二,while循环里用的是<=,也就是遇到相等元素也要弹出。为什么?因为题目要求的是“右边第一个比它大的元素”,相等元素不是答案。如果不加这个等号,当数组里有连续重复元素时,右侧第一个严格更大的元素会被跳过,答案就会出错。这个边界条件正是出题人爱挖的坑。

4.4 单调栈常见的三个坑

实际调试中,单调栈的报错往往集中在三个地方。第一个坑是方向反了。从左往右和从右往左都能求“右边第一个更大元素”,但维护的栈单调性不同,写之前先在草稿纸上确定扫描方向,再确定单调方向。第二个坑是忘记存下标。如果直接在栈里存元素值,得到的是“值是多少”,而不是“位置在哪”,有些题确实只问值,但下标类问题必须存下标。第三个坑是没能处理相等元素。有没有等号直接决定结果,面对有重复数据的用例时,要自己多验证一遍。

我还遇到过一种情况,学生把单调栈写成了单调队列。这两个结构思路相近,但队列处理的是滑动窗口问题,栈处理的是两侧第一个更大更小元素问题。八级考试不会直接考这两个名字,而是通过题目场景让你自行选用。审题时先确认是“相邻位置的比较”还是“区间内的最值”,前者优先考虑单调栈,后者优先考虑单调队列。

5. 复盘与后续备考建议

5.1 八级单选最常挖坑的四个点

把这三道题放在一起看,能发现八级单选题挖坑的常见套路。第一,把时间复杂度和空间复杂度混在一起考。第4题的D选项就是这么设计的,“原地排序”没错,但由此推导出O(1)空间就错了。第二,用绝对化词语掩盖边界条件。第4题的A选项用“无论”开头,第5题的B选项用“也会失败”描述了一个完全相反的结论,这些表述都在诱导你去选一个看似合理实则错误的答案。第三,用“工程知识”筛掉只刷题的学生。第5题的ABA问题,如果没有实际写过并发程序,很容易凭直觉踩坑。第四,把算法模板改编成需要现场推理的题目。第6题看起来是单调栈模板题,但栈内存什么、等号怎么处理,这些细节必须在理解原理的基础上才能答对。

5.2 接下来两个月可以这样练

如果目标是下一次八级考试,我的建议是不要把所有时间花在刷选择题上。单选只占一部分分值,真正的分水岭在编程题。但单选题可以用来做“概念体检”,每做错一道,就把相关知识点扩写成一篇笔记,包括:这个知识点解决什么问题,典型代码怎么写,边界条件有哪些,曾经踩过什么坑。

训练方向上,优先保证这几个模块的题量:树与图遍历、最短路算法、动态规划基础、贪心与排序、单调栈与单调队列。这些都是八级编程题的高频考点。每天至少手写一道算法题,不要只看题解,一定要在编辑环境里跑通,因为GESP的评分标准里有正确性校验,能本地跑通和能默写出来是两个层次。

5.3 一点个人经验

我带学生准备GESP这几年,发现一个很普遍的现象:单选题做错的题,如果只看正确答案,下次遇到类似题目还是会错。真正的提升发生在把每个错误选项都改写成判断题,然后自己写出“为什么错”之后。比如第4题,把“该排序算法空间复杂度为O(1)”单独拿出来,判断对错,写理由,写递归栈的过程,这个动作远比记住“选B”有价值。

准备八级不需要报什么速成班,核心就是“把概念往死里抠,把代码往透里写”。三道单选题看起来只是12分的分数,但如果你能顺着它们把快排、并发、单调栈这三个方向各写十道变体题,后面的编程题也会顺手很多。这次没发挥好的同学也不用慌,GESP每年有多次机会,这次暴露出来的薄弱点,恰恰是下次提分最快的突破口。

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

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

立即咨询