尾调用优化与尾递归

理解尾调用优化原理,用尾递归避免栈溢出

问题

什么是尾调用优化和尾递归?如何利用它们优化递归函数?

解答

什么是尾调用

尾调用是指函数的最后一步是调用另一个函数。

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

以下情况不属于尾调用:

// 情况一:调用后还有赋值操作
function f(x) {
  let y = g(x);
  return y;
}

// 情况二:调用后还有计算操作
function f(x) {
  return g(x) + 1;
}

尾调用不一定出现在函数尾部,只要是最后一步操作即可:

function f(x) {
  if (x > 0) {
    return m(x);
  }
  return n(x);
}

尾调用优化

函数调用会在内存中形成调用帧(call frame),保存调用位置和内部变量等信息。多个函数调用会形成调用栈(call stack)。

尾调用由于是函数的最后一步操作,外层函数的调用位置、内部变量等信息都不会再用到,因此可以直接用内层函数的调用帧取代外层函数的调用帧。

function f() {
  let m = 1;
  let n = 2;
  return g(m + n);
}

执行到最后一步时,可以删除 f() 的调用帧,只保留 g(3) 的调用帧。这就是尾调用优化,能大大节省内存。

尾递归

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

普通递归容易栈溢出:

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

factorial(5) // 120

这个阶乘函数最多需要保存 n 个调用帧,空间复杂度 O(n)。

改写成尾递归后,只需保留一个调用帧,空间复杂度 O(1):

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

factorial(5, 1) // 120

ES6 规定所有实现都必须部署尾调用优化。使用尾递归可以避免栈溢出,节省内存。

关键点

  • 尾调用是函数最后一步调用另一个函数,调用后不能有其他操作
  • 尾调用优化:用内层函数调用帧取代外层函数调用帧,节省内存
  • 普通递归会保存多个调用帧,容易栈溢出
  • 尾递归只保留一个调用帧,空间复杂度从 O(n) 降到 O(1)
  • ES6 强制要求实现尾调用优化