<audio id="audio" title="12 | 数学归纳法：搞定循环与递归的钥匙" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/92/29/9238c982114243a9eb9c437d4264dd29.mp3"></audio>

你好，我是胡光，今天我们正式开始“编码能力训练篇”的学习。

这里给你一个建议，在刚刚完成了语言基础篇的学习后，我希望你用心地体验“螺旋式上升”的学习过程。就是前面的基础篇虽然学完了，可并不是意味着，不需要再学习更多的语言相关的东西了，你可以做如下两件事情：

1. 对于语言基础，你可以选择学习第二遍，当你站在第一遍的基础上，再回头看的时候，肯定会对之前的知识有更深的理解；
1. 选择在其他参考资料中，继续学习语言中更多的知识点。你会发现，某些之前自己认为晦涩难懂的东西，可以自学搞明白了，这就是我提到的“螺旋式上升”的学习方法。

在接下来的“编码能力训练篇”里，我将着重给你讲解一些编程中的重要技巧。今天呢，我们就从理解循环与递归的编码技巧开始吧！

## 今日任务

循环结构，你已经不陌生了，如下代码所示，是一个单层循环的程序，依次地输出从 1 到 n 的每一个数字，每个数字占一行：

```
#include &lt;stdio.h&gt;

int main() {
    int n;
    scanf(&quot;%d&quot;, &amp;n);
    for (int i = 1; i &lt;= n; i++) {
        printf(&quot;%d\n&quot;, i);
    }
    return 0;
}

```

当我们输入 4 的时候，程序的输出结果如下所示：

```
1
2
3
4

```

上面这个是单层循环的情况。下面这个例子，是一个双层循环的例子，每层循环都从 1 循环到 n，循环内部每次输出两个循环遍历的值：

```
#include &lt;stdio.h&gt;

int main() {
    int n;
    scanf(&quot;%d&quot;, &amp;n);
    for (int i = 1; i &lt;= n; i++) {
        for (int j = 1; j &lt;= n; j++) {
            printf(&quot;%d %d\n&quot;, i, j);
        }
    }
    return 0;
}

```

当我们输入 3 的时候，程序的输出结果如下所示：

```
1 1
1 2
1 3
2 1
2 2
2 3
3 1
3 2
3 3

```

看了上面单层循环和双层循环的例子以后，如果让你改写成类似的三层循环的程序，想必这个你一定会做，无非就是在两层循环的内部，多加一层循环，然后 printf 输出的时候，输出的是三个变量的值即可。如果你可以自己理解到这个程序，那么你就可以理解今天这个任务。

今天这个任务，和上面的例子类似，但它不是实现一层循环的程序，也不是实现三层循环的程序，而是实现一个 k 层循环的程序。什么意思呢？就是 k 是一个读入参数，之后再读入一个参数 n，含义和上述程序中的 n 一致，而这个程序的输出结果，与上述例子中的输出结果类似，只不过每行输出 k 个数字。

简单来说，你要实现的是一个可变循环层数的程序。这下你清楚今天的任务了吧？那么我们正式开始学习吧。

## 必知必会，查缺补漏

理解了上面这个任务要做什么了，你可能还会发懵：为什么循环层数是可变的，代码结构不是确定性的么？别着急，今天我们将学习一个重要的编程技巧，那就是递归。

这里我要提醒一下，**递归是一种编程技巧**。你可能会在某些资料中，看到递归算法这种说法，其实这种说法是不合适的，因为明显的事实是，能够用循环实现的算法，都可以用递归这种编程技巧实现。如果递归算作算法，那你听过循环算法一说么？所以，用一个编程技巧，给一类算法命名，实际是不合适的。

#### 1. 温故知新：数学归纳法

你知道么，计算机的本质，是一个用来计算的工具，它最开始就是帮助我们完成一些现实世界里面的计算任务，并且完成的又快又好。那么现实世界的问题，是如何转换成可以在计算机中计算的任务呢？这个转换的过程中，都有哪些必不可少的东西呢？请看下图：<br>
<img src="https://static001.geekbang.org/resource/image/65/9b/65c32d9a5d416d8e8c65783ae59d4a9b.jpg" alt="" title="图1:从现实问题到可计算任务">

在这幅图中，我们把转换过程分成四个部分：“现实世界”“数学”“算法”和“计算机”。这四个部分形成了一个路线，也就是从现实世界中的实际问题，到计算机中的可计算任务的过程。

我稍微来详细解释一下这幅图所表达的含义。首先我们来想想，如果没有数学，现实生活中我们会遇到什么困难？我会毫不夸张地告诉你，可能会面临生存危机。试想一下，因为没有数学，我们不会计算每日食物的消耗，无法合理分配资源，导致食物匮乏，引发生存危机。这也是为什么人类最早的文字记录，或者说是信息传递，用的是结绳记事，以“算术”的形式来解决现实世界问题。可以说，现实世界中的问题，本质是可以计算的，也就是说实际问题都可以做数学建模。

