二叉树的遍历
系列开篇
为进入前端的你建立清晰、准确、必要的概念和这些概念的之间清晰、准确、必要的关联, 让你不管在什么面试中都能淡定从容。没有目录,而是通过概念关联形成了一张知识网络,往下看你就明白了。当你遇到【关联概念】时,可先从括号中的(强/弱)判断简单这个关联是对你正在理解的概念是强相关(得先理解你才能继续往下)还是弱相关(知识拓展)从而提高你的阅读效率。我也会定期更新相关关联概念。
算法拆解是带你分析算法,掌握算法规律,体会概念与关联的力量。更重要的是让你不害怕算法题,利用分治思维拆解它,你会发现,又是回到了 概念 和 关联 上。下面来看看我们遇到问题,如何去拆解, 并举一反三的吧。
算法是逃不掉的,它是你最直接的实现程序能力的体现。你写的每一句代码,每一种架构思考,每一种优化方式,都是你编程路上的硬核能力,所幸这个能力下'功夫'就能得到。
面试题
- 手写二叉树的先序/中序/后序遍历
- 手写二叉树的层序遍历
- 说说这些遍历方式的不同
- 你能不用递归用迭代方式实现遍历吗
二叉树的 先序/中序/后序 遍历
给你二叉树的根节点 root ,返回它节点值的 前/中/后 序 遍历。
挂上leetcode链接,可以用来查看更多示例和测试,并最后通过它。
首先我们把这句话拆成以下几个问题
- 什么是
二叉树
- 什么是
先序/中序/后序
- 什么是
遍历
下面一个个分析,这就是拆解,慢慢来不急,有时候慢就是快
什么是二叉树
我们的问题又能拆为
- 什么是
树
二叉树
是什么树 (二叉树是怎样性质的树)
(这两个问题 后面单独出文章吧 内容比较多)
什么是遍历 (Traversal)
遍历就是可以理解:使每个结点被访问一次且仅被访问一次。而且限制了从左到右的习惯方式。
什么是先序/中序/后序
所谓前序、中序、后序,不过是根的顺序,即也可以称为前根遍历、中根遍历、后根遍历
- 前序 对于二叉树中的任意一个节点,先打印该节点,然后是它的左子树,最后右子树 根 -> 左 -> 右
- 中序 对于二叉树中的任意一个节点,先打印它的左子树,然后是该节点,最后右子树 左 -> 根 -> 右
- 后序 对于二叉树中的任意一个节点,先打印它的左子树,然后是右子树,最后该节点 左 -> 右 -> 根
举个例子
树的图例
1
\
2
/
3
输入:root = [1, null, 2, 3]
先序输出:[1, 2, 3]
中序输出:[1, 3, 2]
后序输出:[3, 2, 1]
我们举个例子 root = [1, null, 2, 3]
先序遍历 为什么是 [1, 2, 3]
按以下步骤思考
- 先序任意节点的打印顺序都是 根 -> 左 -> 右
- 那么首先看root 为 1 (左) (右) => 1 (null) (2)
- null 跳过 右子树 val 是 2 继续 按照 根 -> 左 -> 右 此时 2 为根
- 2 (左) (右) => 2 (3) (null)
- 合并看 2 , 4 其实就是一个序列
[1, null, 2, 3, null]
- 把 null 忽视掉 得到输出
[1, 2, 3]
那么中序也是同理: 我简单列两步
- 中序任意节点的打印顺序都是 左 -> 根 -> 右
- 那么首先看root 为 (左) 1 (右) => (null) 1 (2)
- null 跳过 右子树 val 是 2 继续 按照 左 -> 根 -> 右 此时 2 为根
- (左) 2 (右) => (3) 2 (null)
- 合并看 2 , 4 其实就是一个序列
[null, 1, 3, 2, null]
- 把 null 忽视掉 得到输出
[1, 3, 2]
后序就不细说了,我想你应该会了
所以到此我们清楚了题意:
二叉树的(前/中/后)遍历是指 从根结点出发,按照某种次序(前/中/后)依次访问二叉树中所有结点,使得每个结点被访问一次且仅被访问一次。
下面就给出递归解法思路:
前序遍历实现
const preOrderTraversalRecursion = (root) => {
let resList = []
const preOrderTraversal = (node) => {
if (node !== null) {
// 先访问根节点
resList.push(node.val) // ---------> (根)
// 然后遍历左子树
preOrderTraversal(node.left) // ---------> (左)
// 再遍历右子树
preOrderTraversal(node.right) // ---------> (右)
}
}
// 根节点作为第一个参数传入
preOrderTraversal(root)
return resList
};
中序遍历实现
const midOrderTraversalRecursion = (root) => {
let resList = []
const midOrderTraversal = (node) => {
if(node !== null) {
// 先遍历左子树
midOrderTraversal(node.left) // ---------> (左)
// 然后访问根节点
resList.push(node.val) // ---------> (根)
// 再遍历右子树
midOrderTraversal(node.right) // ---------> (右)
}
}
midOrderTraversal(root)
return resList
}
后序遍历 我也就不写了,再明显不过了。
递归写法就只是访问根的顺序变下,其他都遍历一模一样。
解决这方面问题,用【递归】我们的关键思路是
每个节点需要做的事情是一样的,我们只要弄清每个节点该干嘛就行
比如先序遍历,每个节点就是干这几件事
- 打印自己 (访问根)
- 对自己左子树的遍历
- 对自己右子树的遍历 递归出口 就是 当前节点为 null 就没有再左右子树一说了,也不必打印自己了
其实递归就2要素:
- 自己调用自己
- 有个明确的递归出口 (结束条件)
好了 我想这个递归解决问题思路你已掌握。下一步
迭代解决上面的 二叉树的 先序/中序/后序 遍历
首先迭代算法可能你并不需要掌握,只是用来练练思维和编程实现手法。
前序遍历迭代实现
思路:
- 建立一个栈(后进先出的数据结构)
- 首先根入栈
- 将根节点出栈,将根节点值放入结果数组中
- 然后遍历左子树、右子树,因为栈是先入后出,所以先右子树入栈,后左子树入栈
- 继续出栈。。直到栈空
实现:
const preOrderTraversalIteration = (root) => {
const resList = [];
// 建立一个栈,注意栈是后进先出的数据结构
const stack = [];
// 当根节点不为null的时候,将根节点入栈
if (root !== null) {
stack.push(root)
}
while(stack.length > 0) {
// 推出栈顶元素为当前访问的节点
let curNode = stack.pop()
// 访问当前节点,第一步的时候,访问的是根节点
resList.push(curNode.val)
// 先序遍历的节点访问顺序是 根->左子树->右子树
// 而先入栈的后访问,所以先入栈右子树,再入栈左子树,这样就左子树就会先被栈弹出来访问,再右子树
// 所以先入栈 右子树 这样 右子树就会在后面被 pop() 也就是后访问到
if(curNode.right !== null) {
stack.push(curNode.right)
}
if(curNode.left !== null) {
stack.push(curNode.left)
}
}
return resList
}
后序遍历迭代实现
为什么这里先写后序遍历,因为有个取巧方法。
二叉树后序遍历迭代实现,其实就是先序遍历2个步骤的逆序,其实就是
- 把先序遍历结果先逆序,实现方式也简单: 插入结果列表时
resList.push(node.val)
变更为resList.unshift(node.val)
就行了,那么遍历顺序就由 根 左 右 变更为 右 左 根 。 - 然后我们仅需将 右 左 根 变更为 左 右 根 即可完成后序遍历。做法就是左右子树入栈顺序改变就行了。 实现
const postOrderTraversalIteration = (root) => {
const resList = [];
let stack = [];
if (root !== null) {
stack.push(root)
}
while(stack.length > 0) {
let curNode = stack.pop()
// 根 左 右 => 右 左 根
resList.unshift(curNode.val)
// 右 左 根 => 左 右 根
if(curNode.left !== null) {
stack.push(curNode.left)
}
if(curNode.right !== null) {
stack.push(curNode.right)
}
}
return resList
}
中序遍历迭代实现
const midOrderTraversalIteration = (root) => {
const resList = []
let stack = []
let curNode = null
if (root !== null) {
curNode = root
}
// 当栈不为空 或者 当前节点不为空时进行循环
while (stack.length > 0 || curNode !== null) {
// 中序遍历,是先访问左子树 => 再根 => 再右子树
while (curNode !== null) {
// 我们现在当前的是根,得先入栈,把当前节点置为它的左子树
stack.push(curNode)
curNode = curNode.left
}
if (stack.length > 0) {
// 从栈中取东西了,把最上面先推出来
curNode = stack.pop()
// 进行访问, 输入数据到结果序列
resList.push(curNode.val)
// 把当前节点置为它的右子树
curNode = curNode.right
}
}
}
这个看的可能一脸懵逼,简单来说下面几个步骤,尝试理解
- 遇到根节点,先入栈,如果它有左节点,那么继续入栈左节点,直到当前入栈的节点没有左子树了,那么可以理解为,当前为最左边的节点了
- 出栈,栈顶是刚入栈的最左节点 并 打印 最左边的节点
- 如果这个最左节点有右子树,那么当前应该访问它的右子树了,因为这个节点没有左儿子,现在它自己被打印了当成根看,下面就是右子树的访问了 左 根 右
- 所以,当前节点变成了右子树,本次循环结束
- 把现在的 右子树 当成第一次进来的根 来进行下一轮循环
- 再从第一步开始 入栈,看有没有左子树 继续找这个节点的最左节点 入栈最左节点。。。
二叉树的层序遍历
先了解问题是什么 看下面题目就很清楚了, 层序 即逐层
地,从左到右
访问所有节点。示例:二叉树层序遍历
二叉树:[3,9,20,null,null,15,7] ,
3
/ \
9 20
/ \
15 7
返回其层次遍历结果:
[
[3],
[9,20],
[15,7]
]
我们可以看到层序遍历的结果是个二维数组。 似乎我们可以把根作为起点来走,左右子树当成岔道,来进行BFS或DFS查找,查找其实过程就是遍历。 找东西嘛,就是按照某种策略(DFS/BFS),一个个找过去(遍历)。
我们先简单了解 DFS / BFS 到时候查找算法会精说,先留个印象。
DFS(Deep First Search)深度优先搜索。
深度优先搜索的步骤分为
递归
下去回溯
上来
- 顾名思义,深度优先,先一条路走到底,直到达到目标。这里称之为递归下去。
- 否则既没有达到目标又无路可走了,那么则退回到上一步的状态,走其他路。这便是回溯上来。
BFS(Breath First Search)广度优先搜索。
广度优先搜索较之深度优先搜索之不同在于,深度优先搜索旨在不管有多少条岔路,先一条路走到底,不成功就返回上一个路口然后就选择下一条岔路。
而广度优先搜索旨在面临一个路口时,把所有的岔路口都记下来,然后选择其中一个进入,然后将它的分路情况记录下来,然后再返回来进入另外一个岔路,并重复这样的操作
两者不同
- 数据结构上的运用 这个结合下面代码理解
- DFS用递归的形式,用到了栈结构,先进后出。
- BFS选取状态用队列的形式,先进先出。
- 复杂度
DFS的复杂度与BFS的复杂度大体一致,不同之处在于遍历的方式与对于问题的解决出发点不同,
- DFS适合目标明确
- BFS适合大范围的寻找
- 思想 思想上来说这两种方法都是遍历穷举所有的情况。
下面来说这跟我们的层次遍历解法之间有啥关系。其实就是说层次遍历可以用2种方案实现。
层次遍历BFS实现
BFS 是按层层推进的方式,遍历每一层的节点。题目要求的是返回每一层的节点值,所以这题用 BFS 来做非常合适。
const levelOrderBFS = function(root) {
let resList = []
let queue = []
if (root !== null) {
queue.push(root)
}
while(queue.length > 0) {
let curLevelList = []
let levelSize = queue.length
while(levelSize > 0) {
// 队列先进先出,用shift从队列头取出node并访问
let node = queue.shift()
// 把node推进每层的list
curLevelList.push(node.val)
// 当前层list长度 - 1
levelSize--
// 这个node的左右子树就是下一层的node节点
// 所以分别把当前取出的node的左右子树推进一个临时列表,作为下层循环的队列
// 层次遍历从左到右,先进左子树后进右子树
if(node.left !== null) {
queue.push(node.left)
}
if(node.right !== null) {
queue.push(node.right)
}
}
// 把当前层list推到结果列表
resList.push(curLevelList)
}
return resList
};
BFS 需要用队列作为辅助结构,下面是简单说明步骤
- 我们先将根节点放到队列中,第一层有一个节点,队列长度为 1 ,这个长度也就表示该层有几个节点
- 取出队列头部node 第一次就是根 值放进该层list 并把它左右子树入队列,(其实就是记录这层有多少岔道)
- 把该层list 放进结果数组就行了,一层的遍历就完成了,下面第二层同样的操作就行了,继续重复
实现上来说2层循环,外层循环一次代表一层,得到一个列表,放进结果数组,内部循环循环该层队列中的每个节点,并把这层节点的左右子树推到下一层队列中。(因为循环次数为当前层节点个数,所以后面推进去的是下一层循环才会用到)
层次遍历DFS实现
DFS 是沿着树的深度遍历树的节点,尽可能深地搜索树的分支
const levelOrderDFS = function(root) {
const resList = []
var depthFirst = function (node, level){
if(node === null) {
return
}
// 当遍历到一个新的深度 level, 新建一个列表数组
resList[level] = resList[level] || []
// 把节点值推进当前它的level对应的list中
resList[level].push(node.val)
// 递归调用, 当前节点有左右子树,下级循环level + 1
depthFirst(node.left, level + 1)
depthFirst(node.right, level + 1)
}
// 0正好代表根节点 level 也是数组的开始 index
depthFirst(root, 0)
return resList
};
DFS 不是按照层次遍历的。为了让递归的过程中同一层的节点放到同一个列表中,在递归时要记录每个节点的深度 level 。递归到新节点要把该节点推入 对应 level 的列表的末尾。
当遍历到一个新的深度 level,而最终结果 resList 中还没有创建 level 对应的列表时,应该在 resList 中新建一个列表用来保存该 level 的所有节点。
小结
- 二叉树的遍历是指从根结点出发,按照某种次序依次访问二叉树中所有结点,使得每个结点被访问一次且仅被访问一次。
- 所谓前序、中序、后序,不过是根的顺序。前序(
根
/左/右) 中序(左/根
/右) 后序(左/右/根
) - 前序、中序、后序递归写法核心
const xOrderTraversal = (node) => {
if (node !== null) {
// 前序 resList.push(node.val)
preOrderTraversal(node.left)
// 中序 resList.push(node.val)
preOrderTraversal(node.right)
// 后序 resList.push(node.val)
}
}
- 写树相关的算法,搞清楚当前 root 节点该做什么,然后根据函数定义递归调用子节点,递归调用说明每个节点执行的方法都是一样的。
- 层序遍历是
逐层
地,从左到右
访问所有节点 - 层序遍历可以用
BFS / DFS
两种思路解决。
继续下去,你总会有收获。 上面这句话给你们,同样也给我自己前进的动力。
我是摩尔,数学专业,做过互联网研发,测试,产品
致力用技术改变别人的生活,用梦想改变自己的生活
关注我,找到自己的互联网思路,踏实地打牢固自己的技术体系