你知道尾递归吗

尾递归是相较于普通递归函数的一种优化策略。

递归简介

递归,就是函数内部又发生了调用自身的现象。因此递归函数一定要在内部定义好一个基本情况,当这这种基本情况发生的时候,函数可以返回结果。否则递归将会由于无穷无尽的调用自身,最终导致运行程序所在的内存被占满。

这里举几个可能会使用到递归函数的相似案例

斐波那契数列

斐波那契数列的规律是,第一个数是1,第二个数也是1,但是第三个数是前两个数的加和,后续都如此,整个数列如下:

1  1  2  3  5  8  13  21  34  55  89  144 ...
复制代码

由于除了第一个数是已知的基本条件,其他数都可以通过前两个数相加所得,所以斐波那契数列的规律可以通过递归函数进行表示。

function fibonacci(n) {
    if (n === 1 || n == 2) {
        return 1
    } else {
        return fibonacci(n - 1) + fibonacci(n - 2)
    }
}
复制代码

数组求和

假如有一个数列[n1, n2, n3, ...],你要如何求出它们全部数的所加之和呢。此时我们也可以想到,对于N个数的所加之和,可以推导成第N个数加上该数前面所有数的和,其中第N个数是已知条件,所以可以根据这个作为基本情况,然后使用上面的推导规律作为我们的递归函数。

function sum(arr) {
    function helper(n) {
        if (n === 0) {
            return arr[0];
        } else {
            return arr[n] + helper(arr[n - 1]);
        }
    }
    return helper(arr.length - 1);
}
复制代码

求N的阶乘

阶乘也是一个数学上的概念,任何一个数的阶乘等于这个数依次乘上比它小1的数,直到1为止,比如5的阶乘如下

5! = 5 * 4 * 3 * 2 * 1
复制代码

那么这个规律我们同样可以使用递归函数进行表示,其中1就是那个基本情况,而每次乘比当前数小1的数则为递归函数本体。

function factorial(n) {
    if (n === 1) {
        return 1;
    } else {
        return n * factorial(n - 1);
    }
}
复制代码

通过以上三个案例,我们可以了解递归是如何运用在求解实际问题中的,并且知道了哪些问题的规律可以通过递归进行求解,也就是存在重复调用自身的情况。

那么尾递归又是什么呢?

尾递归介绍

尾递归是针对于递归中所存在的一些问题,从而产生的一种递归优化手段。比如以下几种问题

执行栈溢出

递归本身分为”递“和”归“两个过程,”递“指的是递进,将一个问题递进到更深一层的子问题中去求解,而”归“则指的是当最基本情况被触发后,逆着前面递进的顺序,依次由内而外将结果回归到最外层函数调用的过程。

通过上面的例子我们可以看出,当使用递归求解一个很大的输入时,比如求解第500个斐波那契数是多少,或者求解一个含有五百万个数的数列和,或者求解100的阶乘是多少。

那么我们会面临什么问题呢?

我们知道,不同编程语言都会有自己对于函数调用的设计。以JavaScript为例,每次函数调用,JS引擎实际会把本次函数调用放进一个叫做执行栈的地方,作为待执行的内容。而函数中所用到的变量,又会存在于堆栈中,而这些堆栈和执行栈都是计算机分配给JS引擎的实际内存空间,其上限是一定的。

但我们上面所写的递归函数从逻辑上来看没有什么问题,但从实际执行的角度,当遇到很大的输入时,会有一个很致命的问题就是,会有堆栈溢出的风险,也就是我们所说的Stack Overflow。

以斐波那契数列问题为例,当我们调用 fibonacci(500) 时,JS引擎会将 fibonacci(500) 推入执行栈,在事件循环队列空闲时,会从执行栈中取出栈顶的任务进行执行,也就是解析 fibonacci(500) 的执行。然后由于在执行 fibonacci(500) 时,发现其内部需要分别执行 fibonacci(499) 和 fibonacci(498) ,并在二者得出结果后将二者进行加和返回。

但我们现在根本不知道二者的结果,所以只能继续依次将 fibonacci(499) 和 fibonacci(498) 继续推入执行栈,我们从执行栈中推出了一个 fibonacci(500) ,却又推入了两个新的待执行任务。

