递归优化:尾调用和Memoization

1、递归

1.递归含义:

一个过程或函数在其定义或说明中有直接或间接调用自身的一种方法,它一般把一个大型复杂的问题层层转化为一个与原问题类似的规模较小的问题来求解,递归策略只需少许的程序就可描述出解题过程所须要的屡次重复计算,大大地减小了程序的代码量。javascript

2.递归的优势

  • 简洁
  • 在树的前序,中序,后序遍历算法中,递归的实现明显要比循环简单得多。

例子1java

function foo(i) {
  if (i < 0)
  return;
  console.log('begin:' + i);
  foo(i - 1);
  console.log('end:' + i);
}
foo(3);

// begin:3
// begin:2
// begin:1
// begin:0
// end:0
// end:1
// end:2
// end:3
复制代码

以函数栈的方式来理解以上代码就是:node

  1. 第一次进入函数foo(3),此时的参数为3,假设为foo1()被推入执行栈 首先 i不小于0,输出begin:3
  2. 进入foo(i - 1),参数为3-1 = 2,假设为foo2()被推入执行栈 i不小于0,输出begin:2
  3. 进入foo(i - 1),参数为2-1 = 1,假设为foo3()被推入执行栈 i不小于0,输出begin:1
  4. 进入foo(i - 1),参数为1-1 = 0,假设为foo4()被推入执行栈 i不小于0,输出begin:0
  5. 进入foo(i - 1),参数为0-1 = -1,假设为foo5()被推入执行栈 i小于0 return
  6. 执行栈弹出当前的函数foo5(),进入到上一个函数foo4(),继续执行未完成的代码 输出end:0
  7. 执行栈弹出当前的函数foo4(),进入到上一个函数foo3(),继续执行未完成的代码 输出end:1
  8. 执行栈弹出当前的函数foo3(),进入到上一个函数foo2(),继续执行未完成的代码 输出end:2
  9. 执行栈弹出当前的函数foo2(),进入到上一个函数foo1(),继续执行未完成的代码 输出end:3
  10. 执行栈弹出当前的函数foo1(),到此执行栈所有执行完毕

例子2 阶乘函数算法

function factorial(n) {
  // console.trace()
  if (n === 0) {
    return 1
  }

  return n * factorial(n - 1)
}

factorial(5)

// 拆分红分步的函数调用
// factorial(5) = factorial(4) * 5
// factorial(5) = factorial(3) * 4 * 5
// factorial(5) = factorial(2) * 3 * 4 * 5
// factorial(5) = factorial(1) * 2 * 3 * 4 * 5
// factorial(5) = factorial(0) * 1 * 2 * 3 * 4 * 5
// factorial(5) = 1 * 1 * 2 * 3 * 4 * 5
复制代码

下面是以上函数运行的图例数组

若是在factorial函数中插入console.trace()来查看每次函数运行时的调用栈的状态,当递归到调用factorial(5) = factorial(0) * 1 * 2 * 3 * 4 * 5时,输出结果以下:浏览器

console.trace
factorial @ VM159:2
factorial @ VM159:7
factorial @ VM159:7
factorial @ VM159:7
factorial @ VM159:7
factorial @ VM159:7
(anonymous) @ VM159:10
复制代码

3.递归的问题(缺点)

  • 性能:如以上例子所示:假设传入的参数值特别大,那么这个调用栈将会很是之大,最终可能超出调用栈的缓存大小而崩溃致使程序执行失败。每一次函数调用会在内存栈中分配空间,而每一个进程的栈的容量是有限的,当调用的层次太多时,就会超出栈的容量,从而致使栈溢出。
  • 效率
    • 递归因为是函数调用自身,而函数调用是有时间和空间的消耗的:每一次函数调用,都须要在内存栈中分配空间以保存参数、返回地址以及临时变量,而往栈中压入数据和弹出数据都须要时间。
    • 递归中不少计算都是重复的,因为其本质是把一个问题分解成两个或者多个小问题,多个小问题存在相互重叠的部分,则存在重复计算,如fibonacci斐波那契数列的递归实现。

解决递归的性能问题的方法可使用尾递归缓存

2、尾递归

尾递归是一种递归的写法,能够避免不断的将函数压栈最终致使堆栈溢出。经过设置一个累加参数,而且每一次都将当前的值累加上去,而后递归调用。经过尾递归,咱们能够把复杂度从O(n)下降到O(1)闭包

先说尾调用来理解尾递归函数

