树 Tree
树(tree)是一种抽象数据类型(ADT)或是实现这种抽象数据类型的数据结构,用来模拟具有树状结构性质的数据集合。
哈希表和树都是非顺序数据结构。
树的相关术语
一个树结构包含一系列存在父子关系的节点。每个节点都有一个父节点(除了顶部的第一个 节点)以及零个或多个子节点:
- 位于树顶部的节点叫作根节点(11)。它没有父节点。
- 树中的每个元素都叫作节点,节点分为内部节点和外部节点。至少有一个子节点的节点称为内部节点(7、5、9、15、13 和 20 是内部节点)。没有子元素的节点称为外部节点或叶节点(3、6、8、10、12、14、18 和 25 是叶节点)。
- 一个节点可以有祖先和后代。一个节点(除了根节点)的祖先包括父节点、祖父节点、曾祖 父节点等。一个节点的后代包括 子节点、孙子节点、曾孙节点等。例如,节点 5 的祖先有节点 7 和节点 11,后代有节点 3 和节点 6。
- 有关树的另一个术语是子树。子树由节点和它的后代构成。例如,节点 13、12 和 14 构成了上图中树的一棵子树。
- 节点的一个属性是深度,节点的深度取决于它的祖先节点的数量。比如,节点 3 有 3 个祖先 节点(5、7 和 11),它的深度为 3。
- 树的高度取决于所有节点深度的最大值。一棵树也可以被分解成层级。根节点在第 0 层,它 的子节点在第 1 层,以此类推。上图中的树的高度为 3(最大高度已在图中表示——第 3 层)。
二叉树和二叉搜索树
二叉树中的节点最多只能有两个子节点:一个是左侧子节点,另一个是右侧子节点。这个定义有助于我们写出更高效地在树中插入、查找和删除节点的算法。
二叉搜索树(BST)是二叉树的一种,但是只允许在左侧节点存储(比父节点)小的值,在右侧节点存储(比父节点)大的值。上一节的图中就展现了一棵二叉搜索树。
实现二叉搜索树
创建 Node 类来表示二叉搜索树中的每个节点,代码如下:
class Node {
constructor(key) {
this.key = key; // 节点值
this.left = null; // 左侧子节点引用
this.right = null; // 右侧子节点引用
}
toString() {
return `${this.key}`;
}
}
下图展现了二叉搜索树数据结构的组织方式:
和链表一样,通过指针(引用)来表示节点之间的关系(树相关的术语称其为边)。在双向链表中,每个节点包含两个指针,一个指向下一个节点,另一个指向上一个节点。对于树,使用同样的方式(也使用两个指针),但是一个指向左侧子节点,另一个指向右侧子节点。因此,将声明一个 Node 类来表示树中的每个节点。
值得注意的一个小细节是,一般将节点本身称作节点或项,但在树中也称其为键。键是树相关的术语中对节点的称呼。
下面是将要在 BinarySearchTree 类中实现的方法:
insert(key)
:向树中插入一个新的键。search(key)
:在树中查找一个键。如果节点存在,则返回 true;如果不存在,则返回 false。inOrderTraverse()
:通过中序遍历方式遍历所有节点。preOrderTraverse()
:通过先序遍历方式遍历所有节点。postOrderTraverse()
:通过后序遍历方式遍历所有节点。min()
:返回树中最小的值/键。max()
:返回树中最大的值/键。remove(key)
:从树中移除某个键。
// 对比常量
const Compare = {
LESS_THAN: -1,
BIGGER_THAN: 1,
EQUALS: 0,
};
// 对比函数
function defaultCompare(a, b) {
if (a === b) {
return Compare.EQUALS;
}
return a < b ? Compare.LESS_THAN : Compare.BIGGER_THAN;
}
/* 之前定义的 Node 节点类 */
/**
* 二叉搜索树
*/
class BinarySearchTree {
constructor(compareFn = defaultCompare) {
// 用来比较节点值
this.compareFn = compareFn;
// Node 类型的根节点 undefined | null
this.root = undefined;
}
/**
* 向二叉搜索树中插入一个节点
*/
insert(key) {
// 要向树中插入一个新的节点(或键),要经历三个步骤
// 第一步是验证插入操作是否是特殊情况。
// 插入 的树节点是否为第一个节点。
if (this.root == null) {
// 如果为空,则创建一个 Node 类的实例并将它赋值给 root 属性来将 root 指向这个新节点
this.root = new Node(key);
} else {
// 第二步是将节点添加到根节点以外的其他位置。在这种情况下,需要一个辅助方法(insertNode)
// 如果树非空,需要找到插入新节点的位置。因此,在调用 insertNode 方法时要通过参数传入树的根节点和要插入的节点
this.insertNode(this.root, key);
}
}
/**
* 插入节点时找到新节点应该插入的正确位置
*
*/
insertNode(node, key) {
// 1. 如果新节点的键小于当前节点的键(第一次为根节点)
// 由于键可能是复杂的对象而不是数,使用传入 compareFn 函数来比较值
if (this.compareFn(key, node.key) === Compare.LESS_THAN) {
// 1.1 较小则需要插入到左侧,检查当前节点的左侧子节点
if (node.left == null) {
// 如果它没有左侧子节点,则插入新的节点
node.left = new Node(key);
} else {
// 1.2 如果有左侧子节点,需要通过递归调用 insertNode 方法继续找到树的下一层
// 下次递归要比较的节点将会是当前节点的左侧子节点(左侧节点子树) node.left
this.insertNode(node.left, key);
}
} else if (node.right == null) {
// 2. 如果节点的键比当前节点的键大,同时当前节点没有右侧子节点
// 就在当前位置插入新的节点
node.right = new Node(key);
} else {
// 2.1 如果节点的键比当前节点的键大,且有右侧子节点
// 需要递归调用 insertNode 方法,要用来和新节点比较的节点将会是右侧子节点(右侧节点子树) node.right
this.insertNode(node.right, key);
}
}
}
树的遍历
遍历一棵树是指访问树的每个节点并对它们进行 某种操作的过程。访问树的所有节点有三种方式:中序、先序和后序。
中序遍历
中序遍历是一种以上行顺序访问 BST 所有节点的遍历方式,也就是以从最小到最大的顺序访问所有节点。中序遍历的其中一种应用就是对树进行排序操作。下面来看看它的实现:
/**
* 中序遍历方法接收一个回调函数,用来定义我们对遍历到的每节点进行的操作
*/
inOrderTraverse(callback) {
// 由于在 BST 中最常实现的算法是递归
// 因此需要一个辅助方法(inOrderTraverseNode)来接收一个节点和对应的回调函数作为参数
this.inOrderTraverseNode(this.root, callback);
}
/**
* 函数接收 node 和 callback
*/
inOrderTraverseNode(node, callback) {
// 1. 首先要检查以参数节点是否为 null(也是停止递归继续执行的判断条件,即递归算法的基线条件)
if (node != null) {
// 2. 递归调用相同的函数来访问左侧子节点
this.inOrderTraverseNode(node.left, callback);
// 3. 接着对根节点(行{4})进行一些操作(callback)
callback(node.key);
// 4. 然后再访问右侧子节点
this.inOrderTraverseNode(node.right, callback);
}
}
下图描绘了 inOrderTraverse
方法的访问路径,输出顺序为 3 5 6 7 8 9 10 11 12 13 14 15 18 20 25:
使用回调函数用来定义每节点进行的操作(这也叫作访问者模式,要了解更多关于访问者模式的信息,请参考维基百科)
先序遍历
先序遍历是以优先于后代节点的顺序访问每个节点的。先序遍历的其中一种应用是打印一个结构化的文档。代码实现如下:
/**
*先序遍历
*/
preOrderTraverse(callback) {
this.preOrderTraverseNode(this.root, callback);
}
/**
* 接收 node 和 callback
*/
preOrderTraverseNode(node, callback) {
if (node != null) {
// 先序遍历和中序遍历的不同点是,先序遍历会先访问节点本身
callback(node.key);
// 然后再访问它的左侧子节点
this.preOrderTraverseNode(node.left, callback);
// 最后是右侧子节点
this.preOrderTraverseNode(node.right, callback);
}
}
下图描绘了 preOrderTraverseNode
方法的访问路径,输出顺序为 11 7 5 3 6 9 8 10 15 13 12 14 20 18 25:
后序遍历
后序遍历则是先访问节点的后代节点,再访问节点本身。后序遍历的其中一种应用是计算一个目录及其子目录中所有文件所占空间的大小。代码实现如下:
/**
* 后序遍历
*/
postOrderTraverse(callback) {
this.postOrderTraverseNode(this.root, callback);
}
/**
* 接收 node 和 callback
*/
postOrderTraverseNode(node, callback) {
if (node != null) {
// 后序遍历会先访问左侧子节点
this.postOrderTraverseNode(node.left, callback);
// 然后是右侧子节点,
this.postOrderTraverseNode(node.right, callback);
// 最后是父节点本身
callback(node.key);
}
}
下图描绘了 postOrderTraverseNode
方法的访问路径,输出顺序为 3 6 5 8 10 9 7 12 14 13 18 25 20 15 11:
三种遍历方式的实现方式是很相似的,唯一不同的是访问父节点、左侧子节点和右侧子节点的顺序。
搜索树中的值
在树中,有三种经常执行的搜索类型:搜索最小值、搜索最大值、搜索特定的值。
搜索最小值和最大值
以下面的树作为示例:
观察上图可知树最后一层最左侧的节点,它的值为 3,是这棵树中最小的键。而树最右端的节点(同样是树的最后一层),它的值为 25,是这棵树中最大的 键。这两个信息在实现搜索树节点的最小值和最大值的方法时会很大的帮助。
下面,来看看寻找树的最小值和最大值的方法:
/**
* min 方法将会暴露给用户,调用了 minNode 方法
*/
min() {
// 调用 minNode,传入树的根节点
return this.minNode(this.root);
}
/**
* 该方法可以从树中任意一个节点(接收 node)开始寻找,直到找到一棵树或其子树中最小的键
*/
minNode(node) {
let current = node;
// 遍历树的左边,直到找到树的最下层(最左端)
while (current != null && current.left != null) {
current = current.left;
}
return current;
}
/**
* 获取最大值
*/
max() {
return this.maxNode(this.root);
}
/**
* 接收 node,返回最大值
*/
maxNode(node) {
let current = node;
// 沿着树的右边进行遍历,直到找到最右端的节点
while (current != null && current.right != null) {
current = current.right;
}
return current;
}
搜索一个特定的值
在 BST 中实现搜索的方法:
/**
* 根据传入值搜索树节点
*/
search(key) {
// 调用 searchNode 方法,传入跟节点(root) 和 搜索值(key)
return this.searchNode(this.root, key);
}
/**
* 搜索一棵树或其任意子树中的一个特定的值
*/
searchNode(node, key) {
// 验证作为参数传入的 node 是否合法(不是 null 或 undefined)
if (node == null) {
// 如果是的话,说明要找的键没有找到,返回 false
return false;
}
// 如果传入的节点不是 null,需要继续验证
if (this.compareFn(key, node.key) === Compare.LESS_THAN) {
// 如果要找的键比当前的节点小,继续在左侧的子树上搜索
return this.searchNode(node.left, key);
} else if (this.compareFn(key, node.key) === Compare.BIGGER_THAN) {
// 如果要找的键比当前的节点大,从右侧子 节点开始继续搜索
return this.searchNode(node.right, key);
}
// 否则就说明要找的键和当前节点的键相等,返回 true 来表示找到了这个键
return true;
}
移除一个节点
要在 BST 中实现 remove
方法。这是二叉搜索树中要实现的最复杂的方法。先创建这个方法,使它能够在树的实例上被调用:
/**
* 移除节点,接收要移除的键
*/
remove(key) {
// 调用 removeNode 方法,传入 root 和要移除的键作为参数
this.root = this.removeNode(this.root, key);
}
/**
* 移除节点任意节点,接收 node 和 key
* removeNode 方法的复杂之处在于要处理不同的运行场景,当然也因为它是通过递归来实现的
*/
removeNode(node, key) {
// 1. 节点为 null
if (node == null) {
// 说明键不存在于树中,所以返回 undefined | null
return undefined;
}
// 2. 不为 null,需要在树中找到要移除的键
// 比较键大小
if (this.compareFn(key, node.key) === Compare.LESS_THAN) {
// 2.1 如果要找的键比当前节点的值小
// 就沿着树的左边找到下一个节点
node.left = this.removeNode(node.left, key);
// 更新了节点左指针的值,返回了更新后的节点
return node;
} else if (this.compareFn(key, node.key) === Compare.BIGGER_THAN) {
// 2.2 如果要找的键比当前节点的值大
// 沿着树的右边找到下一个节点
node.right = this.removeNode(node.right, key);
// 更新了节点右指针的值,返回了更新后的节点
return node;
}
// 3. 如果找到了要找的键(键和 node.key 相等)
// 就需要处理三种不同的情况
// 1 - 该节点是一个叶子节点
// 2 - 该节点只包含一个子节点
// 3 - 该节点有两个子节点
// 3.1 第一个种情况:移除一个叶节点
// 该节点是一个没有左侧或右侧子节点的叶节点
if (node.left == null && node.right == null) {
// 给这个节点赋予 null 值来移除它,还需要处理引用(指针)
// 这个节点没有任何子节点,但它有一个父节点,需通过返回 null 来将对应的父节点指针赋为 null 值
node = undefined;
return node;
}
// 3.2 第二个种情况:移除有一个左侧子节点或右侧子节点的节点
// 这种情况下,需要跳过这个节点,直接将父节点指向它的指针指向子节点
if (node.left == null) {
// 如果这个节点没有左侧子节点,也就是说它有一个右侧子节点
// 因此我们把对它 的引用改为对它右侧子节点的引用
node = node.right;
// 并返回更新后的节点
return node;
} else if (node.right == null) {
// 如果这个节点没有右侧子节点
// 也一样把对它的引用改为对它左侧子节点的引用
node = node.left;
// 并返回更新后的值
return node;
}
// 3.3 第三个种情况:移除有两个子节点的节点
// 最复杂的情况,那就是要移除的节点有两个子节点(左侧子节点和右侧子节点)
// 要移除有两个子节点的节点,需要执行四个步骤:
// 3.3.1 当找到了要移除的节点后,需要找到它右边子树中最小的节点(它的继承者)。
const aux = this.minNode(node.right);
// 3.3.2 然后,用它右侧子树中最小节点的键去更新这个节点的值
// 通过这一步改变了这个节点的键,也就是说它被移除了
node.key = aux.key;
// 3.3.3 但是,在树中就有两个拥有相同键的节点了,这是不行的
// 要继续把右侧子树中的最小节点移除,毕竟它已经被移至要移除的节点的位置了
node.right = this.removeNode(node.right, aux.key);
// 3.3.4 最后,向它的父节点返回更新后节点的引用
return node;
}
下图展现了移除一个叶节点的过程:
下图展现了移除只有一个左侧子节点或右侧子节点的节点的过程:
下图展现了移除只有一个左侧子节点或右侧子节点的节点的过程: