前言
这是某🐖厂的一道笔试题,其中涉及到了层序遍历恢复二叉树问题和寻找二叉树中和为某一值的路径问题。考察的比较综合,在此记录一下。
提示:以下是本篇文章正文内容,下面案例可供参考
一、题目描述?
给定一棵二叉树(以层序遍历结果的数组形式给出),和一个目标值,返回从根节点出发和为目标值的路径,如果有多条路径则返回最左侧的路径。
示例如下:
目标值:8
二叉树: [3,3,5,2,null,4,6]
如图有两条路径,返回值就应该是[3,3,2]
二、解题步骤
1.根据层序遍历还原二叉树
代码如下(示例):
let path = [3,3,5,2,null,4,6]
//二叉树结点的构造函数
function treeNode(val, left, right) {
this.val = val || 0
this.left = left || null
this.right = right || null
}
//根据层序遍历生成二叉树
function deserialize(path) {
if(path.length === 0) return null;
let root = new treeNode(path[0]),
queue = [root],
cursor = 1,
len = path.length
while(cursor < len) {
let node = queue.shift();
if(path[cursor] !== null) {
let leftNode = new treeNode(path[cursor])
node.left = leftNode
queue.push(leftNode)
}
if(path[cursor+1] !== null) {
let rightNode = new treeNode(path[cursor+1])
node.right = rightNode
queue.push(rightNode)
}
cursor += 2
}
return root
}
let binaryTree = deserialize(path)
简单介绍一下反序列化的思路:用到了一个队列和一个指针,队列用于存放等待寻找孩子结点的结点,指针指向队首结点的左孩子。所以整个反序列化的流程就是用构造函数新建一个结点(此时是没有左右孩子的),然后就放入队列,取出队首元素,根据指针的指向分别给他分配左孩子与右孩子(如果有的话)同时新建的左孩子和右孩子也入队,然后移动指针移到当前队首元素所对应的左孩子处。当指针超过了数组最大长度说明构造结束,返回根节点。
详细步骤可参考leetcode上反序列化二叉树的解法。
2.找二叉树中等于目标值的路径
代码如下(示例):
//找二叉树中和为某一个值的路径
let sumPath = [],
sum = 0,
res = [],
target = 8
//先写出正常遍历的流程,然后在这个过程中选择需要的放入结果数组
function dfs(root) {
if(root!==null) {
//遍历到一个就放入路径,同时改变路径的合
sumPath.push(root.val)
sum += root.val
//判断当前路径是否达到要求
if(sum === target) {
res.push([...sumPath])
}
//分别遍历左右子树
dfs(root.left)
dfs(root.right)
//此处需要好好理解
sumPath.pop()
sum -= root.val
}
}
dfs(binaryTree)
console.log(res)
整个代码流程比较简洁,但是在实现的时候,却是花了很长时间才弄懂,自己的算法基础还是有待加强。
这里用到了回溯算法的,回溯算法的实现用到了树的深度优先搜索。我发现在写的时候,直接写回溯的话可能不容易写出来,可以先写一个树的先根遍历,然后一步一步演变成回溯算法。下面是演进的过程
//用递归实现树的先根遍历,炒鸡简单
function dfs0(root) {
if(root!==null) {
console.log(root.val)//这里可以按先根遍历的顺序拿到树的结点了,就从这里把回溯结合进来
dfs0(root.left)
dfs0(root.right)
}
}
function dfs(root) {
if(root!==null) {
//遍历到一个就放入路径,同时改变路径的合
sumPath.push(root.val)
sum += root.val
//判断当前路径是否达到要求
if(sum === target) {
res.push([...sumPath])
}
//分别遍历左右子树
dfs(root.left)
dfs(root.right)
//此处需要好好理解
sumPath.pop()
sum -= root.val
}
}
这里面有几个地方需要注意:
- 每遍历到一个值就放入sumPath,同时更新sum,然后判断当前的sum是否符合目标值,若符合就放入结果数组,作为路径的一种可能,这里需要注意的是,在遍历所有可能的路径的时候用的是同一个数组,所以在存储可能的结果的时候需要将路径数组的值拷贝到结果数组中,而不能直接传一个引用,这里是容易犯错的一个点。
- 在判断达到目标值的时候,只是将当前路径存一下就可以,不需要return,否则就打乱了递归的顺序,导致元素没法弹出sumPath。
- 最后弹出sumPath也就是发生回溯的地方,需要好好理解一下。单纯的先根遍历这里是不需要再进行操作了,但是因为在遍历每条路径的时候都是用的同一个sumPath,所以当遇到叶子结点的时候,就说明本条路径结束了,需要返回去走另一条路径。
上图是部分递归的流程,帮助理解。其中绿色的结点是叶子结点,之所以画出来,是因为叶子结点是理解什么时候返回的关键。一共画了调用栈的八种状态,每个结点处对应一个状态。下面开始逐个分析一下流程:
1.开始调用栈为空(其实也不为空,应该有全局上下文,但是这里只关注对咱们理解递归有帮助的调用栈),全局上下文执行dfs(binaryTree),dfs(3)进入调用栈开始执行,判断一下该结点不为空,然后将该结点的值放入sumPath,,同时更新sum值,此时sumPath : [3],sum:3;
2.继续执行判断一下sum不等于target,然后执行dfs(3的左子树)这里就来到了第二个调用栈状态,同样执行如上流程,此时sumPath:[3,3],sum:6;
3.又执行dfs(3的左子树),来到第3个调用栈,此时sumPath:[3,3,2],sum:8;这个时候sum与target的值相等,将sumPath复制一份放到res中。继续往下执行。
4.继续执行dfs(2的左子树),来到第四个调用栈,这里就跟之前不一样了,因为2是叶子结点,它的左右子树均为null,所以执行dfs(null)什么都不做就返回了
5.返回之后又来到了dfs(2)的执行上下文,之前执行到了dfs(root.left),所以接下来就执行dfs(root.right),
6.这样就来到了第6个调用栈,同样什么都没做就返回了
7.返回之后来到了第7个调用栈,上次执行到了dfs(root.right),继续执行就sumPath.pop() sum -= root.val ,执行完这两句之后,sumPath的状态变为[3,3],sum变为6,然后整个dfs(2)算是彻底执行完了,弹出调用栈
8.弹出之后,当前调用栈的状态就变成了第8个图的样子,然后按照就会重复5-7的步骤,这里就不再赘述了,感兴趣的可以继续画下去。
总结
相信通过对调用栈的分析,可以对递归的流程有一个更直观的印象。对比其他语言的实现,可以发现用js实现的函数调用时候可以不用传递许多参数,然后这其中就涉及到对于闭包的理解,比如为什么没有给函数传target的变量,但是函数内部还可以使用呢?为什么不用每次都传sumPath和sum,但是还能对同一个值进行操作呢?为什么返回之后sum-= root.val能找到对应结点的值呢?这些问题就涉及到执行上下文的 问题,之后会另起一篇来详细分析这些问题。
画图不易,各位看官给点个赞再走呗_