JavaScript 数据结构(6)- 递归

91 阅读9分钟

1669725719225.jpg

学习代码 git 仓库地址:gitee.com/zhangning18…

九、递归

一种特殊的方法来操作 数据结构变得更简单,那就是递归。

9.1 理解递归

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

// 递归是像下面这样能够直接调用自身的方法或函数。
function test(){
  test();
}

// 像下面这样间接调用自身的函数,也是递归函数
function fun1(){
  fun2();
}
function fun2(){
  fun1();
}

如果现在执行 test() ,它会一直执行下去。因此每个递归函数都必须有 基线条件,即一个不再递归调用的条件(停止点),以防止无限递归。

function f1 (one) {
  const answer = xxx;// 处理
  if (answer === true) {
    return true;
  }
  f1(answer);// 递归调用
}

上面的 f1 会不断地调用自身,直到 answer 为 true。answer 为 true 就是上面代码的基线条件。

9.2 计算一个数的阶乘 n!

就是 n * (n - 1) * ... * 1

// 用代码表示就是
function fn (n) {
  let number = n;
  if (number < 0) {return undefined;}
  let total = 1;
  while(number > 1) {
    total = number * total
    number--;
  }
  return total;
}

以上就是计算阶乘的代码。

// 使用递归来解决这个问题
function f2 (n) {
  if (n === 1 || n === 0) {return 1;}
  return n * f2(n - 1);
}

代码看着是不是简洁了很多。

  1. 调用栈

在上面讲了栈数据结构。在上面使用递归的形式就是一个调用栈的例子。每当一个函数被一个算法调用时,该函数会进入调用栈的顶部。当使用递归的时候,每个函数调用都会堆叠在调用栈的顶部,这是因为每个调用都可能依赖前一个调用的结果。

使用 debugger 打开调试模式,可以在 控制台看到 Call Stack 调用栈中积压的函数调用

通过图来表达各个步骤和调用栈中的行为

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

  1. JavaScript 调用栈大小的限制

如果忘记加上用以停止函数递归调用的基线条件,会发生什么?递归并不会无线地执行下去,浏览器会抛出错误,也就是所谓的栈溢出错误。就是超出最大调用栈大小。基本上都在 15 万次左右。

尾调用优化,如果函数内的最后一个操作是调用函数,会通过 跳转指令 而不是 子程序调用 来控制,也就是说,在 es2015 中,这里的代码可以一直执行下去。因此具有停止递归的基线条件非常重要。

9.3 这里学习一下 尾调用优化

学习资源来自 阮一峰:尾调用优化

尾调用(Tail Call )是函数式编程的一个重要概念

9.3.1 什么是尾调用?

尾调用的概念非常简单,就是指 某个函数的最后一步是调用另一个函数

// 函数 f 的最后一步是调用函数 g,这就叫尾调用。
function f(x) {
  return g(x)
}

// 下面两种情况都不属于尾调用
// 1
function f(x) {
  let y = g(x)
  return y;
}

// 2
function f(x) {
  return g(x) + 1;
}

情况 1 是调用函数 g 之后,还有别的操作,所以不属于尾调用,即使语义完全一样

情况 2 也属于 调用后还有操作,即使写在 一行 内

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

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

// 这里的 m 函数 和 n 函数都属于尾调用,因为它们都是函数 f 的最后一步操作。

9.3.2 尾调用优化

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

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

尾调用由于是函数的最后一步操作,所以不需要保留外层函数的调用记录,因为调用位置、内部变量等信息都不会再用到了,只要直接用内层函数的调用记录,取代外层函数的调用记录就可以了。

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

// 等同于
function f() {
  return g(3)
}
f();

// 等同于
g(3)

以上,如果函数 g 不是尾调用,函数 f 就需要保存内部变量 m 和 n 的值、g 的调用位置等信息。但由于 调用 g 之后 函数 f 就结束了,所以执行到最后一步 完全可以删除 f() 的调用记录,只保留 g(3) 的调用记录。

这就叫做 ‘尾调用优化’(Tail call optimization),即只保留内层函数的调用记录。如果所有函数都是尾调用,那么完全可以做到每次执行时,调用记录只有一项,这将大大节省内存。这就是 ‘尾调用优化’的意义。

9.3.3 尾递归

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

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

// 阶乘函数
function f(n) {
  if (n === 1) return 1;
  return n * f(n - 1)
}

f(5);// 120

上面代码是一个阶乘函数,计算 n 的阶乘,最多需要保存 n 个调用记录,复杂度O(n)。

