从斐波那契看尾递归

618 阅读3分钟

举个递归的🍖吧

# 从斐波那契数列说起
function fibonacci(n){
    if(n ===0) {
        return 0;
    }
    if(n ===1) {
        return 1;
    }
    return fibonacci(n - 1) + fibonacci(n - 2);
}

简单来说,在自己的内部调用自己就叫做递归

上面看起来是很完美的实现,看看这个

fibonacci(1000000)
//    RangeError: Maximum call stack size exceeded

对,意料之中的爆栈,那...

先来看看执行栈(call stack)📋

函数的执行即是一个入栈出栈的过程

function baz(){
    cosnole.log('top')
}

function bar(){
    baz()
}

function foo(){
    bar()
}
foo()


图中蓝色箭头指的就是栈顶位置,看到这大家应该对执行栈以及函数的执行过程有了直观的认识,在函数被调用时将执行入栈,未执行完毕的函数将在栈中保留,执行完毕将被依次出栈。


回到前面的爆栈问题,现在应该比较明朗了,函数调用过多,导致栈内函数数量超过浏览器的Maximum call stack size,引发报错。[贴一段测浏览器调用栈最大值的代码]

const i =0;
function testStackSize(){
    i++;
    testStackSize();
}

try {
    testStackSize();
} catch (e){
    console.error('Maximum call stack size is :',i)
}

为什么函数会被保留在栈中?我们来看看累加的执行过程,大概是这个样子

fibonacci(3)为例


原因就在于依赖,调用过程中,后面的函数是依赖于前一项函数的作用域的。那这里就可以把我们要说的尾递归拿出来。

尾递归优化的方式就是消除掉递归中函数之间的依赖来保持栈中始终只有一个函数

尾递归 and 尾调用

先来看看尾调用

function foo(){
    return goo();
}
√ 正确栗子

function foo(){
    const res = goo();
    return res;
}
🍵错误栗子

# foo函数的最后一步是调用goo即是尾调用

将goo换成foo就成了尾递归

斐波那契优化

前面我们说尾递归的关键点就在于隔离了函数之间的依赖,使得函数可以被从执行栈释放,那我们要做的其实就是将每一个函数中被依赖项以函数参数的形式进行透传,消除依赖即可。那我们现在来重写之前的斐波那契,来看看

function fibonacci(n){    //避免改变函数形参数量,对外只暴露一个参数
    function fibonacci_sub(total, prev, count){
        if (count === 0) {
            return total;
        }
        return fibonacci_sub(total + prev, total, count - 1);
    }
    return fibonacci_sub(1, 0, n);
}

由于函数执行依赖n-1到n-2对应的返回值,所以在这里我们用prev来进行一次值的保存,方便透传后进行相加

现在来看一下尾递归优化后的函数执行过程


PS:在使用了尾递归之后,很明显我们得到了内存维度上的优化,但其实我们还在原来的基础上避免了很多的重复计算,回到前面我们看到fibonacci(0),fibonacci(1)是被重复计算多次的,n越大重复次数越多。而用了尾递归后,fibonacci(0),fibonacci(1)就在第一次入参时被我们用0,1传了进去。省去大量重复计算,时间自然也得到优化。

# n = 20为例 n越大,时间差距将会越明显

尾递归: 0.213ms普通递归: 8.963ms

尾递归的支持

-->从es6兼容表看尾调用的支持情况,首行ptc即是我们要找的尾调用【满篇飘红😝】

v8其实是已经支持尾调用的,但默认不开启,出于调试不方便以及错误追踪时信息缺失的考虑,只有在严格模式下才会开启。