本文将介绍递归的概念和简单的例子,希望能够帮助读者理解计算机中难以理解的递归。
1.什么是递归
通常我们写出很多函数之后,这些函数是用来提供给其他地方调用完成一些事情的。比如我们有函数gcd用来计算两个数的最小公约数。如果我们要计算三个数的最小公约数呢?我们可以写一个新的函数gcd2,假设三个数为a,b,c,可以调用gcd(a,b)得到a和b的最小公约数ab,再调用gcd(ab,c)得到三者的最小公约数。这种函数之间的调用是很常见的一种情况,也是模块化编程的一种体现。而递归就显得比较“奇葩”,并且它的代码也比较难看懂,因为它的特点就是:我调用我自己。
当你看到这样的代码时,你是否会懵逼呢?
int f(int n){
if(n == 0) return 0;
return n + f(n-1);
}
在函数f的里面竟然调用了函数f自己。要怎么理解这个代码呢?其实,代码在执行的时候,我们每调用一个新的函数,都会被记录起来,比如前面的例子,我们是先调用了gcd2,再调用了gcd,这个顺序其实系统是会帮我们记录保存在一个“栈”里面。当我们的主程序调用f(4)的时候会发生以下的情况:
一开始调用f(4)的时候,要返回给主程序的是 4 + f(3) ,但此时f(3)的值未知,需要继续调用f(3),f(3)要返回给f(4)的返回值是 3 + f(2),f(2)又不知道,继续调用f(2)。同样,f(2)返回 2 + f(1), f(1)返回 1 + f(0)。到了f(0)的时候,n == 0 的判断条件终于为真了,直接返回 0。f(1)收到了 f(0)的值后,计算完1+0后把结果交回给f(2),f(2)又计算完结果之后交给f(3),f(3)计算之后给f(4),f(4)计算出最终结果给我们的主程序。我们可以发现最终这个程序完成工作是“计算1加到n的和”。像这样一层一层的调用自身,其实就是递归,而代码中对n是否为0的判断,也叫做递归边界,使得递归能够及时停止。想一下,如果没有递归边界,在调用完f(0)之后,还会继续调用f(-1),f(-2),f(-3),并且永远不会停止,这个时候,由于系统保存调用层次信息的“栈”是有容量限制的,无限递归最终必定会导致“栈溢出”,也就是到达了系统能够存储的极限了,程序会报错退出。
2. 裴波那契数列
在计算机中有一个经典的斐波那契数列,数列的前两个数为1和1,之后每个数都是前两个数之和。现在要我们求出第10个裴波那契数。我们可以拿纸算一算就出来了,比如我现在直接在大脑里算出前10个为:1,1,2,3,5,8,13,21,34,55,因此第10个数就是55。然而,求第10个数这个问题也可以用递归来实现。要知道第10个数,我们首先要知道第8和第9个数,要知道第8个数,需要知道第6和第7个数……就这样一直往前反推,初始条件反而变成了退出条件。并且,为了能够反推,计算机利用一种称为“栈”的数据结构来存储递归过程中产生的数据。现在让你在大脑里模拟递归的过程,先是f(10),再是f(8)和f(9),要求f(8)需要f(6)和f(7),要求f(6)需要f(4)和f(5)……是不是多来几层你就晕了?这也是我们比较难以理解递归的一个原因,因为我们没办法完全模拟出递归中反推的过程。"递归的实现"难以理解,尤其是利用计算机语言实现递归。因为,实现是反过来的,不是让你从初始条件推,而是反过来一直推到初始条件,初始条件反而变成了退出条件。
初学者需要了解好递归这个概念,利用递归我们能够写出许多优雅的代码,但是像“计算1加到n的和”,或者是“计算裴波那契数列”这样的问题,完全可以用for循环写,甚至直接用公式就实现,速度反而会更快,毕竟系统帮我们保存调用过程的信息也需要时间和空间。