本文章是根据书籍《学习JavaScript数据结构与算法》,以及写者自己的理解所得出,如有不正,还望各路大佬指正
文章目录
1.树的相关术语
在我们了解二叉搜索树前,先要知道一些关于树的相关术语,以便在后面更好的理解。
- 一个树结构包含系列存在父子关系的节点,每个节点都有一个父节点(除了顶部的第一个节点)以及零个或多个子节点:
- 位于树顶部的节点叫作根节点。它没有父节点。树中的每个元素都叫作节点,节点分为内部节点和外部节点。至少有一个子节点的节点称为内部节点,没有子元素的节点称为外部节点或叶节点
- 一个节点可以有祖先和后代。一个节点(除了根节点)的祖先包括父节点、祖父节点、曾祖父节点等。一个节点的后代包括子节点、孙子节点、曾孙节点等。
- 有关树的另一个术语是子树。子树由节点和它的后代构成。
- 节点的另一个术语是深度,节点的深度取决于它的祖先节点的数量
- 树的高度取决于所有节点深度的最大值,一棵树也可以粉分解成层级
2. 二叉树和二叉搜索树
二叉树中的节点最多只能有两个子节点,另一个是右侧节点。这个义有助于我们更高效的在树中插入,查找和删除节点的算法。二叉树在计算机科学中应用非常广泛
2.1 创建BinarySerachTree类
创建Node类来表示二叉搜索树中的每个节点
export class Node{
constructor (key){
this.key = key; // 存放节点值
this.left = null; // 左侧子节点引用
this.right = null; // 右侧子节点引用
}
}
和链表一一样, 我们将通过指针(引用)来表示节点之间的关系(树相关的术语称其为边)。在双向链表中,每个节点包含两个指针,一个指向下一一个节点,另-个指向上一个节点。对于树,使用同样的方式(也使用两个指针),但是一个指向左侧子节点, 另一个指向右侧子节点。因此,将声明一个Node类来表示树中的每个节点。
和以往用C语言不同,C语言是利用一个结构体,将相关方法放在外面,所以只用声明一个包含节点值和保存左右子节点指针的结构体,然后再调用外部的方法,而由于JavaScript是面向对象的,所以我们可以创建一个BInarySearchTree类,在这个类中编写相关增删改查的方法,体现了面向对象封装的思想
BinarySearchTree类
import {Compare,defaultCompare }from '../util';
import { Node } from './models/node';
export default class BinarySearchTree {
constructor (compareFn = defaultCompare) {
// 这个方法是用来笔记节点的值,因为节点的值大部分情况不是简单的数字,需要自定义比较方法
this.compareFn = compareFn;
// 用来存储根节点
this.root = null;
}
}
2.2 向二叉搜索树中插入一个键(增)
代码主体:
insert(key){
if(this.root == null){ // 行1
this.root = new Node(key); // 行2
}else{
this.insertNode(this.root,key) // 行3
}
}
要向树中插人一个新的节点(或键),要经历两个步骤。
-
第一步是验证插人操作是否是特殊情况。对于二叉搜索树的特殊情况是,我们尝试插人的树节点是否为第一一个节点(行1)。如果是,我们要做的就是创建一一个 Node类的实例并将它赋值给root属性来将root指向这个新节点(行2 )。因为在Node构建丽数的属性里,只需要向构造函数传递我们想用来插人树的节点值( key ),它的左指针和右指针的值会由构造函数自动设置为null
-
第二步是将节点添加到根节点以外的其他位置。在这种情况下,我们需要一个辅助方法(行3)来帮助我们做这件事,代码如下:
insertNode(node,key){
if(this.compareFn(key,node.key) == Compare.LESS_THAN){ // 行{4}
if(node.left == null){ // 行{5}
node.left = new Node(key); // 行{6}
}else{
this.insertNode(node.left,key); // 行{7}
}
} else{
if(node.right== null){ // 行{8}
node.right = new Node(key); // 行{9}
}else{
this.insertNode(node.right,key); // 行{10}
}
}
}
insertNode方法会帮助我们找到新节点应该插人的正确位置。下面是这个函数实现的步骤。
- 如果树非空,需要找到插人新节点的位置。因此,在调用insertNode方法时要通过参数传人树的根节点和要插人的节点。
- 如果新节点的键小于当前节点的键(现在,当前节点就是根节点)(行{4}),那么需要检查当前节点的左侧子节点。注意在这里,由于键可能是复杂的对象而不是数,我们使用传人二叉搜索树构造函数的compareFn的数来比较值。如果它没有左侧子节点(行{5} ),就在那里插人新的节点(行{6} )。如果有左侧子节点,需要通过递归调用insertNode方法(行{7})继续找到树的下一层。在这里,下次要比较的节点将会是当前节点的左侧子节点(左侧节点子树)。
- 如果节点的键比当前节点的键大,同时当前节点没有右侧子节点(行{8}),就在那里插入新的节点(行{9} )。如果有右侧子节点,同样需要递归调用insertNode方法,但是要用来和新节点比较的节点将会是右侧子节点(右侧节点子树)(行{10} )。
3. 树的遍历与查找(查)
3.1 树的中序遍历
中序通历是种以上行顺序访问BST所有节点的遍历方式,也就是以从最小到最大的顺序访问所有节点。中序遍历的一种应用就是对树进行排序操作。 我们来看看它的实现。
inOrderTraverse (callback) {
this.inOrderTraverseNode (this.root,callback); // (1)
}
inOrderTraverse方法接收一个回调函数作为参数。回调函数用来定义我们对遍历到的每个节点进行的操作(这也叫作访问者模式,要了解更多关于访问者模式的信息,请参考http://en.wikipedia.org/wiki/Visitor_pattern )。由于我们在BST中最常实现的算法是递归,这里使用了一个辅助方法,来接收个节 点和对应的回调函数作为参数 (行{1} )。辅助方法如下所示。
inOrderTraverseNode (node, callback){
if (node != null) { // {2}
this.inOrderTraverseNode(node.left, callback); ”(3}
callback (node.key); // {4)
this.inOrderTraverseNode(node.right,callback); // {5}
}
}
要通过中序遍历的方法遍历一棵树,首先要检查以参数形式传入的节点是否为 null (行{2}——这就是停止递归继续执行的判断条件,即递归算法的基线条件)。
还有一个注意点:这里传入的是一个callback回调函数,当找到这个值时,执行这个回调方法,自定义方法,而非简单的将找到的值输出。
3.2 树的先序遍历
先序遍历和中序遍历相差不大,当理解了中序遍历,先序遍历也迎刃而解了
代码:
preOrderTraverse (callback) {
this.preOrderTraverseNode (this.root,callback);
}
preOrderTraverseNode方法
preOrderTraverseNode (node, callback){
if (node != null) {
callback (node.key);
this.inOrderTraverseNode(node.left, callback);
this.inOrderTraverseNode(node.right,callback);
}
}
3.3 树的后序遍历
代码:
postOrderTraverse (callback) {
this.postOrderTraverseNode (this.root,callback);
}
postOrderTraverseNode方法:
postOrderTraverseNode (node, callback){
if (node != null) {
this.inOrderTraverseNode(node.left, callback);
this.inOrderTraverseNode(node.right,callback);
callback (node.key);
}
}
遍历过程:
3.4 搜索最大值和最小值
通过二叉搜索树的性质,我们很容易就能够知道,树的最后一层最左侧的节点就是最小值,树的最后一层最右侧的节点,就是最大值
通过这个得出代码:
min(){
return this.minNode(this.root)
}
min方法会暴露给用户。这个方法调用了minNode 方法
minNode(node){
let current = node;
while(current != null){
curremt = current.left;
}
return current;
}
minNode 方法允许我们从树中任意一个节点开始寻找最小的键。我们可以使用他来找到一棵树或者其子树中的最小的键,因此,我们再调用minNode 方法的时候传入树的根节点,因为我们要找整颗树的最小值,而最大值同理:
max(){
return this.maxNode(this.root)
}
maxNode(node){
let current = node;
while(current != null){
curremt = current.right;
}
return current;
}
3.5 搜索特定值
代码实现
search (key){
return this.searchNode(this.root, key); // {1}
}
searchNode (node, key){
if (node == null) { // (2}
return false;
}
if (this. compareFn(key,node.key) === Compare.LESS_THAN) { // (3)
return this. searchNode (node.left, key); // {4}
}
eles if(this.compareFn(key, node.key) === Compare.BIGGER_THAN){//{5}
return this. searchNode (node .right, key); // {6}
}
else {
return true; // {7}
}
}
- 我们要做的第一件事, 是声明search方法。和BST中声明的其他方法的模式相同,我们将会使用一个辅助方法(行{1} )。
- searchNode方法可以用来寻找一棵树或其任意 子树中的一个特定的值。 这也是为什么在行{1}中调用它的时候传入树的根节点作为参数。
- 在开始算法之前,要验证作为参数传人的node是否合法(不是null或undefined)结果不合法,说明要找的键没有找到,返回false。
- 如果传人的节点不是null,需要继续验证。如果要找的键比当前的节点小(行{3}),那继续在左侧的子树上搜索(行{4})。如果要找的键比当前的节点大(行{5}),那么久从右侧子节点开始继续搜索,(行{6},否则说明要找的键和当前节点相等,返回true来表示找到了这个键。
4 移除一个节点
我们要为BST树实现最后一个方法,也是最复杂的方法,就是移除节点,我们先创建这个方法,让它能在树的实例上使用:
remove(key){
this.root = this.removeNode(this.root,key);
}
removeNode方法的复杂之处在于我们要处理不同场景,并且全部使用递归来实现
4.1 移除节点时会遇到的三种情况
在编写删除方法前,我们要先明白删除节点时会遇到的三种情况:
1.第一种情况也是最简单的情况,就是要删除的节点既没有左支又没有右支,在这种情况下,可能很多人初步想到的就是让这个节点为 null 就行了,但这样还是不够的,因为只对这个节点赋值为 null 但其父节点对其的引用还是存在的,这样相当于消除了待删除节点的空间,但是父节点对这个地址还是有保存,这样会出现错误,因为当 node 为 null 时,系统会回收这个实例的空,父节点对其的引用其实已经不存在了,如果存在这样的情况,在遍历的时候就会出现错误,从而没有达到预期的效果,所以在实现的过程中我们要将 null 返回到父节点,这样顺便也结束了递归迭代
2.第二种情况也比较简单,就是 移除一个有左侧或者有右侧节点的节点,这种情况下,我们只需要将待删除的节点替换成其子节点或者右节点,根据BST树的性质,左支树一定比根节点小,而右支树一定比根节点大,这样做其实并不会破坏BST树的结构,如果实在不好理解,可以自己画一画这时出现的两种情况,有助于更好的理解。
3.第三种情况就是最为复杂的情况,就是移除的节点既有左支又有右支的时候 要执行 4 个步骤:
- 一. 当找到要移除的节点后,需要找到它右支子树最小的节点
- 二. 然后,用它右侧子树中最小节点的键去更新这个节点的值,通过这一步,我们改变了这个节点的值,也就是或它被移除了
- 三. 但是,这样树用就有两个相同的节点了,所以我们要把右侧子树中最小的节点移除。
- 四. 向父节点返回更新后的节点的引用
4.2 代码实现以及逐行解释:
removeNody(node,key){
/*
当节点为空时返回空,这行代码的作用并不单纯判断我们要进行删除操作的树是否为空,
而且还有一个重要的作用是为了在后面的递归中作为递归的结束条件,如果
要删除的值没有找到,这一行代码将会至关重要
*/
if(node == null) return null;
// 如果传人的节点不为空,则将进行判断是比当前节点值大还是小,比当前节点小就进入左支,大则进入右支
// 注意,这里的compareFn是用户自定义的比较方法,因为在JavaScript中能够传入方法,所以在此次
// 传入自定义的比较函数,并利用此方法来比较节点值的大小规则,如果要删除的节点小于该节点的值
// 则执行下面代码
if(compareFn(key,node.key) == Compare.LESS_THAN){
/*
表示要删除的值可能在左支树上,所以左支树可能会发生改变,于是node.left
要接受调用 removeNode()方法的返回值,更新改变后的节点地址
*/
node.left = removeNode(node.left,key);
return node; // 返回node,让这一层的递归结束,返回到上一层的递归
}else if(compareFn(key,node.key) == Compare.BIGGER_THAN){
// 这两行同理
node.right = removeNode(node.right,key);
return node;
}else{
// 表示找到了要删除的值
if(node.left == null &&node.right == null){ // 第一种情况
node = null;
return node;
}else if(node.left == null){ // 第二种情况
node = node.right;
return node;
}else if(node.right == null){
node = node.left'
return node;
}else{ // 第三种情况
const tmp = this.minNode(this.right); // 获取右支最小值
node.key = tmp.key; // 赋值
this.right = this.removeNode(this.right,tmp.key); // 更新右支
return node;
}
}
写在最后:
BST是一个很典型的二叉树数据结构,虽然代码看着并不是很多,但是对于思维的锻炼,特别是对于递归的使用要求较高,熟悉并掌握BST树,对后面学习AVL树,红黑树等数据结构都有很大的帮助,在我们学习数据结构的时候,一定要一步一步弄懂每一行代码,深入思考每一行代码的作用,这样才能真正的融会贯通,甚至举一反三。由于本博文纯手写代码,可以有些地方存在小问题,还望各路大佬指正