八、函数
你可能已经注意到了:前面几章里,isPrime、gcd 这些名字反复出现,但我们一直没说它们是怎么定义的。
这一页补上这部分。2 章 9 道题,题量不大,但它决定了你后面的代码是「能看」还是「一坨」。
函数解决的核心问题只有一个:同一段逻辑,不要写两遍。
- 判素数在主循环里要调用 n 次——每次都把那五行抄一遍?
- 求最大公约数在好几个地方都要用——复制粘贴,改一处忘一处?
函数让你「写一次,到处用」,而且用的时候只需要知道「它干什么」,不用关心「它怎么干的」。
27. 定义、调用与封装
27.1 函数的形状
返回类型 函数名(参数类型 参数名) {
// 函数体
return 返回值;
}
一个具体例子:
int add(int x, int y) {
return x + y;
}
拆开看四部分:
| 部分 | 例子 | 说明 |
|---|---|---|
| 返回类型 | int |
这个函数最后交回什么类型的东西 |
| 函数名 | add |
你起的名字,调用时用 |
| 参数表 | (int x, int y) |
调用者要传进来的东西 |
| 函数体 | { return x + y; } |
具体做什么 |
调用就是「函数名 + 括号 + 实参」:
int s = add(3, 5); // s = 8
printf("%d\n", add(10, 20)); // 30
27.2 参数是「复制」进去的
这一点必须搞明白,否则后面会莫名其妙地调不出结果。
void change(int x) {
x = 100; // 改的是副本
}
int main() {
int a = 5;
change(a);
printf("%d\n", a); // 还是 5,不是 100
}
传参是「复制一份进去」,函数里改的是副本,原变量不受影响。
想通过函数改变外面的变量,有两种办法:
- 用返回值(推荐)
- 用引用
int &x(后面再说)
int makeHundred() {
return 100;
}
int main() {
int a = 5;
a = makeHundred(); // 用返回值改
printf("%d\n", a); // 100
}
第一反应应该是「用返回值」,而不是「怎么改外面的变量」。 这个思路会让你的函数更容易看懂。
第一个函数
- max 函数 —— 题目通常要求你自己实现一个求最大值的函数
标准库里已经有 max(a, b) 了,但题目让你手写,是为了让你熟悉语法。按题目要求的函数名和参数写,别自作聪明改名字——有些 OJ 会检查你的函数签名。
27.3 把判定封装成函数
这是函数最实用的场景:一段判断逻辑,后面要用很多次。
bool isPrime(int n) {
if (n < 2) return false;
for (int i = 2; i <= n / i; i++) {
if (n % i == 0) return false;
}
return true;
}
写完之后,主程序变得非常干净:
int cnt = 0;
for (int i = 1; i <= n; i++) {
if (isPrime(i)) cnt++;
}
printf("%d\n", cnt);
对比一下如果没有函数,主循环里要塞进整个判素逻辑——一眼看过去不知道这段在干什么。
bool 是返回类型
bool 只有 true 和 false 两个值。判素、判回文、判闰年这类「是/否」的问题,返回类型就该是 bool。
用 int 返回 0/1 也行,但 bool 更能说明意图:读代码的人一看就知道这是个判断题。
封装判定
- 素数个数 —— 直接调用
isPrime - 2 到 n 的完全数 —— 先写一个
isPerfect(n)判断完全数
完全数是指「所有真因数之和等于自己」的数,比如 6 = 1+2+3。写这个函数的时候,枚举因数那部分可以复用第 17 章的技巧。
这两个函数写完,主程序应该只有五六行。 如果主程序还是很长,说明没封装干净。
27.4 多个判定组合
有些题需要判断一个数同时满足好几个条件,比如「绝对素数」:本身是素数,反过来也是素数。
bool isAbsPrime(int n) {
if (!isPrime(n)) return false; // 本身不是素数,直接否
int rev = 0, t = n;
while (t > 0) {
rev = rev * 10 + t % 10;
t /= 10;
}
return isPrime(rev); // 反转后还是不是素数
}
注意 if (!isPrime(n)) return false; 这个写法——先排除最简单的不满足情况,避免做后面的无用功。这叫「提前返回」,比嵌套 if 清楚得多。
组合判定
第 27 章验收
- 能写出
bool isXxx(int n)形式的判定函数 - 知道参数是复制进去的,改形参不影响实参
- 会用
return false提前返回,而不是层层嵌套 - 主程序因为用了函数而变短了
28. 返回值、分解与递归
28.1 返回值的类型要对
函数返回什么类型,return 就应该是什么类型。类型不对会发生隐式转换,可能悄悄丢精度。
int divide(int a, int b) {
return a / b; // 整数除法,小数丢了
}
double divide(double a, double b) {
return a / b; // 这个才有小数
}
函数名可以重名,只要参数类型不同(这叫重载)。编译器会根据你传的参数自动选。
写函数时先想清楚:这个结果是整数还是小数? 想错了,函数内部算得再对,返回时也会被截断。
返回值的类型
- 进制转换 —— 通常是把十进制转成别的进制,注意输出顺序
进制转换的常见写法是「不断取余,余数存起来,最后倒着输出」。如果写成函数,可以返回一个字符串,或者在函数里直接输出(返回类型 void)。
28.2 把大问题拆成小函数
写复杂程序时,最有效的方法是先想「这件事分几步」,每步写一个函数,最后在 main 里把它们串起来。
以「找 2 到 n 之间的亲和数」为例:
- 第一步:求一个数的真因数和 →
sumDivisors(x) - 第二步:判断一对数是不是亲和数 →
isAmicable(a, b) - 第三步:枚举所有数对 → 主循环
int sumDivisors(int x) {
int s = 0;
for (int i = 1; i < x; i++) {
if (x % i == 0) s += i;
}
return s;
}
每个函数都只有三五行的逻辑,单独看每一段都能看懂。这就是好代码的样子。
分解
28.3 递归:函数调用自己
有些问题,答案里包含着同样形式的小问题,这时候可以用递归。
最简单的例子是阶乘:
long long fact(int n) {
if (n <= 1) return 1; // 终止条件,必须有
return n * fact(n - 1); // 调用自己
}
fact(5) 会展开成 5 * fact(4) → 5 * 4 * fact(3) → …… → 5*4*3*2*1。
递归必须有两个东西
- 终止条件:什么时候不再调用自己(
if (n <= 1) return 1;) - 规模缩小:每次调用,问题要变小(
n - 1)
少了任何一个,函数就会无限调用自己,直到栈溢出——程序直接崩溃(OJ 报 RE 或 Segmentation Fault)。
写递归时,第一件事就是写终止条件。
递归不是必须的。能用循环写就用循环——递归更慢、更占内存、更难调试。只有在「问题天然是嵌套的」时候才用,比如树的遍历、分治。
现在你只需要能看懂递归,不要求熟练写。
递归与迭代
- 求 π 的值 —— 通常是级数求和,用
while循环到精度满足
这题用循环就行,不需要递归。注意终止条件:是「某一项小于 eps」还是「和前一项的差小于 eps」,读题。
函数板块总结
9 道题不多,但要达到这个状态:
- 看到一个「判断某性质」的需求,能立刻写出
bool isXxx(int n) - 主程序超过 20 行时,会本能地想「这段能不能拆成函数」
- 知道递归必须有终止条件,且能看懂简单的递归代码
判断你函数写得好不好,有一个简单标准:把 main 里的注释全删掉,别人还能不能看懂? 能,说明函数名起对了、拆对了。
后面结构体和字符串两页,代码会越来越长。这一页练出来的拆解能力,那时候就是决定性的。
延伸阅读
服务器上有一整套 OI-Wiki(621 页),是给竞赛选手写的参考手册。下面几页跟这一页相关,但不是每一页都该现在看——标了「现在跳过」的,等以后再说。
看不懂是正常的,不是你的问题
OI-Wiki 默认读者已经会写程序、目标是打 NOI。你现在需要的那部分,讲义里已经写了; wiki 是用来「以后查得更深」的,不是用来补课的。