携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第3天,点击查看活动详情 >>
一、递归
1. 递归的原理
首先要知道函数调用的本质,其实就是重复压栈与出栈的操作,而函数在调用栈里边有一个特例,叫做递归:自己调用自己。
递归被调用时,会产生递归栈,而这个递归栈也严格遵循着 LIFO(后进先出,先进后出)。
const bar = () => {
// 递归调用时如果没有出栈条件,就会造成栈溢出。
// 报错:Maximum call stack size exceeded
bar()
}
bar()
// 使用递归求阶乘
// 阶乘:1 到 n 的连续自然数相乘的积:1 * 2 * 3 * n: 5 != 5*4*3*2*1
const factor = n => {
// 从传进来的数开始一直乘,直到 1 停止
if(n == 1){
return 1
}
// 递归调用自身
return n * factor(n -1)
}
// 求 10 的阶乘,输出:3628800
console.log(factor(10))
2. 使用递归求裴波那契数列
裴波那契数列定义:从第三项开始,每一项都等于前两项的和(也叫黄金分割数列)。
公式:f(n) = f(n-1) + f(n-2)
const fibonaccis = n => {
// 数列小于等于 1 时退出递归
if(n <= 1){
return 1
}
// 递归调用
return fibonaccis(n-1) + fibonaccis(n -2)
}
// 输出:8
console.log(fibonaccis(5))
3. 调用帧是什么?
- 函数在被使用时会被压入执行栈中,当使用完成后便会被移出调用栈。而函数在被压入栈到使用被移出调用栈的过程便是一个调用帧。
- 指在函数内部调用时形成的一个调用记录。例如 a 调用 b,b 调用 c,那么此刻就会形成一个 a-b-c 的调用帧记录,其中 c 会最先执行,a 最后。
**Tips:**使用 console.trace() 可以快速查看当前函数的调用帧。
4. 尾调用
尾调用指的是函数的最后一步调用另一个函数。代码执行是基于执行栈的,所以当在一个函数里调用另一个函数时,会保留当前的执行上下文,然后再新建另外一个执行上下文加入栈中。
使用尾调用的话,因为已经是函数的最后一步,所以这时可以不必再保留当前的执行上下文,从而节省了内存,这就是尾调用优化。但是 ES6 的尾调用优化只在严格模式下开启,正常模式是无效的。
简单的说,就是一个函数执行的最后一步是将另外一个函数调用并返回。
function inner(x){
console.log(x)
}
function outer() {
let m = 1
let n = 2
return (inner(m + n))
}
// 1. 调用 outer()
outer()
// 2. 等同于调用
function outer(){
return inner(2)
}
// 3. 所以调用 outer()
outer()
// 4. 等同于调用
inner(3)
// 5. 调用 inner(3) 等同于
console.log(3)
而尾调用自身就是尾递归,只存在一个调用者。
function a(x){
console.log(x)
}
function b(x){
return a(x)
}
b(1)
二、尾递归
-
使用尾递归求累和。
// 正常递归调用 const sum = (n) => { if (n <= 1) return n; return n + sum(n-1) } // 输出:5 sum(5) // 报错,输入的数字超过五位就会栈溢出 sum(12321)
// 使用尾递归 const sum = (n, prevSum = 0) => { if (n <= 1) return n + prevSum; return sum(n-1, n + prevSum) }
// 输出:5 sum(5) // 还是会报错,输入的数字超过五位就会栈溢出 sum(12321)
1. 使用 Trampoline 优化尾递归
其实就是将递归改成循环。内部通过 while 循环一直判断递归函数有无得出结果,如果没有得出结果则一直循环调用下去。直到得到结果则直接将结果返回出去。
// 累加方法
// n 起始数字
// prevSum 累加结果
const sum = (n, prevSum = 0) => {
// 出栈条件:n 小于等于 1 时,返回累加结果
if (n <= 1) return n + prevSum;
// 尾递归调用自身进行相加
return () => sum(n-1, n + prevSum)
}
// trampoline 方法
// f 传入的递归函数
// (...args) 这个递归函数的参数
const trampoline = f => (...args) => {
// 调用递归
let result = f(...args);
// 判断计算结果是否是函数来得知是否还要继续递归调用下去
while (typeof result === 'function') {
// 是函数,则代表函数需要继续递归调用
result = result();
}
// 不是函数,代表已经计算出结果,并直接将结果返回出去
return result;
}
// 调用 trampoline
const sg = trampoline(sum(100000));
// 输出:500000500000
console.log(sg(1000000));
另一种写法,使用普通函数实现,在调用时使用 bind 返回一个函数。
const sum = (n, prevSum = 0) => {
if (n <= 1) return n + prevSum;
return () => sum(n-1, n + prevSum)
}
function trampoline(f){
// 调用传进来的递归函数
let func = f()
// 判断返回结果是不是函数
while(typeof func === 'function'){
// 继续递归
func = func()
}
// 直接返回计算结果
return func
}
// 输出:500000500000
console.log(trampoline(sum.bind(null, 100000)))
2. 其它尾递归示例
-
尾递归乘阶
const factors = (n, total) => { if(n == 1){ return total } return factors(n-1, n * total) } // 输出:3628800 console.log(factors(10, 1))
-
尾递归裴波那契:
把前面两位数,当做参数传进来。
const fibonaccis = (n, ac1 = 1, ac2 = 1) => {
if(n <= 1){
return ac2
}
return fibonaccis(n - 1, ac2, ac1 + ac2)
}
// 输出:8
console.log(fibonaccis(5))
三、递归与尾递归区别
- 调用栈区别:
-
递归需要同时保存成百上千个调用帧,很容易就会发生栈溢出。
-
而尾递归只存在一个调用栈,所以永远不会发生栈溢出错误。
- 调用帧区别:
- 递归非常耗费内存,因为它需要同时保存很多个调用帧。
- 而尾递归它只存在一个调用帧,所以它是不会发生栈溢出错误的。
- 使用区别:
- 递归是对函数的结果进行计算。
- 尾递归是在函数内进行计算。尾递归的参数会保存临时的计算结果,在下次调用时不需要在函数上做任何额外的计算。