1.递归
递归是一种强大的方法,在计算机科学领域中,递归是通过递归函数来实现的,也就是用函数调用自身。
举例:编写一个程序使用递归求n的阶乘
int fac(int n){
if (n < 0){
return 0;
}
else if (n == 0){
return 1;
}
else if (n == 1){
return 1;
}
else
return n * fac(n-1);
}
int main(void){
int m; // 输入n
printf("输入数字m=");
scanf("%d", &m);
printf("%d的阶乘是%d\n",m,fac(m));
return 0;
}
这就是递归的方式,可以正式定义为:
递归的过程分为两个基本阶段:递推和回归。如下图所示:
在递推阶段,每一个递归调用通过进一步调用自己来记住这次递归过程。当其中有调用满足终止条件时,递推结束。比如,在计算n的阶乘时,终止条件是当n=1和n=0。每一个递归函数都必须拥有至少一个终止条件;否则,递推阶段将永远不会结束了。一旦递推阶段结束,处理过程就进入回归阶段,在这之前的函数调用以逆序的方式回归,直到最初调用的函数返回为止,此时递归过程结束。
为了理解递归究竟是如何工作的,有必要先看看C语言中函数的执行方式。为此,还需要了解一点关于C程序在内存中的组织方式。基本上来说一个可执行程序由4个区域组成:代码段、静态数据区、堆与栈。
- 代码段包含程序运行时所执行的机器指令。
- 静态数据区包含在程序生命周期内一直持久的数据,比如全局变量和静态局部变量。
- 堆包含程序运行时动态分配的存储空间,比如用malloc分配的内存。
- 栈包含函数调用的信息。
当程序调用一个函数时,栈中会分配一块空间来保存与这个调用相关的信息,每一个调用被当做是活跃的。栈上的那块存储空间为活跃记录,或者称为栈帧。栈帧由5个部分组成:输入参数、返回值空间、计算表达式时用到的临时存储空间、函数调用时保存的状态信息以及输出参数。
一个活跃记录中的输出参数就成为栈中下一个活跃记录的输入参数。函数调用产生的活跃记录将一直存在于栈中直到这个函数调用结束。例如,计算4!时栈中记录的情况:
初始调用fac会在栈中产生一个活跃记录,输入参数n=4;由于这个调用没有满足函数的终止条件,因此fac将继续以n=3为参数递归调用,这将在栈上创建另一个活跃记录,输入参数n=3;n=3也是第一个活跃期中的输出参数,因为正是在第一个活跃期内调用fac产生了第二个活跃期。这个过程将一直继续,直到n的值变为1,此时满足终止条件,fac将返回1。
一旦当n=1时的活跃期结束,n=2时的递归计算结果就是2×1=2,因而n=2时的活跃期结束,返回值为2。结果就是n=3时的递归计算结果表示为3×2=6…,直到函数已经从最初的调用中返回,递归过程结束。
栈是用来存储函数调用信息的绝好方案,这正是由于其后进先出的特点精确满足了函数调用和返回的顺序。然而,使用栈也有一些缺点。栈维护了每个函数调用的信息直到函数返回后才释放,这需要占用相当大的空间(开销变得很大)。幸运的是,可以采用尾递归的特殊递归方式来避免前面提到的这些缺点。
2.尾递归
一个函数中所有递归形式的调用都出现在函数的末尾。由于函数自身调用次数很多,递归层级很深,尾递归优化则使原本 O(n) 的调用栈空间只需要 O(1)。(空间复杂度)
以python为例,主要区分普通递归和尾递归对栈空间的使用:
def recsum(x):
if x == 1:
return x
else:
return x + recsum(x - 1)
调用recsum(5)为例,描述相应的栈空间变化:
recsum(5)
5 + recsum(4)
5 + (4 + recsum(3))
5 + (4 + (3 + recsum(2)))
5 + (4 + (3 + (2 + recsum(1))))
5 + (4 + (3 + (2 + 1)))
5 + (4 + (3 + 3))
5 + (4 + 6)
5 + 10
15
尾递归的方式
def tailrecsum(x, running_total=0):
if x == 0:
return running_total
else:
return tailrecsum(x - 1, running_total + x)
对内存的消耗
tailrecsum(5, 0)
tailrecsum(4, 5)
tailrecsum(3, 9)
tailrecsum(2, 12)
tailrecsum(1, 14)
tailrecsum(0, 15)
15
尾递归的实现,往往需要改写递归函数,确保最后一步只调用自身。做到这一点的方法,就是把所有用到的内部变量改写成函数的参数。比如上面的例子,阶乘函数 factorial 需要用到一个中间变量 total ,那就把这个中间变量改写成函数的参数。
这样做的缺点就是不太直观,第一眼很难看出来,为什么计算5的阶乘,需要传入两个参数5和1?
两个方法可以解决这个问题。方法一是在尾递归函数之外,再提供一个正常形式的函数。
function tailFactorial(n, total) {
if (n === 1) return total;
return tailFactorial(n - 1, n * total);
}
function factorial(n) {
return tailFactorial(n, 1);
}
factorial(5) // 120
函数式编程有一个概念,叫做柯里化(currying),意思是将多参数的函数转换成单参数的形式。这里也可以使用柯里化。