在之前已经介绍了数据结构:栈、队列、链表以及集合,想了解之前的可以翻看我前期的文章,传送门如下:
前端算法系列之一:时间复杂度、空间复杂度以及数据结构栈、队列的实现
前端算法系列之二:数据结构链表、双向链表、闭环链表、有序链表
前端算法系列之三:数据结构数据集合
言归正传我们通过前面的学习了解已经了解和掌握了一部分的数据结构知识,不过我们前面所讲到的不论是栈、队列异或是集合链表其实都是相对比较简单的数据结构,在我们使用js的时候用个数组或者原生api就能满足这类型的结构,所以也是相对比较容易理解的。今天我们要学习的是数据结构中比较复杂的--树;
树:
树是一种分层数据的抽象模型。现实生活中最常见的树的例子是家谱,由一个根节点开始向两边延伸。而在我们实际的编程中这种数据结构也是经常被用到比如:我们页面的DOM树、vue的组件树、ast抽象语法树等等,那树都有哪些特性呢?通过下图来了解
树是由一系列存在父子关系的节点组成,每一个节点都有一个父节点(根节点除外)零个或者多个子节点,位于这一系列节点的顶点的是根节点,根节点是树的起始他是没有父节点的,树中的每一个元素都是节点;没有子节点的成为叶节点或者说外部节点,一颗树的高度取决于所有节点的最大深度值(高度在平衡二叉树中会有用到)。
二叉树
二叉树中的节点最多只能有两个子节点:一个是左侧子节点,另一个是右侧子节点。如上图就是一个二叉树,每个节点最多只有两个子节点,分别是左节点和右节点。
二叉搜索树
二叉搜索树(BST)是二叉树的一种,不过它严格要求左子节点要小于父节点,而右子节点要大于父节点。
实现一个二叉搜索树类
二叉搜索树中的每个元素都是节点,实现构建一个节点Node类,节点有值以及他的左子节点和右子节点
export class TreeNode {
constructor(key) {
this.key = key;
this.left = null;
this.right = null;
}
}
和链表类似,通过指针来表示节点之间的关系。在双向链表中,每个节点包含两个指针,一个指向下一个节点,另一个指向上一个节点。对于树,使用同样的方式(也使用两个指针),但是一个指向左侧子节点,另一个指向右侧子节点。因此,将声明一个Node类来表示树中的每个节点。
接下来我们实现一个树的类:
import {defaultCompare, COMPARE} from "../util";
import {TreeNode} from "./TreeNode";
export default class BinarySearchTree {
constructor(compareFn = defaultCompare) {
this.compareFn = compareFn;
this.root = null;
}
}
类中初始化根节点root=null,并且设置一个比较函数,用于插入、查找键值的时候比较大小。
先定义二叉树中的一些api:insert(插入)、search(查找)、inOrderTraverse(中序)、prevOrderTraverse(先序)、postOrderTraverse(后序)、min(最小值)、max(最大值)、remove(删除)。接下来我们一个一个去讲解怎么实现这些api方法。
1、insert(key):向树中插入一个新的键。
insert(key) {
if (!this.root) {
this.root = new TreeNode(key);
} else {
this.insertNode(this.root, key);
}
}
insertNode(node, key) {
if (this.compareFn(key, node.key) === COMPARE.LESS_THAN) {
const leftNode = node.left;
if (!leftNode) {
node.left = new TreeNode(key);
} else {
this.insertNode(leftNode, key);
}
} else {
const rightNode = node.right;
if (!rightNode) {
node.right = new TreeNode(key);
} else {
this.insertNode(rightNode, key);
}
}
}
insert(key);向树中插入一个元素,其值大小为key,分步骤:
1、在插入的时候会先判断数是否有根节点,如果还没有根节点,那该插入元素就是被赋予根节点;
2、如果存在根节点,则将根节点root作为值和key一块传入insertNode(node,key);
3、insertNode方法首先会将node的key和传入的key进行比较,如果传入的key较小(大),则判断node的左(右)子节点,如果左(右)子节点不存在则新插入的key节点作为该node的左(右)子节点。
4、否则该左(右)子节点作为node,递归调用insertNode重复上面2、3步骤。
测试代码:实现构建一个二叉搜索树;
const tree = new BinarySearchTree();
tree.insert(11);
tree.insert(7);
tree.insert(15);
tree.insert(5);
tree.insert(3);
tree.insert(9);
tree.insert(8);
tree.insert(10);
tree.insert(13);
tree.insert(12);
tree.insert(14);
tree.insert(20);
tree.insert(18);
tree.insert(25);
测试结果如下图所示:
在这颗二叉搜索树再插入6,tree.insert(6),插入的过程如下图所示;
2、search(key):在树中查找一个键。如果节点存在,则返回true;如果不存在,则返回false。
在树中搜索某一个节点元素时候也是从根节点出发开始查找遍历,找到了该元素返回true没有找到则返回false;search方法原理如下步骤:
1、search方法传入key值,首先会调用searchNode把根节点和key作于参数传入,然后将根节点值和传入的key值进行比较;
2、如果key值较小(大),则将传入的节点的左(右)节点的和key值传入searchNode进行递归调用;
3、此时searchNode又会将传入的node的值和key值进行比较;
4、如果相等则return true,并停止搜索,否则重复2、3步骤直到遍历完整颗树,最后如果还是没有找到就返回false;
search(key) {
return this.searchNode(this.root, key);
}
searchNode(node, key) {
if(node){
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);
} else {
return true;
}
}
return false;
}
测试代码:
tree.search(5);// true
tree.search(55);// true
小结:
从二叉树的搜索查找中我们可以发现如果树分布是均匀(也就是平衡二叉树后面会讲到)那在查找的时候他永远都是二分的查找,这种查找方式的时间复杂度O(n):log2n。
二叉树的遍历
要遍历完一颗二叉树所有的元素节点,通常是有三种方法:中序遍历、先序遍历、后序遍历。下面我们来看一下怎么实现这三种遍历方式。
1、中序遍历
中序遍历的特点是如果节点存在左子树就会先去遍历左边一直到左节点为null的时候,回溯访问node,然后再访问node的右节点一直到访问遍历完整棵树,对于二叉搜索树,这样遍历出来的结果就是从小到大的排序(因为二叉搜索树都是按照严格的左子节点比父节点小,右子节点比父节点大),实现代码如下:
// 中序
inOrderTraverse(callback) {
this.inOrderTraverseNode(this.root, callback);
}
inOrderTraverseNode(node, callback) {
if(node) {
this.inOrderTraverseNode(node.left, callback);
callback(node.key);
this.inOrderTraverseNode(node.right, callback);
}
}
测试代码:
let arr = [];
tree.inOrderTraverse((key) => arr.push(key));
console.log(arr);// [3, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 18, 20, 25]
inOrderTraverse((val) => {arr.push(val)});调用该方法时候会调用inOrderTraverseNode方法并把根节点传入,然后接着递归左子树,递归完后回溯访问本节点node,再接着递归node的右子树,一直到遍历完整棵树,过程如下图:
2、先序遍历
先序遍历和中序不同在于先序遍历是先访问本节点的key值再去递归遍历左子树,然后遍历递归右子树,直到遍历完整颗树;代码实现如下
// 先序
prevOrderTraverse(callback){
this.prevOrderTraverseNode(this.root, callback);
}
prevOrderTraverseNode(node, callback){
if (node) {
callback(node.key);
this.prevOrderTraverseNode(node.left, callback);
this.prevOrderTraverseNode(node.right, callback);
}
}
测试代码:
let arr = [];
tree.prevOrderTraverse((key) => arr.push(key));
console.log(arr);// [11, 7, 5, 3, 9, 8, 10, 15, 13, 12, 14, 20, 18, 25]
3、后序遍历
后序遍历则是先访问节点的后代节点,再访问节点本身。
实现代码如下:
// 后序
postOrderTraverse(callback){
this.postOrderTraverseNode(this.root, callback);
}
postOrderTraverseNode(node, callback){
if (node) {
this.postOrderTraverseNode(node.left, callback);
this.postOrderTraverseNode(node.right, callback);
callback(node.key);
}
}
测试代码:
let arr = [];
tree.postOrderTraverse((key) => arr.push(key));
console.log(arr);//[3, 5, 8, 10, 9, 7, 12, 14, 13, 18, 25, 20, 15, 11]
4、min()、max()返回树中最小\大的值
查找二叉搜索树中最小的节点,通过二叉搜索树的特性我们知道树的左(右)子节点比父节点小(大),而左(右)子树也满足这个条件,所以整个树的最左(右)子节点就是最小的值了,实现代码如下:
// 最小值
min(){
return this.minNode(this.root);
}
minNode(node) {
while (node && node.left) {
node = node.left;
}
return node;
}
// 最大值
max(){
return this.maxNode(this.root);
}
maxNode(node) {
while (node && node.right) {
node = node.right;
}
return node;
}
5、remove(key):从树中移除某个键。
remove(key){
this.root = this.removeNode(this.root, key);
}
removeNode(node, key){
if (!node) {
return null;
}
if (this.compareFn(key, node.key) === COMPARE.LESS_THAN) {
node.left = this.removeNode(node.left, key);
return node;
} else if (this.compareFn(key, node.key) === COMPARE.BIGGER_THAN) {
node.right = this.removeNode(node.right, key);
return node;
} else {
if (!node.left && !node.right) {
node = null;
return node;
}
if(!node.left) {
node = node.right;
return node;
}
if(!node.right) {
node = node.left;
return node;
}
let minNode = this.minNode(node.right);
node.key = minNode.key;
node.right = this.removeNode(node.right, minNode.key);
return node;
}
}
二叉搜索树中删除某一个节点处理起来相对复杂一些:被删除的节点分为三种情况:1、被删除的节点是叶节点(即没有左右子节点);2、被删除的节点有左或者有右子节点;3、被删除的子节点既有左子节点又有右子节点,针对这三种情况分别处理,下面我们来分开讨论这三种情况;
1、被删除的节点没有左右子节点;
这种情况是最最简单的,由于没有子节点,我们只要找到该节点然后把它父节点指向它的指针置于null就删除了,简直是轻松加愉快的完成。
` if (!node.left && !node.right) {
node = null;
return node;
}`
代码中我们把找到的这个叶节点直接赋值为null并将其返回,其实也是相对于给他的父节点的指针赋值为了null;比如tree.remove(3);首先是先找到3这个节点,然后将其置于null并返回给父节点;
2、被删除的节点有左或者有右子节点;
这种情况也相对比较简单,如果被删节点不存在左子节点那就将其右子节点赋值给自己替代自己,并返回,同理如果不存在右子节点,那就将左子节点赋值给自己替代自己,并返回;
if(!node.left) {
node = node.right;
return node;
}
if(!node.right) {
node = node.left;
return node;
}
代码中实现的就是如果存在右子节点你就用右子节点替代自己,如果左子节点存在就用左子节点替代自己;例如:tree.remove(5);
从上图我们可以看到首先在树中找到5这个节点,然后把5这个节点用它的左子节点3替代掉,让节点7的左子节点直接指向了3这个节点,就完成了移除5这个元素,单独有右子节点的情况和这种类似。
3、被删除的子节点既有左子节点又有右子节点;
这种情况比前面两种要复杂一些,他也是1、要先找到该节点,然后2、还要去查找右子树中最小值,来替代该元素而不是直接用子节点去替换,3、同时右子树也要删掉那个最小值并作为该被删除节点的右子节点。
let minNode = this.minNode(node.right);
node.key = minNode.key;
node.right = this.removeNode(node.right, minNode.key);
总结
到目前为止我们比较简单的介绍了二叉搜索树的一些特性以及他的api,了解了怎么去插入和删除一个节点以及3种遍历树的方式,看起来以及很完美,其实不然这些都只是很理想的状态,比如我们举例用的这棵树是很理想化的,两边是平衡的,如果我们在插入的时候一个有序的数,或者我们刚开始插入的根节点是一个很小或者很大的数,导致后续插入的元素都被偏移到根节点的一侧那这样就不是一颗平衡树了,那他的很多特点就会被退化,比如刚刚说的如果插入的是一个有序的数,那树就会退化成一个链表,形成一条而不是一棵树了。这就引入了树的一个重要类型平衡二叉树(AVLTree),平衡二叉树是会在每次插入的时候调整树的结构,保障树的两侧深度之差绝对值小于等于1。平衡二叉树以及红黑树将在后续补充,今天就先到这里,谢谢阅读,如有错误请及时给与指正。
最后:
想了解更多请看:源码
或者搜索公众号:非著名bug认证师