考完GESP四级出来,我收到最多的私信就是问第一部分选择第3题、第5题到底选什么。对答案这种事,考后当天最热闹,但光知道ABCD远远不够,下个季度还得再战。这篇我按考生回忆版本把前8道选择题逐题拆了一遍,每道题都讲清楚三个方面:为什么选它、错选的人通常错在哪、以后遇到同类题怎么快速避坑。无论你是刚考完想确认结果,还是正在准备下一期四级,这份解析都可以直接对照着看。
我先把8道题的答案和考点列在开头,你急着对答案就先看这个表,想看细的就往后翻。
1. 速查答案与核心考点地图
先说结果。本次第一部分第1到第8题的答案依次如下:
| 题号 | 核心考点 | 参考答案 |
|---|---|---|
| 1 | 选择排序的时间复杂度特性 | B |
| 2 | 递归函数的调用追踪 | B |
| 3 | 结构体内存对齐与sizeof计算 | C |
| 4 | 单链表的节点删除与指针顺序 | A |
| 5 | 指针运算与运算符优先级 | B |
| 6 | 二分查找的过程跟踪 | B |
| 7 | 数组作为函数参数时的指针退化 | B |
| 8 | 递归调用的重复统计 | C |
把这张表看完,你会发现GESP四级的出题思路其实很明确:它不考偏题怪题,考的都是你“应该会但容易记混”的基础细节。8道题里,递归占了两道,指针和内存相关的占了四道,复杂度分析考了一道,链表考了一道。整体风格偏向“代码跟踪”而不是“代码默写”,也就是说,你光背结论不够,得能一步一步模拟出程序的执行过程。
这其实给备考指了一个方向:平时刷题时,脑子里要有“程序是怎么跑起来的”这个画面感。接下来的逐题解析,我会重点还原这个画面。
2. 第1-4题详解:复杂度、递归、内存对齐与链表指针
2.1 第1题:选择排序的时间复杂度为什么“永远不变”
题目大意是问:用选择排序对长度为n的数组排序,关于时间复杂度哪个说法正确。这道题的干扰项主要集中在“数组已经有序时,排序会不会变快”。
正确答案是:选择排序对任何初始状态的数组,时间复杂度都是O(n²),只有空间复杂度是O(1)(原地排序版本)。很多考生选错是因为把选择排序和冒泡排序、插入排序搞混了。
先看选择排序的标准代码:
for (int i = 0; i < n - 1; i++) { int minIdx = i; for (int j = i + 1; j < n; j++) { if (a[j] < a[minIdx]) { minIdx = j; } } swap(a[i], a[minIdx]); }外层循环固定执行n-1趟,内层循环第i趟固定比较n-1-i次。总比较次数是:
(n-1) + (n-2) + ... + 1 = n(n-1)/2
这个数跟数组的初始顺序一点关系都没有。就算数组本来就是升序,选择排序依然要把后面的每个元素都比较一遍,才能确认当前i位置就是最小值。它不会像优化版冒泡排序那样“某一趟没有交换就提前结束”,也不像插入排序那样“本来就是有序的只需要遍历一遍”。
我建议把三类排序做个对比记:
| 排序算法 | 最好情况 | 平均/最坏情况 | 特点 |
|---|---|---|---|
| 选择排序 | O(n²) | O(n²) | 比较次数恒定,数据是否有序不影响 |
| 冒泡排序(优化版) | O(n) | O(n²) | 有序时可提前结束 |
| 插入排序 | O(n) | O(n²) | 接近有序时表现好 |
这个对比在GESP选择题里反复出现,考法就是换着方法问“哪种排序对有序数组效率最高”或者“哪种排序比较次数最稳定”。答案都是插入排序和选择排序这两个极端:插入排序最擅长处理“差不多有序”的数据,选择排序则“一视同仁”。
2.2 第2题:递归求值先画递推链
第2题给了一个递归函数,大意是:
int f(int n) { if (n < 3) return n; return f(n - 1) + f(n - 2) - 1; }问f(6)的输出。正确答案是B,也就是6。
这道题看起来只是简单代公式,但从考后反馈看,错选的人不少。多数人是急着从f(6)开始往下拆,拆着拆着就乱,或者直接把它当成了斐波那契数列来算。其实这种题最稳的做法是从小到大“递推”,而不是从上往下“递归”。
先列基例:
f(0) = 0,f(1) = 1,f(2) = 2
然后一步步往上算:
f(3) = f(2) + f(1) - 1 = 2 + 1 - 1 = 2 f(4) = f(3) + f(2) - 1 = 2 + 2 - 1 = 3 f(5) = f(4) + f(3) - 1 = 3 + 2 - 1 = 4 f(6) = f(5) + f(4) - 1 = 4 + 3 - 1 = 6
整理成一张数表更清楚:
| n | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|---|
| f(n) | 0 | 1 | 2 | 2 | 3 | 4 | 6 |
这个数表跟斐波那契很像,但因为有“减1”这个操作,每一项都比纯斐波那契小。如果你直接背斐波那契数列,就会算出7甚至8,正好踩中干扰项。
这类递归求值题,我的习惯是永远先确认基例,再从小到大补完全表。不要从f(6)开始倒推,那样又要记f(5)又要记f(4),中间还会反复重复计算,手写很容易出错。从小到大推,每一步只用上一行的结果,出错概率低得多。
2.3 第3题:结构体对齐不是简单相加
第3题定义了类似这样的结构体:
struct Data { char a; int b; char c; };在32位环境、默认4字节对齐的情况下,问sizeof(Data)等于多少。正确答案是C,也就是12字节。很多人按成员大小相加,算出1+4+1=6,或者考虑int对齐后把内存布局画成10,都不对。
问题出在C++结构体有内存对齐规则。简单说:每个成员的首地址要能被它自身大小整除,结构体的总大小要能被最大成员大小整除。
拿这个结构体来说,内存布局是:
| 偏移 | 内容 |
|---|---|
| 0 | char a,占1字节 |
| 1-3 | 填充字节 |
| 4-7 | int b,占4字节 |
| 8 | char c,占1字节 |
| 9-11 | 填充字节 |
a放在偏移0,没问题。b是int,首地址必须是4的倍数,所以a后面要空出3个字节。c放在偏移8,没问题,但结构体总大小必须对齐到4的倍数。目前用了9个字节,补齐到12。所以最终答案是12。
如果把结构体成员顺序改成:
struct Data2 { int b; char a; char c; };大小就变成8字节。b占0-3,a占4,c占5,总大小对齐到4的倍数就是8。这里能看到一个反直觉的结论:同样三个成员,排列顺序不同,结构体大小完全不同。所以“成员总数加起来等结构体大小”这个直觉非常不可靠。
关于对齐,还有一个容易考的变形是#pragma pack(1),它会让结构体按1字节对齐,去掉所有填充,上面两个结构体都会变成9和6字节。但GESP命题一般不会把pack写进题目,你需要掌握的是默认情况下的计算逻辑。
2.4 第4题:删除链表的“先改指针再释放”
第4题给了一个单链表节点定义,指针p指向某个非尾节点,q指向p的后继节点,问如何删除q且保持链表不断。正确答案是:
p->next = q->next; delete q;也就是选项A。
这个操作的顺序极其关键。必须先把q的后继接到p上,再释放q占用的内存。如果反着来,先执行delete q,那q->next就变成了对已释放内存的访问,属于未定义行为,程序可能当场崩溃,也可能下次运行时才崩。
我在给同学们讲链表时喜欢打个比方:链表就像一列火车,你要摘掉中间一节车厢,必须先把前面车厢的挂钩连到被摘车厢后面的那节上,然后再把要摘的车厢拖走。顺序反了,整列火车就散了。
选项里有个干扰项是“把p的值改成q的值,再删除p指向的节点”,这种操作叫“覆盖删除”,本质上是用后继节点的值覆盖当前节点,然后删除后继。它确实能实现删除当前节点的效果,而且不需要知道前驱指针,但它删的不是“p的后继节点q”,不符合题目要求。
链表题在四级考试里出现频率不低,而且通常会给一段代码,问哪一步会让链表断掉。你在考场上如果心算不清,直接在草稿纸上画三个方框代表节点,把指针用箭头标出来,再按选项逐个改,基本能保证不丢分。
3. 第5-8题详解:指针表达式、二分查找、数组退化和递归计数
3.1 第5题:*(p+3)-1 到底先算什么
第5题给出了数组和指针:
int a[] = {1, 2, 3, 4, 5}; int *p = a;问*(p + 3) - 1的值。正确答案是B,也就是3。
拆解一下:
- p指向a[0],也就是数组首元素
- p + 3 指向 a[3]
- *(p + 3)取出a[3]的值,也就是4
- 4 - 1 = 3
这道题真正想考的是运算符优先级。*是单目运算符,优先级高于减号,所以*(p + 3) - 1等价于(*(p + 3)) - 1,先解引用,再减1。
很多考生错选C(也就是4),可能就是心里想着“指针加3,取出来的再减1”,结果手上把减号跟指针移动写在了一起。这里我建议把一组相近表达式全部列出来对比:
| 表达式 | 计算过程 | 结果 |
|---|---|---|
| *p + 3 | 先取a[0]为1,再加3 | 4 |
| *(p + 3) | 取a[3]的值 | 4 |
| *(p + 3) - 1 | 取a[3]的值为4,再减1 | 3 |
| *(p + 2) | 取a[2]的值 | 3 |
你把这张表过一遍就会发现,*p + 3和*(p + 3)差了十万八千里。前者是“当前值加3”,后者是“往后移3个元素再取值”。GESP非常爱在这种看起来差不多、实际差很多的地方挖坑。
3.2 第6题:二分查找比较次数怎么数
第6题给了一个有序数组:
int a[7] = {1, 3, 5, 7, 9, 11, 13};用二分查找找9,问比较了几次。正确答案是B,也就是3次。
我们按标准二分模拟一遍:
- 第一次:left=0,right=6,mid=3。比较a[3]=7和9,7<9,说明目标在右半部分,left改为4
- 第二次:left=4,right=6,mid=5。比较a[5]=11和9,11>9,说明目标在左半部分,right改为4
- 第三次:left=4,right=4,mid=4。比较a[4]=9和9,相等,查找结束
所以一共比较3次。
这类题最怕学生犯两个错。一个是直接把数组长度7拿来log2取整,算成2次,完全没走流程。另一个是把mid计算过程中那个“等于”的判断忘了。注意二分查找每次循环里至少要比较一次a[mid]和目标值,找到时那次也要算进去。
题目如果考得更细,还会问“mid=(left+right)/2”和“mid=(left+right+1)/2”的区别。其实后者是向上取整,在某些查找路径里会导致mid序列不同,比较次数也可能变。但只要题目没明说,都按最常见的“向下取整”写。做题时把这几次mid值在草稿纸上列出来,就不容易数错。
3.3 第7题:sizeof放在形参数组上为什么“失灵”
第7题考的是数组作为函数参数时的指针退化。题目大概是:
void func(int arr[]) { cout << sizeof(arr) / sizeof(arr[0]) << endl; } int main() { int a[5] = {1, 2, 3, 4, 5}; cout << sizeof(a) / sizeof(a[0]) << " "; func(a); return 0; }在64位环境下,输出是什么。正确答案是“5 2”。
main函数里,sizeof(a)拿到的是整个数组占用的字节数:5个int,每个4字节,共20字节。除以sizeof(a[0])的4字节,得到5,也就是数组长度。
但func函数里,形参int arr[]实际上会被编译器当成int *arr处理。也就是说,数组名传入函数时退化为指针,只保存首地址,长度信息完全丢失。在64位系统里,指针占8字节,除以一个int的4字节,结果是2。这个2是一个“假长度”,不代表任何真实含义。
很多考生看到int arr[]就以为数组完整传进函数了,这是C和C++里最经典的误区之一。数组作为函数参数时,永远不可能通过sizeof在函数内部拿到真实长度。你要么额外传一个长度参数,要么用模板引用参数的方式保留数组大小:
template <size_t N> void func(int (&arr)[N]) { cout << N << endl; }这种方式在GESP四级里出现的概率不高,但我建议你有余力时了解一下,因为它能帮你把“退化”这个概念理解得更透。理解了指针退化,你顺便也能明白为什么二分查找函数总要传一个right边界参数,而不是靠sizeof去算。
3.4 第8题:递归调用次数,画树比口算靠谱
第8题是经典的斐波那契递归计数:
int fib(int n) { if (n <= 1) return n; return fib(n - 1) + fib(n - 2); }问计算fib(6)的过程中,fib(3)被调用了多少次。正确答案是C,也就是3次。
先别急着口算,我们画递归调用树。fib(6)的调用过程是这样:
- fib(6)调用fib(5)和fib(4)
- fib(5)调用fib(4)和fib(3)——这是第1次调用fib(3)
- fib(4)调用fib(3)和fib(2)——这是第2次调用fib(3)
- 另一个fib(4)调用fib(3)和fib(2)——这是第3次调用fib(3)
所以fib(3)一共被调用了3次。
这里有个特别迷惑的干扰项:5。很多人数的其实是fib(2)的调用次数,或者把fib(1)的次数数进去了。在这个递归树里,fib(2)出现了5次,fib(1)和fib(0)也各出现了5次。命题人把“5”放在选项里,就是专门等着那些只记了个大概“斐波那契递归很重复”的人去选。
做这类题,我强烈建议在草稿纸上画出树状结构,每个节点标上参数值,然后数特定参数的节点个数。不要靠心算,递归越深越容易漏。画完之后你会发现一个规律:在纯斐波那契递归里,如果要数fib(k)被调用的次数,本质上就是数递归树里值为k的节点个数,而递归树的结构本身就是斐波那契式的。
4. 从这8道题里读出的四级备考要点
讲完题目,我再说点带个人色彩的备考观察。这8道题至少反映出了四级选择题的几个固定套路,对应到准备动作上,分别是下面四件事。
第一,平时做选择题也必须“跑程序”。考后有几个同学问我,为什么自己刷了那么多套题还是错第7题。我看了下他们的刷题方式,基本上就是看答案、记结论。但第7题这种东西,你在本地把代码敲一遍,输出“5 2”的一瞬间,比你看十遍解析都记得牢。GESP四级的代码题占比很高,如果你只背不跑,遇到代码执行细节就会模棱两可。
第二,把易混结论整理成一张对照表,考前一晚专门过一遍。比如选择、冒泡、插入三种排序在最好和平均情况下的复杂度,比如*p+3和*(p+3)的区别,比如结构体成员顺序影响sizeof。这些内容分散在不同章节,但考试就喜欢把它们编进同一张卷子,你平时就得用表格把它们集中起来反复对比。
第三,指针题和递归题在草稿纸上画图。画内存布局图能解决结构体对齐题,画链表指针图能解决节点删除题,画递归树能解决调用次数题。我见过太多学生草稿纸干干净净,全凭脑子硬算,结果在简单题上翻车。四级考试的草稿纸本来就是给你用的,不要客气。
第四,控制选择题的做题时间。这8道题的正常节奏应该在10分钟以内,每道题平均1分钟多一点。如果一道题你盯了两分钟还没思路,先标记跳过,等后面编程题写完了再回来看。有些选择题的干扰项设计得很深,卡住会严重影响后面大题的心态。
5. 考场上处理选择题的三条实用策略
最后聊点考场上能直接用的技巧。第一,答案不确定时优先排除法。GESP选择题四个选项里,通常有两个是比较离谱的,先划掉,剩下两个再结合代码执行细节判断。比如第3题的结构体大小,如果你能确定至少有内存填充,那小于等于6的选项都不可能,排除后正确率会高很多。
第二,把选择题当成“待验证的小程序”来看。遇到代码执行类的题,就在心里或者草稿纸上把关键变量一步步写出来。比如第2题的递归链,第6题的mid序列,第8题的递归树。只要状态列清楚了,答案基本自己跳出来。千万不要只凭记忆里的某个结论直接选。
第三,利用好“反向验证”。比如第4题链表删除,如果你不确定先改指针还是先释放,就想想:先释放内存再访问它的next,会发生什么?肯定是未定义行为。逻辑上不自洽的选项,往往就是错误项。同样,第7题如果选了“5 5”,那等于认为函数里能拿到数组长度,这跟数组退化的基本事实矛盾,自然不对。
这8道题其实是在提醒所有备考四级的人:四级考的不是你会不会写那些语法,而是你知不知道程序在机器里到底怎么动。指针怎么移动,内存怎么排布,递归怎么展开,比较怎么进行,每一步都是“动”的过程。把这个思路理顺,选择题稳了,后面的编程题自然也能跟着受益。
我个人的习惯是考后把每道选择题都在本地重新改一版跑一遍,不是为了对答案,而是为了看干扰项对应的代码到底是什么行为。比如第3题,我试了#pragma pack(1),sizeof真的变成了9。比如第7题,我把形参改成int *arr,输出保持不变。亲手折腾一圈,比背十遍结论都更管用。这份解析就先写到这里,后面有空我再把第9题之后的题目和大题部分逐题整理出来。