数据结构与算法-树(Tree)-JS
数组优点:
1.根据下表中访问效率很高;
2.倘若希望根据元素查找对应的位置,比较好的方式是先对数组进行排序,在进行二分查找
数组缺点:
1.需要先对数组进行排序,生成有序数组,才能提高查找效率;
2.另外数组在插入和删除数据时,需要有大量的位移操作,效率很低
链表优点:
插入和删除操作效率都很高
链表缺点:
1.查找效率很低,需要从头开始依次访问查找;
2.而且即使插入和删除效率很高,但是如果插入和删除中间位置的数据,还是需要从头先找到对应的数据
哈希表优点:
插入、查询、删除效率都是非常高的
哈希表缺点:
1.空间利用率不高,需要设置总长度二倍的空间,底层使用的是数据,并且某些单元没有被利用;
2.哈希表中的元素时无序的,不能按照固定的顺序来遍历哈希表中的元素;
3.不嫩快速找出哈希表中的最大者或者最小者这些特殊值。
树优点:
不能说明树结构比其他结构都要好,因为每种数据结构都有自己特定的应用场景。
1.空间利用率是非常高的;
2.相对于哈希表来说,利用率高,而且所有元素都是有序的,可以按照某些顺序进行遍历的,可以非常快速地查找到最大值、最小值;
3.某些场景,使用树结构跟家方便,因为树结构是非线性的,可以表示一对多的关系
树缺点:
查找某个值的效率比列表要快,但比哈希表慢;
树的术语:
节点的度(Degree):节点的子树个数;
树的度:树的所有节点中最大的度数;
叶节点/叶子节点(Leaf):度为0的节点;
父节点(Parent);子节点(Child);
兄弟节点(Sibling):具有同一个父节点的各节点彼此是兄弟节点;
路径和路径长度:一个节点到另一个节点的路径,路径长度取决于路径中的边的个数;
节点的层次(Level):规定根节点在1层,其他节点的层数依次加一;树的深度(Depth):树中所有节点中的最大层次是这棵树的深度。
其实所有的树本质上都可以使用二叉树模拟出来
二叉树有五种形态:
空(没有任何节点);只有根节点;根节点和左子节点;根节点和右节点、根节点和左右子节点
二叉树的特性:
1.一个二叉树第i层的最大节点数为2^(i-1),i>=1;
2.深度为k的二叉树的节点总数的最大值为:2^k-1,k>=1;
3.对任何非空二叉树T,若n0表示叶节点的个数,n2表示度为2的非叶节点个数,那么二者关系为n0=n2+1.
满二叉树(完美二叉树Perfect Binary Tree):
除了最下层的叶节点外,每层节点都有两个子节点;且刚好完全铺满
完全二叉树(Complete Binary Tree):
除了最后一层外,其他各层都达到了最大个数,且最后一层从左向右的叶节点连续存在,不能跳着有节点,完美二叉树是特殊的完全二叉树
完全二叉树才可以使用数组进行存储,如果是一般的二叉树,使用数组存储的话会浪费空间;
二叉树最常见的方式是使用链表存储
二叉搜索树(BST,Binary Search Tree),也叫二叉排序树或二叉查找树
二叉搜索树是一颗二叉树,可以为空;
如果不为空,满足以下性质:
1.非空左子树的所有键值小于其根节点的键值;
2.非空右子树的所有键值大于其根节点的键值;
3.左、右子树本身也都是二叉搜索树。
二叉搜索树的特点:
相对较小的值总是保存在左节点上,相对较大的值总是保存在右节点上,这个特点可以使查找效率非常高,这也是二叉搜索树中搜索的来源。
//封装二叉搜索树
function BinarySearchTree() {
//内部类节点
function Node(key) {
this.key = key;
this.left = null;
this.right = null;
}
//属性
this.root = null;
//方法
//插入数据:对外给用户调用的方法
BinarySearchTree.prototype.insert = function (key) {
//根据key创建节点
var newNode = new Node(key);
//判断根节点是否有值
if (this.root == null) {
this.root = newNode;
} else {
this.insertNode(this.root, newNode);
}
}
//内部使用的插入方法,用于比较两个节点值的大小
BinarySearchTree.prototype.insertNode = function (node, newNode) {
if (newNode.key < node.key) { //向左查找
if (node.left == null) {
node.left = newNode;
} else {
this.insertNode(node.left, newNode);
}
} else { //向右查找
if (node.right == null) {
node.right = newNode;
} else {
this.insertNode(node.right, newNode);
}
}
}
//二叉树的遍历常见有三种方式:先序遍历、中序遍历、后序遍历,还有层序遍历,从第一层开始一层一层从左到右进行遍历,这个使用较少,可以使用队列来完成。
//先序遍历:1.访问根节点2.先序遍历其左子树3.先序遍历其右子树
BinarySearchTree.prototype.preOrderTraversal = function (handler) {
this.preOrderTraversalNode(this.root, handler);
}
//如果理解费劲可以画个二叉树的图
BinarySearchTree.prototype.preOrderTraversalNode = function (node, handler) {
if (node != null) {
//处理经过的节点
//这也是叫先序的原因,是先对node进行处理,处理完之后再去找其他节点
//先序还可以理解为啥时候处理根节点,在最最前面处理根节点叫做先序
handler(node.key);
//查找经过节点的左子节点
this.preOrderTraversalNode(node.left, handler);
//查找经过节点的右子节点
this.preOrderTraversalNode(node.right, handler);
}
}
//中序遍历:1.中序遍历其左子树2.访问根节点3.中序遍历其右子树
BinarySearchTree.prototype.midOrderTraversal = function (handler) {
this.midOrderTraversalNode(this.root, handler);
}
BinarySearchTree.prototype.midOrderTraversalNode = function (node, handler) {
if (node != null) {
//查找左子树中的节点,最开始递归后找到左子树最左边那个节点
this.midOrderTraversalNode(node.left, handler);
//处理节点
handler(node.key);
//查找右子树中的节点
this.midOrderTraversalNode(node.right, handler);
}
}
//后序遍历:1.后序遍历其左子树2.后序遍历其右子树3.访问根节点
BinarySearchTree.prototype.postOrderTraversal = function (handler) {
this.postOrderTraversalNode(this.root, handler);
}
BinarySearchTree.prototype.postOrderTraversalNode = function (node, handler) {
if (node != null) {
//查找左子树中的节点
this.postOrderTraversalNode(node.left, handler) ;
//查找右子树中的节点
this.postOrderTraversalNode(node.right, handler);
//处理节点
handler(node.key);
}
}
//寻找最值
//寻找最大值
BinarySearchTree.prototype.max = function () {
//获取根节点
var node = this.root;
//一次向右不断地查找,直到节点为null
while (node.right != null) {
node = node.right;
}
return node.key;
}
//寻找最小值
BinarySearchTree.prototype.min = function () {
//获取根节点
var node = this.root;
//一次向右不断地查找,直到节点为null
while (node.left != null) {
node = node.left;
}
return node.key;
}
//搜索特定值,搜索到了返回true
// //采用递归算法
// BinarySearchTree.prototype.search = function (key) {
// return this.searchNode(this.root, key);
// }
// BinarySearchTree.prototype.searchNode = function (node, key) {
// //如果传入的node为null,那么就退出递归
// if (node == null) {
// return false;
// }
// //判断node节点的值和传入的key大小
// if (key < node.key) {
// return this.searchNode(node.left, key);
// } else if (key > node.key) {
// return this.searchNode(node.right, key);
// } else {
// return true;
// }
// }
//使用循环实现
BinarySearchTree.prototype.search = function (key) {
//获取根节点
var node = this.root;
//循环key
while (node != null) {
if (key < node.key) {
node = node.left;
} else if (key > node.key) {
node = node.right;
} else {
return true;
}
}
return false;
}
//二叉搜索树的节点的删除
//1.先找到要删除的节点,如果没有找到,不需要删除2.找到要删除的节点,分三种情况讨论
//1删除叶子节点2删除只有一个子节点的节点3删除有两个子节点的节点
//删除节点
BinarySearchTree.prototype.remove = function (key) {
//寻找要删除的节点
var current = this.root;
var parent = null;
var isLeftChild = true; //是否是左节点
//开始寻找删除的节点
while (current.key != key) {
parent = current;
if (key < current.key) {
isLeftChild = true;
current = current.left;
} else {
isLeftChild = false;
current = current.right;
}
//已经找到最后的节点,依然没有找到等于key的节点
if (current == null) return false;
}
//根据对应的情况删除节点
//删除的节点时叶子节点(没有子节点)
if (current.left == null && current.right == null) {
if (current == this.root) { //根节点且为叶子节点
this.root = null;
} else if (isLeftChild) { //不是根节点且是左子节点
parent.left = null;
} else { //不是根节点且是右子节点
parent.right = null;
}
//删除的节点有一个子节点,只删除这一个节点,下面的节点还保留,需要考虑六种情况,其中两个是根节点的情况
//比较难理解,需要画图理解
} else if (current.right == null) { //删除的节点只有左子节点
if (current == this.root) {
this.root = current.left;
} else if (isLeftChild) { //删除的节点时父节点左子节点
parent.left = current.left;
} else { //删除的节点时父节点右子节点
parent.right = current.left;
}
} else if (current.left == null) { //删除的节点只有右子节点
if (current == this.root) {
this.root = current.right;
} else if (isLeftChild) { //删除的节点时父节点右子节点
parent.left = current.right;
} else { //删除的节点时父节点右子节点
parent.right = current.right;
}
//删除的节点有两个子节点
} else {
//比较难理解,需要画图理解
//如果我们要删除的节点有两个子节点,甚至子节点还有子节点,我们需要从下面的子节点中找到一个节点来替换当前的节点
//这个节点的特征就是current节点下面所有的节点中最接近current的节点
//比current小一点点的节点,一定是current左子树的最大值;臂current大一点点的节点,一定是current右子树的最小值
//前驱和后继
//比current小一点点的节点,称为current节点的前驱;比current大一点点的节点,称为current节点的后继
//在这里实现找后继
//获取后继节点
var successor = this.getSuccssor(current);
//判断是否为根节点
if (current == this.root) {
this.root = successor;
} else if (isLeftChild) {
parent.left = successor;
} else {
parent.right = successor;
}
//将删除节点的左子树 = current.left
successor.left = current.left;
}
}
BinarySearchTree.prototype.getSuccssor = function (delNode) {
//定义变量,保存找到的后继
var successor = delNode;
var current = delNode.right;
var successorParent = null;
//循环查找
while (current != null) {
successorParent = successor;
successor = current;
current = current.left;
}
//判断寻找的后继节点是否直接就是delNode的right节点
if (successor != delNode.right) {
successorParent.left = successor.right;
successor.right = delNode.right;
}
return successor;
}
}
//测试代码
//创建BinarySearchTree
var bst = new BinarySearchTree();
//插入数据
bst.insert(11);
bst.insert(7);
bst.insert(15);
bst.insert(5);
bst.insert(3);
bst.insert(9);
bst.insert(8);
bst.insert(10);
bst.insert(13);
bst.insert(12);
bst.insert(14);
bst.insert(20);
bst.insert(18);
bst.insert(25);
bst.insert(6);
//测试先序遍历
var resultString = '';
bst.preOrderTraversal(function (key) {
resultString += key + " ";
})
// alert(resultString); //11 7 5 3 6 9 8 10 15 13 12 14 20 18 25
//测试中序遍历
resultString = '';
bst.midOrderTraversal(function (key) {
resultString += key + " ";
})
// alert(resultString); //3 5 6 7 8 9 10 11 12 13 14 15 18 20 25
//测试后序遍历
resultString = '';
bst.postOrderTraversal(function (key) {
resultString += key + " ";
})
// alert(resultString); //3 6 5 8 10 9 7 12 14 13 18 25 20 15 11
// //测试最值
// alert(bst.max());
// alert(bst.min());
// //测试搜索方法
// alert(bst.search(25));
// alert(bst.search(2));
//测试删除代码
bst.remove(9);
bst.remove(7);
bst.remove(15);
resultString = '';
bst.postOrderTraversal(function (key) {
resultString += key + " ";
})
alert(resultString); //3 6 5 10 8 12 14 13 25 20 18 11
非平衡树:
比较好的二叉搜索树数据应该是左右均匀分布的,如果插入的数据是有序的数据,分布不均匀,就叫做非平衡树。
平衡二叉树的插入或查找的效率是O(logN),和深度有关系;非平衡二叉树,相当于编写了一个链表,查找效率变成了O(N)
所以需要保证树总是平衡的,即每个节点左边的子孙节点个数尽可能等于右边的子孙节点的个数
常见的平衡树有AVL树和红黑树
AVL树是最早的一种平衡树,使用每个节点多存储一个额外的数据的方法保持树的平衡,时间复杂度为为O(logN),但是每次插入或删除操作的效率相对于红黑树来说不高,所以整体效率不如红黑树
红黑树的时间复杂度也是O(logN),现在平衡树的应用基本都是红黑树
红黑树的规则:
1.节点时红色或黑色
2.根节点是黑色
3.每个叶子节点都是黑色的空节点(NIL节点)
4.每个红色节点的两个子节点都是黑色(从每个叶子节点到根节点的所有路径上不能有两个连续的红色节点)
5.从任一节点到其每个叶子的所有路径都包含相同数据的黑色节点
前面的约束确保了红黑树的关键特性:从根到叶子的最长可能路径,不会超过最短可能路径的两倍长,结果就是这个树基本是平衡的,虽然没有做到绝对的平衡,但是可以保证在最坏的情况下,依然是高效的。
为什么可以做到最长路径不超过最短路径的二倍?性质4决定了路径不能有两个相连的红色节点,最短的可能路径都是黑色的节点,最长的可能路径是红色和黑色交替,性质5所有路径都有相同数目的黑色节点,这就表明了最长的路径只能是红黑一直相互交替,表明了没有路径能多于任何其他路径的两倍长。
插入一个新节点时,有可能树不再平衡,可以通过三种方式的变换,让树保持平衡:换色;左旋转;有旋转
变色:
为了重新复核红黑树的规则,尝试吧红色节点变为黑色,或者把黑色节点变为红色。
首先,需要知道插入的新节点通常都是红色节点,因为在插入节点为红色的时候,有可能插入一次是不违反红黑树任何规则的,而插入黑色节点,必然会导致一条路径上多了黑色节点,这是很难调整的,插入红色节点可能导致红红相连的情况,但是这种情况可以通过颜色调换和旋转来调整。
左旋转:
逆时针旋转红黑树的两个节点,使得父节点被自己的右孩子取代,而自己成为自己的左孩子;右旋转:顺时针旋转红黑树的两个节点,使得父节点被自己的左孩子取代,而自己成为自己的右孩子。
插入一个新节点会有五种情况:设要插入的节点为N,其父节点为P,其祖父节点为G,其父亲的兄弟节点为U。
情况一:新节点N位于树的根上,没有父节点,这种情况下,直接将红色编程黑色即可,这样满足性质2。
情况二:新节点的父节点P是黑色,性质4没有失效,性质5也没有问题,尽管新节点N有两个黑色的叶子结点NIL,但是新节点N是红色的,所以通过它的路径中黑色节点的个数依然相同,满足性质5。
情况三:P为红色,U为红色,G为黑色,将P和U变换为黑色,并且将G变换为红色,每条路径上的黑色节点的数目没有改变。可能出现的问题:N的祖父节点G的父节点也可能是红色,这就违反了性质3,可以递归地调整颜色,但是如果递归调整颜色到了根节点,就需要进行旋转了,下面会有例子讲到。
情况四:P红U黑G黑,N为左儿子,方案,P黑,G红,GP进行右旋转,也可以将旋转与变色的顺序互换。
情况五:P红U黑G黑,N是右儿子,方案,PN进行左旋转,形成情况四的结果,最祖父节点G进行一次右旋转,并且改变颜色即可。
案例:依次插入10 9 8 7 6 5 4 3 2 1
插入10(将节点10的颜色变为黑色)=>插入9(不需要用任何变换)=>插入8(情况四,父变黑,祖变红,父和祖有旋转)=>插入7(符合情况三,父变黑,叔变黑,祖变红,不满足性质2,只需要把根节点变为黑色就可以了)=>插入6(情况四,父变黑,祖变红,父和祖进行右旋转)=>插入5(情况三,父和叔变黑,祖变红)=>插入4(情况四,父变黑,祖变红,父和祖进行右旋转)=>插入3(情况三,父变黑,叔变黑,祖变红,此时5和7是挨着的红色,是情况四,7变黑,9变红,7和9进行右旋转)=>插入2(情况四,父3变黑,祖4变红,父3祖4进行右旋转)=>插入1(情况三,父2变黑,叔4变黑,祖3变红,此时3和5是挨着的红色,把3以下看做新插入的红色节点,父5变黑,叔9变黑,祖7变红,根节点不满足性质2,将根节点7变黑)
红黑树的删除,需要将二叉搜索树的删除操作和红黑树的插入规则结合起来,难度非常大,需要考虑删除中间节点的话会不会对后序的子节点产生影响,而且一旦删除后会不会对红黑树的整个规则产生影响。
红黑树的插入和删除的代码,需要按照之前分析的不同情况进行分析。前端就先了解到这里,如果需要学习和了解其他后端语言以及其源码,比如Java、Python、Go等,可以再深入了解代码实现。