这还不是最糟的,更糟的是当轮到 fibonacci(499) 执行时,发现它内部又产生了两个新的执行任务,即 fibonacci(498) 和 fibonacci(497) ,而此时前面的 fibonacci(498) 甚至还没执行。以此类推,执行栈的空间很快就被堆满,从而导致JS引擎不堪重负,抛出Stack Overflow的程序错误。

计算冗余

那么普通的递归函数还有什么问题呢,我们以数列加和为例,比如我们想求[3, 5, 9, 7, 11, 28]的总和。那么在我们的程序中的运行过程是

sum(4) + 28
sum(3) + 11 + 28
sum(2) + 7 + 11 + 28
sum(1) + 9 + 7 + 11 + 28
sum(0) + 5 + 9 + 7 + 11 + 28
3 + 5 + 9 + 7 + 11 + 28
8 + 9 + 7 + 11 + 28
17 + 7 + 11 + 28
24 + 11 + 28
35 + 28
63
复制代码

这个也许不够直观,我们再来看阶乘函数的执行过程

factorial(5) = 5 * factorial(4)
             = 5 * 4 * factorial(3)
             = 5 * 4 * 3 * factorial(2)
             = 5 * 4 * 3 * 2 * factorial(1)
             = 5 * 4 * 3 * 2 * 1
             = 5 * 4 * 3 * 2
             = 5 * 4 * 6
             = 5 * 24
             = 120
             
复制代码

你可能会好奇为什么要这样执行呢,明明我们看到很多中间过程一眼就知道可以省略啊,比如 sum(3) 为什么还要 + 11 + 28 呢,不就可以直接 sum(3) + 39 么,你都有两个肉眼可见的数字了,为什么还要等最后再加,直接加完再接着往下不好吗。

还有 factorial 的执行过程,为什么不把中间那些看到的数字都乘好再去乘未知的函数调用呢,比如改造成下面这样

factorial(5) = 5 * factorial(4) 
             = 20 * factorial(3) 
             = 60 * factorial(2) 
             = 120 * factorial(1) 
             = 120
             
复制代码

这样不是好很多吗?诶!你说的对,这个其实呢,就是尾递归所做的优化了。

所谓尾递归,就是在每次递归过程中,将可以计算的内容先执行了,然后再去进行下一次递归函数的调用。这样等整个递归过程进入到基本情况,也就是递归调用的尾部时,就可以直接得出整个递归的执行结果了。

那你可能会说,既然有这么直观的解法,为什么还会有前面那个效率低的递归方式呢?那是因为这是你从程序执行的角度,看到的可以优化的空间,而站在一开始编写递归函数时的角度,那样写程序更符合逻辑直觉。

不信我们再来回看一开始的程序代码

function factorial(n) {
    if (n === 1) {
        return 1;
    } else {
        return n * factorial(n - 1);
    }
}
复制代码

编写这个函数的直观思路是:对于1的阶乘结果我们是已知的,所以将它作为基本情况。然后对于其他任意数的阶乘,都是该数与其前面一个数阶乘结果的相乘。这逻辑看上去没有任何问题,非常符合直觉。

但是它在真正执行的过程中,由于编程语言对于函数执行的设计,就会出现当遇到新的函数调用时,我们会优先处理函数执行,即将函数放入执行栈。因此在递归函数的层与层之间,是依靠”归“的过程再去做具体计算的。因此就产生了前面那些明明看见多个数字相乘,却还要继续进行下一层的函数调用,而不是先把这些中间结果计算后再去进行函数调用。

上面那种看似可以优化的计算过程是我们使用数学思维将整个计算过程平铺在纸面上,所以可以很直观地感受到中间数可以相乘以减少计算。但在计算机程序中,那些数字其实不在同一个区域内。比如调用 factorial(5) 所得到的 5 * factorial(4) ,其中 5 和 factorial(4) 同在 factorial(5) 的执行区域内,此时程序会为 5 和 factorial 划定一片区域进行临时存储,这片范围就是为 factorial(5) 执行时所划分而出的。而当计算 factorial(4) 时,又会为 factorial(4) 的计算划分出另一片区域,以此来存放 4 和 factorial(3) 。所以此时的 5 和 4 彼此隔离着,因此无法做到提前计算。

那么尾递归就是可以做到我们上面所看到的”提前计算“,将中间计算后的结果不断带入下一层递归中,只需要为递归函数增加一个参数即可实现。

