简介:本资源为2024年3月GESP编程能力认证C++四级真题资料,面向备考GESP四级或希望检验C++基础掌握程度的中学生与编程学习者。内容围绕变量与函数、指针与数组、二维数组内存布局、插入排序与时间复杂度、文件输入输出、结构体与封装、递归与迭代、动态内存管理等核心考点展开,并配有单选题与判断题的完整题目及参考答案,便于自测与查漏补缺。资源包共1个文件,为PDF格式,大小约1.04MB,适合打印练习或电子设备阅读。目前已有281人学习下载,读者可借助真题熟悉考试题型与难度分布,结合指针运算、数组传参、排序算法等具体题目理解易错点,并通过答案对照分析解题思路,为正式认证做好针对性准备。
1. 从一道“改不动变量”的题说起:C++ 引用到底在干什么
很多人第一次看到int f(int &x){ x+=3; return x; }这种写法时,脑子里会冒出一个很自然的疑问:函数里的x改了,外面的a凭什么跟着变?更让人困惑的是,选项里既有f(&a)又有f(a),看起来都像在“把 a 传进去”,可只有一个能真正改变a的值。
这背后其实是 C++ 里一个非常核心、也非常容易踩坑的概念——引用传递。通俗地说,引用不是“复制一份数据送进函数”,而是给原来的变量起了个别名。就像你给同一个人起了个外号,叫外号还是叫本名,指的都是同一个人;函数里对这个别名的任何操作,都会原封不动地作用到本尊身上。
搞懂这件事,不只是为了做对一道选择题。它是理解 C++ 函数参数、数组内存布局、指针运算、甚至排序算法效率的一把钥匙。2024 年 3 月的 GESP 编程能力认证 C++ 四级,恰恰就是围绕这些“看起来会、一考就懵”的基础概念展开的。下面我们就借这份资料里的题目,把几个最容易混淆的知识点一个一个拆开讲清楚。
2. 为什么这些题总让人犹豫:引用、指针与数组的内存真相
2.1 引用传递和指针传递,差的不只是一个符号
先回到开头那道题。函数声明是int f(int &x),参数类型是int&,也就是“整型引用”。调用时应该直接写f(a),因为引用要求你给它一个实实在在的变量,而不是变量的地址。写成f(&a)会怎样?&a是a的地址,类型是int*,而函数要的是int&,类型对不上,编译直接报错。f(*a)更离谱,a不是指针,没法解引用。f(a-3)传的是一个临时计算结果,引用不能绑定到这种临时值上(除非是 const 引用)。
所以正确答案是f(a)。这里的关键区别在于:
- 指针传递:函数收到的是一个地址,想改原变量得先解引用,写起来多一层
*。 - 引用传递:函数收到的是变量本身,改它就是在改变量,语法上更干净。
打个比方,指针像是一张写着“某人家地址”的纸条,你得按地址找过去才能动他家的东西;引用则像是直接把你领到了那个人面前,你伸手就能拍他肩膀。两者都能达到目的,但引用少了一道“找地址”的手续。
注意:引用必须在定义时绑定到一个变量,而且一旦绑定就不能再换目标。指针则灵活得多,可以指向不同地址,也可以为空。这是它们最本质的分界线。
2.2 二维数组的内存,不是“表格”而是“长条”
资料里有一道题问:int arr[3][16];中arr[1]占用多少字节?很多人会下意识觉得“一行嘛,16 个 int,那就是 16 字节”。但int在常见平台上占 4 字节,16 个 int 就是 64 字节。正确答案是 64。
这里暴露了一个常见误解:把二维数组想象成一张有行有列的表格,以为“一行”是独立的一块。实际上,C++ 的二维数组在内存里是连续平铺的,先放第一行的 16 个 int,紧接着放第二行的 16 个,再放第三行。arr[1]不是“第二行那个格子”,而是“从第二行开头开始的、包含 16 个 int 的整块内存”,所以它的大小是 16 × 4 = 64 字节。
再看地址题:如果arr的地址是0x28cbc0,那么arr[1]的值是多少?因为arr[0]占了 64 字节,arr[1]的起始地址就是0x28cbc0 + 64。64 的十六进制是0x40,所以结果是0x28cc00。这道题考的不是“会不会算加法”,而是你有没有真正理解“二维数组的行是按字节连续排列的”。
2.3 指针加法和字符串索引,为什么输出总出乎意料
资料里还有一道题:char *p="I love GESP!"; cout << p+5 << endl;输出什么?答案是e GESP!。这里p指向字符串首字符'I',p+5就是向后移动 5 个字符的位置。数一下:I(0)、空格(1)、l(2)、o(3)、v(4)、e(5),所以从e开始一直输出到字符串结束。
另一道题更绕:char geSP[]="Grade Examination of SP"; cout << geSP[sizeof(x)] << endl;其中x是int x[]={2,0,2,4};。sizeof(x)是整个数组的字节数,4 个 int 就是 16。所以geSP[16]是字符串里第 17 个字符。数一下"Grade Examination of SP":G(0)r(1)a(2)d(3)e(4) 空格(5)E(6)x(7)a(8)m(9)i(10)n(11)a(12)t(13)i(14)o(15)n(16),所以输出n。
这两道题共同指向一个能力:把指针当成“带步长的游标”来理解。p+5不是“p 的值加 5”,而是“从 p 开始往后数 5 个元素”。数组下标a[i]本质上就是*(a+i),指针运算和下标访问是同一件事的两种写法。
2.4 排序算法的“最好情况”到底在说什么
插入排序最好情况下的时间复杂度是 O(n)。为什么?因为如果数据本来就已经有序,每次插入新元素时,它只需要和前面最后一个元素比较一次,发现“我比它大,不用动”,直接放在末尾。整个过程只做了 n-1 次比较,没有发生任何元素移动。
这和选择排序不同。选择排序不管数据是否有序,每一趟都要扫描剩余所有元素找最小值,比较次数固定是 n(n-1)/2,所以最好最坏都是 O(n²)。这就是为什么“最好情况”这个词有意义——它衡量的是算法在数据已经理想排列时能有多快。插入排序对近乎有序的数据非常友好,这也是它在实际工程中仍然被使用的原因之一。
3. 那些年我们混淆过的概念:引用、地址、作用域与异常
3.1 易混点一:&是取地址还是引用声明
&这个符号在 C++ 里身兼两职。在表达式里,&a表示“取 a 的地址”;在声明里,int &x表示“x 是一个引用”。初学者最容易把两者搞混,看到f(&a)就以为是在传引用,其实那是在传地址。
怎么区分?看上下文。如果&出现在类型后面、变量名前面(如int &r = a;),它是引用声明;如果&出现在一个已经存在的变量前面(如p = &a;),它是取地址运算符。函数参数列表里的int &x是声明,所以是引用;调用时的f(&a)是表达式,所以是取地址。类型不匹配,编译就会拦下来。
3.2 易混点二:数组名和指针是不是一回事
很多人听过“数组名就是指针”这句话,然后把它当成真理。实际上,数组名在大多数表达式里会“退化”成指向首元素的指针,但它本身不是指针。sizeof(arr)得到的是整个数组的字节数,而sizeof(指针)得到的是指针变量的大小(通常是 4 或 8 字节)。如果数组名真是指针,这两者应该一样,但事实并非如此。
另一个区别:数组名不能被赋值。你不能写arr = p;,但指针可以。所以更准确的说法是:数组名在参与运算时会自动转换为首元素地址,但它保留了自己的“数组类型”身份,在sizeof和取地址&arr时表现不同。
3.3 易混点三:static到底限制了什么
资料里有一道题问“哪个关键字能限定对象的作用域”,答案是static。但static的用法不止一种,容易让人糊涂。在全局变量前加static,表示这个变量只在本编译单元可见,别的文件访问不到;在函数内部加static,表示这个变量只在第一次调用时初始化,之后一直保留,但作用域仍然限于函数内;在类成员前加static,表示这个成员属于类本身而不是某个对象。
限定作用域这个说法,主要对应的是“文件作用域”和“函数作用域”这两种情况。extern是反过来的,它表示“这个变量在别处定义”;inline和public跟作用域限制没有直接关系。记住一点:static的核心含义是“绑在当前范围里,不往外跑”。
3.4 易混点四:cout << 9^2为什么不是 81
^在 C++ 里是按位异或,不是乘方。9 的二进制是1001,2 的二进制是0010,异或结果是1011,也就是十进制的 11。所以cout << 9^2输出 11,不是 81。乘方要用pow(9,2)或者自己写循环。
这个坑的根源是:很多语言用^表示乘方,但 C++ 沿用了 C 的位运算符传统。类似的还有&和|,它们分别是按位与和按位或,不是逻辑与和逻辑或。逻辑运算要用&&和||。看到单个符号,先想“位操作”,看到双符号,再想“逻辑判断”。
3.5 易混点五:异常捕获能不能接住结构体
资料判断题里有一句“try 子句里抛出的结构体等类型的异常无法被 catch 捕获”,这是错的。C++ 的异常机制可以抛出任意类型,包括基本类型、结构体、类对象,catch 也能按类型匹配捕获。真正需要注意的是:如果抛出的是指针,catch 要匹配指针类型;如果抛出的是对象,catch 最好用引用接,避免切片和多余拷贝。
为什么有人会以为结构体不能被捕获?可能是把“异常类型必须继承自某个基类”的规则从其他语言带过来了。C++ 没有这个限制,throw MyStruct{};和catch(MyStruct& e)是完全合法的。
4. 从“相似字符串”到“做题计划”:编程题在考什么
4.1 编辑距离为 1 的判断逻辑
编程题第一道“相似字符串”要求判断两个字符串能否通过一次插入、删除或修改变成对方。这其实是简化版的编辑距离问题,只允许一次操作。参考程序的处理很清晰:
bool isSimilar(string A, string B) { int m = A.size(), n = B.size(); if (abs(m - n) > 1) return false; // 长度差超过1,一次操作不可能 if (m == n) { int diff = 0; for (int i = 0; i < m; ++i) { if (A[i] != B[i]) { if (++diff > 1) return false; // 超过一个字符不同就不行 } } return diff <= 1; } else { string& shorter = (m < n) ? A : B; string& longer = (m < n) ? B : A; int i = 0, j = 0, diff = 0; while (i < shorter.size() && j < longer.size()) { if (shorter[i] != longer[j]) { if (++diff > 1) return false; // 已经跳过两次,不行 ++j; // 长串跳过当前字符,相当于删除 } else { ++i; ++j; } } return true; } }这段代码的核心思路是分两种情况:长度相等时,只允许一个位置不同;长度差 1 时,用双指针同步扫描,遇到不同就让长串指针多走一步,相当于“删除”了那个字符。如果跳了两次以上,说明一次操作不够。
参数说明:A和B是待比较的两个字符串,返回bool表示是否相似。时间复杂度是 O(n),因为每个字符最多被访问一次。这里用引用string& shorter是为了避免拷贝,虽然对性能影响不大,但体现了 C++ 里“能引用就不拷贝”的习惯。
4.2 贪心策略:每天用一套题单,最多撑几天
第二道“做题”题描述了一个场景:每天必须完成一定数量的题,有若干套题单,每套只能用一次,每天也只能用一套,但不必做完一套里所有的题。问最多能撑多少天不偷懒。
参考程序的做法是:先把题单按题目数量从小到大排序,然后从第 1 天开始,每天找一个题目数量足够当天使用的题单,用掉它,并把剩余题目数更新为“当天用了多少就减多少”。具体代码:
sort(a+1, a+n+1); // 题单按题量升序排列 int s = 1; // 记录上一次用过的题单位置,避免重复扫描 for (int j = 1; j <= m; j++) { // j 表示第几天 for (int k = s; k <= n; k++) { if (j <= a[k]) { // 这套题单够今天用 ans++; a[k] = j; // 更新剩余题量 s = k; // 下次从这套开始找 break; } } }这里a[k] = j看起来有点奇怪:为什么把剩余题量设成j?因为第j天用了j道题,原来有a[k]道,剩下a[k] - j道。但代码里直接写a[k] = j,其实是在说“这套题单现在剩下的量刚好等于今天的需求量”,这样下次如果天数增加,它可能又不够了。更准确的理解是:这套题单被“消耗”到只剩j道,而j是当天用掉的量,所以剩余量等于a[k] - j,但代码用a[k] = j是一种简化写法,前提是a[k]原本就大于等于j,且我们只关心“下次还能不能用”。
这个贪心的正确性依赖于:每天的需求是递增的(第 j 天需要 j 道),所以越往后越难满足。把题单按题量排序后,优先用小的题单去满足早期的低需求,把大的留到后面,是一种合理的资源分配策略。
提示:这类题在认证中属于“看起来简单,边界容易错”的类型。比如题单用完就没了,每天只能用一套,这些约束在代码里都要体现清楚。
5. 什么时候该用引用,什么时候该用指针:一个过来人的判断习惯
学完这些概念,真正要落地时,最常遇到的问题不是“知不知道”,而是“什么时候用哪个”。我自己的习惯是:如果函数内部需要改变调用者的变量,而且这个变量一定存在、不会为空,就用引用;如果参数可能为空、或者需要重新指向别的对象,就用指针。
举个例子,写一个交换两个整数的函数,void swap(int &a, int &b)比void swap(int *a, int *b)更干净,因为调用时不用写&,函数体里也不用解引用。但如果要写一个链表节点查找函数,返回找到的节点指针,找不到时返回nullptr,那就必须用指针,因为引用不能为空。
另一个判断依据是看“所有权”和“可空性”。引用表达的是“我借用你,你一定在”;指针表达的是“我可能找到你,也可能找不到”。C++ 里还有智能指针来处理所有权问题,但在 GESP 四级这个阶段,先把裸指针和引用的边界搞清楚,比急着上智能指针更重要。
至于数组和指针的选择,记住一条:数组名在传参时会退化成指针,所以函数参数写int arr[]和int *arr是一回事。但如果你在函数内部需要知道数组长度,必须额外传一个长度参数,因为sizeof在指针上得不到数组的真实大小。这是无数人翻过车的地方。
还有static和extern的取舍:如果你希望一个全局变量只在当前文件可见,加static;如果你希望它在别的文件也能用,用extern声明。不要因为“怕重名”就到处加static,那会让代码的模块边界变得模糊。
最后说异常。C++ 的异常可以抛任意类型,但实践中最好抛继承自std::exception的类,这样 catch 时可以用基类引用统一处理。结构体异常虽然能捕获,但缺乏统一的what()接口,排查问题时信息不够。这是从“能跑”到“好维护”的一步。
从那以后我每次写函数参数,都会先问自己一句:这个参数会不会为空?调用者需不需要我看到他的修改?答案清楚了,引用还是指针也就定了。希望帮到你。
本文还有配套的精品资源,点击获取