JS进阶之递归、尾递归

216 阅读3分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第3天,点击查看活动详情 >>

一、递归

1. 递归的原理

首先要知道函数调用的本质,其实就是重复压栈与出栈的操作,而函数在调用栈里边有一个特例,叫做递归:自己调用自己。

递归被调用时,会产生递归栈,而这个递归栈也严格遵循着 LIFO(后进先出,先进后出)。

const bar = () => {
  // 递归调用时如果没有出栈条件,就会造成栈溢出。
  // 报错:Maximum call stack size exceeded
  bar()
}
bar()

// 使用递归求阶乘
// 阶乘:1 到 n 的连续自然数相乘的积:1 * 2 * 3 * n: 5 != 5*4*3*2*1
const factor = n => {
  // 从传进来的数开始一直乘,直到 1 停止
  if(n == 1){
      return 1
  }
  // 递归调用自身
  return n * factor(n -1)
}
// 求 10 的阶乘,输出:3628800
console.log(factor(10))

2. 使用递归求裴波那契数列

裴波那契数列定义:从第三项开始,每一项都等于前两项的和(也叫黄金分割数列)。

公式:f(n) = f(n-1) + f(n-2)

const fibonaccis = n => {
  // 数列小于等于 1 时退出递归
  if(n <= 1){
      return 1
  }
  // 递归调用
  return fibonaccis(n-1) + fibonaccis(n -2)
}

// 输出:8
console.log(fibonaccis(5))

3. 调用帧是什么?

  • 函数在被使用时会被压入执行栈中,当使用完成后便会被移出调用栈。而函数在被压入栈到使用被移出调用栈的过程便是一个调用帧。
  • 指在函数内部调用时形成的一个调用记录。例如 a 调用 b,b 调用 c,那么此刻就会形成一个 a-b-c 的调用帧记录,其中 c 会最先执行,a 最后。

**Tips:**使用 console.trace() 可以快速查看当前函数的调用帧。

4. 尾调用

尾调用指的是函数的最后一步调用另一个函数。代码执行是基于执行栈的,所以当在一个函数里调用另一个函数时,会保留当前的执行上下文,然后再新建另外一个执行上下文加入栈中。

使用尾调用的话,因为已经是函数的最后一步,所以这时可以不必再保留当前的执行上下文,从而节省了内存,这就是尾调用优化。但是 ES6 的尾调用优化只在严格模式下开启,正常模式是无效的。

简单的说,就是一个函数执行的最后一步是将另外一个函数调用并返回。

function inner(x){
  console.log(x)
}

function outer() {
  let m = 1
  let n = 2
  return (inner(m + n))
}

// 1. 调用 outer()
outer()

// 2. 等同于调用
function outer(){
  return inner(2)
}

// 3. 所以调用 outer()
outer()

// 4. 等同于调用
inner(3)

// 5. 调用 inner(3) 等同于
console.log(3)

而尾调用自身就是尾递归,只存在一个调用者。

function a(x){
	console.log(x)
}

function b(x){
  return a(x)
}

b(1)

二、尾递归

  1. 使用尾递归求累和。

    // 正常递归调用 const sum = (n) => { if (n <= 1) return n; return n + sum(n-1) } // 输出:5 sum(5) // 报错,输入的数字超过五位就会栈溢出 sum(12321)

    // 使用尾递归 const sum = (n, prevSum = 0) => { if (n <= 1) return n + prevSum; return sum(n-1, n + prevSum) }

    // 输出:5 sum(5) // 还是会报错,输入的数字超过五位就会栈溢出 sum(12321)

1. 使用 Trampoline 优化尾递归

其实就是将递归改成循环。内部通过 while 循环一直判断递归函数有无得出结果,如果没有得出结果则一直循环调用下去。直到得到结果则直接将结果返回出去。

// 累加方法
// n 起始数字
// prevSum 累加结果
const sum = (n, prevSum = 0) => {
  // 出栈条件:n 小于等于 1 时,返回累加结果
  if (n <= 1) return n + prevSum;
  // 尾递归调用自身进行相加
  return () => sum(n-1, n + prevSum)
}

// trampoline 方法
// f 传入的递归函数
// (...args) 这个递归函数的参数
const trampoline = f => (...args) => {
  // 调用递归
  let result = f(...args);
  // 判断计算结果是否是函数来得知是否还要继续递归调用下去
  while (typeof result === 'function') {
    // 是函数,则代表函数需要继续递归调用
    result = result();
  }
  // 不是函数,代表已经计算出结果,并直接将结果返回出去
  return result;
}

// 调用 trampoline
const sg = trampoline(sum(100000));
// 输出:500000500000
console.log(sg(1000000)); 

另一种写法,使用普通函数实现,在调用时使用 bind 返回一个函数。

const sum = (n, prevSum = 0) => {
  if (n <= 1) return n + prevSum;
  return () => sum(n-1, n + prevSum)
}

function trampoline(f){
	// 调用传进来的递归函数
	let func = f()
  // 判断返回结果是不是函数
	while(typeof func === 'function'){
    // 继续递归
		func = func()
	}
  // 直接返回计算结果
	return func
}

// 输出:500000500000
console.log(trampoline(sum.bind(null, 100000)))

2. 其它尾递归示例

  1. 尾递归乘阶

    const factors = (n, total) => { if(n == 1){ return total } return factors(n-1, n * total) } // 输出:3628800 console.log(factors(10, 1))

  2. 尾递归裴波那契:

把前面两位数,当做参数传进来。

const fibonaccis = (n, ac1 = 1, ac2 = 1) => {
  if(n <= 1){
      return ac2
  }
  return fibonaccis(n - 1, ac2, ac1 + ac2)
}
// 输出:8
console.log(fibonaccis(5))

三、递归与尾递归区别

  1. 调用栈区别:
  • 递归需要同时保存成百上千个调用帧,很容易就会发生栈溢出。

  • 而尾递归只存在一个调用栈,所以永远不会发生栈溢出错误。

  1. 调用帧区别:
  • 递归非常耗费内存,因为它需要同时保存很多个调用帧。
  • 而尾递归它只存在一个调用帧,所以它是不会发生栈溢出错误的。
  1. 使用区别:
  • 递归是对函数的结果进行计算。
  • 尾递归是在函数内进行计算。尾递归的参数会保存临时的计算结果,在下次调用时不需要在函数上做任何额外的计算。