本博客学习书籍:《学习JavaScript数据结构与算法》
本博客学习章节:第八章
二叉搜索树BST
特点
二叉树与普通的树结构相比,一个树节点只能有两个子节点,而二叉搜索树有以下特点:
1. 一个树节点只能有两个子节点
2. 左侧子节点的值 < 父节点的值
3. 右侧子节点的值 >= 父节点的值
-
节点深度
节点的一个属性是深度,节点的深度取决于它的祖先节点的数量。比如,节点3有3个祖先节点(5、7和11),它的深度为3。 -
树的高度
树的高度取决于所有节点深度的最大值。一棵树也可以被分解成层级。根节点在第0层,它的子节点在第1层,以此类推。上图中的树的高度为3(最大高度已在图中表示——第3层)。
实现
- 先声明一个
Node类
来表示树中的每个节点,left
存储左侧子节点,right
存储右侧子节点,key
存储键(树节点); - 再声明一个
tree类
来表示二叉搜索树,数据结构如下图所示。
// 树节点
class Node {
constructor(key) {
this.key = key; // 键
this.left = null; // 左侧子节点
this.right = null; // 右侧子节点
}
}
// 二叉搜索树
class BinarySearchTree {
constructor() {
this.root = null; // 根节点
}
}
向树中插入一个键
流程图
- 新建节点
newCode
,newCode.key = 5
; - 判断有没有
根节点
,没有则赋值到root
,有的话比较与root.key大小
; - 比较小的话,赋值到
left
;比较大的话,赋值到right
; - 在赋值到
left
或right
时,先判断left
是否存在,已存在的话则查找下一层;到了下一层再执行第3步
;
代码
// 树节点
class Node {
constructor(key) {
this.key = key; // 键
this.left = null; // 左侧子节点
this.right = null; // 右侧子节点
}
}
// 二叉搜索树
class BinarySearchTree {
constructor() {
this.root = null; // 根节点
}
insertNode(node, newNode){
// 如果new键小于current键
if (newNode.key < node.key) {
// 检查左侧子节点
if (node.left === null) { // current没有左侧子节点,则赋值到left
node.left = newNode;
} else { // 否则,递归查找下一层、别的树节点有没有左侧空位置
this.inserNode(node.left, newNode);
}
} else { // 如果new键大于等于current键
if (node.right === null) { // current没有右侧子节点,则赋值到right
node.right = newNode;
} else { // 否则,递归查找别的树节点有没有右侧空位置
this.inserNode(node.right, newNode);
}
}
}
insert(key){
// 1.创建用来表示新节点的Node类实例
let node = new Node(key);
// 2.验证可插入的情况
if(this.root === null){
// 2.1 如果没有根节点,则赋值到根节点的位置上
this.root = node;
}else{
// 2.2 调用insertNode方法时,要通过参数传入树的根节点和要插入的节点。
this.inserNode(this.root, node);
}
}
}
树的遍历
遍历一棵树是指访问树的每个节点并对它们进行某种操作的过程。访问 树的所有节点有三种方式:中序、先序和后序。
中序遍历
中序遍历是一种以上行顺序访问BST所有节点的遍历方式,也就是以从最小到最大的顺序访问所有节点。中序遍历的一种应用就是对树进行排序操作。
只看书上的定义呢就比较难理解,但自己把代码执行顺序捋一遍后,就可以有个概念了。
代码
// 二叉搜索树
class BinarySearchTree {
// 初始化 ...
inOrderTraverseNode(node, callback){
// 停止递归继续执行的判断条件
if(node != null){
// 1.递归调用相同的函数来访问左侧子节点
// console.log('\n当前递归到左侧子节点:',node.key);
this.inOrderTraverseNode(node.left, callback);
// 2.对这个节点进行一些操作(callback)
callback(node.key);
// 3.访问右侧子节点
// console.log('\n当前递归到右侧子节点:',node.key);
this.inOrderTraverseNode(node.right, callback);
}
}
// inOrderTraverse方法接收一个回调函数作为参数。
// 回调函数用来定义对遍历到的每个节点进行的操作
inOrderTraverse(callback){
this.inOrderTraverseNode(this.root, callback);
}
}
let tree = new BinarySearchTree();
let arr = [11,7,15,5,9,13,20,3,6,8,10,12,14,18,25];
arr.forEach((a)=>{
tree.insert(a)
});
tree.inOrderTraverse((a)=>{
console.log('callback--key:',a);
});
// 打印顺序
3 5 6 7 8 9 10 11 12 13 14 15 18 20 25
代码执行顺序流程图
- 顺序执行代码,寻找到叶子节点;
- 找到叶子节点后,原路返回执行回调函数
访问路径图
总结
- 这里的
遍历
指的是访问到了节点,对节点的一些操作
; 递归
指的是一种拿走一层套娃的过程
,拿走第一层套娃,看到了第二层套娃,但只是看到,不是访问,这时候还要拿走第三层套娃,因为我们的目的是看到且操作最后一层套娃。- 当最后一层套娃出现后,可以给它贴上个小星星,接着把倒数第二个套回去,并且也贴上一个星星,这种
给套娃贴上小星星的操作
就是访问节点
。
中序遍历树节点的过程,就是从根节点向下一直递归到某个叶子节点,操作左叶子节点后(比如打印),接着操作这个叶子节点的父级,最后操作右叶子节点,依次往上。
先序遍历
先序遍历是以优先于后代节点的顺序访问每个节点的。先序遍历的一种应用是打印一个结构化的文档。
我们理解了中序遍历后,先序遍历和后序遍历就可以攻克啦!
代码
// 二叉搜索树
class BinarySearchTree {
// 初始化 ...
preOrderTraverseNode(node, callback){
// 停止递归继续执行的判断条件
if(node != null) {
// 第一步 访问自身
callback(node.key);
// 第二步 访问左侧子节点
this.preOrderTraverseNode(node.left, callback);
// 第三步 访问右侧子节点
this.preOrderTraverseNode(node.right, callback);
}
}
// 先序遍历
preOrderTraverse(callback){
this.preOrderTraverseNode(this.root, callback);
}
}
let tree = new BinarySearchTree();
let arr = [11,7,15,5,9,13,20,3,6,8,10,12,14,18,25];
arr.forEach((a)=>{
tree.insert(a)
});
tree.preOrderTraverse((a)=>{
console.log(a);
});
// 打印顺序
11 7 5 3 6 9 8 10 15 13 12 14 20 18 25
访问路径图
总结
先序遍历和中序遍历的不同点是,先序遍历会先访问节点本身,然后再访问它的左侧子节点,最后是右侧子节点。
后序遍历
后序遍历则是先访问节点的后代节点,再访问节点本身。后序遍历的一种应用是计算一个目录和它的子目录中所有文件所占空间的大小。
代码
// 二叉搜索树
class BinarySearchTree {
// 初始化 ...
postOrderTraverseNode(node, callback){
// 停止递归继续执行的判断条件
if(node != null) {
// 第一步 访问左侧子节点
this.postOrderTraverseNode(node.left, callback);
// 第二步 访问右侧子节点
this.postOrderTraverseNode(node.right, callback);
// 第三步 访问自身
callback(node.key);
}
}
// 后序遍历
postOrderTraverse(callback){
this.postOrderTraverseNode(this.root, callback);
}
}
let tree = new BinarySearchTree();
let arr = [11,7,15,5,9,13,20,3,6,8,10,12,14,18,25];
arr.forEach((a)=>{
tree.insert(a)
});
tree.postOrderTraverse((a)=>{
console.log(a);
});
// 打印顺序
3 6 5 8 10 9 7 12 14 13 18 25 20 15 11
访问路径图
总结
后序遍历和中序遍历的不同点是,后序遍历会先访问左侧子节点,然后再访问它的右侧子节点,最后是自身。
树的查找和删除
寻找最小值和最大值
对于寻找最小值,总是沿着树的左边;而对于寻找最大值,总是沿着树的右边。
// ..
minNode(node){
if(node != null){
// BST的左侧子节点 < 父节点
// 循环树的左边,找到左边树的最底层
while(node && node.left !== null ){
node = node.left;
}
return node.key;
}
return null;
}
// 搜索最小值
min(){
return this.minNode(this.root);
}
maxNode(node){
if(node != null){
// BST的右侧子节点 < 父节点
// 循环树的右边,找到右边树的最底层
while(node && node.right !== null ){
node = node.right;
}
return node.key;
}
return null;
}
// 搜索最大值
max(){
return this.maxNode(this.root);
}
// ...
tree.min(); // 3
tree.max(); // 25
查找特定的值
searchNode(node, key){
if(node == null){
// console.error('不存在该节点!');
return false;
}
// 如果key < 当前节点key,则递归查找当前节点的左侧子节点
if(key < node.key){
return this.searchNode(node.left, key)
}else if(key > node.key){ // 如果key > 当前节点key,则递归查找当前节点的右侧子节点
return this.searchNode(node.right, key)
}else{ // 相等的话,就找到了
return true
}
}
search(key){
return this.searchNode(this.root, key);
}
// ...
console.log(tree.search(1) ? 'Key 1 found.' : 'Key 1 not found.');
console.log(tree.search(8) ? 'Key 8 found.' : 'Key 8 not found.');
删除
findMinNode(node){
while(node && node.left !== null ){
node = node.left;
}
return node;
}
removeNode(node, key){
if(node === null){
return null
}else if(key < node.key){
// 更新节点的左侧子节点的指针
node.left = this.removeNode(node.left, key);
return node;
}else if(key > node.key){
// 更新节点的右侧子节点的指针
node.right = this.removeNode(node.right, key);
return node;
}else{
// 递归到该删除的节点
// 第一种情况--叶子节点
if(node.left === null && node.right === null){
node = null;
return node
}
// 第二种情况--只有一个子节点
// 2.1 只有右节点
if(node.left === null){
node = node.right;
return node;
}else if(node.right === null){ // 2.1 只有左节点
node = node.left;
return node;
}
// 第三种情况--一个有两个子节点的节点
var aux = this.findMinNode(node.right);
node.key = aux.key;
node.right = this.removeNode(node.right, aux.key);
return node;
}
}
remove(key){
this.root = this.removeNode(this.root, key)
}
移除一个叶子节点
如果要移除的节点是个叶子节点,第一步需要赋值null给它,第二步需要返回null来将对应的父节点指针赋值null。
现在节点的值已经是null了,父节点指向它的指针也会接收到这个值,这也是我们要在函数中返回节点的值的原因。父节点总是会接收到函数的返回值。
递归路径与赋值路径
移除有一个左侧或右侧子节点的节点
移除有一个左侧子节点或右侧子节点的节点。 这种情况下,需要跳过这个节点,直接将父节点指向它的指针指向的子节点。
如果这个节点5
没有右侧子节点,把对父节点7
对节点5
的引用改为对节点5
左侧子节点的引用,并返回更新后的值。
递归路径与赋值路径
移除有两个子节点的节点
要移除有两个子节点的节点,需要执行四个步骤:
- 当找到了需要移除的节点后,需要找到它右边子树中最小的节点(它的继承者)。
- 然后,用它右侧子树中最小节点的键去更新这个节点的值。通过这一步,我们改变了这个节点的键,也就是说它被移除了。
- 但是,这样在树中就有两个拥有相同键的节点了,这是不行的。要继续把右侧子树中的最小节点移除,毕竟它已经被移至要移除的节点的位置了 。
- 最后,向它的父节点返回更新后节点的引用。
递归路径
总结
- 寻找到需要删除的节点,一样用的递归,找到后再执行操作,并且操作后返回当前节点(比如重新赋值成null或原先的左右节点)
- 从该删除的节点,一路返回赋值回去,没删除没关联的还是原样。
- 删除一个有两个节点的节点那里,总觉得有点问题,如果右侧子树的最小节点比要删除的节点还小呢?