尾调用是指一个函数里的最后一个动做是返回一个函数的调用结果的情形,即最后一步新调用的返回值直接被当前函数的返回结果性能

代码表现形式为:

function f(x) {
  a(x)
  b(x)
  return g(x) //函数执行的最后调用另外一个函数
}
复制代码

1.尾调用核心理解

就是看一个函数在调用另外一个函数得时候,自己是否能够被“释放”

2.尾调用好处

如下面函数调用栈和调用帧为例

function f(x) {
  res = g(x)
  return res+1
}

function g(x) {
  res = r(x)
  return res + 1
}

function r(x) {
  res = x + 1
  return res + 1
}
复制代码

如图,普通调用过程当中,假如函数的调用层数很是多时,调用栈会消耗大量内存,甚至栈溢出,形成程序严重卡顿或意外崩溃。

用尾调用解决栈溢出风险

function f() {
  m = 10
  n = 20
  return g(m + n)
}
f()

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

// 等同于
g(30)
复制代码

上述代码,咱们能够看到,咱们调用g以后,和f就没有任何关系了,函数f就结束了,因此执行到最后一步,彻底能够删除 f() 的调用记录,只保留 g(30) 的调用记录。

尾调用的意义 若是将函数优化为尾调用,那么彻底能够作到每次执行时,调用帧为一,这将大大节省内存,提升能效。

3.尾递归 = 尾调用 + 递归

function factorial(n, total = 1) {
  // console.trace()
  if (n === 0) {
    return total
  }

  return factorial(n - 1, n * total)
}
复制代码

调用factorial(3)函数执行步骤以下:

factorial(3, 1) 
factorial(2, 3) 
factorial(1, 6) 
factorial(0, 6) // n = 0; return 6
复制代码

调用栈再也不须要屡次对factorial进行压栈处理,由于每个递归调用都不在依赖于上一个递归调用的值。所以,空间的复杂度为o(1)而不是0(n)。查看控制台,发现第三次打印的结果以下:

console.trace
factorial @ VM362:2
factorial @ VM362:7
factorial @ VM362:7
factorial @ VM362:7
(anonymous) @ VM362:9
复制代码

既然说了调用栈再也不须要屡次对factorial进行压栈处理,那为何结果仍是不会在每次调用的时候压栈,只有一个factorial呢?

正确的使用方式应该是

'use strict';

function factorial(n, total = 1) {
  // console.trace()
  if (n === 0) {
    return total
  }

  return factorial(n - 1, n * total)
}

// 注意,虽说这里启用了严格模式,可是经测试,在Chrome和Firefox下,仍是会报栈溢出错误,并无进行尾调用优化
// Safari浏览器进行了尾调用优化,factorial(500000, 1)结果为Infinity,由于结果超出了JS可表示的数字范围
// 若是在node v6版本下执行,须要加--harmony_tailcalls参数,node --harmony_tailcalls test.js
// 可是node最新版本已经移除了--harmony_tailcalls功能
复制代码

3、Memoization

memoization最初是用来优化计算机程序使之计算的更快的技术,是经过存储调用函数的结果而且在一样参数传进来的时候返回结果。大部分应该是在递归函数中使用。memoization 是一种优化技术,避免一些没必要要的重复计算,能够提升计算速度。

一样以阶乘函数为例:

1.不使用memoization

const factorial = n => {
  if (n === 1) {
    return 1
  } else {
    return factorial(n - 1) * n
  }
}
复制代码

2.使用memoization

const cache = [] // 定义一个空的存放缓存的数组
const factorial = n => {
  if (n === 1) {
    return 1
  } else if (cache[n - 1]) { // 先从cache数组里查询结果,若是没找到的话再计算
    return cache[n - 1]
  } else {
    let result = factorial(n - 1) * n
    cache[n - 1] = result
    return result
  }
}
复制代码

3.搭配闭包使用memoization

const factorialMemo = () => {
  const cache = []
  const factorial = n => {
    if (n === 1) {
      return 1
    } else if (cache[n - 1]) {
      console.log(`get factorial(${n}) from cache...`)
      return cache[n - 1]
    } else {
      let result = factorial(n - 1) * n
      cache[n - 1] = result
      return result
    }
  }
  return factorial
}

const factorial = factorialMemo()
复制代码

4.总结

memorization 能够把函数每次的返回值存在一个数组或者对象中,在接下来的计算中能够直接读取已经计算过而且返回的数据,不用重复屡次相同的计算。是一个空间换时间的方式,这种方法可用于部分递归中以提升递归的效率。

相关文章
相关标签/搜索