本文参考《学习JavaScript数据结构与算法(第3版)》
什么是递归?
有一句编程的至理名言是这样的:
“要理解递归,首先要理解递归。”
递归是一种解决问题的方法,它从解决问题的各个小部分开始,直到解决最初的大问题。递归通常涉及函数调用自身。
递归是一种解决问题的方法,它从解决问题的各个小部分开始,直到解决最初的大问题。递归通常涉及函数调用自身。
function recursion(args) {
recursion(args)
}
假设现在执行 recursion,结果是什么?单就上述情况而言,它会一直执行下去。因此,每个递归函数都必须有基线条件,即一个不再递归调用的条件(停止点),以防止无限递归。
回到之前的编程至理名言,在理解了什么是递归之后,我们也就解决了最初的问题。如果我们把这句话翻译成JavaScript代码的话,可以写成下面这样。
function recursion(num) {
if(num === 0) return;
console.log(num);
recursion(num - 1);
}
recursion(4);
recursion 函数会不断地调用自身,直到 num===0。num===0就是上述代码的基线条件。
function recursion(args) {
recursion(args)
}
下面我们通过一些著名的递归算法来看看递归的本质。
案例1:计算一个数的阶乘
数n的阶乘,定义为n!,表示从1到n的整数的乘积。5的阶乘表示为5!,和5×4×3×2×1相等,结果是120。
迭代阶乘
如果尝试表示计算任意数n的阶乘的步骤,可以将步骤定义如下:(n) * (n -1) * (n-2) * (n -3) * ... * 1。
可以使用循环来写一个计算一个数阶乘的函数,如下所示。
function factorialIterative(number) {
if (number < 0) {
return undefined;
}
let total = 1;
for (let n = number; n > 1; n--) {
total = total * n;
}
return total;
}
console.log('factorialIterative(5): ', factorialIterative(5));
console.log('factorialIterative(3): ', factorialIterative(3));
我们可以从给定的number开始计算阶乘,并减少n,直到它的值为2,因为1的阶乘还是1,而且它已经被包含在total变量中了。零的阶乘也是1。负数的阶乘不会被计算。
递归阶乘
现在我们试着用递归来重写factorialIterative函数,但是首先使用递归的定义来定义所有的步骤。5的阶乘用5×4×3×2×1来计算。4(n -1)的阶乘用4×3×2×1来计算。计算n -1的阶乘是我们计算原始问题n!的一个子问题,因此可以像下面这样定义5的阶乘。
- factorial(5) = 5 * factorial(4):我们可以用5×4!来计算5!。
- factorial(5) = 5 * (4 * factorial(3)):我们需要计算子问题4!,它可以用4×3!来计算。
- factorial(5) = 5 * 4 * (3 * factorial(2)):我们需要计算子问题3!,它可以用3×2!来计算。
- factorial(5) = 5 * 4 * 3 * (2 * factorial(1)):我们需要计算子问题2!,它可以用2×1!来计算。
- factorial(5) = 5 * 4 * 3 * 2 * (1):我们需要计算子问题1!。
- factorial(1)或factorial(0)返回1。1!等于1。我们也可以说1! = 1×0,0!也等于1。
使用递归的factorial函数定义如下。
function factorial(n) {
if (n === 1 || n === 0) {
return 1;
}
return n * factorial(n - 1);
}
调用栈
每当一个函数被一个算法调用时,该函数会进入调用栈的顶部。当使用递归的时候,每个函数调用都会堆叠在调用栈的顶部,这是因为每个调用都可能依赖前一个调用的结果。
我们可以用浏览器看到调用栈的行为,如下图所示:
如果执行factorial(3),打开浏览器的开发者工具,打开Sources标签页,在Factorial.js文件中增加一个断点,当n的值为1时,我们可以看到Call Stack里有三个factorial函数的调用。如果继续执行,会看到当factorial(1)被返回后,CallStack开始弹出factorial的调用。
我们也可以在函数开头添加console.trace()来在浏览器的控制台中查看结果。
function factorial(n) {
console.trace();
if (n === 1 || n === 0) {
return 1;
}
return n * factorial(n - 1);
}
当factorial(3)被调用时,我们能在控制台中得到下面的结果。
factorial @ 02-Factorial.js:18
(anonymous) @ 02-Factorial.js:25 // console.log(factorial(3))调用
当factorial(2)被调用时,我们能在控制台中得到下面的结果。
factorial @ 02-Factorial.js:18
factorial @ 02-Factorial.js:22 // factorial(3)在等待factorial(2)
(anonymous) @ 02-Factorial.js:25 // console.log(factorial(3))调用
最后,当factorial(1)被调用时,我们能在控制台中得到下面的结果
factorial @ 02-Factorial.js:18
factorial @ 02-Factorial.js:22 // factorial(2)在等待factorial(1)
factorial @ 02-Factorial.js:22 // factorial(3)在等待factorial(2)
(anonymous) @ 02-Factorial.js:25 // console.log(factorial(3))调用
下图展示了执行的各个步骤和调用栈中的行为。
当factorial(1)返回1时,调用栈会开始弹出调用,返回结果,直到3 * factorial(2)被计算。
JavaScript调用栈大小的限制
如果忘记加上用以停止函数递归调用的基线条件,会发生什么呢?递归并不会无限地执行下去,浏览器会抛出错误,也就是所谓的栈溢出错误(stack overflowerror)。每个浏览器都有自己的上限,可用以下代码测试。
let i = 0;
function recursiveFn() {
i++;
recursiveFn();
}
try {
recursiveFn();
} catch (ex) {
console.log('i = ' + i + ' error: ' + ex);
}
在Chrome v65中,该函数执行了15662次,而后浏览器抛出错误RangeError:Maximum call stack size exceeded(超限错误:超过最大调用栈大小)。在Firefox v59中,该函数执行了188641次,然后浏览器抛出错误InternalError: toomuch recursion(内部错误:递归次数过多)。在Edge v41中,该函数执行了17654次。
案例2:斐波那契数列
斐波那契数列是另一个可以用递归解决的问题。它是一个由0、1、1、2、3、5、8、13、21、34等数组成的序列。数2由1+1得到,数3由1+2得到,数5由2+3得到,以此类推。斐波那契数列的定义如下。
- ❑ 位置0的斐波那契数是零。
- ❑ 1和2的斐波那契数是1。
- ❑ n(此处n > 2)的斐波那契数是(n -1)的斐波那契数加上(n -2)的斐波那契数。
迭代求斐波那契数
我们用迭代的方法实现了fibonacci函数,如下所示。
function fibonacciIterative(n) {
if (n < 1) return 0;
if (n <= 2) return 1;
let fibNMinus2 = 0;
let fibNMinus1 = 1;
let fibN = n;
for (let i = 2; i <= n; i++) { // n >= 2
fibN = fibNMinus1 + fibNMinus2; // f(n-1) + f(n-2)
fibNMinus2 = fibNMinus1;
fibNMinus1 = fibN;
}
return fibN;
}
递归求斐波那契数
function fibonacci(n){
if (n < 1) return 0; // {1}
if (n <= 2) return 1; // {2}
return fibonacci(n -1) + fibonacci(n -2); // {3}
}
如果我们试着寻找fibonacci(5),下面是调用情况的结果。
记忆化斐波那契数
还有第三种写fibonacci函数的方法,叫作记忆化。记忆化是一种保存前一个结果的值的优化技术,类似于缓存。如果我们分析在计算fibonacci(5)时的调用,会发现fibonacci(3)被计算了两次,因此可以将它的结果存储下来,这样当需要再次计算它的时候,我们就已经有它的结果了。
下面的代码展示了使用记忆化的fibonacci函数。
function fibonacciMemoization(n) {
const memo = [0, 1]; // {1}
const fibonacci = (n) => {
if (memo[n] ! = null) return memo[n]; // {2}
return memo[n] = fibonacci(n -1, memo) + fibonacci(n -2, memo); // {3}
};
return fibonacci;
}
为什么要用递归?它更快吗?
我们运行一个检测程序来测试本章三种不同的fibonacci函数。
迭代的版本比递归的版本快很多,所以这表示递归更慢。但是,再看看三个不同版本的代码。递归版本更容易理解,需要的代码通常也更少。另外,对一些算法来说,迭代的解法可能不可用,而且有了尾调用优化,递归的多余消耗甚至可能被消除。
所以,我们经常使用递归,因为用它来解决问题会更简单。
为什么学习递归???
因为我看不懂啊🤷♂️🤷♂️🤷♂️