神奇的尾递归

时间:2020-9-11 作者:admin

一、引言

相信递归大家都不陌生,在平时的开发中也经常用到。可以说递归它是一把双刃剑,关键看你怎么用它,用得好,它就是你手中的一把利剑,用得不好,小心伤到自己!最近看到了一种好的递归方法—尾递归,我个人觉得非常有用,然后再看一下自己以前写的关于递归的代码,确实很多都没有考虑使用尾递归的方法。所以,在这里,想和大家讨论一下尾递归这个神奇的东西。

二、从递归说起

递归,简单来说,就是自己调用自己。

递归的好处就是使代码变得简洁;坏处有二,其一就是对于刚接触编程的人来说并不友好,不如迭代容易理解;其二就是如果使用不当的话很容易造成运行超时,严重的话会照成进程崩溃。

针对上面所说的两点坏处,下面给出解决办法,第一个好解决,使用函数栈的概念就很好理解。下面采用一段代码和一张图发表一下我个人对递归的理解。

  1. 阶乘的计算问题

    相信大家都知道阶乘,n的阶乘就是n! = n (n-1) (n-2) …. 1;那好,接下来的这段函数就是用来计算n的阶乘的。

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

factorial(5) // 120

下面就用一张图结合函数栈来说明一下这个函数是如何执行的。

神奇的尾递归

上面的正方形大方框就是函数栈,factorial(5)、factorial(4)、factorial(3)、factorial(2)、factorial(1)依次入栈,按照栈后进先出的规律,factorial(5)是最后才出栈的,这才算是执行完了factorial(5)这个函数。乍一看,这没问题啊,不挺好的吗,一个接着一个出栈,但是如果不只是算5的阶乘,我想算100甚至10000的阶乘呢?这样函数栈就会有上万个函数等待出栈,这就很容易造成“栈溢出”错误(stack overflow)

再看下面一个例子,也是这种问题:

2.斐波那契数列第n项的计算问题

相信斐波那契数列大家应该都不陌生,如果没听过的话,也没关系,这里简单介绍一下,其实它是一个这样的东西:

[1, 1, 2, 3, 5, 8, …],到了这里大家有没有发现有什么规律?没发现?没关系,接着看:其实斐波那契数列是一个有着这样规律的数组,a[n] = a[n-1] + a[n-2] (n >= 2),说白了,就是数组中任何一项都等于前面两项的和。

OK,到这里,相信大家都对斐波那契数列有了一定的概念,那接下来的这段代码就是用来计算斐波那契数列的第n项。

function Fibonacci (n) {
  if ( n <= 1 ) {return 1};

  return Fibonacci(n - 1) + Fibonacci(n - 2);
}

Fibonacci(10) // 89
Fibonacci(100) // 超时
Fibonacci(500) // 超时

这里就出现问题了,计算第10项没有问题,但是当我使用这种递归方式去计算第100项甚至是第500项时,发现整个程序都崩溃了,原因很简单,就是上面所说的“栈溢出”错误造成的。这有点类似于内存得不到清理造成内存泄漏,严重的话会造成整个应用程序崩溃。

读到这里,如果对递归不了解的小伙伴会问:为什么栈下面的函数要等到栈上面的函数出栈之后才能出栈呢?为什么会有这样一层依赖关系?没关系,带着这个疑问接着读下去。

三、什么是尾调用

尾调用(Tail Call)是函数式编程的一个重要概念,本身非常简单,一句话就能说清楚,就是指某个函数的最后一步是调用另一个函数。

function f(x){
  return g(x);
}

上面代码中,函数f的最后一步是调用函数g,这就叫尾调用。

但是,注意,有一些情况会让人混淆。下面列举的三种情况就不是尾调用

// 情况一
function f(x){
  let y = g(x);
  return y;
}

// 情况二
function f(x){
  return g(x) + 1;
}

// 情况三
function f(x){
  g(x);
}

