Files
hustoj/web/doc/4.1 自定义函数和递归.md

16 KiB
Raw Blame History

4.1 自定义函数和递归


一、这一节到底学什么?

这一节不要求你一下子把函数和递归全背下来。

你只要先抓住下面 4 件事就够了:

  1. 知道什么时候要把一段代码单独写成函数。
  2. 会写最基础的自定义函数定义。
  3. 能顺着流程图读懂“函数被调用时发生了什么”。
  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. 函数名

就是这个函数的名字。

名字最好能让人一眼看出用途,比如:

  • add
  • is_even
  • sum_to_n

如果是给六年级同学自己写,名字能看懂比“高大上”更重要。

像:

  • total_score
  • print_line
  • sum_to_n

都比随便写成 f1abc 更容易读。

3. 参数列表

表示调用这个函数时,要给它哪些数据。

例如前面的成绩函数:

int total_score(int chinese, int math, int english)

意思就是:

  • 这个函数叫 total_score
  • 它需要 3 个整数,分别叫 chinesemathenglish
  • 它最后会返回一个整数,也就是总分。

如果把真实数字代进去,比如:

  • 语文 92 分
  • 数学 87 分
  • 英语 95 分

那么这个函数的任务就是算:


92 + 87 + 95 = 274

四、先看一个最简单的自定义函数

int total_score(int chinese, int math, int english)
{
    int sum = chinese + math + english;
    return sum;
}

这段代码的意思是:

  1. 接收 3 个整数:语文、数学、英语分数。
  2. 把这 3 个分数加起来,存到 sum 里。
  3. sum 作为结果交回去。

如果在主函数里这样写:

int chinese = 92;
int math = 87;
int english = 95;
int ans = total_score(chinese, math, english);

可以把它读成人话:

  1. 先准备好 3 个分数92、87、95。
  2. 调用 total_score(chinese, math, english)
  3. 函数把 3 个分数加起来,得到 274。
  4. 把 274 交回来。
  5. 最后 ans 得到 274。

用流程图走一遍会更清楚

flowchart TD
    A([开始]) --> B["主函数:准备 3 个分数\nchinese = 92, math = 87, english = 95"]
    B --> C["调用 total_score(92, 87, 95)"]
    C --> D["函数收到参数\nchinese = 92, math = 87, english = 95"]
    D --> E["函数内部计算\nsum = 92 + 87 + 95 = 274"]
    E --> F["return 274\n把答案交回主函数"]
    F --> G["主函数接住返回值\nans = 274"]
    G --> H[/输出 ans 得到 274/]
    H --> I([结束])

五、值传递到底是什么意思?

这是这一节最重要的基础概念。

看下面这组代码:

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。

所以:

  • scoremath 不是同一个变量。

你可以把它理解成:

  • 主程序把这个分数抄了一份给函数。
  • 函数只是在自己的小房间里处理这份副本。
  • 函数结束以后,主程序里的原变量不受影响。

用一组数据走一遍

math = 87 时:

  • 进入函数时,score = 87
  • 执行 score = score + 10 后,score = 97
  • 返回 97。

但主函数里的:

  • math 还是 87。

最后会变成:

  • math = 87
  • new_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 步:

  1. 调用函数。
  2. 把实参的值交给形参。
  3. 函数算完以后,把结果返回。

这里有两个名字要分清

  • 实参:调用时真正传进去的东西,比如 chinesemathenglish
  • 形参:函数定义里接收数据的变量,比如 chinesemathenglish 这 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. 一定要有“停止条件”。
  2. 每次递归都要朝着停止条件靠近。

如果没有停止条件,函数就会没完没了地继续调用。

如果虽然有停止条件,但每次都没有更接近它,那也一样会出问题。


十、先看一个最简单的递归函数

题目:求 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)
  • 一直等到最小那层先返回。

所以递归过程常常像两段:

  1. 一层一层往下走。
  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 - 1
  • n / 2

如果这两个问题答不出来,递归代码通常也写不稳。


十七、最常见的错误

下面这些错非常常见:

  1. 写了递归调用,却没有停止条件。
  2. 虽然写了停止条件,但永远到不了它。
  3. 只会往下套,不会分析怎么往回返回。
  4. 把“这一层的参数”和“下一层的参数”混成同一个东西。
  5. 以为函数里的参数改了,主函数里的变量也会跟着改。

第 5 条在这一节里尤其要避免,因为本节只讲值传递。


十八、这一节最该记住的几句话

  1. 函数就是把一段固定任务单独拿出来,起个名字反复用。
  2. 最基础的函数定义要看清:返回值类型、函数名、参数列表。
  3. 调用函数时,主程序是“发任务”,函数是“做任务”。
  4. 本节只讲值传递,所以函数拿到的是一份新的值,不会直接改掉外面的原变量。
  5. 递归就是函数调用自己。
  6. 递归必须同时具备两件事:停止条件、缩小问题。
  7. 读递归最稳的方法,是拿小数据手推,先看往下调用,再看往回返回。

十九、课堂练习

练习 1

写一个函数 int square(int x),返回 x * x

要求:

  • 先说清楚参数是什么。
  • 再说清楚返回值是什么。

练习 2

主函数中有 a = 6,调用 triple(a),其中函数内部把形参乘以 3。

问题:

  • 函数返回什么?
  • 主函数里的 a 会不会变?

练习 3

sum_to_n(5) 的递归调用过程手写出来,写清楚:

  • 往下调用到了哪一层。
  • 往回返回时每层结果是多少。

如果这 3 题都能自己说顺,这一节就算真正入门了。