JS尾递归优化

目录

1. 写在前面

2. 递归

3. 递归导致的问题

4. 尾递归

5. 如何快速的发现尾递归的思路

6. 实战演练


1. 写在前面

本文适合对JS基础有较好的理解的基础上阅读体验最佳,若对JS基础没太搞明白的也难会有点晦涩难懂。不知道什么是尾递归的请去看我上一篇对于尾调用的系统讲解。

2. 递归

函数的递归就是在函数中调用自身,递归在我理解有点像数学中的数学归纳法,数学归纳法的原理在于,不停的利用已知的假设进行推导;而函数的递归同理,不停的调用自身。

先上一个小例子,让大家感受一下递归:

// 求1 + 2 + 3 + ... + n 的和
function sum(n) {
    if (n == 1) return 1
    return n + sum(n-1)
}

3. 递归导致的问题

递归用于解决某些问题,比如深层遍历等问题很有效,但是用不好很容易导致栈溢出错误(stack overflow),就算不发生栈溢出,使用不善则会导致严重的性能问题。

我们知道,函数调用会在内存形成一个“调用记录”,又称为“调用帧”(call frame),保存调用位置和内部变量等信息。递归导致的一系列嵌套函数的调用,会产生一系列的调用帧,所有的调用帧就形成了一个“调用栈”(call stack)。调用帧是保存在内存中的,当调用帧足够多的时候,就会出现栈溢出错误。

首先,我们来看看阶乘的递归函数:

// 仅限非严格模式
function factorial(n) {
    if (n <= 1) return 1
    return n * arguments.callee(n-1)
}

// 严格模式和非严格莫是都可用
var  factorial = (function f(n){
    if (n == 1) return 1
    return n * f(n-1)
})

递归非常消耗性能,因为需要同时保存成百上千个调用帧,很容易发生栈溢出错误。

像阶乘这样的递归,我们同学们一般会写成像上述这样,那么当我们来计算足够大的数的阶乘时,就会在内存中保存大量的函数调用帧,不但会造成非常差的性能问题,还有可能会出现栈溢出错误。

总结来说,递归有可能导致的问题主要有两个:比较差的性能问题和栈溢出错误

4. 尾递归

什么是尾递归?如果函数尾调用自身就成为尾递归。

上述的例子,显然不属于尾递归,很明显可以看出,return 后面的语句并不只是函数的调用,还有乘法操作,故不属于尾递归。

尾递归可以保证函数执行时内存中始终只保留一个调用帧,这将永远不会发生栈溢出错误,也不会造成差的性能问题。

那么,我们如何对上述的阶乘函数进行优化让其变为尾递归呢?请看

function factorial(n, a1) {
    if (n == 1) return a1
    return factorial(n-1, a1 * n)
}

如此,我们成功的将阶乘函数通过改造变为了尾递归。

5. 如何快速的发现尾递归的思路

记得,笔者在学生阶段学习数学的时候,最喜欢数学中的数学归纳法,笔者作为一名比钢铁还直的理工男在学生阶段最喜欢的学科也是数学。数学归纳法对我的人生产生了很重要的影响,回忆往昔......

扯远了,咱们言归正传,如何快速而又准确的找到尾递归的思路,进而快速而又正确的对递归函数进行尾递归优化。笔者经过科学的推敲和多年反复的验证,发现了一美妙的思路,讲给大家听(笔者是很喜欢分享的)。

首先,我们要找到实现尾递归的思路,我们先把上述的例子前后两个函数拿来对比一下:

// 尾递归之前
function factorial(n) {
    if (n == 1) return 1
    return n * factorial(n-1)
}

// 尾递归之后
function factorial(n, total) {
    if (n == 1) return total
    return factorial(n-1, n * total)
}

如何将优化之前的return 之后的表达式变成只有函数的调用而不包含其它额外的操作呢?我们仔细观察第一个函数的return 后的表达式:n * factorial(n-1),由此可知某一项的计算结果factorial(n)依赖于上一次计算的结果factorial(n-1),我们最后返回的是factorial(n-1, total * n),这是一个函数调用的结果,我们就把函数返回的结果记为total,那么不妨在函数factorial加上第二个参数total,即把函数此次调用返回的结果当作第二个参数,那么第一行和第二行就变成了下面这样

function factorial(n, total) {
    if (n == 1) return total

难点就在第三行怎么变,接着,我们再来观察第一个函数的return 后面的表达式:n * factorial(n-1),此时函数factorial已经变了,其拥有两个参数,所以,我们给return后面的调用表达式加上第二个参数,首先第一个参数不变,还是n-1,第二个参数为何是n * total呢?大家想一想factorial(n-1)此次的调用结果是什么呢?举个最简单的例子,当n = 2时,那么大家想想是啥?很容易想到是 2 * 1 = n * total了,所以,优化之后就变成了下面这样:

function factorial(n, total = 1) {
    if (n == 1) return total
    return factorial(n-1, n * total)
}

当n = 1,我们1! = 1,所以很容易知道参数中total必须赋值为1时,才是计算正确的阶乘,故将第二个参数的默认参数设为1,这样我们就不用传第二个参数了。

6. 实战演练

实战我们来将著名的斐波那契数列进行尾递归优化:

优化之前:

function getFibo(n) {
    if (n <= 1) return 1
    return getFibo(n-1) + getFibo(n-2)
}

很明显,这不是尾递归。

开撸,首先结果依赖前两项计算的结果,所以函数需要再额外添加两个参数,这两个参数分别是前两项的计算结果,类比上面的阶乘例子,我们暂且把上一项的计算结果和本次的计算结果分别记为a1和a2,由于本次计算的结果被我们记为了a2,所以当n<=1时,return 后面应该是a2了,即第一行和第二行代码就变为了:

function getFibo(n, a1, a2){
    if (n <= 1) return a2

类比上面的例子,也很容易得到第三行的代码:首先第一个参数 n - 1 不变,因为改变后的函数的第一个参数是n。接下来第二个和第三个参数是我们添加上去的,最简单直接的方法还是令 n = 2,当n = 2 时,上一次结算的结果由第二行代码可知为a2,所以return 后面的函数的第二个参数确定下来了,即为a2,那么当 n = 2 时,本次计算的结果是多少呢,由第一行和第二行代码结合来看,可知当 n = 1 时的上一次计算结果为a1,那么我们可得当 n = 2 时的计算结果为 a2 + a1,故return 后面的表达式的第三个参数就为 a1 + a2,所以第三行代码就变成了:

    return getFibo2(n-1, a2, a1+ a2)

至此,整个优化之后的函数就变成了下面这样:

function getFibo2(n, a1, a2){
    if (n <= 1) return a2
    return getFibo2(n-1, a2, a1+ a2)
}

带入特殊值验证,当 n = 1 时,可知a1 = 1,a2 = 1;所以完整的计算斐波那契数列的尾递归函数为:

function getFibo2(n, a1 = 1, a2 = 1){
    if (n <= 1) return a2
    return getFibo2(n-1, a2, a1+ a2)
}

至此,已经讲完了,你get到尾递归以及如何进行尾递归函数的编写了吗?

猜你喜欢

转载自blog.csdn.net/weixin_40920953/article/details/87392754