“我发起狠来连本身都打”这句话,其实有那么一丢丢递归的意思。好了,递归,什么是递归?递归就是函数本身调用本身。本文主要分两部分,第一部分讲的递归经常使用场景,第二部分讲递归带来的问题和解决方案。那么,👇开始直击你灵魂深处的自虐之旅吧!javascript
递归的概念上面👆已经说了,就是函数本身调用本身。这句话的意思也很明白,那么咱们就看几个递归的实际例子,来探讨一下javascript中好玩的递归操做吧。html
先看下不经过递归的方式:vue
var factorial = function (n) {
var result = 1;
for (var i = 1; i <= n; i++) {
result *= i;
}
return result;
}
console.log(factorial(5)) // 120复制代码
经过一个循环,实现来阶乘函数,老铁,没毛病!java
下面看一下递归的方式实现阶乘函数:node
var factorial = function (n) {
if (n <= 1) return 1;
return factorial(n - 1) * n;
}
console.log(factorial(5)) // 120复制代码
经过不停的调用自身,最终返回的是n * (n - 1)* (n - 2) …* 2 * 1就得出来咱们想要结果。es6
var fibona = function (n) {
// 若是求第一项或者第二项,则直接返回1
if (n === 1 || n === 2) return 1;
// 不然的话,返回前两项的和
return fibona(n - 1) + fibona(n - 2);
}
console.log(fibona(4)) // 3复制代码
根据递归的思想,首先设置递归的终止条件,就是n=1或者n=2的时候返回1。不然的话就重复调用自身,即第n项的值是第n-1和第n-2项的和,那么同理,第n-1项的值是第n-1-2和n-1-1项的值,依次类推,经过递归,最终都转化成了f(1)或f(2)的值相加。面试
Tip:像斐波契数列这类的求值函数,计算量仍是有些大的,因此咱们彻底能够作个缓存,在屡次求相同的值的时候,咱们能够直接从缓存取值。来吧,举个🌰:编程
// 有缓存的斐波那契数列函数
var fibona = (function() {
var cache = {};
return function (n) {
if (cache[n]) return cache[n];
if (n === 1 || n === 2) return 1;
return cache[n] = fibona(n - 1) + fibona(n - 2);
}
})();
console.log(fibona(4)) // 3复制代码
利用闭包的思想,咱们在闭包中定义一个缓存对象cache
,将计算过的值存进该对象中。每次函数调用的时候,首先会从查看cache
对象中是否已经存在这个值,有的话就直接返回,没有的话则从新计算。json
对象的深拷贝但是咱们平常工做中很经常使用一个方法,几乎到处都有它的影子。常见的深拷贝方式有两种:数组
// 利用json的深拷贝
var deepClone = function (obj) {
return JSON.parse(JSON.stringify(obj))
}
// 或这简写一下
const deepClone = obj => JSON.parse(JSON.stringify(obj))复制代码
这种方法很简单,就是利用json的解析和序列化的两个方法。然鹅!曲项向天歌,白毛浮绿水,红掌拨清波 ! ! ! 原谅我,控制不住我本身呀~~~该方法只能对符合json格式的对象进行拷贝,并且属性值不能是函数等一些特殊类型。我是并不推荐使用这种方法做为项目基础函数库中的深拷贝方法的。👇咱们看下第二种深拷贝,也就是用递归来实现:
/**
* 判断是否是对象(除null之外等对象类型),这里isObject函数借鉴underscore中的实现
* js中函数/数组/对象,都是对象类型
*/
var isObject = function (obj) {
var type = typeof obj;
return type === 'function' || type === 'object' && !!obj;
}
// 定义深拷贝对象
var deepClone = function (obj) {
if (!isObject(obj)) return obj;
var result = new obj.constructor();
for (var i in obj) {
if (obj.hasOwnProperty(i)) {
result[i] = deepClone(obj[i]);
}
}
return result;
}
// 打印拷贝效果
console.log(deepClone([123, {a: 1, b: {c: 2}}, 456]))
// 输出:
[
123,
{
a: 1,
b: {
c: 2
}
},
456
]复制代码
!isObject
),若是是原始值则直接返回该原始值。若是不是原始值,则认为它是数组或者对象(这里忽略了函数/正则/日期等特殊数据类型,后面会介绍为何)。而后经过for/in
循环,经过递归调用自身进行赋值(递归调用的时候,若是是原始值则返回进行赋值,若是不是原始值则又进行for/in
循环重复上面步骤)new obj.constructor()
巧妙的避免了对当前数据是数组仍是真对象的判断。这个深拷贝函数是市面上很常见的深拷贝作法,基本覆盖了绝大部分的业务场景。可是它是有bug的,好比:对于属性值是函数/日期对象/正则/环对象
(对象本身引用本身)等特殊类型,是有bug的。一样的json的那个深拷贝方法也是如此。
可是,仍是那句话,该函数基本覆盖了咱们平常拷贝需求,能够放心使用。若是你须要处理上述的这些特殊类型数据的话,该方法就行不通了。关于深拷贝的话题,仔细深聊下去,东西实际上是蛮多的,彻底能够单独拿出来讨论。本文旨在讲述递归,不深刻讨论深拷贝了。若是有兴趣研究上述拷贝难题,能够查看lodash的深拷贝原理,或者MDN的结构化拷贝(也没有处理对函数的拷贝)。
若是咱们想遍历元素的全部子节点,咱们能够经过递归很是方便的作到。
/**
* 递归子节点,给每一个元素节点添加'data-v-123456'属性
* @param {节点或元素} root 要递归的节点或元素
* @param {Function} callback 每一次遍历的回调函数
*/
const getChildNodes = (root, callback) => {
// 判断是存在子元素
if (root && root.children && root.children.length) {
// 将子元素转换成可遍历的数组
Array.from(root.children).forEach(node => {
callback && typeof callback === 'function' && callback(node);
// 递归子元素,重复上述操做
getChildNodes(node, callback);
});
}
// 例如,咱们想像vue的scoped那样,为每个html标签添加data-v-123456属性
const root = document.getElementById('app');
getChildNodes(root, function (node) {
// 为每一个子元素添加属性
node.setAttribute('data-v-123456', '');
});
// 输出结果以下图复制代码
二分法快排,或许是面试中常问到的数组排序方法。核心思想就是,从待排序数组中取出最中间的拿个值(注意,只是下标是中间的那个,并非值是中间的那个),而后遍历剩余数组项,将比中间的值小的放在一个数组拼接在左边,比这个中间值大的所有放在一个数组而后拼接在右边。利用递归,知道每一次的数组个数只剩一项的时候,中止。如此,最终拼接出来的数组就是排序后的数组。
/**
* 利用二分法快速排序
*/
var quickSort = function (arr) {
if (arr.length <= 1) return arr;
var left = [],
right = [],
middle = arr.splice(Math.floor(arr.length / 2), 1)[0],
i = 0,
item;
for (; item = arr[i++];) {
item < middle ? left[left.length] = item : right[right.length] = item;
}
return quickSort(left).concat([middle], quickSort(right));
}
// 输出: [2, 3, 5]
console.log(quickSort([3, 2, 5]))复制代码
树结构就是有个根结点,根结点底下能够有多个子节点,每一个子节点又能够有子节点。常见的树结构数据以下:
var tree = {
name: '电脑',
children: [
{
name: 'F盘',
children: [
{
name: '照片',
children: []
},
{
name: '文件',
children: [
{
name: '工做文件',
children: [
{
name: '报告',
children: []
}
]
}
]
}
]
},
{
name: 'E盘',
children: [
{
name: '视频',
children: [
{
name: 'js教程',
children: []
}
]
}
]
}
]
}复制代码
遍历树结构,有深度优先的原则,也有广度优先的原则。能够经过循环,也能够经过递归。接下来咱们演示深度优先遍历。
所谓深度优先的原则:就是顺着一个节点延伸下去,先遍历它的第一个子节点,而后是第一个孙节点,而后重孙节点,直到没有子节点为止。即先纵深遍历完以后在遍历同级的其余节点。
// 深度优先的非递归遍历
function deepTraversal (root, cb) {
if (!root) return;
cb && typeof cb === 'function' && cb(root);
while (root.children && root.children.length) {
var node = root.children.shift();
cb && typeof cb === 'function' && cb(node);
while (node && node.children && node.children.length) {
root.children.unshift(node.children.pop());
}
}
}
// 调用,输出每一项的name值
deepTraversal(tree, function (node) {
console.log(node.name);
});
// 输出:
// 电脑
// F盘
// 照片
// 文件
// 工做文件
// 报告
// E盘
// 视频
// js教程复制代码
下面看下用递归如何来处理深度优先的遍历:
// 深度优先的递归遍历
function deepTraversal (root, cb) {
if (!root) return;
cb && typeof cb === 'function' && cb(root);
if (root.children && root.children.length) {
var i = 0, node;
for (; node = root.children[i++];) {
deepTraversal(node, cb);
}
}
}
// 输出结果同上
deepTraversal(tree, function (node) {
console.log(node.name);
});复制代码
经过上面的例子,虽然循环和递归均可以实现深度优先原则的遍历。可是使用循环的方式进行遍历,其实性能是更好的。
fn = fn(n - 1) + f(n - 2) // 伪代码复制代码
照着这个思路,要到达某一级的全部走法等于到达前一级的全部走法加上到达前两级的全部走法之和。那总过有个下限吧。哎,你想对了,这个下限就是:
因此,这个下限就是:
if (n === 1) return 1;
if (n === 2) return 2;复制代码
这样,咱们的求走法的函数也就顺着这个思路出来了:
var getRoutes = function (n) {
if (n === 1) return 1;
if (n === 2) return 2;
return fibona(n - 1) + fibona(n - 2);
}
console.log(getRoutes(10)) // 89级复制代码
这个核心思想就是,到达某一级的走法永远等于到达前一级和前两级的全部走法之和。所以很适合用递归来处理。
这个题目的解题思路就是要分清细胞的状态,以及细胞的计算方式。粗略的说,细胞只有死亡和活着的状态,而最终求细胞个数也是指的求最后还活着的细胞。
因为细胞能够分裂,所以细胞能够细分为四种状态:
所以,计算n小时后的细胞数,就是计算n小时后细胞状态为1/2/3的细胞总和。如今咱们假设,求1状态的细胞总数的函数为funA,求2状态的为funB,求3状态的为funC。
最终咱们的计算函数就是:
//获取n小时后的细胞总数
var calcCells = function (n) {
return funA(n) + funB(n) + funC(n)
}复制代码
老铁,这样应该没毛病吧!
下面,重点在各个状态细胞的计算函数。
先来看1状态的细胞----刚分裂的细胞。前一次的1状态,前一次的2状态和前一次的3状态均可以分裂新细胞。而前一次的4状态则不能够。有人问,为啥?死了呀!难道还要诈尸呀~~还有一点,0小时的时候,1状态的细胞数量是1,就是这个母细胞。
由此:
// 获取1状态的细胞数量
var funA = function (n) {
if (n === 0) return 1;
return funA(n - 1) + funB(n - 1) + funC(n - 1);
}复制代码
再看2状态的细胞----分裂一小时的。2状态的细胞,只能是刚分裂状态的细胞,在一小时后变成此状态(也就是前一次分裂状态为1的细胞)。可是,在0小时的时候,是没有此状态的细胞。因此:
// 获取2状态的细胞数量
var funB = function (n) {
if (n === 0) return 0;
return funA(n - 1);
}复制代码
同理,3状态的细胞,则是由2状态的细胞在一小时变成的,so:
// 获取3状态的细胞数量
var funC = function (n) {
if (n === 0 || n === 1) return 0;
return funB(n - 1);
}复制代码
这样,咱们利用递归便实现该方法:
console.log(fibo(1), fibo(2), fibo(3), fibo(4), fibo(5)) // 2 4 7 13 24复制代码
前面讲了这么多有趣的递归,然而,递归并不是完美的!不只如此,还会有性能和内存问题。最经典的莫过于堆栈溢出。在讲递归的问题以前,咱们先了解几个概念:
了解了这些概念以后,咱们再来看这个阶乘函数。
// 经典的阶乘函数
var factorial = function (n) {
if (n <= 1) return 1;
return factorial(n - 1) * n
}
console.log(factorial(5)) // 120
console.log(factorial(6594)) // 6594爆栈复制代码
输出结果咱们看到,在递归近7000次的时候,堆栈溢出了(注意:这个数字毫无心义,不是w3c规范规定的,而是js的解释器定的,根据不一样的平台/不一样的浏览器/不一样的版本可能都会不同)。错误结果以下图所示,之因此浏览器会如此蛮横加个溢出,强制终止掉你的递归,是为了包含你的系统由于不当的程序而被耗尽内存。
为何会堆栈溢出呢?从上面的概念咱们理解到,每次函数调用,都会为其开辟一小块内存,并把函数推入堆栈,执行完毕后才会释放。而咱们的阶乘函数,在最后一句return factorial(n - 1) * n 包含了一个对自身的调用 * n,这就使得该函数必需要等待新的函数调用执行完毕后再乘以n以后才算执行完毕返回,一样的新的函数调用在最后的时候又要等待内部的新的函数嗲调用执行完毕后进行计算再返回。如此一来,就比如如,a内有个b,b有个c,c内有个d……而a要等b执行完才释放,b要等c,c要等d……这样在堆栈内便存放了n多个函数的“调用记录”,而每个“调用记录”是开辟了一块内存的,因此,便超出了浏览器的限制,溢出了。
知道了问题,那解决办法呢?办法就是尾调用优化。
尾调用就是:在函数执行的最后一步返回一个一个函数调用。这个概念很简单,咱们看下几个例子:
/**
* 函数最后一行虽然是一个函数调用,而后并未返回
* funA函数会等funB执行完毕后才算执行完毕,才能被推出栈。
* 因此不是尾调用
*/
function funA () {
funB();
}
/**
* 函数执行到最后一行,须要等到funB执行完毕的结果,而后funA再计算后才返回结果
*/
function funA () {
var x = 10;
return x + funB();
}
/**
* 在funB执行完毕后还有赋值操做,所以也不是尾调用
* 本质由于要等funB执行完毕后funA才能执行完毕
*/
function funA () {
var x = funB();
return x;
}复制代码
以上这些都不是尾调用,缘由都写在注释了。下面再看下是尾调用的几种状况:
// 函数最后的一行返回了一个函数调用
function func () {
// 省略函数的逻辑
// ……
return funA()
}
// 函数经过判断,最后仍是返回的函数调用
function func () {
// 省略函数的逻辑
// ……
var x = 0;
if (x >= 0) return funB()
return funA()
}
// 虽然最后一行是一个三元运算符,可是最终返回的也是一个函数调用
function func () {
// 省略函数的逻辑
// ……
return 0 > 1 ? funA() : funB();
}复制代码
尾调用的核心就是:在函数执行的最后一步返回一个函数调用。注意哦,是最后一步,而没必要须是最后一行代码。
知道了尾调用的核心思想,咱们回过头再来看一下咱们的阶乘函数,若要达到最后一步只返回一个函数调用,那咱们就要想办法去掉函数返回中的*n
这块。
由此,咱们能够在函数的参数中,携带一个sum参数来存放每一次递归后的值,最后在递归到1的时候,将这参数返回便可!ok,下面咱们来实现:
// 使用了一个参数sum来保存阶乘后的值
// 函数执行到n==1的时候,返回sum的值
var factorial = function (n, sum) {
if (n <= 1) return sum;
return factorial(n - 1, sum * n)
}
console.log(factorial(5, 1)) // 120
console.log(factorial(12040, 1)) // 12000左右依然爆栈了,可是比以前的爆栈上限提高了很多复制代码
咱们每次递归调用的时候,把当前的计算结果存在函数的第二个参数sum中,并传递给下一次的递归调用,知道最后n===1的时候,返回最终的结果。
可是,如今的这种调用方法,咱们每次都须要加一个默认的参数1,感受好麻烦哦!内心好不爽!怎么办?固然是盘他啊,给他干掉。
var newFactorial = function(n) {
return factorial(n, 1)
};复制代码
var factorial = function (n, sum = 1) {
if (n <= 1) return sum;
sum *= n;
return factorial(n - 1, sum)
}复制代码
// 经过写一个向右柯里化函数来封装新的阶乘函数
var curry = function (fn, sum) {
return function (n) {
return fn.call(fn, n, sum);
}
}
var curryFactorial = curry(factorial, 1);
console.log(curryFactoial(5)) // 120复制代码
经过curry函数,对阶乘函数进行来封装,返回一个新的带默认参数sum为1的新函数。关于柯里化函数,有兴趣的小伙伴能够去研究研究,也颇有意思的呦。
注意:咱们虽然经过了尾调用优化了咱们的递归函数(这里是尾递归,尾调用自身即尾递归),可是上面的操做,在达到某个值的时候依然会爆栈。这是为何呢?究其缘由:
最后,还要再说明几点: