持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第9天,点击查看活动详情
递归
概念
一个函数在其定义中有直接或间接调用自身的一种方法,它通常把一个大型复杂的问题层层转化为一个与原问题相似的规模较小的问题来求解,递归策略只需少量的程序就可描述出解题过程所需要的多次重复计算,大大地减少了程序的代码量,代码不但简介,而且很优雅。
斐波那契数列
概念和要求
通常用 F(n) 表示,形成的序列称为 斐波那契数列 。该数列由 0 和 1 开始,后面的每一项数字都是前面两项数字的和。求F(n)。
F(0) = 0
F(1) = 1
F(n) = F(n - 1) + F(n - 2),其中 n >= 2
这是一个典型的递归问题,而且实现起来也很简单。
实现一
function fibonacci(n) {
if (n === 0 || n === 1) {
return n;
}
return fibonacci(n-1) + fibonacci(n-2);
}
console.time();
let result = fibonacci(40);
console.timeEnd();
上面的代码运行起来没有什么问题,发现f(40)的时候时间花费大概2s,50的时候就会一直处于执行等待的状态,所以这个代码功能没有问题,但是性能上是有问题的。发现同一个fibonacci值,计算了多次。
f(10) = f(9) + f(8);
f(9) = f(8) + f(7);
f(8) = f(7) + f(6);
实现二
用缓存减少重复计算。
let cache = {};
function fibonacci(n) {
if (n === 0 || n === 1) {
return n;
}
if (cache[n-1] == null) {
cache[n-1] = fibonacci(n-1);
}
if (cache[n-2] == null) {
cache[n-2] = fibonacci(n-2);
}
return cache[n-1] + cache[n-2];
}
console.time();
let result = fibonacci(40);
console.timeEnd();
实现三
用动态规划来实现。
基本思想: 问题的最优解如果可以由子问题的最优解推导得到,则可以先求解子问题的最优解,在构造原问题的最优解;若子问题有较多的重复出现,则可以自底向上从最终子问题向原问题逐步求解。
动态规划特点:
- 把原始问题划分成一系列子问题
- 求解每个子问题仅一次,并将其结果保存在一个表中,以后用到时直接存取,不重复计算,节省计算时间
- 自底向上地计算
对于斐波那契数列来说,自下而上意味着从0和1开始向n递进计算。可以简单理解为递推。如果告诉这里可以用动态规划去解决,那问题就变的简单了,难点在于从题目要求中总结出动态规划的特征,这需要平时的积累。
function fibonacci(n) {
let arr = new Array(n+1);
arr[0] = 0;
arr[1] = 1;
for(let i=2; i<= n;i++) {
arr[i] = arr[i-1] + arr[i-2];
}
return arr[n];
}
递归调用栈
对比上面的第二种递归实现,和第三种非递归实现,发现他们的执行时间比较类似,但是递归是比较耗费性能的,因为它会消耗一定内存。对于循环来说,那循环的优势就比较明显了。
再以n的阶乘举例,来看递归。
function factorial(n) {
if (n === 0) {
return 1;
}
return n * factorial(n - 1);
}
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
如果我们传入参数3,将会递归调用factorial(2)、factorial(1)和factorial(0),因此会额外再调用factorial三次。
每次函数调用都会压入调用栈,整个调用栈如下:
factorial(0) // 0的阶乘为1
factorial(1) // 该调用依赖factorial(0)
factorial(2) // 该调用依赖factorial(1)
factorial(3) // 该掉用依赖factorial(2)
添加console.trace() 来查看每次当前的调用栈状态。
function factorial(n) {
console.trace();
if (n === 0) {
return 1;
}
return n * factorial(n - 1);
}
factorial(3);
调用过程大致为:
每次函数调用会在内存形成一个“调用记录”(函数执行上下文)(全局执行上下文,函数执行上下文,eval执行上下文), 保存着调用位置和内部变量等信息。如果函数中调用了其他函数,则js引擎将其运行过程暂停,去执行新调用的函数,新调用函数成功后继续返回当前函数的执行位置,继续往后执行。执行新调用的函数过程,也是为其开辟一块内存,并将其推入到堆栈的最顶部。新函数执行完毕后将其推出堆栈,并回收内存,然后将执行控制权交给栈顶函数执行上下文。由此便形成了函数的堆栈。
如果传入的参数值特别大,那么这个调用栈将会非常之大,最终可能超出调用栈的缓存大小而崩溃导致程序执行失败,所以书写递归的时候一定要注意层级不能太深。
这个问题如果必须用递归,能怎么优化吗?使用尾递归。
注意:
栈空间非常有限。
实现四
尾递归 tail call
尾递归是一种递归的写法,可以避免不断的将函数压栈最终导致堆栈溢出。需要将每次的计算结果当做递归参数往下传。
function factorial(n, total = 1) {
console.trace();
if (n === 0) {
return total;
}
return factorial(n - 1, n * total);
}
console.time();
let res = factorial(3000);
console.timeEnd();
优化前:
· \
优化后:
新的函数执行上下文会覆盖栈顶(有的文章说的是,直接利用旧的函数执行上下文的内存),因为每一个递归调用都不在依赖于上一个递归调用的值,这是js引擎对尾递归的优化。(大部分的编译器都已对尾递归进行了优化。)
注意尾递归的特征,最会一行返回的是递归函数,且不能有其他依赖计算。
但是添加 console.trace();依然有很多入栈操作。
原因:
- 尾调用只在严格模式下生效,正常模式下是没有效果的;
- es6明确表示,只要实现了尾调用,就不会栈溢出。然后js解释器在实现的时候并未遵守这一规范。v8曾经实现过,后来又删除了调用优化,因为进行函数尾递归优化之后,会破坏函数的调用栈记录。w3c也在致力于寻找新的语法来指明函数的尾调用。
在NodeJS中开启尾递归
在Nodejs下面,我们可以通过开启strict mode, 并且使用--harmony_tailcalls来开启尾递归(proper tail call)。
'use strict'
node --harmony_tailcalls factorial.js
调用栈信息已经像我们预料的那样了。
注意:
如果在node v6版本下执行,需要加--harmony_tailcalls参数,node --harmony_tailcalls test.js
node最新版本已经移除了--harmony_tailcalls功能。经过测试,Chrome和Firefox并没有对尾调用进行优化,Safari对尾调用进行了优化。
尾递归不一定会将你的代码执行速度提高,不过,尾递归可以让你使用更少的内存,使你的递归函数更加安全。消耗了更少的内存,在一定情况下,是可以将代码执行速度提高的。
总结
- 递归本质就是自己调用自己,在终止条件前会一直递归。在递归层级到一定程度时(递归层级太深),会出现栈溢出(栈空间很有限)而停止递归。
- 由于递归存在一些性能和内存的问题,我们要在使用递归时需要更加慎重。但并不是不能使用递归,递归还是有很多其适宜的使用场景(比如对树的操作,树是天然递归的数据结构)。如果通过循环可以解决问题,就不要使用递归。
- 通常我们在客户端的编程,也基本不会涉及到需要递归成千上万的层级,所以在确保不会触碰到这些阈值前,大部分情况下还是可以安心使用的(比如:不确定层级数的菜单导航)。
- 在客户端编程的时候,所需要考虑的性能问题,更多的不在语言层面,因此,我们有些是更需要关注写出来的代码的可读性和可维护性。
思考
假设你正在爬楼梯。需要 n 阶你才能到达楼顶。
每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?
注意: 给定 n 是一个正整数。
例如: n = 3
有三种方法可以爬到楼顶。
-
1 阶 + 1 阶 + 1 阶
-
1 阶 + 2 阶
-
2 阶 + 1 阶