function factorial(n) {
    function helper(n, temp) {
        if (n === 1) {
            return temp;
        } else {
            temp = n * temp;
            return helper(n - 1, temp);
        }
    }
    return helper(n, 1);
}
复制代码

这个函数的执行过程如下

执行栈 temp return
- 1 -
factorial(5) 5 helper(4, 5)
factorial(4) 20 helper(3, 20)
factorial(3) 60 helper(2, 60)
factorial(2) 120 helper(1, 120)
factorial(1) 120 120

相比普通的递归函数,我们为递归函数增加了一个存放中间计算过程的参数,从而使得整个递归过程仅需在”递“的过程进行计算,然后将每层递归中计算的中间结果更新到该参数内,并继续向下传递。

这样在”归“的过程中,由于这个中间参数一直都保持着最新的计算结果,因此当到达递归尾部时,这个参数所存放的值即是整个递归的最终结果,只需逐层直接返回即可。这样还省去了我们原本在每层递归执行时为临时值5、4、3、2分配的额外空间。

内存占用

上面这些临时值所占用的空间同时也是普通递归的第三个缺点,也就是内存占用,因为要将这些中间值保留到递归执行到”归“的过程中再进行使用,所以每层递归都会产生大量的中间数据,而这些中间数据在递归达到基本情况前都将占用程序大量的内存空间。

尾递归转为迭代函数

我们知道,大部分使用递归写的程序代码,都可以改写成迭代的形式,因为程序执行器内部对于递归函数最终还是会解析成迭代函数进行执行。

那我们试着将我们的递归代码改写成迭代的形式,这里用一种循序渐进的方式进行转化,即先将原始的递归写成”goto“的执行形式。

以阶乘函数为例,原始递归写法是

function factorial(n) {
    function helper(n, temp) {
        if (n === 1) {
            return temp;
        } else {
            temp = n * temp;
            return helper(n, temp);
        }
    }
    return helper(n, 1);
}
复制代码

如果使用 goto 语法,我们可以稍微改动为如下

function factorial(n) {
    let temp = 1;
    function helper(n) {
        if (n === 1) {
            return temp;
        } else {
            temp = n * temp;
            n = n - 1;
            goto 第3行
        }
    }
    return helper(n);
}
复制代码

这里我们将原本带入到每一层递归的中间变量参数抽离到递归外部,接着,我们在此基础上将 goto 形式的程序改造为迭代函数的形式。

function factorial(n) {
    let temp = 1;
    while (n !== 1) {
        temp = n * temp;
        n = n - 1;
    }
    return temp;
}
复制代码

这里的关键在于,经过我们分析,递归的部分其实就是不断重复执行的内容。而其触发的条件,就是一直未达到”基本情况“。因此我们可以将触发递归的条件转变为触发循环的条件,比如上面递归程序的代码稍微改改判断逻辑,就是如下的样子

function factorial(n) {
    let temp = 1;
    function helper(n) {
        if (n !== 1) {
            temp = n * temp;
            n = n - 1;
            goto 第3行
        } else {
            return temp;
        }
    }
    return helper(n);
}
复制代码

这样看是不是就清晰了很多

  1. 对于中间变量存储的方式和位置,二者都已经一致。
  2. 然后是主要重复执行的部分,对于递归和迭代循环,也就是程序重复执行的触发条件,都是 n 未到达 1,也就是程序尚未到达基本情况。
  3. 而每一层执行内部,都需要计算截止目前能够得到的中间结果,即 temp = n * temp,并且要继续进入更下一层递进,则需要将轮次标识减1,即 n = n - 1
  4. 递归中的 goto 则对应迭代循环中的本轮循环执行结束,继续进入下一次迭代
  5. 最后,当程序继续重复的条件不满足,即到达基本情况,程序将一直存储着最新中间结果的变量值返回

小结

本篇是我在学习尾递归过程中对于一些个人理解的总结,其中不乏参考了网上已有的众多解释,如有内容错误或解释不准确的地方欢迎纠正。

学习递归有助于帮助我们理解程序运行的内部原理,比如其中涉及到递归执行过程中不同编程语言对于递归中间变量的存储和处理方式差异,以及由此而产生出的各种程序优化手段。此外,理解递归在程序执行中最终还是有可能会被转化为迭代循环的情况,以及掌握如何手动将递归写法转变为迭代形式都是很有价值的编程练习。

猜你喜欢

转载自juejin.im/post/7053400226995896334