然后，我们说说算法。算法是将数学问题，转换到计算机中的计算任务的桥梁。因为计算机是依靠指令序列来执行的，而不同的指令序列代表了不同的效率，不同的效率在很多时候就意味着可行或者不可行。试想一个数学抽象出来的公式，需要计算机运算1000年才能得出结果，你认为这种任务可以放到计算机上面做么？答案显然是否定的。算法就是使得计算任务变得更高效，更可行。

至此，你就对我所说的内容，有个大致的体会了：计算机的核心是算法，算法的核心是数学。接下来呢，我们就需要介绍一种，可以指导我们进行程序设计的数学方法：数学归纳法。

高中的时候，我们就接触过数学归纳法，你可能已经对这个概念了然于胸，不过我们还是来回顾一下数学归纳法证明过程中重要的三步骤。<br>
<img src="https://static001.geekbang.org/resource/image/d6/c3/d6624009d55447e273fc58a8799afbc3.jpg" alt="" title="图2:数学归纳法的三个步骤">

其实数学归纳法的三个步骤，总结起来就是，有一个已知正确的初始状态，然后证明如果前一个状态成立，那么后一个状态也成立（这一步主要在做过程正确性的证明），最后就是得出结论，在这个初识状态和转移过程的正确保证下，所有问题中的状态都成立。

举个例子，便于你更好地理解。假设我们要利用数学归纳法来证明：如果我推倒了第一块多米诺骨牌，那么所有的多米诺骨牌都会倒下。那么放到这三个步骤里，就是：

- 第一步，验证边界条件，第一块多米诺骨牌倒下了。
- 第二步，就是假设，第 n 块倒下了，根据多米诺骨牌的结构性质，那么如果存在 n + 1 块，第 n + 1 块也一定会倒下。
- 第三步，得出结论，只要第一块倒了，所有的多米诺骨牌都会倒下。

注意，上面说的这个是广义层面数学归纳法，这个过程对于循环过程的正确性证明，是非常有效的。

想一想，进入循环之前的程序中关键变量的值，就是上面所说的第一步中的 k<sub>0</sub>；而每一次的循环，其实就是第二步中所要证明的那个上一个状态到下一个状态的过程。如果这两者都正确，我们就能很确信地知道，我们的整个循环过程就是正确的。

关于上面说的数学归纳法和循环程序之间的这一点联系，在日后的学习中，我还会详细地去举例说明，尤其是到了后续，我们学到了递推算法和动态规划算法的时候，会尤为明显。所以你要有足够的耐心和信心，咱们一起把这些问题搞懂。

#### 2. 深入浅出：理解递归函数

放在编程的语境中，什么是递归呢？我这里先强调一句：递归是一种编程技巧。

你学完了函数以后，已经可以熟练地掌握在一个函数中，调用另外一个函数的方法了。可你有没有想过，如果在某个函数内部，调用自己同名函数过程，会发生什么？其实，和普通的函数调用过程一样，在具体执行过程中，只有等内部调用的函数执行完后，本层函数才会继续执行。

递归是一个过程，这个过程的每一步都类似，只是面对的问题规模不同。

下面我来举个例子：假如今年我上小学5年级，我现在想知道1～5年级的年级主任名字，但我现在只知道5年级的年级主任的名字，我可能会问一个4年级的学弟，希望他能告诉我1～4年级主任的姓名。

我这个学弟呢，也只知道他们年级主任的名字，那么我这个学弟就会问3年级学弟，问他3年级及以下的年级主任都有谁，依次类推，最后到了1年级的小学弟。

1年级的小学弟，就会告诉2年级的学长自己年级主任的名字，2年级的学长拿到1年级的年级主任的名字以后，会把2年级年级主任的名字填上去，然后再交给3年级的他学长……这样最终到我手里的就会是1～4年级的年级主任的所有名字，再加上我自己知道的5年级的年级主任姓名，这样，我就知道了全部信息。整个过程，如下图所示：<br>
<img src="https://static001.geekbang.org/resource/image/71/5a/713f6589e7b8eb51c8af82ddc1efa65a.jpg" alt="" title="图3:年级主任问题示意图">

在这个过程中，每个人问学弟的过程，就是我们所谓的“递”，而拿到学弟给的结果名单以后，再加上自己知道的结果反馈给自己学长的这个过程，就是“归”，整个过程就是我们所谓的“递归”。“递归”的过程，每一步的过程类似，可是问题规模不同。

接下来，我来举一个编程中的具体递归例子，看如下代码：

```
#include &lt;stdio.h&gt;

int f(int n) {
    if (n == 1) return 1;
    return f(n - 1) * n;
}

int main() {
    int n;
    scanf(&quot;%d&quot;, &amp;n);
    printf(&quot;%d\n&quot;, f(n));
    return 0;
}

```

这段代码中，f 函数的作用，是计算 n 的阶乘的值，也就是从 1 乘到 n 的结果。在 f 函数内部，首先是一个边界条件，就是当 n == 1 的时候，直接返回 1 的阶乘的结果。否则，n 的阶乘的结果，应该等于 n - 1 阶乘的结果再乘上 n ，就得到了 n 的阶乘。在得到 n - 1 阶乘结果的过程中，我们调用的不是别的函数，还是 f 函数本身，只不过传入的参数范围，是一个比 n 更小的范围 n - 1。

