「数据结构与算法Javascript描述」二叉树
树是计算机科学中经常用到的一种数据结构。树是一种非线性的数据结构,以分层的方式存储数据。树被用来存储具有层级关系的数据,比如文件系统中的文件;树还被用来存储有序列表。本章将研究一种特殊的树:二叉树。选择树而不是那些基本的数据结构,是因为在二叉树上进行查找非常快(而在链表上查找则不是这样),为二叉树添加或删除元素 也非常快(而对数组执行添加或删除操作则不是这样)。
1. 树的定义
树由一组以边连接的节点组成。现实生活中最常见的树的例子是家谱,或是公司的组织架构图。
下图的树展示了更多有关树的术语,在后续讨论中将会提到。一棵树最上面的节点称为「根节点」,如果一个节点下面连接多个节点,那么该节点称为「父节点」,它下面的节点称为「子节点」。一个节点可以有 0 个、1 个或多个子节点。没有任何子节点的节点称为「叶子节点」。
一棵树的局部
「二叉树」是一种特殊的树,它的子节点个数不超过两个。二叉树具有一些特殊的计算性质,使得在它们之上的一些操作异常高效。后续将深入讨论二叉树。
继续回到上图,沿着一组特定的边,可以从一个节点走到另外一个与它不直接相连的节点。从一个节点到另一个节点的这一组边称为「路径」,在图中用虚线表示。以某种特定顺序访问树中所有的节点称为「树的遍历」。
树可以分为几个层次,根节点是第 0 层,它的子节点是第 1 层,子节点的子节点是第 2层,以此类推。树中任何一层的节点可以都看做是子树的根,该子树包含根节点的子节点,子节点的子节点等。我们定义树的层数就是树的深度。
2. 二叉树
正如前面提到的那样,二叉树每个节点的子节点不允许超过两个。通过将子节点的个数限定为 2,可以写出高效的程序在树中插入、查找和删除数据。
在使用 JavaScript
构建二叉树之前,需要给我们关于树的词典里再加两个新名词。一个父节点的两个子节点分别称为「左节点」和「右节点」。在一些二叉树的实现中,左节点包含一组特定的值,右节点包含另一组特定的值。下图展示了一棵二叉树。
二叉树
当考虑某种特殊的二叉树,比如「二叉搜索树」时,确定子节点非常重要。二叉搜索树是一种特殊的二叉树,相对较小的值保存在左节点中,较大的值保存在右节点中。这一特性使得查找的效率很高,对于数值型和非数值型的数据,比如单词和字符串,都是如此。下面我们来介绍一下二叉树的实现。
2.1 二叉搜索树
「二叉搜索树」由节点组成,所以我们要定义的第一个对象就是 Node
,该对象和前面介绍链表时的对象类似。Node
类的定义如下:
function Node(data, left, right) {
this.data = data;
this.left = left;
this.right = right;
this.show = function() {
return this.data;
}
}
Node 对象既保存数据,也保存和其他节点的链接(left 和 right),show()
方法用来显示保存在节点中的数据。
现在可以创建一个类,用来表示二叉搜索树(BST)。我们让类只包含一个数据成员:一个表示二叉搜索树根节点的 Node 对象。该类的构造函数将根节点初始化为 null
,以此创建一个空节点。
BST 先要有一个 insert()
方法,用来向树中加入新节点。这个方法有点复杂,需要着重讲解。首先要创建一个 Node 对象,将数据传入该对象保存。
其次检查 BST 是否有根节点,如果没有,那么这是棵新树,该节点就是根节点,这个方法到此也就完成了;否则,进入下一步。
如果待插入节点不是根节点,那么就需要准备遍历 BST,找到插入的适当位置。该过程类似于遍历链表。用一个变量存储当前节点,一层层地遍历 BST。
进入 BST 以后,下一步就要决定将节点放在哪个地方。找到正确的插入点时,会跳出循环。查找正确插入点的算法如下:
- 设根节点为当前节点。
- 如果待插入节点保存的数据小于当前节点,则设新的当前节点为原节点的左节点;反 之,执行第 4 步。
- 如果当前节点的左节点为 null,就将新的节点插入这个位置,退出循环;反之,继续执行下一次循环。
- 设新的当前节点为原节点的右节点。
- 如果当前节点的右节点为 null,就将新的节点插入这个位置,退出循环;反之,继续执行下一次循环。
有了上面的算法,就可以开始实现 BST 类了。下面包含了 BST 类和 Node 类的定义:
代码语言:javascript复制function Node(data, left, right) {
this.data = data;
this.left = left;
this.right = right;
this.show = function() {
return this.data;
}
}
function BST() {
this.root = null;
this.insert = function(data) {
const node = new Node(data, null, null);
if (this.root === null) {
this.root = node;
} else {
let current = this.root;
while(true) {
if (data < current.data) {
if (current.left === null) {
current.left = node;
break;
}
current = current.left;
} else {
if (current.right === null) {
current.right = node;
break;
}
current = current.right;
}
}
}
}
}
2.2 二叉搜索树的遍历
现在 BST 类已经初步成型,但是操作上还只能插入节点,我们需要有能力遍历 BST,这样就可以按照不同的顺序,比如按照数字大小或字母先后,显示节点上的数据。
有三种遍历 BST 的方式:「中序」、「先序」和「后序」。中序遍历按照节点上的键值,以升序访问 BST 上的所有节点。先序遍历先访问根节点,然后以同样方式访问左子树和右子树。后序遍历先访问叶子节点,从左子树到右子树,再到根节点。
需要中序遍历的原因显而易见,但为什么需要先序遍历和后序遍历就不是那么明显了。我们先来实现这三种遍历方式,在后续中再解释它们的用途。
中序遍历使用递归的方式最容易实现。该方法需要以升序访问树中所有节点,先访问左子树,再访问根节点,最后访问右子树。
中序遍历的代码如下:
代码语言:javascript复制function inOrder(node) {
if (node !== null) {
inOrder(node.left);
console.log(node.show());
inOrder(node.right);
}
}
下图展示了 inOrder()
方法的访问路径。
中序遍历的访问路径
先序遍历的定义如下:
代码语言:javascript复制function preOrder(node) {
if (node !== null) {
console.log(node.show());
preOrder(node.left);
preOrder(node.right);
}
}
注意 inOrder()
和 preOrder()
方法的唯一区别,就是 if 语句中代码的顺序。在 inOrder()
方法中,show()
函数像夹在两个递归调用之间;在 preOrder()
方法中,show()
函数放在两个递归调用之前。
下图展示了先序遍历的访问路径。
先序遍历的访问路径
后序遍历的定义如下:
代码语言:javascript复制function postOrder(node) {
if (node !== null) {
postOrder(node.left);
postOrder(node.right);
console.log(node.show());
}
}
后序遍历的访问路径如下图所示。
后序遍历的访问路径
2.3 二叉搜索树上进行查找
2.3.1 查找最小值和最大值
查找 BST 上的最小值和最大值非常简单。因为较小的值总是在左子节点上,在 BST 上查找最小值,只需要遍历左子树,直到找到最后一个节点。
getMin()
方法查找 BST 上的最小值,该方法的定义如下:
BST.prototype.getMin = function() {
let current = this.root;
while (current.left !== null) {
current = current.left;
}
return current.data;
}
在 BST 上查找最大值,只需要遍历右子树,直到找到最后一个节点,该节点上保存的值即为最大值。
getMax()
方法的定义如下:
BST.prototype.getMax = function() {
let current = this.root;
while (current.right !== null) {
current = current.right;
}
return current.data;
}
2.3.2 查找给定值
在 BST 上查找给定值,需要比较该值和当前节点上的值的大小。通过比较,就能确定如果给定值不在当前节点时,该向左遍历还是向右遍历。
find()
方法用来在 BST 上查找给定值,定义如下:
BST.prototype.find = function(data) {
let current = this.root;
while(current !== null) {
if (current.data === data) {
return current;
} else if (current.data < data) {
current = current.right;
} else {
current = current.left;
}
}
return null;
}
如果找到给定值,该方法返回保存该值的节点;如果没找到,该方法返回 null
。
2.4 二叉搜索树上删除节点
从 BST 上删除节点的操作最复杂,其复杂程度取决于删除哪个节点。如果删除没有子节点的节点,那么非常简单。如果节点只有一个子节点,不管是左子节点还是右子节点,就变得稍微有点复杂了。删除包含两个子节点的节点最复杂。
为了管理删除操作的复杂度,我们使用递归操作,同时定义两个方法:remove()
和 removeNode()
。
从 BST 中删除节点的第一步是判断当前节点是否包含待删除的数据,如果包含,则删除该节点;如果不包含,则比较当前节点上的数据和待删除的数据。如果待删除数据小于当前节点上的数据,则移至当前节点的左子节点继续比较;如果删除数据大于当前节点上的数据,则移至当前节点的右子节点继续比较。
如果待删除节点是叶子节点(没有子节点的节点),那么只需要将从父节点指向它的链接指向 null
。
如果待删除节点只包含一个子节点,那么原本指向它的节点久得做些调整,使其指向它的子节点。
最后,如果待删除节点包含两个子节点,正确的做法有两种:要么查找待删除节点左子树上的最大值,要么查找其右子树上的最小值。这里我们选择后一种方式。
我们需要一个查找子树上最小值的方法,后面会用它找到的最小值创建一个临时节点。将临时节点上的值复制到待删除节点,然后再删除临时节点。下图展示了这一过程。
删除包含两个子节点的节点
整个删除过程由两个方法完成。remove()
方法只是简单地接受待删除数据,调用 removeNode()
删除它,后者才是完成主要工作的方法。两个方法的定义如下:
BST.prototype.remove = function(data) {
this.root = this.removeNode(this.root, data);
}
BST.prototype.removeNode = function (node, data) {
if (node === null) {
return null;
}
if (data === node.data) { // 没有子节点的节点
if (node.left === null && node.right === null) {
return null;
} // 没有左子节点的节点
if (node.left === null) {
return node.right;
} // 没有右子节点的节点
if (node.right === null) {
return node.left;
} // 有两个子节点的节点
var tempNode = this.getSmallest(node.right);
node.data = tempNode.data;
node.right = this.removeNode(node.right, tempNode.data);
return node;
} else if (data < node.data) {
node.left = this.removeNode(node.left, data);
return node;
} else {
node.right = this.removeNode(node.right, data);
return node;
}
}
2.5 更多二叉树
BST存在一个问题:取决于你添加的节点数,树的一条边可能会非常深;也就是说,树的一条分支会有很多层,而其他的分支却只有几层,如下图所示:
image-20220129120852646
这会在需要在某条边上添加、移除和搜索某个节点时引起一些性能问题。为了解决这个问题,有一种树叫作「阿德尔森-维尔斯和兰迪斯树」(AVL树)。AVL树是一种自「平衡二叉搜索树」,意思是任何一个节点左右两侧子树的高度之差最多为1。也就是说这种树会在添加或移除节点时尽量试着成为一棵完全树。
关于树的内容有很多,例如还有「红黑树」、「线索树」等,后续会为读者一一介绍,敬请期待。