文章目录
通过数学问题全面理解递归函数
在最开始接触到 递归(recursion) 的时候,我整个人是懵逼的,几行简单的代码就可以把我绕晕。由于递归会引起 栈溢出(stack overflow) 等问题,我便以此为借口,在很长一段时间里刻意地去回避思考和应用递归函数。但是后来发现,递归在计算机科学中是一个十分重要的概念,很多算法和数据结构的设计都离不开递归。在一些情况下,递归函数是可以作为解决问题的手段,虽然存在着内存使用情况的不确定性,但是递归函数有着无可比拟的简洁度。
学习和理解递归,最好的办法就是动手尝试用递归去解决一些具体的问题。这篇文章将用一系列的数学问题来全面剖析递归。除了递归的基本概念,本文还包括了以下几个知识点:
- 使用 动态规划 优化递归
- 使用 循环 代替递归
- 使用 分治 优化递归
- 尾递归 的特殊性
- 递归和 回溯算法
在开始之前,我们先了解一下递归函数的核心功能:
- 将一个问题分解成有限小的子问题并解决。
接着我们要记住递归的两大要素:
- 结束条件(base case):不需要调用自身就能解决的最小子问题。
- 递归关系(recurrence relation):通过调用自身使问题更加接近于所定义的结束条件。
1. 阶乘(Factorial)
阶乘相信大家肯定很熟悉了,它的数学表达式是(这里 n n n为非负整数):
n ! = 1 × 2 × 3 × ⋯ × ( n − 1 ) × n n! = 1 \times 2 \times 3 \times \cdots \times (n-1) \times n n!=1×2×3×⋯×(n−1)×n
定义一个函数fac(n)
,让它的返回值等于 n ! n! n!。我们当然可以使用循环来轻易地实现这个函数。但是,如果想要用递归来解决这个问题的话,首先我们要找到上面所说的递归两大要素——结束条件 和 递归关系。我们可以用数学表达式将它们写出来:
n ! = { 1 if n ≤ 1 n ⋅ ( n − 1 ) ! otherwise n! = \begin{cases} 1 & \text{if } n \le 1 \\ n \cdot (n-1)! & \text{otherwise} \end{cases} n!={ 1n⋅(n−1)!if n≤1otherwise
大括号里的第一行就是我们要找的结束条件,也就是 0 ! = 1 0!=1 0!=1和 1 ! = 1 1!=1 1!=1。第二行是递归关系,我们将 n ! n! n!这个问题拆解成了一个乘法和 ( n − 1 ) ! (n-1)! (n−1)!;而 ( n − 1 ) ! (n-1)! (n−1)!可以继续拆解,直到满足结束条件。把这个式子写成代码就是:
unsigned fac(unsigned n) {
if (n <= 1) {
return 1; } // 结束条件
return n * fac(n - 1); // 递归关系
}
1.1. 递归调用(Recursive Calls)
这个函数具体是怎么运作的呢?我们可以举一个例子,假设我们在主函数中调用了fac(3)
,那么我们可以把接下来的递归调用画成下图。从图中就可以看出如何通过递归关系将fac(3)
分解成结束条件的。
1.2. 递归和栈
除了理解递归调用,我们还必须知道递归是怎么使用计算机内存的。任何函数的调用都会占用内存中 栈(stack) 的一部分,用来储存函数的局部变量和参数(比如该函数中的n
),而且在函数返回之前,被占用的栈是不会被释放的。在上面这个例子中,在进程运行到fac(1)
时,栈的使用情况如下(注意:栈是向下增长的):
| +------------+
| | main() |
| +------------+
Grow | fac(3) |
Downward +------------+
| | fac(2) |
| +------------+
| | fac(1) |
| +------------+
V | .... |
所以不管是主函数,还是fac(n)
,都在调用时有着自己的 栈帧(stack frame)。由此可见,如果n
是一个非常大的值,递归调用需要使用大量的栈空间,而内存不是无限的,这就会引起 栈溢出,导致进程被操作系统强制中断。
2. 斐波那契数列(Fibonacci Sequence)
通过阶乘了解了递归的基本概念之后,我们可以看一些其他例子。斐波那契数列是最经典的例子之一,几乎所有的教课书都会用它来介绍递归。
斐波那契数列是这样一个数列:0,1,1,2,3,5,8,13,21…,从第二项开始,每一项为前两项的和,求第n项的值。
它的表达式是:
F n = { n if n ≤ 1 F n − 1 + F n − 2 otherwise F_{n} = \begin{cases} n & \text{if } n \le 1 \\ F_{n-1} + F_{n-2} & \text{otherwise} \end{cases} Fn={ nFn−1+Fn−2if n≤1otherwise
式子中直接就包含了结束条件和递归关系,所以我们可以很容易地写出代码:
int fib(int n) {
if (n <= 1) {
return n; } // 结束条件
return fib(n - 1) + fib(n - 2); // 递归关系
}
2.1. 递归调用树
函数中如果出现超过一次的递归调用,很多人就会觉得不好理解。我们可以画出递归调用树来帮助我们理解,这里我们用fib(4)
举例: