原来我一直不知道递归在调用栈中是怎么执行的

626 阅读6分钟

本文参考《学习JavaScript数据结构与算法(第3版)》

什么是递归?

有一句编程的至理名言是这样的:

“要理解递归,首先要理解递归。”

递归是一种解决问题的方法,它从解决问题的各个小部分开始,直到解决最初的大问题。递归通常涉及函数调用自身。

递归是一种解决问题的方法,它从解决问题的各个小部分开始,直到解决最初的大问题。递归通常涉及函数调用自身。

function recursion(args) {
  recursion(args)
}  

假设现在执行 recursion,结果是什么?单就上述情况而言,它会一直执行下去。因此,每个递归函数都必须有基线条件,即一个不再递归调用的条件(停止点),以防止无限递归。

回到之前的编程至理名言,在理解了什么是递归之后,我们也就解决了最初的问题。如果我们把这句话翻译成JavaScript代码的话,可以写成下面这样。

function recursion(num) {
  if(num === 0) return;
  console.log(num);
  recursion(num - 1);
}
recursion(4);

recursion 函数会不断地调用自身,直到 num===0。num===0就是上述代码的基线条件。

function recursion(args) {
  recursion(args)
}  

下面我们通过一些著名的递归算法来看看递归的本质。

案例1:计算一个数的阶乘

数n的阶乘,定义为n!,表示从1到n的整数的乘积。5的阶乘表示为5!,和5×4×3×2×1相等,结果是120。

迭代阶乘

如果尝试表示计算任意数n的阶乘的步骤,可以将步骤定义如下:(n) * (n -1) * (n-2) * (n -3) * ... * 1。

可以使用循环来写一个计算一个数阶乘的函数,如下所示。

function factorialIterative(number) {
  if (number < 0) {
    return undefined;
  }
  let total = 1;
  for (let n = number; n > 1; n--) {
    total  = total * n;
  }
  return total;
}

console.log('factorialIterative(5): ', factorialIterative(5));
console.log('factorialIterative(3): ', factorialIterative(3));

我们可以从给定的number开始计算阶乘,并减少n,直到它的值为2,因为1的阶乘还是1,而且它已经被包含在total变量中了。零的阶乘也是1。负数的阶乘不会被计算。

递归阶乘

现在我们试着用递归来重写factorialIterative函数,但是首先使用递归的定义来定义所有的步骤。5的阶乘用5×4×3×2×1来计算。4(n -1)的阶乘用4×3×2×1来计算。计算n -1的阶乘是我们计算原始问题n!的一个子问题,因此可以像下面这样定义5的阶乘。

  • factorial(5) = 5 * factorial(4):我们可以用5×4!来计算5!。
  • factorial(5) = 5 * (4 * factorial(3)):我们需要计算子问题4!,它可以用4×3!来计算。
  • factorial(5) = 5 * 4 * (3 * factorial(2)):我们需要计算子问题3!,它可以用3×2!来计算。
  • factorial(5) = 5 * 4 * 3 * (2 * factorial(1)):我们需要计算子问题2!,它可以用2×1!来计算。
  • factorial(5) = 5 * 4 * 3 * 2 * (1):我们需要计算子问题1!。
  • factorial(1)或factorial(0)返回1。1!等于1。我们也可以说1! = 1×0,0!也等于1。

使用递归的factorial函数定义如下。

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

调用栈

每当一个函数被一个算法调用时,该函数会进入调用栈的顶部。当使用递归的时候,每个函数调用都会堆叠在调用栈的顶部,这是因为每个调用都可能依赖前一个调用的结果。

我们可以用浏览器看到调用栈的行为,如下图所示:

如果执行factorial(3),打开浏览器的开发者工具,打开Sources标签页,在Factorial.js文件中增加一个断点,当n的值为1时,我们可以看到Call Stack里有三个factorial函数的调用。如果继续执行,会看到当factorial(1)被返回后,CallStack开始弹出factorial的调用。

我们也可以在函数开头添加console.trace()来在浏览器的控制台中查看结果。

function factorial(n) {
  console.trace();
  if (n === 1 || n === 0) {
    return 1;
  }
  return n * factorial(n - 1);
}

当factorial(3)被调用时,我们能在控制台中得到下面的结果。

    factorial @ 02-Factorial.js:18
    (anonymous) @ 02-Factorial.js:25 // console.log(factorial(3))调用

