今天是系列第八篇,来迟了。上一篇文章还有一个尾递归的问题遗留。所以今天我就想针对递归和尾递归专门写一篇文章。


先来了解一下递归是什么。

什么是递归

递归就是函数自己调用自己,很简单的一句话,方便记忆和理解。但是在知道了什么是递归只有,我们需要搞清楚怎么更好的写出一个递归函数来解决现实问题。下面我们分两步来完善递归函数。

第一步: 写出递归公式

这一步最重要的是找出这些数据之间的规律,然后把这个规律用递归公式方式写出来。
举一个例子,比如现在要用一个函数实现10 * 9 ... 5 * 4 * 3 * 2 * 1 的结果,应该怎么实现这个函数呢?现在对这块的数据进行思考。我们发现是从当前值 *(当前值-1)*(当前值-2)...,假设当前值是n,以参数的形式传入函数f中,那么这个递归公式就是这样的:

function f(n){
    return n * f(n-1)
}
console.log(f(10))

但是如果函数像上面那样执行的话,会出现死循环,从而产生内存溢出的问题。所以我们需要再加上一个终止条件终止这个函数,然后从执行栈中弹出函数f,释放这个函数中的内存空间。

第二步:找出终止条件

最后分析可以得出,当n===1的时候,整个计算终止,下面我们加上这个条件完善该函数。

function f(n){
    if(n === 1){
        return 1
    }
    return n * f(n-1)
}
console.log(f(10))  // 3628800

现在再来看2个例子,感受一下递归函数的一些使用。

数字的累加

假设我们要做这样一个操作,即当输入5时,计算5+4+3+2+1,实现一个数字累加的结果。我们按照上面的思想去做:第一步写出递归公式,即function fn(n){return n + fn(n-1)};第二步找出终止条件,即当n===1的时候,不再继续递归了,返回1。可以由此写出递归函数:

function fn(n){
    if(n <= 1) return 1
    return n + fn(n-1)
}
斐波那契数列
function fib(n){
   if(n == 0 || n ==1) return 1;
   return fib(n-1) + fib(n-2);
}

上面这个数列实现的是[1,1,2,3,5,8...],即f(n) = f(n-1) + f(n-2)这样一种关系。

但是如果值特别大,递归调用特别深的时候,将会出现栈内存溢出的问题,那对于栈溢出有什么好的解决办法?答案是尾递归。

上篇文章谈到递归中可能存在这样几个问题:
1、由于递归是调用函数自身,而函数调用需要消耗时间和空间:每次调用都要在内存栈中分配空间以存储参数、临时变量、返回地址等,往栈中压入和弹出数据都需要消耗时间。这势必导致执行效率大打折扣。
2、递归中的计算大都是重复的。本质是把一个问题拆解成多个小问题,他们之间存在互相重叠的部分,这些重复计算也会导致效率降低。
3、调用栈可能会溢出。栈是有容量限制的,当调用层次过多,就会超出栈的容量限制,从而导致栈溢出!
为了解决上面递归的一些问题,引入了几种解决方法。如尾递归,循环的方式,事件循环的方式等,详情移步到【JS基础系列】彻底搞懂执行上下文和调用栈(下)。这里单独讲尾递归。

尾递归

尾递归是一种可以避免不断的将函数压栈导致堆栈溢出的递归解决方案。他的技术原理是:在函数中return一个函数后,当前函数在栈内的调用记录会被删除,当前函数的执行上下文会从调用栈弹出。那怎样识别尾递归呢?下面的几个例子可以帮到你。

尾递归的例子

下面看几个例子,分析一下怎样的情况是尾递归。测试方法:在safafi浏览器下开启严格模式看调用栈中的执行上下文压入和弹出情况。一般的递归在函数体中断点能够看到在栈内会创建大量的执行上下文并且不销毁(这就是造成栈溢出的原因);而尾递归是在栈内增加函数执行上下文,然后在该函数返回函数时,销毁当前函数执行上下文,创建返回的函数的执行上下文。所以尾递归中只有一个活跃的执行上下文。

// 函数0是尾递归
function a(){
    const r = c()
    return r
}

// 函数1是尾递归
function a(){
    return c()
}

// 函数2不是尾递归
function a(){
    return c() * 20
}

// 函数3不是尾递归
function a(){
    return c() || b()
}

上面对于判断是否为尾递归都是自己在safari实际测试之后的结果。针对上面测试的结果总结一下:函数2不是尾递归,是因为c()调用后还需要进一步计算。函数3不是尾递归,是因为c()调用之后还有判断的动作以及可能的对于c的调用。而函数0和函数1是尾递归是因为返回的是一个函数或者一个已经计算好的值,而不需要再做多余的判断和计算。

ok,知道了尾递归是怎样的一种方式。接下来我们将使用尾递归的知识来对上面累加的递归做一个优化。

用尾递归改造上面的例子

下面是一个针对数组里面所有项的累加递归:

function fn(arr, sum = 0){
    if(arr.length === 0) return sum
    return fn(arr.slice(1), arr[0] + sum)
}
fn([1,2,3,4])

直接在浏览器上执行,发现仍然有很多压栈,这似乎没起到效果。为什么呢?我们在Nodejs下面通过开启strict mode, 并且使用--harmony_tailcalls来开启尾递归,即:

'use strict'
function fn(arr, sum = 0){
    if(arr.length === 0) return sum
    return fn(arr.slice(1), arr[0] + sum)
}
fn([1,2,3,4])

输入命令:node --harmony_tailcalls factorial.js
我们可以看到每次压栈的时候,只有一个fn。
刚刚看到在标准浏览器中和普通递归一样,会有大量的执行上下文压入调用栈中。那是因为现在浏览器对尾递归的兼容和优化做的还不好,所以看起来没啥变化。

但是尾递归也有自己的一些缺陷。如尾递归是一个隐式行为,如果代码存在死循环尾递归调用,爆栈后难以被开发者察觉;堆栈信息会丢失,造成调试困难。还有目前各大浏览器厂商对尾递归的支持和兼容性不太好。所以在尾递归目前还不被各大浏览器支持的情况下,可以对递归的一些重复内容做优化。

重复计算的优化

如上面的斐波那契数列,会出现大量的fib(3)的计算。我们其实是可以把这块重复的计算保存下来,只计算一次就好,可以使用Map来做优化。

// 原递归方式
function fib(n){
   if(n == 0 || n ==1) return 1;
   return fib(n-1) + fib(n-2);
}

// 优化后的递归方式
let mapData = new Map()
function fib(n){
  if(n == 0 || n ==1) return 1;
  if(mapData.get(n)){  //这一步就避免了重复计算
       return mapData.get(n);
   }
  let value = fib(n-1) + fib(n-2)
  mapData.set(n, value)
  return value
}
fib(20)

ok,递归的一些常见例子比如:实现深拷贝,数组扁平化等功能。我们会在后期的专题对这些常见问题进行分析。

总结

  • 我们在拿到一个功能的时候,首先会分析这个功能是不是能够用递归来实现。写出好的递归需要从这两步来做:(1)编写出递归公式;(2)找到终止条件。
  • 如果递归的层级太深或者数据量过大时,可能会引起栈溢出。而解决栈溢出的方法一般有尾递归,循环方式,事件循环方式
  • 尾递归的原理是在一个递归函数中返回一个函数,当前函数在栈内的调用记录会被删除,这时会把原函数中的执行上下文从执行栈中弹出。
  • 还有一个就是可以通过Map这种数据结构对相同值的计算进行保存,减少重复计算

关键词

递归、尾递归、栈溢出。

参考资料


摩根
11 声望2 粉丝

前端工程师