今天我们来学习一下二叉树这种数据结构。它是一种什么样的数据结构呢?
1. 二叉树的概念
在js中,也没有一个特殊的符号来表示二叉树,我们会用对象来模拟一个二叉树。上一个我们聊到过用对象模拟的数据结构是什么?是链表吧。其实链表和二叉树还有点相似。
链表里面是有一个对象,对象里面有一个值和一个子对象,子对象里面又有一个值和一个子对象,它始终只能有一个子对象。而树这种结构无非就是可以有多个子对象,像树的枝丫一样分叉出去。
就像下图一样,如果某一种数据按下图所示的结构存取的话,我们就称它为数结构:
一个对象里面有很多个子对象,每个子对象中又可以有很多个子对象。
我们把第一个对象称为根节点,不再有子对象的节点称为叶子节点,其他的都叫节点。
这样不就像一棵树一样嘛,从树的根部分出很多个枝丫,每个枝丫又可以分出很多个枝丫,直到树的顶部就是叶子了,不再有枝丫。所以我们管这种数据结构叫树。
我们还需要介绍一些树的概念:
- 层次计算规则:根节点为第1层,依次往下
- 高度的计算规则:叶子节点高度为 1,依次往上
- 度:一个节点开叉的个数
我们今天要聊的就是一种特殊的树——二叉树。二叉树是什么呢?从字面意思上来理解就是分了两个叉嘛。
每个节点的度不超过2,我们就管这种树叫二叉树。意思就是说如果一个节点只有一个子节点的话,我们可以说它还有一个空子节点,所以它也是二叉树,当然如果一个节点没有子节点的话它就是叶子节点了。
如果这棵二叉树叶子节点都在最底层,除了叶子节点,每个节点都有左右两个子节点(每个节点的度都是 2)的话,我们就管它叫做满二叉树。
那我们来用js来描述一下二叉树,我们说过我们可以使用对象来模拟二叉树。那这个对象的结构就可以分为:数据域、左侧的子节点和右侧的子节点。
所以我们可以来写一个创建二叉树节点的构造函数:
function TreeNode(val) {
this.val = val;
this.left = this.right = null;
}
const node1 = new TreeNode(1);
const node2 = new TreeNode(2);
const node3 = new TreeNode(3);
node1.left = node2;
node1.right = node3;
和链表创建节点的构造函数差不多。我们可以创建3个节点node1,node2和node3。如果我们让node1.left = node2,node1.right = node3。这3个节点不就构成了一棵二叉树吗。node1节点为根节点,node2和node3就为叶子节点。
这棵二叉树的对象结构就长这样:
const obj = {
val: 1,
left: {
val: 2,
left: null,
right: null
},
right: {
val: 3,
left: null,
right: null
}
}
它的根节点就为obj。obj有一个数据域 1,还有一个左节点left和一个右节点right。
如果我们想取这棵二叉树中的值来用的话,是不是也和链表差不多。我们要取1就得obj.val,要取2就得obj.left.val,取3就得obj.right.val。
二叉树的一些基本概念就这些,接下来我们来聊聊如何遍历一棵二叉树。
2. 二叉树的遍历
二叉树有三种遍历方式,分别是前序遍历,中序遍历和后序遍历。
- 前序遍历:根节点 -> 左子树 -> 右子树
- 中序遍历:左子树 -> 根节点 -> 右子树
- 后序遍历:左子树 -> 右子树 -> 根节点
规律就是根节点是在什么时候进行访问的。如果根节点第一个访问就叫前序遍历,如果根节点是最后一个访问的就叫后序遍历,左子树和右子树就不用管,永远是先左后右。很好记对吧。那我们通过一个例子来具体看一下每一种遍历方式是如何遍历的。
2.1 前序遍历
假如有这样一棵树:
如果我们前序遍历它,它的输出结果应该是什么?
那就按照前序遍历的规则来,先根节点,再左子树,再右子树。
那第一个输出的是不是就是A了,然后再左子树,第二个就是B了,那第三个呢?是C吗?不是的,我们前序遍历的规则应该是先根节点,再左子树,再右子树。要把左子树走完了才能去遍历右子树。所以我们得这样,把右边这个子树看成是一个整体,一棵新的二叉树,再去前序遍历。
对于左边的这棵子树再去先根节点,左子树,右子树,所以输出顺序应该是A、B、D、E。然后左子树遍历完了,去遍历右子树,就C、F。所以前序遍历这棵树的最终输出结果应该是A、B、D、E、C、F。
那我们用代码来实现它应该怎么写呢?
function preorder(root) {
}
我们定义一个函数preorder,用它来实现前序遍历。root代表根节点。
因为先遍历根节点,所以我们首先输出一下console.log(root.val),这样就能获取到A。
function preorder(root) {
console.log(root.val)
}
然后应该去遍历左子树。怎么遍历左子树呢?是不是用递归就可以了呀。将root.left传给preorder函数,就相当于我们将整棵左子树传给了它,它又重新执行这个函数,就会获取到左子树根节点的值,就会读到B,然后又会将此时的左子树传给它,就会读到E,然后E没有左子树,我们就以此来设置递归的出口,然后再进行右子树的递归。
function preorder(root) {
if (!root) return
console.log(root.val);
preorder(root.left)
preorder(root.right)
}
注意preorder(root.left)这一行代码是不是带来了上下文入栈,这一条语句就会一直递归下去,等递归结束后才会执行preorder(root.right)语句,此时递归结束root落在了B身上,于是去读它的右子树,就会读到D,然后D没有右子树,返回上一层,就会去读A的右子树,C就开始递归,遍历它的左子树,左子树遍历完才会去遍历它的右子树。
所以最后输出结果会是A、B、D、E、C、F。实现了我们的前序遍历。
2.2 中序遍历
那我们再来看看中序遍历。中序遍历的规则是先左子树,再根节点,再右子树。
所以还是对于这棵树,输出结果会是什么?
A为根节点,所以不输出,先去找它的左子树,找到了B,B此时为这棵左子树的根节点,所以也不输出,去找B的左子树,找到了D,D没有左子树和右子树,所以先输出D,左子树找完了,再根节点,接着就输出B,然后再右子树,就输出E,然后A的左子树遍历完了,输出根节点A,然后遍历A的右子树,以此类推。
所以最终输出结果会是:D、B、E、A、C、F。
那它的代码不就是这样吗:
function inorder(root) {
if (!root) return
inorder(root.left)
console.log(root.val);
inorder(root.right)
}
根节点进来的时候先不输出,先将它的左子树进行递归,直到递归到D,然后输出D,回到上一层,输出B,B的左子树和根节点都遍历完了,遍历B的右子树,就输出E,然后回到A,以此类推。
2.3 后序遍历
那后序遍历同理吧。后序遍历的规则是先左子树再右子树再根节点。
那还是这棵树:
输出结果就会是:D、E、B、F、C、A。
先得遍历完左子树才能去遍历右子树,最后遍历根节点。
所以它的代码就是:
function postorder(root) {
if (!root) return
postorder(root.left)
postorder(root.right)
console.log(root.val);
}
先递归左子树,再递归右子树,然后才输出值。
3. DFS(深度优先), BFS(广度优先)
聊到二叉树那就不得不聊聊两种遍历的思想了,DFS,深度优先遍历;BFS,广度优先遍历。并不是说DFS和BFS是专门为二叉树打造的,其它数据结构也有这两种思想。这里,我们来通过二叉树来聊聊DFS和BFS会更好理解。
3.1 DFS(深度优先)
那我们先来聊聊DFS,深度优先遍历。它是怎么进行遍历的呢?从字面意思上来看,就是以深度为优先去遍历嘛。
我来给你举个例子你会更好理解。假如我们正在走一个迷宫,迷宫有一个入口和一个出口。我们在入口这,我们就沿着入口当前的路一直走,直到碰到了岔路口。当碰到了岔路口之后,我们什么都不管,就往左岔路口走,然后又碰到一个岔路口,还是往左走,直到没有左岔路口了,是死路,我们才换一个方向。这样就是以深度为优先,可以走的很深。
所以深度优先:贯彻落实一条道走到底,不撞南墙不回头的思想。本质是栈结构。
为什么说本质是栈结构呢?你看这样一张图:
如果我们要用栈来存这个迷宫的一条正确路径会怎么存呢?
栈是先进后出的,所以我们会这样存。先存进A,B,碰到岔路口,先存进C看看,发现C不是我们想要的,就让C出栈,然后D入栈看看,发现D也不是我们想要的,D出栈,然后E入栈,是我们想要的,就让它留着,依次类推。整个过程就可以使用栈的入栈出栈来模拟,所以我们说DFS本质是栈结构。
哎,你看,这种遍历方式是不是和我们二叉树的前序遍历很相似啊。
我们再来看这张图:
还记得前序遍历的顺序是什么吗?是A、B、D、E、C、F。这是不是相当于一条道路走到底,走到了D身上,然后碰到死胡同了,就返回上一个岔路口,换一个方向走。其实,二叉树的前序遍历就是深度优先遍历的思想,先一条道路走到底,以深度为优先。
3.2 BFS(广度优先)
那BFS,广度优先遍历是如何进行遍历的呢?那就是以广度为优先吧。
我也来给你举个例子,警察们追捕逃进深山的逃犯,假如上山的路有3条,警察们会如何追捕逃犯呢?他们会先一条道路走到底,发现没有,再换一条道路搜查吗?那这样逃犯早就逃之夭夭了吧。警察们一定是会兵分3路,同步进行搜查,这就是广度优先遍历。
所以广度优先就是:地毯式层层推进,从起始顶点开始,依次往外遍历。本质是队列结构。
广度优先也能叫做层序遍历。
为什么说广度优先本质是队列结构呢?还是这张图,我们来看看:
队列是先进先出的。
我们要用队列来描述一下广度优先遍历,那先走到A身上,A入队列,发现只有B,那广度优先走过的是不是就不用了,那A就出队列,B就入队列,站在B身上我们观察到有C、D、E三条路可走,那B走过了,就出队列,C、D、E就入队列,每一条路都要走一下,于是C发现不是,C就出队列;D发现不是,D就出队列;E发现还有路走,就将E的下一层F、G入队列,E就出队列,然后继续进行下去。
所以我们可以来写一下广度优先遍历的伪代码。
function BFS(入口坐标) {
let queue = [入口坐标]
while (queue.length) {
const top = queue.shift()
// 审查 top
// 站在top坐标上能看到的所有坐标全部记录下来
for (top坐标上能看到的所有坐标) {
queue.push(top能到达的坐标)
}
}
}
先将入口坐标入队列,然后去遍历搜查队列的头部元素top,将它从队列中取出来审查,将在top身上能看到的坐标先全部入队列,然后只要队列中有元素,我们就取top元素出来审查,因为我们使用的是shift方法,所以每次队列的top元素都会改变,保证我们遍历到每一个元素。这样就是广度优先遍历。
4. 二叉树的层序遍历
那对于一棵二叉树,我们对它进行层序遍历,输出结果会是什么呢?
还是这棵二叉树,对它进行层序遍历,输出结果应该是A、B、C、D、E、F吧。
那代码应该怎么写呢?对应力扣上的第102题,102. 二叉树的层序遍历。
题目为:给你二叉树的根节点 root ,返回其节点值的 层序遍历 。 (即逐层地,从左到右访问所有节点)。
示例 1:
输入: root = [3,9,20,null,null,15,7]
输出: [[3],[9,20],[15,7]]
示例 2:
输入: root = [1]
输出: [[1]]
示例 3:
输入: root = []
输出: []
它要求我们返回一个二维数组,每一层的数据放到一个数组里。
就按照我们广度优先遍历时写伪代码的思路来,我们准备一个队列queue来广度优先遍历我们的二叉树。然后先将root push到队列中。此时就是将第一层放到了队列中去。
var levelOrder = function (root) {
const queue = []
queue.push(root)
};
然后去循环将queue中的第一个元素取出来,循环条件就为queue.length,目的是为了将队列此时每一个元素都取出来。
var levelOrder = function (root) {
const queue = []
queue.push(root)
while (queue.length) {
}
};
此时取出来的元素就是二叉树第一层的元素,于是我们要准备一个数组level,用来存放这一层的值,到时候准备添加到那个二维数组中去。
var levelOrder = function (root) {
const queue = []
queue.push(root)
while (queue.length) {
const level = []
const len = queue.length
for (let i = 0; i < len; i++) {
const top = queue.shift()
level.push(top.val)
}
}
};
for循环,每次都将队列头部元素取出来放到level数组中,取完了之后我们就应该将二叉树第二层的值都放进队列中。
var levelOrder = function (root) {
const queue = []
const res = []
if (!root) return res
queue.push(root)
while (queue.length) {
const level = []
const len = queue.length
for (let i = 0; i < len; i++) {
const top = queue.shift()
level.push(top.val)
if (top.left) {
queue.push(top.left)
}
if (top.right) {
queue.push(top.right)
}
}
}
};
这一次for循环后我们就将二叉树第一层的值从队列中取出来push到了level数组中,并且将二叉树第二层的值都放进来队列中。于是此时我们还要准备一个res数组,用来存放我们的结果,于是我们将level添加到res中。
var levelOrder = function (root) {
const queue = []
const res = []
if (!root) return res
queue.push(root)
while (queue.length) {
const level = []
const len = queue.length
for (let i = 0; i < len; i++) {
const top = queue.shift()
level.push(top.val)
if (top.left) {
queue.push(top.left)
}
if (top.right) {
queue.push(top.right)
}
}
res.push(level)
}
return res
};
然后此时队列中又有值了,又去进行while循环,直到遍历完二叉树中所有的值。就返回这个res。当然还要在开头判断一下root是否存在。
这样二叉树广度优先遍历的代码我们就写完了。就是秉承我们在写伪代码时的思想,想到广度优先,就要想到队列,用队列来遍历二叉树每一层的值。先将根节点存进去,然后取出来读一下它的值,再将根节点的左子树和右子树存进去,也就是二叉树的第二层存进去,然后在每一次取出来读值时将它的左子树和右子树也存进去,也就是第三层。这样我们就实现了二叉树的层序遍历。
5. 总结
本次我们一起学习了一下二叉树这种数据结构,我们把每个节点的度小于等于2的树称为二叉树,我们可以使用对象来模拟二叉树,这个对象由一个数据域、两个子对象组成。我们还聊了一下二叉树是如何进行遍历的。有三种遍历方式:前序遍历、中序遍历和后序遍历,我们可以使用递归来完成遍历。还简单聊了聊DFS(深度优先)和BFS(广度优先)是什么样的遍历方式,最后我们完成了二叉树的层序遍历。
如果对你有帮助的话不妨点个赞把!