上面代码中,情况一是调用函数g之后,还有赋值操作,所以不属于尾调用;情况二也属于调用后还有操作,即使写在一行内。情况三等同于下面的代码。

另外,尾调用不是指调用出现在函数的末尾,而是指调用出现在函数的最后一步。比如下面这段代码也是属于尾调用。

function f(x) {
  if (x > 0) {
    return m(x)            //如果x > 0,这就是该函数执行的最后一步
  }
  return n(x);
}

四、什么是尾递归

好了,前面讲了这么多,重头戏来了,要解决在第二点中出现的“栈溢出”错误问题,尾递归是一个不错的方法。

函数调用自身,称为递归。如果尾调用自身,就称为尾递归。

递归非常耗费内存,因为需要同时保存成千上百个调用帧,很容易发生“栈溢出”错误(stack overflow)。但对于尾递归来说,由于只存在一个调用帧,所以永远不会发生“栈溢出”错误。

看到尾递归的概念,我当时就有点疑惑,为什么普通的递归就要在函数栈里面维护多个调用帧,但是对于尾递归来说只存在一个调用帧?

其实只要稍微对比一下尾递归和普通递归的代码就很容易搞懂这一点。

下面给出第二点中的两个例子的尾递归代码和普通递归代码的比较。

1.阶乘的计算问题

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

factorial(5, 1) // 120

//普通递归
function factorial(n) {
  if (n === 1) return 1;
  return n * factorial(n - 1);
}

factorial(5) // 120

2.斐波那契数列第n项的计算问题

//尾递归
function Fibonacci2 (n , ac1 = 1 , ac2 = 1) {
  if( n <= 1 ) {return ac2};

  return Fibonacci2 (n - 1, ac2, ac1 + ac2);
}

Fibonacci2(100) // 573147844013817200000
Fibonacci2(1000) // 7.0330367711422765e+208
Fibonacci2(10000) // Infinity

//普通递归
function Fibonacci (n) {
  if ( n <= 1 ) {return 1};

  return Fibonacci(n - 1) + Fibonacci(n - 2);
}

Fibonacci(10) // 89
Fibonacci(100) // 超时
Fibonacci(500) // 超时

就拿第一个例子来讨论,先看普通递归的代码,真正递归的其实是这一句代码:return n factorial(n – 1); 再看一下尾递归的递归代码:return factorial(n – 1, n total); 其实尾递归这句代码可能写成这样更好理解:

//普通递归原本的代码
return n * factorial(n - 1);

//尾递归原本的代码
return factorial(n - 1, n * total);

//可以写成这样
let n1 = n - 1;
let total1 = n * total;
return factorial(n1, total1);

普通递归和尾递归递归代码最大的区别就是:

普通递归:

当执行factorial(n – 1)这句代码的时候,还引用着factorial(n)函数作用域定义的n,也就是:n * factorial(n – 1),由于n被引用,所以导致factorial(n)不能出栈

尾递归

但是尾递归不同就不同在这一点,尾递归执行递归的代码的时候没有继续引用factorial(n, total)的变量,所以factorial(n, total)可以顺利出栈。所以以此类推,尾递归在整个函数执行的过程中,函数栈至始至终都只有一个调用帧,所以也就不会产生“栈溢出”的问题。

五、总结

由此可见,“尾调用优化”对递归操作意义重大,所以一些函数式编程语言将其写入了语言规格。ES6 亦是如此,第一次明确规定,所有 ECMAScript 的实现,都必须部署“尾调用优化”。这就是说,ES6 中只要使用尾递归,就不会发生栈溢出(或者层层递归造成的超时),相对节省内存。

所以,大家体会到尾递归的神奇之处了吗?

参考链接:阮一峰:尾调用优化

声明:本文内容由互联网用户自发贡献自行上传,本网站不拥有所有权,未作人工编辑处理,也不承担相关法律责任。如果您发现有涉嫌版权的内容,欢迎进行举报,并提供相关证据,工作人员会在5个工作日内联系你,一经查实,本站将立刻删除涉嫌侵权内容。