16 KiB
4.1 自定义函数和递归
一、这一节到底学什么?
这一节不要求你一下子把函数和递归全背下来。
你只要先抓住下面 4 件事就够了:
- 知道什么时候要把一段代码单独写成函数。
- 会写最基础的自定义函数定义。
- 能顺着流程图读懂“函数被调用时发生了什么”。
- 初步看懂递归是怎样一层一层调用、再一层一层返回的。
整份讲义里,我们会尽量用能直接代数字的小例子来讲,不走太空的定义路线。
二、为什么要写函数,而不是一直往下写?
先看一种很常见的情况:
- 你要多次做同一件事。
- 这件事本身可以起一个名字。
- 把它拆出去以后,主程序会更清楚。
比如你要处理一张全班成绩表。
题目要求你完成下面这些事:
- 读入每个同学的语文、数学、英语成绩。
- 计算每个同学的总分。
- 计算每个同学的平均分。
- 判断每个同学是否及格。
- 最后输出全班总分前 3 名。
如果你完全不用函数,程序很可能会变成这样:
- 主程序里一大段是在算总分。
- 后面又来一大段是在算平均分。
- 再后面又来一大段是在判断是否及格。
- 如果输出前 3 名时还要反复比较、交换,主程序会更长。
这样写会有几个明显问题:
- 主程序会越来越像一团挤在一起的步骤,不容易看出主线。
- 同样的计算规则如果写了好几次,改错时很容易漏掉某一处。
- 过几天再回头看,你很难一下分清哪一段在“算分数”,哪一段在“判及格”,哪一段在“排前 3 名”。
这时如果把任务拆开:
- 写一个函数专门求总分。
- 写一个函数专门求平均分。
- 写一个函数专门判断是否及格。
主程序就会清楚很多。
所以你可以把“一段完成固定任务的代码”单独拿出来,给它起个名字,这就是函数。
你可以先把函数理解成:
一个能完成某项任务的小工具。
主程序需要它时,就“叫它来做事”。
三、最基础的函数长什么样?
最常见的写法是:
返回值类型 函数名(参数列表)
{
函数体
}
先不用被名字吓住,直接把这 3 部分拆开看:
先配一个很具体的例子:
int total_score(int chinese, int math, int english)
{
int sum = chinese + math + english;
return sum;
}
这个函数做的事非常明确:
- 给它语文、数学、英语 3 个分数。
- 它帮你算出总分。
- 最后把总分交回来。
1. 返回值类型
表示这个函数做完以后,要交回什么类型的结果。
例如:
int:交回一个整数。double:交回一个小数。bool:交回真或假。void:不交回结果。
在刚才那个例子里:
total_score最后交回来的是总分。- 总分通常是整数。
- 所以这里写
int。
2. 函数名
就是这个函数的名字。
名字最好能让人一眼看出用途,比如:
addis_evensum_to_n
如果是给六年级同学自己写,名字能看懂比“高大上”更重要。
像:
total_scoreprint_linesum_to_n
都比随便写成 f1、abc 更容易读。
3. 参数列表
表示调用这个函数时,要给它哪些数据。
例如前面的成绩函数:
int total_score(int chinese, int math, int english)
意思就是:
- 这个函数叫
total_score。 - 它需要 3 个整数,分别叫
chinese、math、english。 - 它最后会返回一个整数,也就是总分。
如果把真实数字代进去,比如:
- 语文 92 分
- 数学 87 分
- 英语 95 分
那么这个函数的任务就是算:
92 + 87 + 95 = 274
四、函数的意义:同一件事,只写一次
先看一个场景。
你需要算 3 个同学各自的总分:
- 小明:语文 92,数学 87,英语 95
- 小红:语文 78,数学 85,英语 80
- 小刚:语文 88,数学 90,英语 76
如果不用函数,主程序会这样写:
int sum1 = 92 + 87 + 95; // 小明的总分
int sum2 = 78 + 85 + 80; // 小红的总分
int sum3 = 88 + 90 + 76; // 小刚的总分
如果有 30 个同学,就要写 30 行。
而且"加 3 个数"这件事,每次都要重复写一遍。
这时可以定义一个函数,把"怎么加"这件事单独写好:
int total_score(int chinese, int math, int english)
{
int sum = chinese + math + english;
return sum;
}
然后在主函数里,每个同学用一行搞定:
int sum1 = total_score(92, 87, 95); // 小明的总分,得到 274
int sum2 = total_score(78, 85, 80); // 小红的总分,得到 243
int sum3 = total_score(88, 90, 76); // 小刚的总分,得到 254
"怎么算总分"只定义了一次,却被叫了 3 次。
这就是函数最核心的意义:
把一件事的做法写一次,想用多少次就叫多少次。
用流程图看清楚"调用"发生了什么
下面这张图展示的是:主函数叫了 total_score 两次,每次都跳出去执行函数,结束后再回来继续。
flowchart TD
A([开始]) --> B["主函数:准备小明的分数\n92, 87, 95"]
B --> C["调用 total_score(92, 87, 95)"]
subgraph F["函数 total_score"]
D["收到参数"] --> E["sum = chinese + math + english"]
E --> G["return sum"]
end
C --> D
G --> H["sum1 = 274,回到主函数"]
H --> I["准备小红的分数\n78, 85, 80"]
I --> J["调用 total_score(78, 85, 80)"]
J --> D
G --> K["sum2 = 243,回到主函数"]
K --> L[/输出 sum1, sum2/]
L --> M([结束])
注意流程图里的关键点:
- 每次调用,都会"跳进"函数那个框里执行。
- 函数执行完以后,再"跳回"主函数继续往下走。
- 函数本身的代码只写了一份,但被用了两次。
五、值传递到底是什么意思?
这是这一节最重要的基础概念。
看下面这组代码:
int add_bonus(int score)
{
score = score + 10;
return score;
}
int main()
{
int math = 87;
int new_score = add_bonus(math);
}
很多同学第一眼会担心:
- 函数里把
score改了,那外面的math会不会也变?
这一节里,答案是:
不会。
因为我们现在只讲值传递。
调用 add(x, y) 时,发生的是:
调用 add_bonus(math) 时,发生的是:
- 把
math当前的值 87 交给函数里的score。 - 函数内部拿到的是一份新的 87。
所以:
score和math不是同一个变量。
你可以把它理解成:
- 主程序把这个分数抄了一份给函数。
- 函数只是在自己的小房间里处理这份副本。
- 函数结束以后,主程序里的原变量不受影响。
用一组数据走一遍
当 math = 87 时:
- 进入函数时,
score = 87。 - 执行
score = score + 10后,score = 97。 - 返回 97。
但主函数里的:
math还是 87。
最后会变成:
math = 87new_score = 97
也就是说:
- 原来的分数没被直接改掉。
- 只是函数帮你算出了“加 10 分以后”的新结果。
这一点一定要和“函数内部变量的变化”区分开。
六、函数调用时,流程图应该怎么看?
先看一个非常基础的例子:主函数调用 total_score(chinese, math, english)。
flowchart TD
A([开始]) --> B[chinese = 92, math = 87, english = 95]
B --> C[调用 total_score(chinese, math, english)]
C --> D[把 92, 87, 95 交给函数里的 3 个参数]
D --> E[sum = 92 + 87 + 95]
E --> F[return 274]
F --> G[ans 得到返回值]
G --> H[/输出 ans/]
H --> I([结束])
这张图里最重要的是中间 3 步:
- 调用函数。
- 把实参的值交给形参。
- 函数算完以后,把结果返回。
这里有两个名字要分清
- 实参:调用时真正传进去的东西,比如
chinese、math、english。 - 形参:函数定义里接收数据的变量,比如
chinese、math、english这 3 个参数名。
你不需要死背这两个词,但一定要明白它们不是同一个层面的名字。
七、把“主函数”和“被调用函数”分开看,会更清楚
如果你总觉得函数调用时会乱,可以把它想成两块:
- 主函数负责“发任务”。
- 被调用函数负责“做任务”。
看这张图:
flowchart LR
subgraph M[主函数 main]
A[准备 3 个分数] --> B[调用 total_score(...)]
C[接住返回值 ans] --> D[/输出 ans/]
end
subgraph F[函数 total_score]
E[收到 3 个分数] --> F1[把 3 个分数加起来]
F1 --> G[返回总分]
end
B --> E
G --> C
这样看会更像真实过程:
- 主函数把任务送进去。
add做完再把结果送回来。
函数不是“贴在原地执行的一行代码”,而是一次完整的小过程。
八、什么时候用 void 函数?
不是所有函数都一定要“返回一个结果”。
比如下面这个函数:
void print_line()
{
cout << "----------" << endl;
}
它的任务只是输出一行分隔线。
所以:
- 它做事。
- 但它不需要交回一个数。
这时就可以写成 void。
你可以把它理解成:
void函数负责“做动作”,不负责“交答案”。
如果把它放进主函数里:
print_line();
cout << "欢迎来到成绩统计程序" << endl;
print_line();
它的作用就很直观:
- 不是算分数。
- 不是算答案。
- 只是帮你把输出排得更整齐。
九、递归到底是什么?
递归只有一句核心定义:
函数在自己的函数体里调用自己。
第一次看到这句话,很多同学会立刻觉得:
- “那不是会一直套下去吗?”
所以学递归时,最关键的不是先背代码,而是先抓住两件事:
- 一定要有“停止条件”。
- 每次递归都要朝着停止条件靠近。
如果没有停止条件,函数就会没完没了地继续调用。
如果虽然有停止条件,但每次都没有更接近它,那也一样会出问题。
十、先看一个最简单的递归函数
题目:求 1 到 n 的和。
我们把它写成:
int sum_to_n(int n)
{
if (n == 1)
{
return 1;
}
return n + sum_to_n(n - 1);
}
这段代码可以读成:
- 如果
n == 1,那答案就是 1,直接返回。 - 如果
n > 1,那么1 + 2 + ... + n就等于n + (1 + 2 + ... + (n - 1))。
所以它把原问题拆成了一个更小的问题:
sum_to_n(n)- 变成
n + sum_to_n(n - 1)
这就是递归最常见的思路:
大问题,拆成一个同类但更小的问题。
你可以把它想成一堆叠起来的小卡片:
- 要算前 4 张卡片上的数字和。
- 可以先拿最上面这一张 4。
- 剩下的问题就变成“前 3 张卡片的和是多少”。
这样就从“4 张”慢慢变成“3 张”“2 张”“1 张”。
十一、递归流程图怎么画?
先看结构图:
flowchart TD
A([进入 sum_to_n(n)]) --> B{n == 1 吗?}
B -- 是 --> C[return 1]
B -- 否 --> D[计算 sum_to_n(n - 1)]
D --> E[返回 n + 子问题结果]
C --> F([结束本层调用])
E --> F
这张图里,你必须抓住两条路:
- 一条是“直接结束”的路,也就是停止条件。
- 一条是“继续缩小问题”的路,也就是递归调用。
没有前者,停不下来。
没有后者,就不是递归。
十二、拿 sum_to_n(4) 手推一遍,最重要
只看代码,很多同学会晕。
这时最稳的方法就是:
拿一个很小的数,自己顺着调用过程走一遍。
第一步:往下调用
sum_to_n(4)
= 4 + sum_to_n(3)
sum_to_n(3)
= 3 + sum_to_n(2)
sum_to_n(2)
= 2 + sum_to_n(1)
sum_to_n(1)
= 1
看到这里,终于碰到了停止条件。
第二步:往回返回
sum_to_n(1) = 1
sum_to_n(2) = 2 + 1 = 3
sum_to_n(3) = 3 + 3 = 6
sum_to_n(4) = 4 + 6 = 10
所以最后答案是 10。
再用表格看一遍
| 这一层在算谁? | 它先等谁? | 等到以后自己返回多少? |
|---|---|---|
sum_to_n(4) |
等 sum_to_n(3) |
4 + 6 = 10 |
sum_to_n(3) |
等 sum_to_n(2) |
3 + 3 = 6 |
sum_to_n(2) |
等 sum_to_n(1) |
2 + 1 = 3 |
sum_to_n(1) |
不用再等 | 1 |
这张表很重要,因为它把“先等谁、再返回多少”写得很清楚。
十三、递归最容易绕晕的地方,不在“往下”,而在“往回”
很多同学能接受:
sum_to_n(4)去叫sum_to_n(3)。sum_to_n(3)再去叫sum_to_n(2)。
但一到返回时就乱了。
原因通常是没有意识到:
每一层函数调用,都在等自己的“下一层”先把结果交回来。
也就是说:
sum_to_n(4)不能立刻算完。- 它必须先等
sum_to_n(3)返回。 sum_to_n(3)又得先等sum_to_n(2)。- 一直等到最小那层先返回。
所以递归过程常常像两段:
- 一层一层往下走。
- 一层一层往上交答案。
十四、把递归看成“排队等结果”,会更好懂
再看一张图:
flowchart TD
A[sum_to_n(4)] --> B[等待 sum_to_n(3)]
B --> C[等待 sum_to_n(2)]
C --> D[等待 sum_to_n(1)]
D --> E[sum_to_n(1) 返回 1]
E --> F[sum_to_n(2) 返回 3]
F --> G[sum_to_n(3) 返回 6]
G --> H[sum_to_n(4) 返回 10]
这张图要表达的重点不是代码细节,而是“顺序”:
- 先不断往下等。
- 再从最底层开始往回交结果。
你甚至可以把它读成下面这几句话:
- 4 这一层先别急,先去问 3。
- 3 这一层也先别急,先去问 2。
- 2 这一层再去问 1。
- 1 最简单,直接回答:我是 1。
- 然后答案一层一层往回传。
如果你能把这张图说顺,说明你已经抓住递归最核心的运行过程了。
十五、值传递放到递归里,还是一样的
递归虽然看起来更复杂,但参数传递规则没有变。
例如:
- 调用
sum_to_n(4)时,这一层的n是 4。 - 它再调用
sum_to_n(3)时,下一层会有一个新的n,值是 3。 - 再下一层调用
sum_to_n(2)时,又会有一个新的n,值是 2。
所以:
- 每一层都有自己的
n。 - 它们不是同一个变量。
- 只是名字都叫
n而已。
这也是值传递的一部分:
每次调用,都会得到这一层自己的那份参数值。
十六、写递归时,先问自己这两个问题
在真正动手写递归前,最好先问:
1. 什么时候停?
也就是停止条件是什么。
例如:
n == 1时停。n == 0时停。
2. 怎么把问题变小?
也就是下一次调用时,参数怎么变化。
例如:
n - 1n / 2
如果这两个问题答不出来,递归代码通常也写不稳。
十七、最常见的错误
下面这些错非常常见:
- 写了递归调用,却没有停止条件。
- 虽然写了停止条件,但永远到不了它。
- 只会往下套,不会分析怎么往回返回。
- 把“这一层的参数”和“下一层的参数”混成同一个东西。
- 以为函数里的参数改了,主函数里的变量也会跟着改。
第 5 条在这一节里尤其要避免,因为本节只讲值传递。
十八、这一节最该记住的几句话
- 函数就是把一段固定任务单独拿出来,起个名字反复用。
- 最基础的函数定义要看清:返回值类型、函数名、参数列表。
- 调用函数时,主程序是“发任务”,函数是“做任务”。
- 本节只讲值传递,所以函数拿到的是一份新的值,不会直接改掉外面的原变量。
- 递归就是函数调用自己。
- 递归必须同时具备两件事:停止条件、缩小问题。
- 读递归最稳的方法,是拿小数据手推,先看往下调用,再看往回返回。
十九、课堂练习
练习 1
写一个函数 int square(int x),返回 x * x。
要求:
- 先说清楚参数是什么。
- 再说清楚返回值是什么。
练习 2
主函数中有 a = 6,调用 triple(a),其中函数内部把形参乘以 3。
问题:
- 函数返回什么?
- 主函数里的
a会不会变?
练习 3
把 sum_to_n(5) 的递归调用过程手写出来,写清楚:
- 往下调用到了哪一层。
- 往回返回时每层结果是多少。
如果这 3 题都能自己说顺,这一节就算真正入门了。