树是一种非散列数据结构,和非散列表一样,它对于存储需要快速查找的数据非常有用。
树是一种分层数据的抽象模型。现实生活中最常见的树的例子是家谱,或是公司的组织架构
几个专业术语:
根节点:位于树顶部的节点叫作根节点(11)。它没有父节点。
节点:树中的每个元素都叫作节点,节点分 为内部节点和外部节点。
内部节点:至少有一个子节点的节点称为内部节点(7、5、9、15、13 和 20 是内部 节点)。
外部节点(叶节点):没有子元素的节点称为外部节点或叶节点(3、6、8、10、12、14、18 和 25 是叶节点)。
子树:子树由节点和它的后代构成。例如,节点 13、12 和 14 构成了 上图中树的一棵子树。
节点的深度:节点的深度取决于它的祖先节点的数量
树的高度:树的高度取决于所有节点深度的最大值。
二叉树:
二叉树中的节点最多只能有两个子节点:一个是左侧子节点,另一个是右侧子节点。
这个定义有助于我们写出更高效地在树中插入、查找和删除节点的算法。
二叉搜索树(BST)
二叉搜索树(BST)是二叉树的一种,但是只允许你在左侧节点存储(比父节点)小的值, 在右侧节点存储(比父节点)大的值。
创建BinarySearchTree类
先来创建 Node 类来表示二叉搜索树中的每个节点
export class Node {
constructor(key) {
this.key = key; // {1} 节点值
this.left = null; // 左侧子节点引用
this.right = null; // 右侧子节点引用
}
}
和链表数据结构对比:
和链表一样,我们将通过指针(引用)来表示节点之间的关系(树相关的术语称其为边)。
双向链表:每个节点包含两个指针,一个指向下一个节点,另一个指向上一个节点
树:也使用两个指针,一个指向左侧子节点,另一个指向右侧子节点
关于树的键:这里声明一个 Node 类来表示树中的每个节点,并称其为键(行{1})。键是树相关的术语中对节点的称呼。
声明 BinarySearchTree 类的基本结构
import { Compare, defaultCompare } from '../util';
import { Node } from './models/node';
class BinarySearchTree {
constructor(compareFn = defaultCompare) {
this.compareFn = compareFn;
this.root = undefined; //root表示数据结构的第一个节点(键)
}
}
实现的方法:
-
insert(key):向树中插入一个新的键。
-
search(key):在树中查找一个键。如果节点存在,则返回 true;如果不存在,则返回false。
-
inOrderTraverse():通过中序遍历方式遍历所有节点。
-
preOrderTraverse():通过先序遍历方式遍历所有节点。
-
postOrderTraverse():通过后序遍历方式遍历所有节点。
-
min():返回树中最小的值/键。
-
max():返回树中最大的值/键。
-
remove(key):从树中移除某个键。
这些方法在这里不一一实现,只实现insert(key)方法
实现插入新的键:分为两种情况:
1,在根节点插入新的键
insert(key) {
// special case: first key
if (this.root == null) {
this.root = new Node(key);
} else {
this.insertNode(this.root, key);
}
}
2,插入根节点以外的其他位置
思路:
1,首先是二叉搜索树,也就是要在左侧节点存储(比父节点)小的值, 在右侧节点存储(比父节点)大的值
a:所以如果新节点的键比当前节点的键小,
则要检查当前节点的左侧子节点,相对复杂的情况是,节点的键可能是复杂的对象 或者函数,所以要给二叉搜索树创建一个比较函数或者对象的方法compareFn。
如果当前子节点没有左侧子节点,就在那里插入新的节点
如果当前子节点有左侧子节点,需要递归左侧子节点的子节点
b:如果新节点的键比当前节点的键大
和比节点值小的情况是类似的,一是判断有没有右侧子节点,二是存在右侧子节点,递归判断
2,实现:
insertNode(node, key) { //key代表节点值
if (this.compareFn(key, node.key) === Compare.LESS_THAN) {
if (node.left == null) {
node.left = new Node(key);
} else {
this.insertNode(node.left, key);
}
} else if (node.right == null) {
node.right = new Node(key);
} else {
this.insertNode(node.right, key);
}
}
树的遍历:
遍历一棵树是指访问树的每个节点并对它们进行某种操作的过程。
访问树的所有节点有三种方 式:中序、先序和后序。
中序遍历
中序遍历是一种以上行顺序访问 BST 所有节点的遍历方式,
也就是以从最小到最大的顺序访问所有节点。
中序遍历的一种应用就是对树进行排序操作。
实现:
要通过中序遍历的方法遍历一棵树,首先要检查以参数形式传入的节点是否为 null(行 {2}——这就是停止递归继续执行的判断条件,即递归算法的基线条件)。
inOrderTraverse(callback) {
this.inOrderTraverseNode(this.root, callback);
}
inOrderTraverseNode(node, callback) {
if (node != null) {
this.inOrderTraverseNode(node.left, callback);
callback(node.key);
this.inOrderTraverseNode(node.right, callback);
}
}
实例:
打印结果: 3 5 6 7 8 9 10 11 12 13 14 15 18 20 25
执行过程分析:从最外层11进入到左侧最底层子节点,并把相应函数任务放入任务队列,到达最底层后按队列执行函数打印出3,下一个任务打印出5,然后遍历节点5的右侧子节点,打印出6,然后接着执行队列,打印出7,7遍历到最底层打印出8,依次执行递归。
规律总结:中序遍历,遍历节点的左侧最深节点,再到父节点,再遍历父节点剩余节点,再访问该父节点的父节点,从左侧最深遍历该节点,直到全部遍历完成。
简单记忆:左侧最深向外走,走遍节点全后代。
先序遍历
先序遍历是以优先于后代节点的顺序访问每个节点的。
先序遍历的一种应用是打印一个结构化的文档。
preOrderTraverse(callback) {
this.preOrderTraverseNode(this.root, callback);
}
preOrderTraverseNode(node, callback) {
if (node != null) {
callback(node.key);{1}
this.preOrderTraverseNode(node.left, callback);{2}
this.preOrderTraverseNode(node.right, callback);{3}
}
}
先序遍历和中序遍历的不同点是,先序遍历会先访问节点本身(行{1}),然后再访问它的左侧子节点(行{2}),最后是右侧子节点(行{3}),
实例:
执行过程分析:遍历节点开始后,先执行到左侧最深子节点,并打印出访问到的节点的键值,11,7,5,3
左侧最深已无子节点,这时会遍历该节点父节点的右侧节点,同样的方式遍历,再执行该父节点的父节点的右侧节点,直到遍历完。
简单记忆:左侧顺序到最深,依次向上找兄弟
后序遍历
后序遍历则是先访问节点的后代节点,再访问节点本身。
后序遍历的一种应用是计算一个目录及其子目录中所有文件所占空间的大小。
postOrderTraverse(callback) {
this.postOrderTraverseNode(this.root, callback);
}
postOrderTraverseNode(node, callback) {
if (node != null) {
this.postOrderTraverseNode(node.left, callback);
this.postOrderTraverseNode(node.right, callback);
callback(node.key);
}
}
实例:打印结果:3 6 5 8 10 9 7 12 14 13 18 25 20 15 11
执行过程分析:左侧到最深子节点后,子节点为null,打印当前节点3,然后遍历6,再到5,依次遍历
简单记忆:左侧到最深,遍历当前节点,向上找兄弟,左侧最底层遍历每个兄弟节点。
搜索树中的值:
在树中,有三种经常执行的搜索类型:
搜索最小值
搜索最大值
搜索特定的值
最大值:树的最右侧子节点
最小值:树的最左侧子节点
这里实现一个最大值:
max() {
return this.maxNode(this.root);
}
maxNode(node) {
let current = node;
while (current != null && current.right != null) {
current = current.right;
}
return current;
}
搜索一个特定的值
思路:比较要找的节点的键和当前节点的键的大小,如果小去当前节点的左侧,如果大,去当前节点的右侧,直到找到相等的键,如果节点为null直接返回。
实现:
search(key) {
return this.searchNode(this.root, key);
}
searchNode(node, key) {
if (node == null) {
return false;
}
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);
}
return true;
}
节点的移除
几种情况:
1,移除一个叶节点
这种情况可以给这个节点赋予 null 值来移除它,但是当学习了链表的实现之后,我们 知道仅仅赋一个 null 值是不够的,还需要处理引用(指针)。
这个节点没有任何子节 点,但是它有一个父节点,需要通过返回 null 来将对应的父节点指针赋予 null 值。
2,移除有一个左侧或右侧子节点的节点
3,移除有两个子节点的节点
这里不再具体实现
自平衡树:
BST 存在一个问题:取决于你添加的节点数,树的一条边可能会非常深;
也就是说,树的一 条分支会有很多层,而其他的分支却只有几层,如下图所示。
为了解决这个问题, 有一种树叫作 Adelson-Velskii-Landi 树(AVL 树)。
AVL 树是一种自平衡二叉搜索树,意思是任 何一个节点左右两侧子树的高度之差最多为 1。
AVL 树是一个 BST,我们可以扩展我们写的 BST 类,只需要覆盖用来维持 AVL 树平衡 的方法,也就是 insert、insertNode 和 removeNode 方法。所有其他的 BST 方法将会被 AVLTree 类继承。
实现一个核心方法:获取一个节点的高度:
getNodeHeight(node) {
if (node == null) {
return -1;
}
return Math.max(this.getNodeHeight(node.left), this.getNodeHeight(node.right)) + 1;
}
在 AVL 树中,需要对每个节点计算右子树高度(hr)和左子树高度(hl)之间的差值,该 值(hr-hl)应为 0、1 或1。
如果结果不是这三个值之一,则需要平衡该 AVL 树。这就是平衡 因子的概念。
平衡操作——AVL 旋转
在对 AVL 树添加或移除节点后,我们要计算节点的高度并验证树是否需要进行平衡。
向 AVL 树插入节点时,可以执行单旋转或双旋转两种平衡操作,分别对应四种场景。
左-左(LL):向右的单旋转
右-右(RR):向左的单旋转
左-右(LR):向右的双旋转(先 LL 旋转,再 RR 旋转)
右-左(RL):向左的双旋转(先 RR 旋转,再 LL 旋转)
左-左:
这里不展开详细介绍
红黑树
还有一种树结构:红黑树
和 AVL 树一样,红黑树也是一个自平衡二叉搜索树。一个包含多次插入和删除的自平衡树,红黑树是比较好的
总结:
本文主要讨论了树结构的概念,和一种应用比较广泛的二叉搜索树,以及遍历二叉搜索树的三种方法:中序遍历,先序遍历,后序遍历。另外了解了一下AVL 自平衡树以及红黑树。