大家好,我是若川。最近组织了源码共读活动,感兴趣的可以点此加我参与,每周大家一起学习200行左右的源码,共同进步。同时极力推荐订阅我写的《学习源码整体架构系列》 包含20余篇源码文章。对于大部分前端(包括我)接触到递归的时候就是学函数的时候,对于递归的认知就是函数自己调用自己,然后给函数定义一个出口,通过这个函数将一件的事情拆分成同类型的小事情。但你会发现你写的递归情况很少,因为很多情况下递归是可以用循环去实现同样的效果,那对于递归具体使用场景,什么时候需要用递归,就是本文主要想探讨的话题。

递归只能去解决树形结构的问题吗?

对于很少使用递归来解决问题的很容易就会把递归想成只有树形情况才能使用递归,下面我们先通过解决树形情况深入了解递归能解决哪些场景的问题以及除了树形结构的数据,它还能处理什么问题?

「问题一」:获取根节点名称

点击当前节点获取根级节点的名称

这里用element的​​el-tree​​组件来做测试,根级节点不包括tree中的虚拟根节点,指的是数据的根节点

这就是虚拟根节点

图文并茂重新认识下递归_递归实现

实现的效果是这样的

图文并茂重新认识下递归_数据_02

这个需求很简单,我们很容易就想到了递归,为什么?

  • 获取根级:不停的获取当前节点的上级,至到没有上级就是根级
  • 递归出口条件:通过​​node.parent或者node.level​
  • 递归中变化的状态值:​​node.parent​
getRootNodeName(node) {
if (!node) return
if (node.level === 1) {
return node.label
}
return this.getRootNodeName(node.parent)
}

通过上面三个条件我们实现了这个递归函数,这里需要注意的是递归中变化的状态值,这个是整个递归中最难理解也最重要的部分,后文会展开来讲

这种递归其实很易就能写出来,循环就只能实现一样的效果

getRootNodeName(node) {
if (!node) return
while (node.level > 1) {
node = node.parent
}
return node.label
}

「问题二」:获取当前节点所在树的指定层级节点

这里有还有个约束条件,node节点中没有level属性了,只有parent属性指向节点的父节点,这里为了更好的表达,我们把node节点的中的虚拟根节点去除。

图文并茂重新认识下递归_递归_03

我们要实现这样的效果:点击当前节点获取当前节点所在的层级

图文并茂重新认识下递归_递归_04

递归实现

getNodeLevel(node) {
if (!node.parent) return 1
return this.getNodeLevel(node.parent) + 1
}

当前这个递归函数主要实现思路就是在不停的递进,当​​node.parent​​​不存在就说明当前节点是一级节点,然后在回溯的过程中​​在上级节点的层级 + 1 = 当前节点层级​

图文并茂重新认识下递归_递归实现_05

递归:你打开面前这扇门,看到屋里面还有一扇门。你走过去,发现手中的钥匙还可以打开它,你推开门,发现里面还有一扇门,你继续打开它。若干次之后,你打开面前的门后,发现只有一间屋子,没有门了。然后,你开始原路返回,每走回一间屋子,你数一次,走到入口的时候,你可以回答出你用这把钥匙打开了几扇门。

循环实现

getNodeLevel(node) {
let n = 1
let p = node
while (p.parent) {
p = p.parent
n++
}
return n
}

循环:你打开面前这扇门,看到屋里面还有一扇门。你走过去,发现手中的钥匙还可以打开它,你推开门,发现里面还有一扇门(若前面两扇门都一样,那么这扇门和前两扇门也一样;如果第二扇门比第一扇门小,那么这扇门也比第二扇门小,你继续打开这扇门,一直这样继续下去直到打开所有的门。但是,入口处的人始终等不到你回去告诉他答案

上面两段话其实就很好的说出了递归和循环之间的差别,就是因为循环没有回溯的过程,我们当前的循环就不得不存储每个递归的状态值数据

「问题三」: 树节点的过滤

有人可能会问树节点的过滤这个在el-tree的用例中不就有吗?但是那个并不能解决我们实际场景的问题,举个例子来看下

<el-tree
...
:filter-node-method="filterNode"
/>

<script>
export default {
methods: {
filterNode(value, data) {
if (!value) return true
return data.label.indexOf(value) !== -1
},
}
}
</script>

存在的问题

图文并茂重新认识下递归_递归_06

当前的过滤只是通过label包含的方式把不符合的节点全都过滤掉了,而我们想要的效果应该是过滤不符合条件的分支,如果符合当前层级符合条件那它的下级就不应该过滤掉

递归实现

methods: {
filterNodeMethod(value, data, node) {
if (!value) {
return true
}
return this.deepfilterChildNode(value, node)
},
deepfilterChildNode(value, node) {
if (node.level === 1) return false
const { filterNodeByIndexOf, deepfilterChildNode } = this
if (filterNodeByIndexOf(node.label, value)) {
return true
}
return deepfilterChildNode(value, node.parent)
},
filterNodeByIndexOf(label, value) {
return label.indexOf(value) !== -1
}
}

循环实现

主要实现函数就是​​deepfilterChildNode​​, 接着再用循环来实现下同步的效果

methods: {
deepfilterChildNode(value, node) {
const { filterNodeByIndexOf } = this
if (filterNodeByIndexOf(node.label, value)) {
return true
}
// 一级节点没有父级
if (node.level === 1) return false
// 往上找到最大那个节点结束
const maxNode = 1
// 多级节点父级符合条件,不要、过滤子级
let parentNode = node.parent
while (parentNode.level > maxNode) {
if (filterNodeByIndexOf(parentNode.label, value)) {
return true
}
parentNode = parentNode.parent
}
// 当前节点的最大父节点都没找到
return false
},
}

「问题四」:实现instanceof

  • 作用:判断右边构造函数的原型对象是否存在于左边对象的原型链上
  • 原理:左侧的原型链中存在右侧的原型对象

左侧必须是对象

1 instaceof Number // false
Number(1) instaceof Number // false
new Number(1) instaceof Number // true

右侧必须是构造函数

let f1 = () => {};
let f2 = function () {}
class f3 {}
obj instanceof f1; // 报错
obj instanceof f2 // false
obj instanceof f3 // false

左侧的原型链中存在右侧的原型对象

let obj = {}
class ctor {}
console.log(obj instanceof ctor); // false
Object.setPrototypeOf(obj, new ctor)
console.log(obj instanceof ctor); // true

let getProto = Object.getPrototypeOf
getProto(getProto(obj)) === ctor.prototype // true

递归实现

function _instaceof(obj, ctor) {
// 左边必须是对象
if (!(obj && typeof obj === 'object')) {
return false
}
// 获取对象的原型链对象
let proto = Object.getPrototypeOf(obj)
// 判断对象的原型链对象是否是构造函数函数的原型对象
return proto === ctor.prototype || _instaceof(proto, ctor)
}

循环实现

function instanceOf1(obj, ctor) {
if (!(obj && typeof obj === 'object')) {
return false
}
let proto
while(proto = Object.getPrototypeOf(obj)) {
if (Object.is(proto, ctor.prototype)) return true
obj = proto
}
return false
}

「问题五」深度属性

测试数据

let obj = {
a: {
b: {
c: 1,
cc: {
dd: {
f: 'f Val'
}
}
}
},
data: {
v1: 'v1',
v2: 'v2',
v3: 'v3'
}
}

获取属性

function getDeepObjAttr(obj, deepPath) {
const deepGet = (o, paths) => {
if (!paths.length) return o
return deepGet(Reflect.get(o, paths.shift()), paths)
}
return deepGet(obj, deepPath.split('.'))
}
console.log(getDeepObjAttr(obj, 'a.b.cc.dd.f')) // f Val

设置属性

function setDeepObjAttr(model, deepPath, val) {
let paths = deepPath.split('.')
if (!paths.length) return model
const setDeep = (o, p, i) => {
if (i < 0) return o
let prop = p[i]
// 最后一层要设定的值
if (i === p.length - 1) {
Reflect.set(o, prop, val)
} else {
Reflect.set(o, prop, {
...getDeepObjAttr(model, p.slice(0, i + 1).join('.')),
...o
})
Reflect.deleteProperty(o, paths[i + 1])
}
return setDeep(o, p, --i)
}
return Object.assign(model,
setDeep({}, [...paths], paths.length - 1)
)
}
setDeepObjAttr(obj, 'a.b.c', '2')
setDeepObjAttr(obj, 'a.b.cc.dd.f', 'f Val21')

改变后的obj

{
"a": {
"b": {
"c": "2",
"cc": {
"dd": {
"f": "f Val21"
}
}
}
},
"data": {
"v1": "v11",
"v2": "v2",
"v3": "v3"
}
}

这个递归有多个出口条件且并不是在单纯的做一件类型事情,且递归函数中引用的自由变量(全局变量)model,递归过程中如果修改model,其他递归中的变量也会受影响

对于递归那些状态值(变量)需要提取到全局,可以这样思考

类似于盗梦空间,我手里有10块钱,我做了第一个梦(n),在这个梦中我花了2块,接着又做了一个梦(n + 1), 在n+1中我还是有10块钱,前面梦中花掉的钱并不影响我这个梦中的钱。那这个状态值(变量)就是递归函数内部创建的局部变量,反之就需要把状态值(变量)放到全局

这里同样给出循环的实现

function getDeepObjAttr(obj, deepPath) {
let paths = deepPath.split('.')
if (!paths.length) return obj
let prop,
targetVal,
tempObj = obj
while ((prop = paths.shift()) && paths.length) {
if (!Reflect.has(tempObj, prop)) {
return
}
tempObj = tempObj[prop]
}
return tempObj[prop]
}
function setDeepObjAttr(model, deepPath, val) {
// 路径
let paths = deepPath.split('.')
// 目标值,存放符合路径下的所有属性
let targetVal = {}
// 用于查找每个对象的prop
let pathsNew = paths.concat([])
let prop
for (let i = paths.length - 1, j = i; i >= 0; i--) {
prop = paths[i]
// 最后一层要设定的值
if (i === j) {
targetVal[prop] = val
} else if (i === 0) {
// 第一层需要直接替换第一个属性
const obj = this.getDeepObjAttr(model, prop);
Reflect.set(model, prop, Object.assign({}, obj, targetVal))
} else {
// 更新每一个层级的值(去除存起来的值)
let curDeppObj = getDeepObjAttr(model, pathsNew.join('.'))
// 将当前层级的值存储起来
Reflect.set(targetVal, prop, Object.assign({}, curDeppObj, targetVal))
// 删除上个路径存储的值
Reflect.deleteProperty(targetVal, paths[i + 1])
}
// 将处理过的路径去除
pathsNew.pop()
}
return model
}

对于递归是否只能解决树形结构的问题,还没有给出答案,又出现了一个问题,递归和循环的区别?

递归和循环有什么区别?

通过上面的五个例子我们发现递归和循环都能实现,其实递归与循环是两种不同的解决问题的典型思路, 都具有相同的特性,即做重复任务 。 单从算法设计上看,递归和循环并无优劣之别。然而,在实际开发中,因为函数调用的开销,递归常常会带来性能问题,特别是在求解规模不确定的情况下;而循环因为没有函数调用开销,所以效率会比递归高。

插一句,求解规模不确定还包含隐示的数据规模很大,但前端页面很少会碰到处理数据规模特别大的情况,所以用递归也没啥问题

相同点:

  • 将大事件拆分成小事情即**重复任务 **(循环、递归调用)
  • 处理过程中将状态值变换即状态展开

区别:

  • 如果使用循环我们就得建立堆栈来替代系统栈保存处理当前状态的内容(变量)

再次认识递归

let arr = [1, 2, 3, 4, 5, 6]

function logArr(arr) {
for (let i = 0; i < arr.length; i++) {
console.log(arr[i]);
}
}
logArr(arr)

让你依次打印输出中的每一项,这个能用递归实现吗???

先想想


答案是肯定可以

function logArr(arr) {
const dfs = (idx) => {
if (idx === arr.length) return
console.log(arr[idx]);
dfs(idx + 1)
}
dfs(0)
}

斐波纳契数列

斐波纳契数列,指的是这样一个数列:1、1、2、3、5、8、13、21,简单来讲就是从第二个数之后的每个数是前两个数之和: F0=0,F1=1,Fn=F(n-1)+F(n-2)(n>=2)

递归求解

function fibonacci(n) {
// 递归终止条件
if (n <= 2) {
return 1
}
// 相同重复逻辑,缩小问题的规模
return fibonacci(n - 1) + fibonacci(n - 2)
}
fibonacci(5)

状态展开生成树

图文并茂重新认识下递归_数据_07

这里面包含了许多重复计算,计算fib(5)时

1次fib(4)
2次fib(3)
3次fib(2)
5次fib(1)
3次fib(0)

像这种重复的计算就相当于相同的分支,在dfs(深度优先搜索)中有个很重要的操作叫做剪枝; 在当前递归的实现中那些重复的计算我们可以进行缓存当后面出现重复计算就不再调用,也算是剪枝,这对于递归是很大的性能优化。

// 创建一个空对象,作为缓存的容器
let cache = {};
function fibonacci(n) {
if (n === 1 || n === 2) {
return 1;
}
if (cache[n]) {
return cache[n];
}
return cache[n] = fibonacci(n - 1) + fibonacci(n - 2);
}

还有一种缓存的方案,在递归的过程中进行计算,回溯的时候把最后的结果返回

function fibonacci(n, cur = 0, next = 1) {
if(n == 0) return 0;
if(n == 1) return next;
return fibonacci(n - 1, next, cur + next);
}

循环求解

function fibonacci(n) {
if (n <= 2) return 1
// 维护"栈"的状态值,以便状态回溯
let first = 1
// 维护"栈"的状态值,以便状态回溯
let second = 1
let ret = 0
n -= 2

while (n--) {
// 当前数 = 前一个数 + 前两个数
ret = first + second
first = second
second = ret
}
return ret
}

现在可以回答第一个问题了,递归不是只能去解决树形数据结构和明显的上下级关系的数据,对这种情况的数据是非常符合递归的定义所以我们很容易能想像出来,像斐波纳契数列 、 阶乘 、杨辉三角..., 这种通过递推可以求解的方式也可以用递归,通过递归过程中变化状态值来求解答案。

二分查找

循环实现

function binarySearch(nums, target) {
let l = 0
let r = nums.length - 1
let mid

while (l <= r) {
mid = (l + r) >> 1
if (nums[mid] === target) {
return mid
} else if (nums[mid] > target) {
r = mid - 1
} else {
l = mid + 1
}
}

return -1
}

递归实现

变换状态值,状态展开生成树

function binarySearch(nums, target, l = 0, r = nums.length - 1) {
let mid
while (l <= r) {
mid = (l + r) >> 1
if (nums[mid] === target) {
return mid
} else if (nums[mid] > target) {
return binarySearch(nums, target, l, mid - 1)
} else {
return binarySearch(nums, target, mid + 1, r)
}
}
return -1
}

算法中递归的应用

在算法中对于递归的应用场景特别特别多,dfs:回溯、二叉树遍历、翻转、路径总和...,把以上这些类型的题用递归刷几题,对于递归就会有更深的认知和理解,后面碰到的需求如果可以用递归解,自然你就能想到递归。

现在给出一些递归可解的leetcode题,并不是说去学算法刷题,这里的目的是做这些题可以让我们对于用递归有更深入的了解,递归用的不太多的可能需要花点时间,全都掌握之后对自己也是个提升

二叉树遍历

二叉树有三种遍历方式:

  • 前序遍历:根、左、右
  • 中序遍历:左、根、右
  • 后续遍历:左、右、根

前序遍历

leetcode144. 二叉树的前序遍历

/**
* @param {TreeNode} root
* @return {number[]}
*/
var preorderTraversal = function(root) {
let ans = []
const preIterator = (root) => {
if (!root) return
ans.push(root.val)
preIterator(root.left)
preIterator(root.right)
}
preIterator(root)
return ans
}

中序遍历

leetcode94. 二叉树的中序遍历

/**
* @param {TreeNode} root
* @return {number[]}
*/
var inorderTraversal = function(root) {
let ans = []
const inIterator = (root) => {
if (!root) return
inIterator(root.left)
ans.push(root.val)
inIterator(root.right)
}
inIterator(root)
return ans
};

后序遍历

leetcode145. 二叉树的后序遍历

/**
* @param {TreeNode} root
* @return {number[]}
*/
var postorderTraversal = function(root) {
let ans = []
const postIterator = (root) => {
if (!root) return
if (!postIterator(root.left)) {
if (!postIterator(root.right)) {
ans.push(root.val)
}
}
}
postIterator(root)
return ans
};

路径总和

leetcode112. 路径总和

var hasPathSum = function (root, targetSum) {
if (!root) return false
targetSum = targetSum - root.val

// 当前节点是叶子节点
if (!root.left && !root.right) {
// 如果当前节点刚好能符合所有ragetSum, 表示当前节点就是目标节点
return targetSum === 0
}

return hasPathSum(root.left, targetSum) || hasPathSum(root.right, targetSum)
};

leetcode113. 路径总和 II

/**
* @param {TreeNode} root
* @param {number} targetSum
* @return {number[][]}
*/
var pathSum = function (root, targetSum) {
let ans = []
const dfs = (root, targetSum, temp = []) => {
if (!root) return
temp.push(root.val)
targetSum -= root.val
if (!root.left && !root.right && targetSum === 0) {
ans.push([...temp])
}
dfs(root.left, targetSum, temp)
dfs(root.right, targetSum, temp)
temp.pop()
}
dfs(root, targetSum)
return ans
};

回溯

leetcode39. 组合总和

/**
* @param {number[]} candidates
* @param {number} target
* @return {number[][]}
*/
var combinationSum = function (candidates, target) {
const ans = []
const dfs = (idx, target, temp = []) => {
if (idx === candidates.length) return
if (target < 0) return
if (target === 0) {
ans.push([...temp])
temp = []
return
}
temp.push(candidates[idx])
dfs(idx, target - candidates[idx], temp)

temp.pop()
dfs(idx + 1, target, temp)
}
dfs(0, target)
return ans
};

写在最后

业精于勤,荒于嬉

如果文章中有那块写的不太好或有问题欢迎大家指出,我也会在后面的文章不停修改。也希望自己进步的同时能跟你们一起成长。喜欢我文章的朋友们也可以关注一下,我会很感激第一批关注我的人。此时,年轻的我和你,轻装上阵;而后,富裕的你和我,满载而归。

图文并茂重新认识下递归_递归实现_08

················· 若川简介 ·················

你好,我是若川,毕业于江西高校。现在是一名前端开发“工程师”。写有《学习源码整体架构系列》20余篇,在知乎、掘金收获超百万阅读。

从2014年起,每年都会写一篇年度总结,已经写了7篇,点击查看​年度总结​。同时,最近组织了源码共读活动,帮助3000+前端人学会看源码。

略。分享、收藏、点赞我的文章就是对我最大的支持~