当factorial(2)被调用时,我们能在控制台中得到下面的结果。

    factorial @ 02-Factorial.js:18
    factorial @ 02-Factorial.js:22 // factorial(3)在等待factorial(2)
    (anonymous) @ 02-Factorial.js:25 // console.log(factorial(3))调用

最后,当factorial(1)被调用时,我们能在控制台中得到下面的结果

    factorial @ 02-Factorial.js:18
    factorial @ 02-Factorial.js:22 // factorial(2)在等待factorial(1)
    factorial @ 02-Factorial.js:22 // factorial(3)在等待factorial(2)
    (anonymous) @ 02-Factorial.js:25 // console.log(factorial(3))调用

下图展示了执行的各个步骤和调用栈中的行为。

当factorial(1)返回1时,调用栈会开始弹出调用,返回结果,直到3 * factorial(2)被计算。

JavaScript调用栈大小的限制

如果忘记加上用以停止函数递归调用的基线条件,会发生什么呢?递归并不会无限地执行下去,浏览器会抛出错误,也就是所谓的栈溢出错误(stack overflowerror)。每个浏览器都有自己的上限,可用以下代码测试。

    let i = 0;
    function recursiveFn() {
      i++;
      recursiveFn();
    }

    try {
      recursiveFn();
    } catch (ex) {
      console.log('i = ' + i + ' error: ' + ex);
    }

在Chrome v65中,该函数执行了15662次,而后浏览器抛出错误RangeError:Maximum call stack size exceeded(超限错误:超过最大调用栈大小)。在Firefox v59中,该函数执行了188641次,然后浏览器抛出错误InternalError: toomuch recursion(内部错误:递归次数过多)。在Edge v41中,该函数执行了17654次。

案例2:斐波那契数列

斐波那契数列是另一个可以用递归解决的问题。它是一个由0、1、1、2、3、5、8、13、21、34等数组成的序列。数2由1+1得到,数3由1+2得到,数5由2+3得到,以此类推。斐波那契数列的定义如下。

  • ❑ 位置0的斐波那契数是零。
  • ❑ 1和2的斐波那契数是1。
  • ❑ n(此处n > 2)的斐波那契数是(n -1)的斐波那契数加上(n -2)的斐波那契数。

迭代求斐波那契数

我们用迭代的方法实现了fibonacci函数,如下所示。

    function fibonacciIterative(n) {
      if (n < 1) return 0;
      if (n <= 2) return 1;

      let fibNMinus2 = 0;
      let fibNMinus1 = 1;
      let fibN = n;
      for (let i = 2; i <= n; i++) { // n >= 2
        fibN = fibNMinus1 + fibNMinus2; // f(n-1) + f(n-2)
        fibNMinus2 = fibNMinus1;
        fibNMinus1 = fibN;
      }
      return fibN;
    }

递归求斐波那契数

    function fibonacci(n){
      if (n < 1) return 0; // {1}
      if (n <= 2) return 1; // {2}
      return fibonacci(n -1) + fibonacci(n -2); // {3}
    }

如果我们试着寻找fibonacci(5),下面是调用情况的结果。

记忆化斐波那契数

还有第三种写fibonacci函数的方法,叫作记忆化。记忆化是一种保存前一个结果的值的优化技术,类似于缓存。如果我们分析在计算fibonacci(5)时的调用,会发现fibonacci(3)被计算了两次,因此可以将它的结果存储下来,这样当需要再次计算它的时候,我们就已经有它的结果了。

下面的代码展示了使用记忆化的fibonacci函数。

    function fibonacciMemoization(n) {
      const memo = [0, 1]; // {1}
      const fibonacci = (n) => {
        if (memo[n] ! = null) return memo[n]; // {2}
        return memo[n] = fibonacci(n -1, memo) + fibonacci(n -2, memo); // {3}
      };
      return fibonacci;
    }

为什么要用递归?它更快吗?

我们运行一个检测程序来测试本章三种不同的fibonacci函数。

迭代的版本比递归的版本快很多,所以这表示递归更慢。但是,再看看三个不同版本的代码。递归版本更容易理解,需要的代码通常也更少。另外,对一些算法来说,迭代的解法可能不可用,而且有了尾调用优化,递归的多余消耗甚至可能被消除。

所以,我们经常使用递归,因为用它来解决问题会更简单。

为什么学习递归???

因为我看不懂啊🤷‍♂️🤷‍♂️🤷‍♂️