如果改写成 尾递归,只保留一个调用记录,复杂度O(1)。

// 阶乘函数
function f(n, total) {
  if (n === 1) return total;
  return f(n-1, n * total)
}

f(5, 1);// 120
// 执行顺序
// f(5)
// f(4, 5)
// f(3, 20)
// f(2, 60)
// f(1, 120)
// 120

以上可以看出,‘尾调用优化’对递归操作意义重大,所以一些函数式编程语言将其写入了语言规格,ES6 也是如此,第一次明确规定,所有 ES 的实现都必须部署 ‘尾调用优化’。这就是说,ES6 中,只要使用尾递归,就不会发生栈溢出,相当节省内存。

9.3.4 递归函数的改写

尾递归的实现,往往需要改写递归函数,确保最后一步只调用自身。做到这一点的方法,就是把所有用到的内部变量改写成函数的参数。比如上面的实例,阶乘函数 f 需要用到一个中间变量 total,那就把这个中间变量改写成函数的参数。这样做的缺点就是不太直观,第一眼很难看出来,为什么计算 5 的阶乘需要传入两个参数 5 和 1?

两个方法可以解决这个问题,

  1. 方法一是在为递归函数之外,再提供一个正常形式的函数。
function f(n, total) {
  if (n === 1) return total;
  return f(n - 1, n * total)
}
function f2(n) {return f(n, 1)}

f2(5);// 120

上面的代码通过一个正常形式的阶乘函数 f2 ,调用尾递归函数 f,看起来正常多了。

函数式编程有一个概念,叫做 柯里化(currying),意思是将多参数的函数转换成单参数的形式,这里也可以使用 柯里化

function currying(f, n) {
  return function(m) {
    // 这里为什么还要用 call 啊,call 知识改变了 this 的指向,好像没有什么意义,
    // call 和 apply ,就是参数不一样,apply 第二个参数是一个数组,把参数都放在数组里
    return f.call(this, m, n);
  }
}

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

const fun = currying(tf, 1);

fun(5);// 120

上面代码通过柯里化,将尾递归函数 tf 变为 只接受 1 个参数的 fun;

  1. 第二种方法就比较简单了(和我想得一样设默认值,哈哈)
function f(n, total = 1) {
  if (n === 1) return total;
  return f(n - 1, n * total);
}

f(5) // 120

总结:递归本质上是一种循环操作,纯碎的函数式编程语言没有循环操作命令,所有的循环都用递归实现,这就是为什么尾递归对这些语言极其重要。对于其他支持‘尾调用优化’的语言(比如:es6),只需要知道循环可以用递归代替,而一旦使用递归,就最好使用尾递归。

9.3.5 严格模式

ES6 的尾调用优化只在严格模式下开启,正常模式是无效的。

这是因为在正常模式下,函数内部有两个变量,可以跟踪函数的调用栈。

arguments:返回调用时函数的参数
func.caller:返回调用当前函数的那个函数

尾调用优化发生时,函数的调用栈会改写,因此上面两个变量就会失真。严格模式禁用这两个变量,所以尾调用模式仅在严格模式下生效。

9.4 斐波那契数列

斐波那契数列是另一个可以用递归解决的问题。它是由0 1 1 2 3 5 8 13 21 34 等数组成的序列,由前两个数相加得到后面一个数这种类型序列。

斐波那契数列定义:

  • 位置 0 的斐波那契数是 零
  • 1 和 2 的斐波那契数是 1
  • n(这里 n > 2)的斐波那契数是(n-1)的斐波那契数加上(n-2)的斐波那契数

9.4.1 迭代求斐波那契数

function fibonacciFun(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++) {
    fibN = fibNMinus1 + fibNMinus2;
    fibNMinus2 = fibNMinus1;
    fibNMinus1 = fibN;
  }
}

9.4.2 递归求 斐波那契数

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

9.4.3 记忆化斐波那契数

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

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

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

迭代的版本比递归的版本快很多,所以这表示递归更慢。但是,这三种不同的方法里面,递归时更容易理解的,需要的代码也是更少的。另外,对一些算法来说,迭代的解法可能不可用,而且有了尾调用优化,递归的多余消耗甚至可能被消除。所以通常使用递归,因为它解决问题会更简单。

9.4.5 小结

本章,学习了怎样写两种著名算法的迭代版本和递归版本:数的阶乘和斐波那契数列。还了解到一种叫做记忆化的优化技术,它可以防止递归算法重复计算一个相同的值。