原文连接:Tree Data Structures for Beginnersnode
众成翻译地址:初学者应该了解的数据结构: Tree程序员
系列文章,建议不了解树的同窗慢慢阅读一下这篇文章,但愿对你有所帮助~至于系列的最后一篇自平衡二叉搜索树就再也不翻译了(主要是原文坑太多,很难填),如下是译文正文:算法
Tree 是不少(上层的)数据结构(如 Map、Set 等)的基础。同时,在数据库中快速搜索(元素)也用到了树。HTML 的 DOM 节点也经过树来表示对应的层次结构。以上仅仅是树在实际应用中的一小部分例子。在这篇文章中,咱们将探讨不一样类型的树,如二叉树、二叉搜索树以及如何实现它们。数据库
在上一篇文章(译文)中,咱们探讨了数据结构:图,它是树通常化的状况。让咱们开始学习树吧!数据结构
本篇是如下教程的一部分(译者注:若是你们以为还不错,我会翻译整个系列的文章):ide
初学者应该了解的数据结构与算法(DSA)函数
在树中,每一个节点可有零个或多个子节点,每一个节点都包含一个值。和图同样,节点之间的链接被称为边。树是图的一种,但并非全部图都是树(只有无环无向图才是树)。post
这种数据类型之因此被称为树,是由于它长得很像一棵(倒置的)树 🌳。它从根节点出发,它的子节点是它的分支,没有任何子节点的节点就是树的叶子(即叶节点)。学习
如下是树的一些属性:动画
A
的度是 3。I
的度是 0(译者注:子树也是树,I 的度是指 I 为根节点的子树的度)。H
的层次是 2。B
的层次是 1。正如此前所见,树的节点有一个值,且存有它每个子节点的引用。
如下是节点的例子:
class TreeNode {
constructor(value) {
this.value = value;
this.descendents = [];
}
}
复制代码
咱们能够建立一棵树,它有三个叶节点:
// create nodes with values
const abe = new TreeNode('Abe');
const homer = new TreeNode('Homer');
const bart = new TreeNode('Bart');
const lisa = new TreeNode('Lisa');
const maggie = new TreeNode('Maggie');
// associate root with is descendents
abe.descendents.push(homer);
homer.descendents.push(bart, lisa, maggie);
复制代码
这样就完成啦,咱们有了一棵树!
节点 abe
是根节点,而节点 bart
、lisa
和 maggie
则是这棵树的 叶节点。注意,树的节点的子节点能够是任意数量的,不管是 0 个、1 个、3 个或是多个都可。
树的节点能够有 0 个或多个子节点。然而,当一棵树(的全部节点)最多只能有两个子节点时,这样的树被称为二叉树。
二叉树是树中最多见的形式之一,它应用普遍,如:
取决于二叉树节点的组织方式,一棵二叉树能够是完满二叉树、彻底二叉树或完美二叉树。
(译者注:国内外的定义是不一样的,此处根据原文与查找的资料,做了必定的修改,用的是国外的标准)
下图是上述概念的例子:
完满二叉树、彻底二叉树与完美二叉树并不老是互斥的:
二叉搜索树(Binary Search Tree,简写为:BST)是二叉树的特定应用。BST 的每一个节点如二叉树同样,最多只能有两个子节点。然而,BST 左子节点的值必须小于父节点的值,而右子节点的值则必须大于父节点的值。
强调一下:一些 BST 并不容许重复值的节点被添加到树中,如若容许,那么重复值的节点将做为右子节点。有些二叉搜索树的实现,会记录起重复的状况(这也是接下来咱们须要实现的)。
一块儿来实现二叉搜索树吧!
BST 的实现与上文树的实现相像,然而有两点不一样:
左子节点 < 父节点 < 右子节点
。如下是树节点的实现,与以前树的实现相似,但会为左右子节点添加 getter
与 setter
。请注意,实例中会保存父节点的引用,当添加新的子节点时,将更新(子节点中)父节点的引用。
const LEFT = 0;
const RIGHT = 1;
class TreeNode {
constructor(value) {
this.value = value;
this.descendents = [];
this.parent = null;
//译者注:原文并无如下两个属性,但不加上去话下文的实现会报错
this.newNode.isParentLeftChild = false;
this.meta = {};
}
get left() {
return this.descendents[LEFT];
}
set left(node) {
this.descendents[LEFT] = node;
if (node) {
node.parent = this;
}
}
get right() {
return this.descendents[RIGHT];
}
set right(node) {
this.descendents[RIGHT] = node;
if (node) {
node.parent = this;
}
}
}
复制代码
OK,如今已经能够添加左右子节点。接下来将编写 BST 类,使其知足 左子节点 < 父节点 < 右子节点
。
class BinarySearchTree {
constructor() {
this.root = null;
this.size = 0;
}
add(value) { /* ... */ }
find(value) { /* ... */ }
remove(value) { /* ... */ }
getMax() { /* ... */ }
getMin() { /* ... */ }
}
复制代码
下面先编写插入新节点相关的的代码。
要将一个新的节点插入到二叉搜索树中,咱们须要如下三步:
让咱们经过如下例子来讲明,树中将依次插入30、40、十、1五、十二、50:
代码实现以下:
add(value) {
const newNode = new TreeNode(value);
if (this.root) {
const { found, parent } = this.findNodeAndParent(value);
if (found) { // duplicated: value already exist on the tree
found.meta.multiplicity = (found.meta.multiplicity || 1) + 1;
} else if (value < parent.value) {
parent.left = newNode;
//译者注:原文并无这行代码,但不加上去的话下文实现会报错
newNode.isParentLeftChild = true;
} else {
parent.right = newNode;
}
} else {
this.root = newNode;
}
this.size += 1;
return newNode;
}
复制代码
咱们使用了名为 findNodeAndParent
的辅助函数。若是(与新插入节点值相同的)节点已存在于树中,则将节点统计重复的计数器加一。看看这个辅助函数该如何实现:
findNodeAndParent(value) {
let node = this.root;
let parent;
while (node) {
if (node.value === value) {
break;
}
parent = node;
node = ( value >= node.value) ? node.right : node.left;
}
return { found: node, parent };
}
复制代码
findNodeAndParent
沿着树的结构搜索值。它从根节点出发,往左仍是往右搜索取决于节点的值。若是已存在相同值的节点,函数返回找到的节点(即相同值的节点)与它的父节点。若是没有相同值的节点,则返回最后找到的节点(即将变为新插入节点父节点的节点)。
咱们已经知道如何(在二叉搜索树中)插入与查找值,如今将实现删除操做。这比插入而言稍微麻烦一点,让咱们用下面的例子进行说明:
删除叶节点(即没有任何子节点的节点)
30 30
/ \ remove(12) / \
10 40 ---------> 10 40
\ / \ \ / \
15 35 50 15 35 50
/
12*
复制代码
只须要删除父节点(即节点 #15)中保存着的 节点 #12 的引用便可。
删除有一个子节点的节点
30 30
/ \ remove(10) / \
10* 40 ---------> 15 40
\ / \ / \
15 35 50 35 50
复制代码
在这种状况中,咱们将父节点 #30 中保存着的子节点 #10 的引用,替换为子节点的子节点 #15 的引用。
删除有两个子节点的节点
30 30
/ \ remove(40) / \
15 40* ---------> 15 50
/ \ /
35 50 35
复制代码
待删除的节点 #40 有两个子节点(#35 与 #50)。咱们将待删除节点替换为节点 #50。待删除的左子节点 #35 将在原位不动,但它的父节点已被替换。
另外一个删除节点 #40 的方式是:将左子节点 #35 移到节点 #40 的位置,右子节点位置保持不变。
30
/ \
15 35
\
50
复制代码
两种形式均可以,这是由于它们都遵循了二叉搜索树的原则:左子节点 < 父节点 < 右子节点
。
删除根节点
30* 50
/ \ remove(30) / \
15 50 ---------> 15 35
/
35
复制代码
删除根节点与此前讨论的机制状况差很少。惟一的区别是须要更新二叉搜索树实例中根节点的引用。
如下的动画是上述操做的具体展现:
在动画中,被移动的节点是左子节点或者左子树,右子节点或右子树位置保持不变。
关于删除节点,已经有了思路,让咱们来实现它吧:
remove(value) {
const nodeToRemove = this.find(value);
if (!nodeToRemove) return false;
// Combine left and right children into one subtree without nodeToRemove
const nodeToRemoveChildren = this.combineLeftIntoRightSubtree(nodeToRemove);
if (nodeToRemove.meta.multiplicity && nodeToRemove.meta.multiplicity > 1) {
nodeToRemove.meta.multiplicity -= 1; // handle duplicated
} else if (nodeToRemove === this.root) {
// Replace (root) node to delete with the combined subtree.
this.root = nodeToRemoveChildren;
this.root.parent = null; // clearing up old parent
} else {
const side = nodeToRemove.isParentLeftChild ? 'left' : 'right';
const { parent } = nodeToRemove; // get parent
// Replace node to delete with the combined subtree.
parent[side] = nodeToRemoveChildren;
}
this.size -= 1;
return true;
}
复制代码
如下是实现中一些要注意的地方:
false
。将左子树组合到右子树的函数以下:
combineLeftIntoRightSubtree(node) {
if (node.right) {
//译者注:原文是 getLeftmost,寻找左子树最大的节点,这确定有问题,应该是找最小的节点才对
const leftLeast = this.getLefLeast(node.right);
leftLeast.left = node.left;
return node.right;
}
return node.left;
}
复制代码
正以下面例子所示,咱们想把节点 #30 删除,将待删除节点的左子树整合到右子树中,结果以下:
30* 40
/ \ / \
10 40 combine(30) 35 50
\ / \ -----------> /
15 35 50 10
\
15
复制代码
如今把新的子树的根节点做为整个二叉树的根节点,节点 #30 将不复存在!
根据遍历的顺序,二叉树的遍历有若干种形式:中序遍历、先序遍历与后序遍历。同时,咱们也可使用在《初学者应该了解的数据结构: Graph》 (译文一文中学到的 DFS 或 BFS 来遍历整棵树。如下是具体的实现:
中序遍历(In-Order Traversal)
中序遍历访问节点的顺序是:左子节点、节点自己、右子节点。
* inOrderTraversal(node = this.root) {
if (node.left) { yield* this.inOrderTraversal(node.left); }
yield node;
if (node.right) { yield* this.inOrderTraversal(node.right); }
}
复制代码
用如下这棵树做为例子:
10
/ \
5 30
/ / \
4 15 40
/
3
复制代码
中序遍历将按照如下顺序输出对应的值:三、四、五、十、1五、30、40。也就是说,若是待遍历的树是一颗二叉搜索树,那输出值的顺序将是升序的。
后序遍历(Post-Order Traversal)
后序遍历访问节点的顺序是:左子节点、右子节点、节点自己。
* postOrderTraversal(node = this.root) {
if (node.left) { yield* this.postOrderTraversal(node.left); }
if (node.right) { yield* this.postOrderTraversal(node.right); }
yield node;
}
复制代码
后序遍历将按照如下顺序输出对应的值:三、四、五、1五、40、30、10。
先序遍历与 DFS(Pre-Order Traversal)
先序遍历访问节点的顺序是:节点自己、左子节点、右子节点。
* preOrderTraversal(node = this.root) {
yield node;
if (node.left) { yield* this.preOrderTraversal(node.left); }
if (node.right) { yield* this.preOrderTraversal(node.right); }
}
复制代码
先序遍历将按照如下顺序输出对应的值:十、五、四、三、30、1五、40。与深度优先搜索(DPS)的顺序是一致的。
广度优先搜索 (BFS)
树的 BFS 能够经过队列来实现:
* bfs() {
const queue = new Queue();
queue.add(this.root);
while (!queue.isEmpty()) {
const node = queue.remove();
yield node;
node.descendents.forEach(child => queue.add(child));
}
}
复制代码
BFS 将按照如下顺序输出对应的值:十、五、30、四、1五、40、3。
目前,咱们已经讨论了如何新增、删除与查找元素。然而,咱们并未谈到(相关操做的)时间复杂度,先思考一下最坏的状况。
假设按升序添加数字:
树的左侧没有任何节点!在这颗非平衡树( Non-balanced Tree)中进行查找元素并不比使用链表所花的时间短,都是 O(n)。 😱
在非平衡树中查找元素,如同以逐页翻看的方式在字典中寻找一个单词。但若是树是平衡的,将相似于对半翻开字典,视乎该页的字母,选择左半部分或右半部分继续查找(对应的词)。
须要找到一种方式使树变得平衡!
若是树是平衡的,查找元素不在须要遍历所有元素,时间复杂度降为 O(log n)。让咱们探讨一下平衡树的意义。
若是在非平衡树中寻找值为 7 的节点,就必须从节点 #1 往下直到节点 #7。然而在平衡树中,咱们依次访问 #四、#6 后,下一个节点将到达 #7。随着树规模的增大,(非平衡树的)表现会愈来愈糟糕。若是树中有上百万个节点,查找一个不存在的元素须要上百万次访问,而平衡树中只要20次!这是天壤之别!
咱们将在下一篇文章中经过自平衡树来解决这个问题。
咱们讨论了很多树的基础,如下是相关的总结: