程序调用自身的编程技巧称为递归(recursion)。

阶乘

  1. function factorial(n) {
  2. if (n === 1) return n;
  3. return n * factorial(n - 1);
  4. }
  5. console.log(factorial(5)); // 5 * 4 * 3 * 2 * 1 = 120

斐波那契数列

  1. function fibonacci(n) {
  2. return n < 2 ? n : fibonacci(n - 1) + fibonacci(n - 2);
  3. }
  4. console.log(fibonacci(5)); // 1 1 2 3 5

递归条件

可以看出:

构成递归需要具备边界条件、递归前进段和递归返回段,当边界条件不满足时,递归前进,当边界条件满足时,递归返回。阶乘中的 n === 1 和 斐波那契数列中的 n < 2 都是边界条件。

递归特点:

  1. 子问题必须与原始问题为同样的问题,且更为简单。
  2. 不能无限制地调用本身,须有一个出口,化简为非递归状况处理。

执行上下文

当执行一个函数的时候,就会创建一个执行上下文,并且压入执行上下栈,当函数执行完毕的时候,就会将函数的执行上下文从栈中弹出。

如果对阶乘函数分析的话,会发现阶乘会不断地压栈。

image.png

如果压栈的数量比较大的话就会有可能出现爆栈的情况。

如何优化?

尾调用

尾调用,是指函数内部的最后一个动作是函数调用。该函数的返回值,直接返回给函数。

例子

  1. // 尾调用
  2. function f(x) {
  3. return g(x);
  4. }
  1. // 非尾调用
  2. function f(x){
  3. return g(x) + 1;
  4. }

因为 g(x) 的返回值还需要跟 1 进行计算后,f(x) 才会返回值,所以是非尾调用。

这两者的区别是执行上下文栈变化不一样。

为了模拟执行上下文栈的行为,定义执行上下文栈是一个数组。

  1. ESCtack = [];


第一段代码执行上下文栈变化:

  1. // 伪代码
  2. ECStack.push(<f> functionContext);
  3. ECStack.pop();
  4. ECStack.push(<g> functionContext);
  5. ECStack.pop();

第二段代码执行上下文栈变化:

  1. // 伪代码
  2. ECStack.push(<f> functionContext);
  3. ECStack.push(<g> functionContext);
  4. ECStack.pop();
  5. ECStack.pop();

尾调用函数执行时,虽然也调用了一个函数,但是因为原来的函数已经执行完毕,执行上下文会被弹出,执行上下文栈相当于只多压入了一个执行上下文。然而非尾调用函数,就会创建多个执行上下文压入执行上下文栈。

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

优化例子

阶乘

  1. function factorial(n, res) {
  2. if (n === 1) {
  3. debugger
  4. return res;
  5. }
  6. return factorial(n - 1, n * res);
  7. }
  8. console.log(factorial(5, 1)); // 120

斐波那契数列

  1. function fibonacci(n, sum1 = 1, sum2 = 1) {
  2. if (n <= 2) return sum2;
  3. return fibonacci(n - 1, sum2, sum1 + sum2);
  4. }
  5. console.log(fibonacci(5)); // 15

求和

  1. function sum(n, res = 0) {
  2. if (n < 1) return res;
  3. return sum(n - 1, n + res);
  4. }
  5. console.log(sum(5)); // 5

理论上只用一个执行上下文,但是 Chrome 的 v8 没有做尾调用优化,所以还是会压栈。

image.png

参考:

[1] JavaScript专题之递归