1. 为什么要整理这套函数题,从期末周的一次复盘说起
大一下学期期末前两周,我在课程平台上翻自己整个学期的提交记录,发现很多函数题都是当时“碰巧过了”,代码到底怎么写的、边界条件有没有考虑全,基本已经模糊。那种感觉挺难受——题库放在那里,提交记录二百多条,可真正能讲清楚原理的,可能不到一半。于是就有了整理函数题篇的念头。
PTA平台上的题目分很多类型,函数题恰好是“最适合集中复盘”的一类。和完整编程题不同,函数题通常已经给你一个函数壳子,主函数、头文件、测试样例往往由系统预置,你要做的只是把指定函数实现出来。这更贴近真实工程里“面向接口编程”的状态:别人把接口约定好了,你要保证在约定范围内行为正确。很多同学觉得函数题比编程题简单,确实,它省去了输入输出格式的纠缠;但它坑就坑在,你只看到函数局部,看不到全貌,一旦漏考虑某个调用场景,就会被隐藏测试点卡住。
对于所有需要靠PTA刷C++题目的同学来说,大一下册的函数题集,基本上是从“语法”迈向“设计”的过渡带。函数题一方面比单纯的分支循环要进阶,另一方面又还没有进入类与对象那种复杂抽象,性价比非常高。我整理的内容不一定只对某一所学校适用,因为我翻了很多同学的题目列表后发现,这个阶段的考核点高度相似:数学公式、字符串处理、数组指针、结构体比较、递归调用。把这些核心逻辑吃透,期末考试基本上就站住了。
1.1 整理题集的第一原则:按考点归类,不按题号堆叠
很多人整理答案,就是把官网上的题目列表复制一遍,一个函数一段代码粘贴进去,看着很全,实际复习时根本不会去翻。我尝试了一种相反的顺序:先不看题号,把所有做过的函数题按照考核点重新排。
举个例子,我在整理时把题集分成五个大文件夹:数值计算与判定、字符串操作、数组与指针、结构体与简单对象、递归。每个文件夹下面再按难度递进放题。这样期末复习时,我不用面对一百多个杂乱无章的题号,只需要看这几个知识点,哪个薄弱就补哪个。题集本身变成了一个知识图谱,比单纯堆积答案有用得多。
如果你手头也有很多分散的题目记录,我建议也试试这个思路。不需要一次性整理完,每周抽出半小时,把本周做过的题归类进已有文件夹就行。坚持到第八周,你手里已经有了一套很系统的复习材料。这种整理方式的好处是,到考前你不需要重新审一遍所有代码,只需要针对自己的薄弱文件夹集中突破。
1.2 函数题和完整编程题的差别,直接决定整理策略
整理函数题,要先意识到它与编程题在判题逻辑上的区别。完整编程题一般需要自己写输入输出,要操心scanf、printf、换行符、空格这些格式细节;而PTA的函数题往往已经内置了main函数和测试框架,你只需要填充某个函数体。
这意味着函数题的判题更看重“单一功能正确性”。你不需要管理整段程序的交互流程,只需要关心函数输入、处理、输出或返回值是否满足约定。这个特点反而更接近真实软件开发中写模块函数的状态——接口固定,调用方不可改。所以我把函数题的整理重点放在两个词上:接口契约与行为约定。
说得更直白一点,整理函数题答案时,不必像完整编程题那样贴一大堆上下文代码,只贴那个核心函数就够了。但恰恰因为只贴函数,很多依赖关系容易被忽略,比如头文件是否在系统预置代码里、参数是值传递还是指针传递、有没有全局变量、是否允许修改原数组。这些信息一旦漏掉,你单看代码根本不知道题目在问什么。所以我在每个题目标题下面加了一行“题目隐含信息”,把函数原型的准确形式、需要include的头文件、题目是否允许修改原值都记录下来。
这样做的好处非常明显:考试时遇到类似题目,不需要回忆整道题的细节,只要看到函数原型和你标注的约束,就能迅速进入编码状态。我后来能比较快地适应各种笔试在线编辑器,很大程度要归功于这种整理习惯。
1.3 整理过程中,我对“答案”的看法发生了转变
最开始我也以为整理答案就是把标准解法带回家。可真到整理时才发现,哪怕是最短的一段代码也至少有三四种写法。是写最精简的一行表达式好,还是写最容易被读懂的分步判断好,或者写执行效率更高的穷举方案好?
这个问题看似是选风格,实际上反映出的是编程思维的差异。PTA判题不会管你代码风格是否优雅,只要功能正确、满足时间和内存限制就能过。但作为学习者,不能把“通过测试”当成终点。整理的过程,恰好是逼迫自己比较不同写法的绝佳机会。
以我自己整理的题集为例,我保留每题的主要解法和备注解法。主要解法选最容易读、最好给人讲清楚的写法;备注解法记录我在练习时想到的优化或变体。期末复习的时候,我发现自己能对着这两栏把题目完整讲出来,这比单纯背代码有效得多。能把题给别人讲明白,才算是真正学会了。
2. 函数题里最经典的几类考核套路
在PTA的函数题模块里,题目看似五花八门,但整理下来会发现核心考核套路其实屈指可数。每个知识块里的难点都集中在特定方向。下面分四类展开,结合我整理时见到的高频考法来说明。这些套路不是某一所学校专属,而是这个教学阶段的普遍设计逻辑。
2.1 数值计算类:边界条件决定生死
数值计算类函数题是题库里出现频率最高的,常见类型包括素数判定、闰年判断、最大公约数、水仙花数、斐波那契项等。这类题表面上都是套公式,但真正的坑往往藏在边界条件里。
以经典的素数判定函数为例:
bool isPrime(int n) { if (n <= 1) { return false; } if (n == 2) { return true; } if (n % 2 == 0) { return false; } for (int i = 3; i * i <= n; i += 2) { if (n % i == 0) { return false; } } return true; }这段代码看起来并不复杂,但很多人第一次写都会漏掉 n <= 1 的分支。判题系统的隐藏用例里特别爱放 0、1、负数这些边角数据,你要是没做防御,可能在最后一个测试点才暴露问题。我还见过一种更隐蔽的错误:只知道判断能被2整除的情况,却没有把 n = 2 单独放行,导致素数2被误判。这种问题在本地手写运行不容易发现,因为很多人测试的时候只会输入6、9、17这类常规数,根本不会拿边界值去试。
整理此类题目时,我会特意把函数可能接收的所有值列出来:最小值、负值、零、一、二、大数边界附近。在代码注释里写清这些边界语义,再开始写正文,过测率会有非常明显的提升。这个方法同样适用于最大公约数、进制转换、日期计算等所有数值型函数题。
再举一个例子——最大公约数。很多初学者会用穷举法,从较小的数向下试除,没错但效率差。更标准的写法是辗转相除法:
int gcd(int a, int b) { while (b != 0) { int t = a % b; a = b; b = t; } return a; }这里容易被忽略的是负数输入。如果题目没有明确说明输入范围,传入 -8 和 12,辗转相除法的结果符号是未定义的。不同平台对这类问题有不同约定,有些约定输入为正整数,有些则不然。我在整理每个数值题时,都会顺手在题解旁边标注“输入是否包含负数”。这一行注释,考试时也许用不上,但写通用函数时能避免很多争执。
2.2 字符串与字符处理:测试数据总在你没想到的地方
字符串类是第二个大热点。C风格的字符数组、C++的string、字符指针的区分,都会在函数题里出现。PTA高频的子题型包括:统计某个字符出现的次数、字符串连接、反转字符串、去除空格、判断回文串。
这类题的共同痛点是字符串终止符判断和数组越界问题。以“原地反转一个char数组表示的字符串”为例:
void reverseString(char s[]) { int len = 0; while (s[len] != '\0') { len++; } for (int i = 0, j = len - 1; i < j; i++, j--) { char tmp = s[i]; s[i] = s[j]; s[j] = tmp; } }单看这段代码没有大问题。但在函数题环境下,你要确定“到底应该原地修改,还是返回新的字符串”。如果题目要求的是原地反转,而你额外申请了一个临时数组把结果存回去,虽然逻辑上也能得到正确字符串,但函数对原数组的修改行为可能与测试代码预期不一致,判题照样会报错。
字符处理的第二大雷区是空格。有些题目要求统计字母个数但不含空格,有些要求统计除空格以外的所有字符,还有些要求去除首尾空格后再返回。这些描述字面上差异很小,可判题程序会严格区分。我整理答案时,会把“空格如何处理”单独列成一个备注字段,写进每个字符串题目的说明栏。这个习惯至少帮我在综合练习中躲开了三次低级失误。
如果允许使用C++的string,又要注意find、substr、erase、insert这些成员函数的行为边界。PTA上不少函数题虽然允许使用C++标准库,但函数原型仍然要求char数组参数。班里有些同学只熟悉string那套API,看到char数组反而不会操作。这里我的建议是,整理的时候两种风格都写一遍:先用C风格方式实现,再用C++ string方式重写。这种对比练习的操作量不大,但对你理解C和C++在处理字符串时的本质差异特别有价值。
2.3 数组、指针与结构体:接口设计是基本功
数组和指针往往是大一下学期的核心难点,函数题也特别喜欢在这儿做文章。常见题目包括:求整型数组的最大值并返回下标、通过指针参数返回多个结果、删除数组中指定元素并返回新长度、用指针遍历二维数组。
这类题真正的分水岭在于:你是否理解指针参数的本质是“引用传递”而不是“值传递”。举一个很常见的“在数组中查找第一个大于给定值的元素并返回下标”的例子:
int findFirstGreater(const int *arr, int n, int key) { for (int i = 0; i < n; i++) { if (arr[i] > key) { return i; } } return -1; }如果题目注明数组非空,你甚至可以不写判空逻辑;如果没有注明,那主动判断arr == NULL就是加分项。整理时还需要注意const int *arr表示函数不允许修改数组内容,而int *arr则允许。PTA的题目说明不一定每次都提示你能不能修改原数组,但函数原型是固定死的,所以你要学会从原型推断调用方的预期。
结构体类函数题往往围绕日期时间、学生信息、成绩排序展开。典型例子是给定结构体表示日期,写函数比较两个日期的先后:
typedef struct { int year; int month; int day; } Date; int compareDates(Date d1, Date d2) { if (d1.year != d2.year) { return d1.year < d2.year ? -1 : 1; } if (d1.month != d2.month) { return d1.month < d2.month ? -1 : 1; } if (d1.day != d2.day) { return d1.day < d2.day ? -1 : 1; } return 0; }这种代码没有高深技巧,但它的考点在于,你能不能把一个复杂比较逻辑拆成逐层判断。不少人习惯把年月日拼成一个整数再比大小,如果年月日是正整数并且位数固定,这种方式可行;一旦出现负数或者位数不齐,拼接法就不可靠。整理这类题目时,我除了在表格里记录代码,还会特别注意记录“为什么不能直接用拼接整数”。这种思考题比代码本身更能反映你是否真正理解了数据组织方式。
2.4 递归和嵌套调用:能过测试,也要看效率
递归在函数题里同样占有一席之地,常见题目包括阶乘、斐波那契数列、汉诺塔步数、快速幂。递归题有一个明显的现象:很多同学用朴素递归能写出来,面对小数据也能通过,但当判题把测试规模加大后,就会出现运行超时。
以斐波那契为例,最朴素的递归写法:
long long fib(int n) { if (n == 0) return 0; if (n == 1) return 1; return fib(n - 1) + fib(n - 2); }n在30以内毫无压力,到40多性能就开始明显下降,50以上的隐藏测试直接可能超时。原因是这种写法会反复计算大量重叠子问题,调用次数随n指数级增长。更合适的做法是用递推:
long long fib(int n) { if (n == 0) return 0; if (n == 1) return 1; long long a = 0, b = 1, c = 0; for (int i = 2; i <= n; i++) { c = a + b; a = b; b = c; } return c; }这个版本的时间复杂度是O(n),空间复杂度是O(1),比朴素递归更适合判题系统。我在整理题集时专门设了一个“递归/非递归对比”栏目,每次遇到递归题都写下两种思路的取舍。期末复习时这个栏目特别有用,因为考试很可能不只看功能对错,还会在时间限制上设卡。如果能在复习阶段就建立起“递归能写,但不一定最优”的意识,考试中遇到类似变体题,思路会开阔很多。
3. PTA判定过程中容易被忽略的规则
许多同学把在线判题平台当成“做对就行”的黑盒。但如果能提前理解它的行为模式,做题和整理答案时都能省下大量时间。下面按我踩过坑的顺序讲几个关键点。
3.1 可见样例只是冰山一角
PTA的题目页面上通常会给出样例输入和样例输出,方便你初步验证。但真正的判题运行还会额外测试一组“隐藏用例”。隐藏用例专门找思维的漏洞,比如边界值、极端数据、格式细节。所以样例通过绝不等于答案正确。
印象比较深的一次是字符串删除指定字符的题目,样例只给了一个正常长度的串,而隐藏用例里有空串。我那版代码遇到长度为0的串会访问不确定的内存区域,虽然样例过了,可最终得分90,剩下的分就丢在隐藏用例上。事后复盘才发现,越是看起来“没什么好考”的题,越容易在极端情况上翻车。
这给整理工作的启示是:记录答案时不要只写代码本身,最好顺便记录“这题的隐藏用例可能测什么”。隐藏用例我们看不到,但可以根据知识点推断。字符串题多半会测空串、单字符、全空格;数值题多半会测零、负数、大边界;数组题多半会测长度为1、全相同元素、降序排列。这种推断能力靠大量练习培养,而整理题集恰恰是逼你沉淀这些规律的过程。
3.2 函数题更看重接口契约
PTA的函数题判题方式与编程题不同,它会把考生代码和一段预置代码(主函数、相关辅助函数)一起编译运行,再对比运行结果。这意味着你写的函数不需要自己带main,也不需要负责输入输出,调用方式已经被固定。
这种设计导致函数题特别看重接口契约。函数名不能拼错,参数顺序不能调换,返回类型必须与声明一致,部分题目还规定函数内部能否修改某个全局变量。很多同学写函数题时容易觉得“关系不大,反正内部逻辑对就行”。这个想法在完整编程题里可能影响不大,但在函数题里是致命的。系统代码可能用int ret = cal(a, b)来调用你的函数,如果你写成了cal(b, a),即使逻辑正确,结果也可能完全不对。
因此,我在整理时会在每个题目记录区单独标注“函数原型”“返回类型”“参数类型”三行。与其在考试现场反复推敲,不如平时就把这些信息变成条件反射。这样做还有个额外好处:刷多了你会发现,很多题目的函数原型本身就暗示着考点,例如const int *arr提醒你不要改原数组,char *s提醒你可能要原地处理。
3.3 能过测试,不代表答案质量高
PTA只会反馈对了几个测试点,不会评价代码质量。但函数题作为日后做项目、参加竞赛、应对机器阅卷笔试的基本功,学习阶段不能只满足于“过了”。我所说的质量,包含三个层面:时间效率、空间效率、可读性。
举个例子,有个练习题目是删除数组中的指定值。第一版我写了两层for循环,每删除一个元素就把后面所有元素往前移动一位。代码很短,可最坏情况下的时间复杂度是O(n^2)。题目数据量小时,判题系统可能不超时,但一旦数据量上升到十万元级别,运行速度就会断崖式下降。后来我改成双指针写法,一趟遍历完成删除,时间复杂度降为O(n)。整理题目时,我把两版代码放在同一个记录里,旁边写一句“为什么双指针的O(n)可行”。这种整理方式已经完全超越应付考试了,它是在帮自己建立算法直觉。
另外提醒一下,函数题的全局变量使用要格外谨慎。有些题目在描述里没有明说,但预置的main函数和你的函数共享同一个全局变量。如果你的函数改变了那个变量,后面的判题逻辑就会被带偏。所以整理时看到全局变量,一定要在备注里标出来。这类隐性约束,是函数题里最容易出问题的部分之一。
4. 我的具体整理流程:从分散提交到体系化题集
前面说了这么多原理,下面分享实操性比较强的整理方法。不需要花哨的工具,一个文档或表格足够。重点是字段设计合理,能让问题浮出水面。
4.1 题集表格的基本字段设计
我维护的函数题集表格包含以下字段:题目标记、考核点、函数原型、核心思路、易错点、日期、是否二刷。表格结构如下,你可以直接拿来用:
| 题目标记 | 考核点 | 函数原型 | 核心思路 | 易错点 | 二刷状态 |
|---|---|---|---|---|---|
| 数值-01 | 素数判定 | bool isPrime(int n) | 排除1和偶数,从3开始步进2 | n <= 1 容易漏 | 已完成 |
| 字符串-03 | 去除空格 | void eraseSpace(char s[]) | 双指针原地覆盖 | 结尾要补\0 | 待二刷 |
| 结构-02 | 日期比较 | int compareDates(Date d1, Date d2) | 逐层比较年月日 | 不要拼接成整数 | 已完成 |
| 递归-04 | 斐波那契 | long long fib(int n) | 递推代替递归 | 预防整数溢出 | 已完成 |
表格不是用来背的,而是用来暴露弱点的。“待二刷”如果长期存在,说明这个知识点还没有真正消化。考试前的复习重点,就从这些“待二刷”行开始。
4.2 每题保留两种解法,价值超出预期
我在整理时要求自己,每题至少记录两种解法的思路,哪怕第二种只是写一句“如果数据范围更大,可以改成XX算法”。这看起来费时,但实际价值很大。
首先,它逼着我把知识从“背代码”升级为“理解原理”。以字符串反转为例,可以用双下标交换,也可以用栈,还可以用递归。当你理解了多种实现之后,遇到变种题就不会慌:核心思想不变,变化的是外层限制条件。其次,复盘时这种对比解法最省脑力,扫一眼就知道什么场景该用哪种策略。到后来,面对新函数题,我基本能预判题目想考的是哪种优化方向。这个能力无法靠临时刷题获得,只能靠持续整理慢慢积累。
4.3 错题复盘的根因分析,比“改对”重要十倍
有一段时间我改正错题的方式很粗糙:看别人答案,发现自己漏了某个分支,补上,提交通过,完事。但后来发现这样效率很低,因为相同错误换个包装还会再来。于是我把错题旁备注一栏改成写“根因分析”。
比如有一次模拟计算器的函数题没通过,表面原因是我把除法运算结果存成int,导致小数部分被截断。深挖之后我发现根因是,编写前没有提前分析函数的返回类型。如果返回类型是double,我自然会用double保存中间结果。我的备忘录里写的是:“编写前未确认返回类型,默认当成int处理。”这比“除法出错了”有迁移价值得多,下次遇到任何数值计算类函数题,我都会先问自己一句:返回类型是什么,中间计算要不要转成浮点。
错题复盘要做到位,就需要在每次提交失败后问自己三个问题:是哪类输入导致失败?是边界没考虑,还是思路本身有误?这个错误的根源能迁移到哪一类题目上?把这三个答案写进整理文档,错题才算真正转化成了经验。
5. 这套整理方案延续到大二之后的长期价值
大一下的函数题只是起点。大二接触数据结构时,链表操作、树遍历、栈和队列的模拟,很多也以函数题形式出现。大一打下的“接口契约”思维,可以很自然地迁移过去。到了大二下学期写综合课程设计,面对多文件的复杂项目,你会不自觉地先把大功能拆成小函数,先定好参数和返回值,再实现内部逻辑。这就是整理函数题带来的习惯性收益。
PTA这类在线判题平台上,绝大部分练习都可以套用我上面说的整理方法论。不需要为了整理而整理,不需要把答案抄得漂漂亮亮。你需要的是把“我会做”升华为“我理解为什么这样做”,以便在期末考试、编程竞赛、机器阅卷的笔试中都能保持稳定表现。
我个人还有一个很深的体会:整个题集整理到后期,真正消耗精力的不是代码本身,而是“我该怎么给自己讲明白这道题”。如果你也能在自己整理的题集中,对着每一条记录讲出个所以然来,那这个学期的C++基础,大概率是真正扎实了。
最后多说一句,如果你手边已经有别人整理好的现成函数题答案,不要收藏后就完事。先自己动手做一遍,再把自己的版本和别人版本对照,把差异记录下来。这个过程比单纯看答案慢一些,但从学习效果来说非常值得。我自己的题集里,很多真正有用的注释,就是从这种对照中悟出来的。