递归
何为递归?
递归定义是数理逻辑和计算机科学用到的一种定义方式,使用被定义对象的自身来为其下定义(简单说就是自我复制的定义)
递归,就是在运行的过程中不断地调用自己
a. 递归有两个过程,简单地说一个是递的过程,一个是归的过程
b. 如果内部没有结束条件,就会形成一个死循环
递归经典案例:阶乘、自然数、斐波那契数和康托尔三元集,我们来看一个阶乘的案例:
先了解一下什么是阶乘:
我们看看阶乘的两种实现方式
1. 迭代(循环)
function factorial(n) {
let product = 1
for (let i = 0; i <= n; i++) {
product *= i
}
return product
}
factorial(5) // 120
2. 递归
function factorial(n) {
if (n <= 1) return 1
return n * factorial(n - 1)
}
factorial(5) // 120
分析一下,递归运行过程中,执行上下文栈是如何变化的
step1: 函数执行前,上下文栈已经存在一个全局上下文
step2: 开始执行函数,js引擎创建 factorial 的执行上下文,将该执行上下文压(push)入栈中,然后开始执行函数中的代码 - 递的过程
step3: 执行到末尾时,再次调用 factorial,重复step2
step4: 一直到 n <= 1 函数有了返回值 1 才不会产生新的执行上下文,此时栈中已存在6个执行上下文
step5: 执行上下文栈开始出(pop)栈,返回结果 - 归的过程
我们可以借助图片更好的理解这个过程:
到这我们可以设想一下,如果n为100、1000、10000时上下文栈中有几个上下文?
对于上下文栈来说,水满则溢,它是有一定容量限制的,当超出了该容量限制,就会发生栈溢出:
在不同的浏览器中,上下文栈的空间限制不同,对函数的递归次数限制也不同
优点:代码简洁
缺点:
1、时间和空间的消耗比较大:每一次函数调用,都需要在内存栈中分配空间以保存参数、返回值和临时变量,往栈中压入和弹出数据也都需要时间
2、重复计算:递归的本质时把一个问题分解成两个或多个问题,多个问题存在重叠的部分,即存在重复计算
3、栈溢出风险
尾递归
通过上面的递归学习,我们不难知道 factorial(n) 是依赖于 factorial(n-1) 的,factorial(n) 只有在得到 factorial(n-1) 的结果之后,才能计算它自己的返回值,因此理论上,在 factorial(n-1) 返回之前,factorial(n),不能结束返回
尾递归的作用就是消除这个限制,也就是对递归的优化,防止爆栈
何为尾递归?
1、每一个函数在调用下一个函数之前,可以将当前自己占用的栈给释放掉,在调用链上可以做到只有一个函数在使用栈,只存在一个调用帧,所以永远不会发生栈溢出
2、简单来说,就是当前的调用先计算出程序的部分结果,在下一次递归调用时将该结果作为参数传递,那么到了最后一次调用时,就能得到程序的最终结果
3、复杂度O(1)
如果我们把递归调用看作一条链:
递归是链头通过逐次调用自身到达链尾,又由链尾开始逐层计算并向上返回,最终从链头获取最终结果。
尾递归则由链头开始逐层计算部分结果,并向下传递,在链尾获得最终结果。
阶乘尾递归实现
// 1.
// 尾递归的实现,往往需要改写递归函数,确保最后一步只调用自身
// 要做到这一点,需要把函数内部所有用到的中间变量改写为函数的参数
// 缺点:语义不明显
function factorial(n, total) {
if (n === 1) {
console.trace() // 打印堆栈跟踪消息
return total
}
return factorial(n - 1, n * total)
}
factorial(5, 1) // 120
// 2.
// 用一个符合语义的函数去调用改写后的尾递归函数
function factorial(n){
return inner(n, n - 1)
}
function inner(product, n){
console.log(inner.caller) // 返回调用当前函数的那个函数
if(n <= 1){
return product
}
return inner(product * n , n - 1);
}
factorial(5) // 120
注意:
我们使用了尾递归后(下图为 chrome 浏览器),发现调用栈还是会压入多个执行上下文,这是为什么呢?(注:栈最底部的 anonymous,是全局的函数入口)
原因
1、目前只有Safari浏览器支持尾递归优化,Chrome和Fiefox都不支持
2、ES6的尾递归优化只在严格模式(strict mode)下开启,正常模式下是无效的(因为在 正常模式下,函数内部有两个变量,可以跟踪函数的调用栈)
注:
- arguments:返回调用时函数的参数。
- func.caller:返回调用当前函数的那个函数
尾调用优化发生时,函数的调用栈会改写,上面两个变量就会失真。严格模式禁用这两个变量,所以尾调用模式仅在严格模式下生效
总结
- 直接递归需慎用,不仅会带来极差的运行效率,还有可能导致浏览器崩溃
- 尾递归有着与循环同样优秀的计算性能,以及递归的数学表达能力