关于这个 f 函数，类比于上面年级主任的那个例子，f(n) 就是我整理的信息，f(n - 1)就是比我要小 1 个年级的学弟所整理得到的信息，而 n == 1 的边界条件判断，就是我那个最小的 1 年级的学弟。最后 f(n - 1) * n 当中的 * n 这个过程，就相当于每个人拿到了学弟整理的信息以后，再加上自己知道的信息，最后递交给自己的学长。

为什么这么做，能保证每个人所得到的信息都是正确的呢？在证明这个过程的时候，我们就需要用到前面提到的数学归纳法了。首先，我们知道 1 年级的学弟肯定能给出正确的信息，这就是数学归纳法中的边界条件。然后我们假设，如果上一个学弟，给出的信息是正确的，那么我所整理出来的信息，就一定是正确的，这就是数学归纳法中的证明过程的正确性。最终，我们就可以得到结论，在这个过程中，所有人获得的信息都是正确的，包括我自己。

其实，到了这里，我们也就得到了递归程序设计中的重要的两部分：**边界条件**和**处理过程**。

- 所谓边界条件，就是当递归函数中的参数等于多少的时候，可以直接返回的条件。
- 处理过程呢，就是设计程序过程，处理递归调用的返回结果，根据递归调用的返回结果，得到本函数的结果。

这两部分，分别对应了数学归纳法中的两步，step1和step2。当这两步都可以保证正确，所涉及的递归函数程序，也绝对是正确的。

## 一起动手，搞事情

今天的思考题呢，是关于一段递归程序的：

```
#include &lt;stdio.h&gt;

int fib(int n) {
    if (n == 1 || n == 2) return 1;
    return fib(n - 1) + fib(n - 2);  
}

int main() {
    int n;
    scanf(&quot;%d&quot;, &amp;n);
    printf(&quot;%d\n&quot;, fib(n));
    return 0;
}

```

上面这段程序中，fib 函数是求菲波那契数列第 n 项值的函数。菲波那契数列的定义如下：<br>
<img src="https://static001.geekbang.org/resource/image/fa/9a/faa57fedb330f6c3fa27c22aac2f739a.jpg" alt="" title="图4:斐波那契数列">

根据如上内容，你需要完成两个小的思考题：

1. 请将上述菲波那契数列求解的程序从递归程序，改成循环程序。
1. 请将上述递归程序的代码和数学归纳法中的步骤做一一对应，留在留言区中。

## 完成不定层数的循环程序

准备完了基础知识以后，让我们回到今天的任务，完成一个可变循环层数的程序。我们可以一开始假设，有一个函数，是实现 5 层循环打印的程序，那么它会循环 n 次，每次调用一个实现 4 层循环打印的程序。

依照这个大体的思路，我们就可以写出如下代码框架：

```
int print_loop(int k, int n) {
    if (k == 0) {
        // 打印一行
    }
    for (int i = 1; i &lt;= n; i++) {
        print_loop(k - 1, n);
    }
    return;
}

```

在这个代码框架中，我们先来看递归的过程，print_loop(k, n)代表 k 层循环的程序，然后循环 n 次，每次调用一个 k - 1 层循环的程序。而递归的边界条件就是当 k == 0 的时候，就是所谓的 0 层循环，也就是程序打印一行具体内容的地方，可打印的这行内容究竟是什么呢？

你会发现，要打印的这行内容，与每层循环遍历到的数字有关系，那么我们就需要记录每层循环遍历到的数字。这个信息，我们可以记录在一个数组中，数组中存储的，就是当前要打印这行的每一个数字。基于上述代码框架，我们就可以得到下面这个更完善的代码：

```
int arr[100];
void print_loop(int k, int n, int total_k) {
    if (k == 0) {
        for (int i = total_k; i &gt;= 1; i--) {
            if (i != total_k) printf(&quot; &quot;);
            printf(&quot;%d&quot;, arr[i]);
        }
        printf(&quot;\n&quot;);
        return ;
    }
    for (int i = 1; i &lt;= n; i++) {
        arr[k] = i;
        print_loop(k - 1, n, total_k);
    }
    return ;
}

```

正如你看到的，我们把每一层循环的值，放到了一个 arr 数组中，第 k 层循环变量的值，存储到 arr[k] 的位置。而在上述代码中，多了一个递归参数，就是 total_k，代表了一共有多少层循环，这个参数是为了方便我们最后确定循环输出的上界。至此，我们就完成了今天的任务。

## 课程小结

今天的重点，一个关于数学归纳法，一个关于递归，需要你记住如下两点：

1. 数学归纳法中重要的两部分，一是要边界条件成立，二是证明转移过程成立。
1. 程序设计最重要的是正确性，递归函数的正确性可以利用数学归纳法来保证。

关于数学归纳法和递归函数的设计，还需要你在日后不断的加以练习。注意总结两者的联系，能够使得你在接下来的学习中事半功倍。

好了，今天就讲到这里，我是胡光，我们下期见。
