尾调用尾递归及其优化(笔记)

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

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

上述代码中,函数的最后一步是调用函数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){
      g(x);
      return undefined;
  }

尾调用之所以与其他调用不同,就在于它的特殊的调用位置。

我们知道,函数的调用会在内存姓曾一个调用记录,又称为call frame,保存调用位置和内部变量等信息。如果在函数A的内部调用函数B,那么在A的call frame的上方还会形成一个B的call frame。等B运行结束,将结果返回A,B的call frame才会消失。如果函数B内部还调用C,那么还有一个C的call frame,以此类推,所有的call frame 就形成了一个call stack。

尾调用优化
尾调用由于是函数的最后一步操作,如果调用位置,内部变量等信息都不会再用到了。尾调用时可以删除此call frame下方的call frame。这就叫做尾调用优化(Tail call optimization)。

当然,有些尾调用还会用到内部变量如:

 function addOne(a){
     var one = 1;
     function inner(b){
         return b + one;
     }
     return inner(a);
 }

这时候就无法进行尾调用优化。

尾递归
函数调用自身,称为递归。如果尾调用自身,就称为尾递归。
递归非常的耗费内存,因为需要同时保存成百上千个call frame。很容易发生栈溢出错误。对于尾调用优化后的尾递归来说,因为只有一个call frame。所以不会存在栈溢出的问题。
如下面阶乘计算递归:

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

这种情况下降无法进行尾调用优化。所以这里的空间复杂度为O(n)。

而如果改写成尾递归并进行优化,则空间复杂度为O(1),像下面这样

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

尾递归改写
尾递归的实现,往往需要改写递归函数,确保最后一步只调用自身。做到这一点的方法,就是把所有用刀的内部变量改写成函数的参数。像上面的阶乘函数改写一样。这样做的缺点是函数不再直观,或者说会更改函数的调用形式。
有两种方法可以解决这个问题:
一是进行进一步的封装,并提供默认值

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

function factorial(n) {
  return tailFactorial(n, 1);
}

二是使用(柯理化)currying。意思是将多参数的函数转换成单参数的函数的形式。

 function curring(fn, n) {
    return function (m) {
        return fn.call(this, m, n);
    }
}

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

const factorial = curring(tailFactorail, 1);

factorial(5);

这种方法的原理与第一种类似。只是使用了较为规范的封装。

尾调用优化
前面一直提到尾调用优化,那么尾调用优化是怎么实现的呢。优化的目标就是减少调用栈,普通尾调用优化实现不清楚。下面阐述尾递归函数的优化,尾递归优化的策略就是用循环替换掉递归:
如下面这个递归函数:

 function sum(x, y) {
  if (y > 0) {
    return sum(x + 1, y - 1);
  } else {
    return x;
  }
}

sum(1, 100000)

上述函数将会产生100000个call frame,通常情况下回报栈溢出异常或错误。

蹦床函数
可以使用蹦床函数(trampoline)将递归转化为循环

  function trampoline(f){
      while(f && f instanceof Function ){
          f = f();
      }
      return f;
  }

上面就是蹦床函数的实现,可见其中的参数f必须在执行之后返回一个同形式的函数。
所以我们需要将递归函数改写成这样:

 function sum(x, y) {
   if (y > 0) {
     return sum.bind(null, x + 1, y - 1);
   } else {
     return x;
   }
 }

即使用bind将一个函数形式绑定到sum变量上并返回。以下是bind的作用

bind()方法会创建一个新函数,当这个新函数被调用时,它的this值是传递给bind()的第一个参数, 它的参数是bind()的其他参数和其原本的参数.

上述代码中,sum函数每次执行,都会返回自身的另一个版本(新函数)。像下面这样调用,就不会发生栈溢出

trampoline(sum(1, 100000))

真正的优化策略
然而,蹦床函数并不是真正的尾递归的优化, 下面的实现才是。

function tco(f) {
  var value;
  var active = false;
  var accumulated = [];

  return function accumulator() {
    accumulated.push(arguments);
    if (!active) {
      active = true;
      while (accumulated.length) {
        value = f.apply(this, accumulated.shift());
      }
      active = false;
      return value;
    }
  };
}

var sum = tco(function(x, y) {
  if (y > 0) {
    return sum(x + 1, y - 1)
  }
  else {
    return x
  }
});

sum(1, 100000)

以上实现中,每当调用sum时,实际上会调用tco函数,并执行其中的return语句,此语句将执行accumulator函数。这个函数的精妙之处一个是在于,他将原本每次递归调用时使用的参数保存在accumulated中,而每次执行sum操作时都会使用正确的参数。另一个是使用active标志来表示是否进入尾调用优化,第一次调用时进入优化过程后会屏蔽if下面的代码。这就导致在每次执行f.apply(this, accumulated.shift())时只会将正确的参数pushaccumulated变量中,不会产生循环调用。这样的效果是f.apply(this, accumulated.shift())执行sum函数中的x+1,y-1 操作,也就是递归的核心变化程序,然后将参数保存在accumulated变量中,并返回一个undefinedvalue。之后第一层调用会判断参数是否存在,如果存在继续执行调用。最终,在sum返回一个结果之而非函数时,accumulated将为空,则跳出循环并得到结果。由于每一次执行f.apply函数只会产生三个call frame就会返回,继而再次调用执行, 所以不会造成栈溢出情况。

你可能感兴趣的:(随笔)