一、树结构简介
1.简单了解树结构
什么是树?
2. 树的特点:
- 树一般都有一个根,连接着根的是树干;
- 树干会发生分叉,形成许多树枝,树枝会继续分化成更小的树枝;
- 树枝的最后是叶子;
现实生活中很多结构都是树的抽象,模拟的树结构相当于旋转180°
的树。
3.树结构对比于数组/链表/哈希表有哪些优势
数组:
- 优点:可以通过下标值访问,效率高;
- 缺点:查找数据时需要先对数据进行排序,生成有序数组,才能提高查找效率;并且在插入和删除元素时,需要大量的位移操作;
链表:
- 优点:数据的插入和删除操作效率都很高;
- 缺点:查找效率低,需要从头开始依次查找,直到找到目标数据为止;当需要在链表中间位置插入或删除数据时,插入或删除的效率都不高。
哈希表:
- 优点:哈希表的插入/查询/删除效率都非常高;
- 缺点:空间利用率不高,底层使用的数组中很多单元没有被利用;并且哈希表中的元素是无序的,不能按照固定顺序遍历哈希表中的元素;而且不能快速找出哈希表中最大值或最小值这些特殊值。
树结构:
优点:树结构综合了上述三种结构的优点,同时也弥补了它们存在的缺点(虽然效率不一定都比它们高),比如树结构中数据都是有序的,查找效率高;空间利用率高;并且可以快速获取最大值和最小值等。
4.树的常用术语:
- 节点的度(Degree):节点的子树个数,比如节点B的度为2;【子节点的个数】
- 树的度:树的所有节点中最大的度数,如上图树的度为2;
- 叶节点(Leaf):度为0的节点(也称为叶子节点),如上图的H,I等;
- 父节点(Parent):度不为0的节点称为父节点,如上图节点B是节点D和E的父节点;
- 子节点(Child):若B是D的父节点,那么D就是B的子节点;
- 兄弟节点(Sibling):具有同一父节点的各节点彼此是兄弟节点,比如上图的B和C,D和E互为兄弟节点;
- 路径和路径长度:路径指的是一个节点到另一节点的通道,路径所包含边的个数称为路径长度,比如A->H的路径长度为3;
- 节点的层次(Level):规定根节点在1层,其他任一节点的层数是其父节点的层数加1。如B和C节点的层次为2;
- 树的深度(Depth):树种所有节点中的最大层次是这棵树的深度,如上图树的深度为4;
2.树结构的表示方式
- 最普通的表示方法:
如图,树结构的组成方式类似于链表,都是由一个个节点连接构成。不过,根据每个父节点子节点数量的不同,每一个父节点需要的引用数量也不同。比如节点A需要3个引用,分别指向子节点B,C,D;B节点需要2个引用,分别指向子节点E和F;K节点由于没有子节点,所以不需要引用。
这种方法缺点在于我们无法确定某一结点的引用数。
- 儿子-兄弟表示法:
- 儿子-兄弟表示法旋转(顺时针旋转45°)
任何树都可以通过二叉树进行模拟。但是这样父节点不是变了吗?其实,父节点的设置只是为了方便指向子节点,在代码实现中谁是父节点并没有关系,只要能正确找到对应节点即可。
3.二叉树
1.二叉树简介
如果树中的每一个节点最多只能由两个子节点,这样的树就称为二叉树;
二叉树十分重要,不仅仅是因为简单,更是因为几乎所有的树都可以表示成二叉树形式。
2.二叉树的组成
- 二叉树可以为空,也就是没有节点;
- 若二叉树不为空,则它由根节点和称为其左子树TL和右子树TR的两个不相交的二叉树组成;
3.二叉树的五种形态:
上述表示为: 空的二叉树、只有一个节点的二叉树、只有左子树TL的二叉树、只有右子树TR的二叉树和有左右两个子树的二叉树。
4.二叉树的特性:
4.特殊的二叉树
1.完美二叉树(Perfect Binary Tree)
完美二叉树(Perfect Binary Tree)也成为满二叉树(Full Binary Tree),在二叉树中,除了最下一层的叶子节点外,每层节点都有2个子节点,这就构成了完美二叉树。
2.完全二叉树 (Complete Binary Tree)
- 除了二叉树最后一层外,其他各层的节点数都达到了最大值;
- 叶子节点只能出现在最下两层
- 并且,最后一层的叶子节点从左向右是连续存在,只缺失右侧若干叶子节点;
- 完美二叉树是特殊的完全二叉树;
满二叉树一定是完全二叉树,但是完全二叉树不一定是满二叉树
5.二叉树的存储
常见的二叉树存储方式为数组和链表:
使用数组:
- 完全二叉树:按从上到下,从左到右的方式存储数据。
- 非完全二叉树:非完全二叉树需要转换成完全二叉树才能按照上面的方案存储,这样会浪费很大的存储空间。
使用链表
二叉树最常见的存储方式为链表:每一个节点封装成一个Node,Node中包含存储的数据、左节点的引用和右节点的引用。
三、二叉搜索树(BST,Binary Search Tree)
1.二叉搜索树的概念
二叉搜索树(BST,Binary Search Tree),也称为二叉排序树和二叉查找树。
二叉搜索树是一棵二叉树,可以为空;
如果不为空,则满足以下性质:
- 条件1:非空左子树的所有键值小于其根节点的键值。比如三中节点6的所有非空左子树的键值都小于6;
- 条件2:非空右子树的所有键值大于其根节点的键值;比如三中节点6的所有非空右子树的键值都大于6;
- 条件3:左、右子树本身也都是二叉搜索树;
总结:二叉搜索树的特点主要是较小的值总是保存在左节点上,相对较大的值总是保存在右节点上。这种特点使得二叉搜索树的查询效率非常高,这也就是二叉搜索树中"搜索"的来源。
2.二叉搜索树的应用
若想在其中查找数据10,只需要查找4次,查找效率非常高。
- 第1次:将10与根节点9进行比较,由于10 > 9,所以10下一步与根节点9的右子节点13比较;
- 第2次:由于10 < 13,所以10下一步与父节点13的左子节点11比较;
- 第3次:由于10 < 11,所以10下一步与父节点11的左子节点10比较;
- 第4次:由于10 = 10,最终查找到数据10 。
3.二叉树的性质
1.在二叉树的第i层上至多有2^(i-1)个结点(i>1)。
2.深度为k的二叉树至多有2^k-1个结点(k>=1)。
3.对任何一颗二叉树T,如果其叶子数为n0,度为2的结点数为n2,则n0=n2+1.
4. 具有n个结点的完全二叉树的深度为(log2N)+1。5.
四、二叉搜索树的封装
1.二叉树搜索树的基本属性:
二叉搜索树有四个最基本的属性:指向节点的根(root),节点中的键(key)、左指针(right)、右指针(right)。
//封装二叉搜索树
function BinarySearchTree() {
//节点内部类
function Node(key) {
this.key = key
this.left = null
this.right = null
}
//属性
this.root = null
}
2.二叉搜索树常见操作
3.二叉搜索树的插入
实现思路:
- 首先根据传入的key创建节点对象;
- 然后判断根节点是否存在,不存在时通过:this.root = newNode,直接把新节点作为二叉搜索树的根节点。
- 若存在根节点则重新定义一个内部方法insertNode()用于查找插入点。
内部方法insertNode()的实现思路:
根据比较传入的两个节点,一直查找新节点适合插入的位置,直到成功插入新节点为止。
当newNode.key < node.key向左查找:
情况1:当node无左子节点时,直接插入:
情况2:当node有左子节点时,递归调用insertNode(),直到遇到无左子节点成功插入newNode后,不再符合该情况,也就不再调用insertNode(),递归停止。
当newNode.key >= node.key向右查找,与向左查找类似:
情况1:当node无右子节点时,直接插入:
情况2:当node有右子节点时,依然递归调用insertNode(),直到遇到传入insertNode方法的node无右子节点成功插入newNode为止:
代码实现
// 封装二叉搜索树
function BinarySearchTree() {
// 封装内部类
function Node(key) {
this.key = key;
this.left = null;
this.right = null;
};
// 属性
this.root = null;
// 方法
// 插入数据:对外给用户调用的方法
BinarySearchTree.prototype.insert = function (key) {
// 1. 根据key创建节点
var newNode = new Node(key);
// 2. 判断根节点是否有值
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);
}
}
};
}
过程详解:
想要上述的二叉搜索树(蓝色)中插入数据10:
- 先把key = 10 传入insert方法,由于存在根节点 9,所以直接调用insetNode方法,传入的参数:node = 9,newNode = 10;
- 由于10 > 9,进入分支2,向右查找适合插入的位置;
- 由于根节点 9 的右子节点存在且为 13 ,所以进入分支2.2,递归调用insertNode方法,传入的参数:node = 13,newNode = 10;
- 由于 10 < 13 ,进入分支1,向左查找适合插入的位置;
- 由于父节点 13 的左子节点存在且为11,所以进入分支1.2,递归调用insertNode方法,传入的参数:node = 11,newNode = 10;
- 由于 10 < 11,进入分支1,向左查找适合插入的位置;
- 由于父节点 11 的左子节点不存在,所以进入分支1.1,成功插入节点 10 。由于不符合分支1.2的条件所以不会继续调用insertNode方法,递归停止。
测试代码:
//测试代码
//1.创建BinarySearchTree
let 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);
console.log(bst);
4.查找最大值 && 最小值
在二叉搜索树中查找最值非常简单,最小值在二叉搜索树的最左边,最大值在二叉搜索树的最右边。只需要一直向左/右查找就能得到最值
代码实现:
// 查找最大值
BinarySearchTree.prototype.max = function () {
// 1.获取根节点
let node = this.root
// 2.依次向右不断查找,直到节点为null
while (node.right != null) {
node = node.right
}
return node.key
}
// 查找最小值
BinarySearchTree.prototype.min = function () {
// 1.获取根节点
let node = this.root
// 2.依次向左查找,直到节点为null
while (node.left != null) {
node = node.left
}
return node.key
}
测试代码:
//测试代码
//1.创建BinarySearchTree
let bst = new BinarySearchTree()
//2.插入数据
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);
//4.测试最值
console.log(bst.max());//25
console.log(bst.min());//3
5.查找特定的key值
方法一:使用循环的方式
// 查找特定值
// 方法一:使用循环的方式
BinarySearchTree.prototype.search=function(key){
// 1.获取根节点
var node=this.root
// 2.循环搜索key
while(node!=null){
if(key<node.key){
node=node.right
}else if(key>node.key){
node=node.left
}else{
return true
}
}
return false
}
方法二:使用递归的方式
// 方法二:使用递归的方式
BinarySearchTree.prototype.search=function(key){
return this.searchNode(this.root,key)
}
BinarySearchTree.prototype.searchNode=function(node,key){
// 1.如果传入的node为null,那么退出递归
if(node===null){
return false
}
// 2.判断node节点的值和传入的key大小
if(node.key>key){ //传入的key较小,向左边继续查找
return this.searchNode(node.left,key)
}else if(node.key<key){ //传入的key较大,向右边继续查找
return this.searchNode(node.right,key)
}else{ //相同,表示找到key
return true
}
测试代码
//测试代码
//1.创建BinarySearchTree
let bst = new BinarySearchTree()
//2.插入数据
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);
//3.测试搜索方法
console.log(bst.search(24));
console.log(bst.search(13));
console.log(bst.search(2));
五、二叉搜索树的遍历
三种方式:
这里所说的树的遍历不仅仅针对二叉搜索树,而是适用于所有的二叉树。由于树结构不是线性结构,所以遍历方式有多种选择,常见的三种二叉树遍历方式为:
- 先序遍历;
- 中序遍历;
- 后序遍历;
还有层序遍历,使用较少。
1 先序遍历
先序遍历的过程为:
- 首先,遍历根节点;
- 然后,遍历其左子树;
- 最后,遍历其右子树;
二叉树的节点遍历顺序为:A -> B -> D -> H -> I -> E -> C -> F -> G。
代码实现
// 先序遍历
// handler->传入要进行执行的操作
BinarySearchTree.prototype.preOrderTraversal = function (handler) {
this.preOrderTraversalNode(this.root, handler)
}
// 封装内部方法,对某一个节点进行遍历
BinarySearchTree.prototype.preOrderTraversalNode = function (node, handler) {
// 1.先判断根节点是否为空
if (node != null) {
// 1.1 如果不为空,则对根节点进行判断
handler(node.key)
// 递归1:对node的左子节点判断
this.preOrderTraversalNode(node.left, handler)
// 递归2:对node.left节点右子节点判断
this.preOrderTraversalNode(node.right, handler)
}
}
测试代码
//测试代码
//1.创建BinarySearchTree
let bst = new BinarySearchTree()
//2.插入数据
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);
//3.测试遍历
let resultString = ""
//掺入处理节点值的处理函数
bst.preOrderTraversal(function (key) {
resultString += key + " "
})
alert(resultString)
过程详解:
首先调用preOrderTraversal方法,在方法里再调用preOrderTraversalNode方法用于遍历二叉搜索树。在preOrderTraversalNode方法中,递归1负责遍历左子节点,递归2负责遍历右子节点。先执行递归1,执行过程如下图所示:
记:preOrderTraversalNode() 为 A()
可以看到一共递归调用了4次方法A,分别传入11、7、5、3,最后遇到null不满足 node != null 条件结束递归1;注意此时只是执行完最开始的递归1,并没有执行递归2,并且递归1执行到null停止后要一层层地往上返回,按顺序将调用的函数压出函数调用栈。
关于函数调用栈:之前的四次递归共把4个函数压入了函数调用栈,现在递归执行完了一层层地把函数压出栈。
值得注意的是:每一层函数都只是执行完了递归1,当返回到该层函数时,比如A(3)要继续执行递归2遍历二叉搜索树中的右子节点;
在执行递归2的过程中会不断调用方法A,并依次执行递归1和递归2,以此类推直到遇到null不满足 node != null 条件为止,才停止递归并一层层返回,如此循环。同理A(5)层、A(7)层、A(11)层都要经历上述循环,直到将二叉搜索树中的节点全部遍历完为止。
2.中序遍历
实现思路
- 首先,遍历其左子树;
- 然后,遍历根(父)节点;
- 最后,遍历其右子树;
代码实现:
// 中序遍历
BinarySearchTree.prototype.midOrderTraversal=function(handler){
this.midOrderTraversalOrder(this.root,handler)
}
BinarySearchTree.prototype.midOrderTraversalOrder=function(node,handler){
if(node!=null){
// 1.遍历左子树中的节点
this.midOrderTraversalOrder(node.left,handler)
// 2.处理节点
handler(node.key)
// 3.遍历右子树中的节点
this.midOrderTraversalOrder(node.right,handler)
}
}
测试代码:
//测试代码
//1.创建BinarySearchTree
let bst = new BinarySearchTree()
//2.插入数据
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);
//3.测试遍历
let resultString = ""
//掺入处理节点值的处理函数
bst.midOrderTraversal(function (key) {
resultString += key + " "
})
alert(resultString)
过程详解:
遍历的顺序应如下图所示:
首先调用midOrderTraversal方法,在方法里再调用midOrderTraversalNode方法用于遍历二叉搜索树。先使用递归1遍历左子树中的节点;然后,处理父节点;最后,遍历右子树中的节点。
3.后序遍历
实现思路
- 首先,遍历其左子树;
- 然后,遍历其右子树;
- 最后,遍历根(父)节点;
代码实现:
//后序遍历
BinarySearchTree.prototype.postOrderTraversal = function(handler){
this.postOrderTraversalNode(this.root, handler)
}
BinarySearchTree.prototype.postOrderTraversalNode = function(node, handler){
if (node != null) {
//1.遍历左子树中的节点
this.postOrderTraversalNode(node.left, handler)
//2.遍历右子树中的节点
this.postOrderTraversalNode(node.right, handler)
//3.处理节点
handler(node.key)
}
}
测试代码:
//测试代码
//1.创建BinarySearchTree
let bst = new BinarySearchTree()
//2.插入数据
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);
//3.测试后序遍历
let resultString3 =""
bst.postOrderTraversal(function(key){
resultString3 += key + "->"
})
alert(resultString3)
过程详解:
遍历的顺序应如下图所示:
首先调用postOrderTraversal方法,在方法里再调用postOrderTraversalNode方法用于遍历二叉搜索树。先使用递归1遍历左子树中的节点;然后,遍历右子树中的节点;最后,处理父节点。
4.总结
总结:以遍历根(父)节点的顺序来区分三种遍历方式。比如:先序遍历先遍历根节点、中序遍历第二遍历根节点、后续遍历最后遍历根节点。
5.树的前序,中序,后序的相互转换
六、二叉树的删除
基本步骤
1.先找到要删除的节点,如果没有找到,不需要删除
2.找到要删除的节点
1.删除叶子节点
2.删除只有一个子节点的节点
3.删除有两个子节点的节点
实现思路:
第一步:寻找删除节点
第一步:先找到需要删除的节点,若没找到,则不需要删除;
首先定义变量current用于保存需要删除的节点、变量parent用于保存它的父节点、变量isLeftChild保存current是否为parent的左节点,这样方便之后删除节点时改变相关节点的指向。
代码实现
// 删除节点
BinarySearchTree.prototype.remove=function(key){
// 1.寻找要删除的节点
// 1.1定义一些变量,存储一些信息
// current:存储当前遍历到的数值
var current=this.root
// parent:current的父节点
var parent=null
// isLeftChild:用于判断应该向左还是向右查找
var isLeftChild=true
// 1.2 开始寻找删除的节点
while(current.key!=key){
// 要current要进行变化前,先赋值给parent
parent=current
// 判断应该向左还是向右
if(key<current.left){
isLeftChild=true
current=current.left
}else{
isLeftChild=false
current=current.right
}
// 某一种情况:已经找到了最后的节点,依然没有找到==key
if(current==null) return false
}
// 2.根据对应的情况删除节点
}
第二步:删除找到的指定节点
- 删除叶子节点;
- 删除只有一个子节点的节点;
- 删除有两个子节点的节点;
情况一:删除叶子节点
没有子节点时也有两种情况:
当该叶子节点为根节点时,如下图所示,此时current == this.root,直接通过:this.root = null,删除根节点。
当该叶子节点不为根节点时也有两种情况,如下图所示:
若current = 8,可以通过:parent.left = null,删除节点8;
若current = 10,可以通过:parent.right = null,删除节点10;
// 2.1删除的节点是叶子节点(没有子节点)
if(current.left===null && current.right===null){
if(current==this.root){ //此时为根节点
this.root=null
}else if(isLeftChild){
parent.left=null
}else {
parent.right=null
}
}
情况二:删除只有一个子节点的节点
有六种情况分别是:
当current存在左子节点时(current.right == null):
情况1:current为根节点(current == this.root),如节点11,此时通过:this.root = current.left,删除根节点11;
情况2:current为父节点parent的左子节点(isLeftChild == true),如节点5,此时通过:parent.left = current.left,删除节点5;
情况3:current为父节点parent的右子节点(isLeftChild == false),如节点9,此时通过:parent.right = current.left,删除节点9;
当current存在右子节点时(current.left = null):
情况4:current为根节点(current == this.root),如节点11,此时通过:this.root = current.right,删除根节点11。
情况5:current为父节点parent的左子节点(isLeftChild == true),如节点5,此时通过:parent.left = current.right,删除节点5;
情况6:current为父节点parent的右子节点(isLeftChild == false),如节点9,此时通过:parent.right = current.right,删除节点9;
// 2.根据对应的情况删除节点
// 2.1删除的节点是叶子节点(没有子节点)
if (current.left === null && current.right === null) {
if (current == this.root) { //此时为根节点
this.root = null
} else if (isLeftChild) {
parent.left = null
} else {
parent.right = null
}
}
//情况2:删除的节点有一个子节点
//当current存在左子节点时
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
}
//当current存在右子节点时
} else if (current.left == null) {
// 当只有一个根子节点和右子节点
if (current == this.root) {
this.root = current.rigth
} else if (isLeftChild) {
parent.left = current.right
} else {
parent.right = current.right
}
}
情况3:有两个子节点
这种情况十分复杂,首先依据以下二叉搜索树,讨论这样的问题:
删除节点9
在保证删除节点9后原二叉树仍为二叉搜索树的前提下,有两种方式:
- 方式1:从节点9的左子树中选择一合适的节点替代节点9,可知节点8符合要求;
- 方式2:从节点9的右子树中选择一合适的节点替代节点9,可知节点10符合要求;
删除节点7
在保证删除节点7后原二叉树仍为二叉搜索树的前提下,也有两种方式:
- 方式1:从节点7的左子树中选择一合适的节点替代节点7,可知节点5符合要求;
- 方式2:从节点7的右子树中选择一合适的节点替代节点7,可知节点8符合要求;
删除节点15
在保证删除节点15后原树二叉树仍为二叉搜索树的前提下,同样有两种方式:
- 方式1:从节点15的左子树中选择一合适的节点替代节点15,可知节点14符合要求;
- 方式2:从节点15的右子树中选择一合适的节点替代节点15,可知节点18符合要求;
规律
规律总结:如果要删除的节点有两个子节点,甚至子节点还有子节点,这种情况下需要从要删除节点下面的子节点中找到一个合适的节点,来替换当前的节点。
若用current表示需要删除的节点,则合适的节点指的是:【差值最接近current的】
- current左子树中比current小一点点的节点,即current左子树中的最大值;
- current右子树中比current大一点点的节点,即current右子树中的最小值;
代码实现
查找需要被删除的节点current的后继时,需要在current的右子树中查找最小值,即在current的右子树中一直向左遍历查找;
查找前驱时,则需要在current的左子树中查找最大值,即在current的左子树中一直向右遍历查找。
this.remove = function(key){
root = removeNode(root, key);
}
// 发现最小节点
function findMinNode(node) {
if(node) {
while(node && node.left !== null){
node = node.left;
}
return node
}
return null
}
// 移除节点辅助方法
function removeNode(node, key) {
if(node === null) {
return null
}
if(key < node.key){
node.left = removeNode(node.left, key);
return node
} else if( key > node.key){
node.right = removeNode(node.right, key);
return node
} else {
// 一个页节点
if(node.left === null && node.right === null) {
node = null;
return node
}
// 只有一个子节点的节点
if(node.left === null) {
node = node.right;
return node
}else if(node.right === null) {
node = node.left;
return node
}
// 有两个子节点的节点
let aux = findMinNode(node.right);
node.key = aux.key;
node.right = removeNode(node.right, aux.key);
return node
}
}
前驱&后继
在二叉搜索树中,这两个特殊的节点有特殊的名字:
- 比current小一点点的节点,称为current节点的前驱。比如下图中的节点5就是节点7的前驱;
- 比current大一点点的节点,称为current节点的后继。比如下图中的节点8就是节点7的后继;
七、.二叉搜索树完整封装
//封装二叉搜索树
function BinarySearchTree(){
//节点内部类
function Node(key){
this.key = key
this.left = null
this.right = null
}
//属性
this.root = null
//方法
//一.插入数据:insert方法:对外向用户暴露的方法
BinarySearchTree.prototype.insert = function(key){
//1.根据key创建节点
let newNode = new Node(key)
//2.判断根节点是否存在
if (this.root == null) {
this.root = newNode
//根节点存在时
}else {
this.insertNode(this.root, newNode)
}
}
//内部使用的insertNode方法:用于比较节点从左边插入还是右边插入
BinarySearchTree.prototype.insertNode = function(node, newNode){
//当newNode.key < node.key向左查找
if(newNode.key < node.key){
//情况1:node无左子节点,直接插入
if (node.left == null) {
node.left = newNode
//情况2:node有左子节点,递归调用insertNode(),直到遇到无左子节点成功插入newNode后,不再符合该情况,也就不再调用insertNode(),递归停止。
}else{
this.insertNode(node.left, newNode)
}
//当newNode.key >= node.key向右查找
}else{
//情况1:node无右子节点,直接插入
if(node.right == null){
node.right = newNode
//情况2:node有右子节点,依然递归调用insertNode(),直到遇到无右子节点成功插入newNode为止
}else{
this.insertNode(node.right, newNode)
}
}
}
//二.树的遍历
//1.先序遍历
//掺入一个handler函数对得到的key进行处理
BinarySearchTree.prototype.preOrderTraversal = function(handler){
this.preOrderTraversalNode(this.root, handler)
}
//封装内部方法,对某个节点进行遍历
BinarySearchTree.prototype.preOrderTraversalNode = function(node,handler){
if (node != null) {
//1.处理经过的节点
handler(node.key)
//2.遍历经过节点的左子节点
this.preOrderTraversalNode(node.left, handler)
//3.遍历经过节点的右子节点
this.preOrderTraversalNode(node.right, handler)
}
}
//2.中序遍历
BinarySearchTree.prototype.midOrderTraversal = function(handler){
this.midOrderTraversalNode(this.root, handler)
}
BinarySearchTree.prototype.midOrderTraversalNode = function(node, handler){
if (node != null) {
//1.遍历左子树中的节点
this.midOrderTraversalNode(node.left, handler)
//2.处理节点
handler(node.key)
//3.遍历右子树中的节点
this.midOrderTraversalNode(node.right, handler)
}
}
//3.后序遍历
BinarySearchTree.prototype.postOrderTraversal = function(handler){
this.postOrderTraversalNode(this.root, handler)
}
BinarySearchTree.prototype.postOrderTraversalNode = function(node, handler){
if (node != null) {
//1.遍历左子树中的节点
this.postOrderTraversalNode(node.left, handler)
//2.遍历右子树中的节点
this.postOrderTraversalNode(node.right, handler)
//3.处理节点
handler(node.key)
}
}
//三.寻找最值
//寻找最大值
BinarySearchTree.prototype.max = function () {
//1.获取根节点
let node = this.root
//2.定义key保存节点值
let key = null
//3.依次向右不断查找,直到节点为null
while (node != null) {
key = node.key
node = node.right
}
return key
}
//寻找最小值
BinarySearchTree.prototype.min = function(){
//1.获取根节点
let node = this.root
//2.定义key保存节点值
let key = null
//3.依次向左不断查找,直到节点为null
while (node != null) {
key = node.key
node = node.left
}
return key
}
//查找特定的key
BinarySearchTree.prototype.search = function(key){
//1.获取根节点
let node = this.root
//2.循环搜索key
while(node != null){
if (key < node.key) {
//小于根(父)节点就往左边找
node = node.left
//大于根(父)节点就往右边找
}else if(key > node.key){
node = node.right
}else{
return true
}
}
return false
}
//四.删除节点
BinarySearchTree.prototype.remove = function(key){
/*------------------------------1.寻找要删除的节点---------------------------------*/
//1.1.定义变量current保存删除的节点,parent保存它的父节点。isLeftChild保存current是否为parent的左节点
let current = this.root
let parent = null
let isLeftChild = true
//1.2.开始寻找删除的节点
while (current.key != key) {
parent = current
// 小于则往左查找
if (key < current.key) {
isLeftChild = true
current = current.left
} else{
isLeftChild = false
current = current.right
}
//找到最后依然没有找到相等的节点
if (current == null) {
return false
}
}
//结束while循环后:current.key = key
/*------------------------------2.根据对应情况删除节点------------------------------*/
//情况1:删除的是叶子节点(没有子节点)
if (current.left == null && current.right ==null) {
if (current == this.root) {
this.root = null
}else if(isLeftChild){
parent.left = null
}else {
parent.right =null
}
}
//情况2:删除的节点有一个子节点
//当current存在左子节点时
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
}
//当current存在右子节点时
} 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
}
}
//情况3:删除的节点有两个子节点
else{
//1.获取后继节点
let successor = this.getSuccessor(current)
//2.判断是否根节点
if (current == this.root) {
this.root = successor
}else if (isLeftChild){
parent.left = successor
}else{
parent.right = successor
}
//3.将后继的左子节点改为被删除节点的左子节点
successor.left = current.left
}
}
//封装查找后继的方法
BinarySearchTree.prototype.getSuccessor = function(delNode){
//1.定义变量,保存找到的后继
let successor = delNode
let current = delNode.right
let successorParent = delNode
//2.循环查找current的右子树节点
while(current != null){
successorParent = successor
successor = current
current = current.left
}
//3.判断寻找到的后继节点是否直接就是删除节点的right节点
if(successor != delNode.right){
successorParent.left = successor.right
successor.right = delNode.right
}
return successor
}
}
八、平衡树
二叉搜索树的缺陷:
当插入的数据是有序的数据,就会造成二叉搜索树的深度过大。比如原二叉搜索树右 11 7 15 组成,如下图所示:
当插入一组有序数据:6 5 4 3 2就会变成深度过大的搜索二叉树,会严重影响二叉搜索树的性能。
非平衡树
- 比较好的二叉搜索树,它的数据应该是左右均匀分布的;
- 但是插入连续数据后,二叉搜索树中的数据分布就变得不均匀了,我们称这种树为非平衡树;【极端情况下是”链表“】
- 对于一棵平衡二叉树来说,插入/查找等操作的效率是O(logN);
- 而对于一棵非平衡二叉树来说,相当于编写了一个链表,查找效率变成了O(N);
树的平衡性
为了能以较快的时间O(logN)来操作一棵树,我们需要保证树总是平衡的:
- 起码大部分是平衡的,此时的时间复杂度也是接近O(logN)的;
- 这就要求树中每个节点左边的子孙节点的个数,应该尽可能地等于右边的子孙节点的个数;
常见的平衡树
AVL树
是最早的一种平衡树,它通过在每个节点多存储一个额外的数据来保持树的平衡。由于AVL树是平衡树,所以它的时间复杂度也是O(logN)。但是它的整体效率不如红黑树,开发中比较少用。
红黑树
同样通过一些特性来保持树的平衡,时间复杂度也是O(logN)。进行插入/删除等操作时,性能优于AVL树,所以平衡树的应用基本都是红黑树。
九、红黑树
1.红黑树的规则
红黑树除了符合二叉搜索树的基本规则外,还添加了以下特性:
- 规则1:节点是红色或黑色的;
- 规则2:根节点是黑色的;
- 规则3:每个叶子节点都是黑色的空节点(NIL节点);
- 规则4:每个红色节点的两个子节点都是黑色的(从每个叶子到根的所有路径上不可能有两个连续的红色节点);
- 规则5:从任一节点到其每个叶子节点的所有路径都包含相同数目的黑色节点;
2. 红黑树的相对平衡
前面5条规则的约束确保了以下红黑树的关键特性:
- 从根到叶子节点的最长路径,不会超过最短路径的两倍;
- 结果就是这棵树基本是平衡的;
- 虽然没有做到绝对的平衡,但是可以保证在最坏的情况下,该树依然是高效的;
为什么可以做到最长路径不超过最短路径的两倍呢?
- 性质4决定了路径上不能有两个相连的红色节点;
- 所以,最长路径一定是红色节点和黑色节点交替而成的;
- 由于根节点和叶子节点都是黑色的,最短路径可能都是黑色节点,并且最长路径中一定是黑色节点多于红色节点;
- 性质5决定了所有路径上都有相同数目的黑色节点;
- 这就表明了没有路径能多于其他任何路径两倍长。
3.红黑树的三种变换
插入一个新节点时,有可能树不再平衡,可以通过三种方式的变换使树保持平衡:
- 变色;
- 左旋转;
- 右旋转;
1.变色【插入的新节点通常都是红色节点】
为了重新符合红黑树的规则,需要把红色节点变为黑色,或者把黑色节点变为红色;
插入的新节点通常都是红色节点:
当插入的节点为红色的时候,大多数情况不违反红黑树的任何规则;
而插入黑色节点,必然会导致一条路径上多了一个黑色节点,这是很难调整的;
红色节点虽然可能导致红红相连的情况,但是这种情况可以通过颜色调换和旋转来调整;
2.左旋转【逆时针】
以节点X为根逆时针旋转二叉搜索树,使得父节点原来的位置被自己的右子节点替代,左子节点的位置被父节点替代;
详解:
如上图所示,左旋转之后:
- 节点X取代了节点a原来的位置;
- 节点Y取代了节点X原来的位置;
- 节点X的左子树 a 仍然是节点X的左子树(这里X的左子树只有一个节点,有多个节点时同样适用,以下同理);
- 节点Y的右子树 c 仍然是节点Y的右子树;
- 节点Y的左子树 b 向左平移成为了节点X的右子树;
除此之外,二叉搜索树左旋转之后仍为二叉搜索树:
3.右旋转
以节点X为根顺时针旋转二叉搜索树,使得父节点原来的位置被自己的左子节点替代,右子节点的位置被父节点替代;
详解:
- 节点X取代了节点a原来的位置;
- 节点Y取代了节点X原来的位置;
- 节点X的右子树 a 仍然是节点X的右子树(这里X的右子树虽然只有一个节点,但是多个节点时同样适用,以下同理);
- 节点Y的左子树 b 仍然是节点Y的左子树;
- 节点Y的右子树 c 向右平移成为了节点X的左子树;
除此之外,二叉搜索树右旋转之后仍为二叉搜索树:
4.红黑树的插入操作
首先需要明确,在保证满足红黑树5条规则的情况下,新插入的节点必然是红色节点。
为了方便说明,规定以下四个节点:新插入节点为N(Node),N的父节点为P(Parent),P的兄弟节点为U(Uncle),U的父节点为G(Grandpa),
1.情况1
当插入的新节点N位于树的根上时,没有父节点。
这种情况下,只需要将红色节点变为黑色节点即可满足规则2 。
2.情况2
新界点N的父节点P为黑色节点,此时不需要任何变化。
此时既满足规则4也满足规则5。尽管新节点是红色的,但是新节点N有两个黑色节点NIL,所以通向它的路径上黑色节点的个数依然相等,因此满足规则5 。
3.情况3 :父红叔红祖黑。【变成:父黑叔黑祖红】
节点P为红色,节点U也为红色,此时节点G必为黑色,即父红叔红祖黑。【变成:父黑叔黑祖红】
在这种情况下需要:
- 先将父节点P变为黑色;
- 再将叔叔节点U变为黑色;
- 最后将祖父节点G变为红色;
即变为父黑叔黑祖红
可能出现的问题:
- N的祖父节点G的父节点也可能是红色,这就违反了规则4,此时可以通过递归调整节点颜色;
- 当递归调整到根节点时就需要旋转了,如下图节点A和节点B所示,具体情况后面会介绍;
4.情况4 :父红叔黑祖黑,N为节点P的左子节点【变成:父黑祖红,进行右旋转】
节点P是红色节点,节点U是黑色节点,并且节点N为节点P的左子节点,此时节点G一定是黑色节点,即父红叔黑祖黑。
在这种情况下需要:
- 先变色:将父节点P变为黑色,将祖父节点G变为红色;
- 后旋转:以祖父节点G为根进行右旋转;
5.情况5 :N为节点P的右子节点,父红叔黑祖黑
节点P是红色节点,节点U是黑色节点,并且节点N为节点P的右子节点,此时节点G一定是黑色节点,即父红叔黑祖黑。
在这种情况下需要:
- 先以节点P为根进行左旋转,旋转后如图b所示;
- 随后将红色节点P和黑色节点B看成一个整体的红色节点N1,将新插入的红色节点N看成红色节点P1 如图c所示。此时整体就转换为了情况4。
接着可以按照情况4进行处理:
-
先变色:将N1节点的父节点P1变为黑色,将祖父节点G变为红色;
-
后旋转:以祖父节点G为根进行右旋转,旋转后如图 e 所示;
-
最后将节点N1和P1变换回来,完成节点N的插入,如图 f 所示;
区分
快速判断属于情况3还是情况4的方法:
从新插入的节点N出发,按图示箭头经过的四个节点,若为红红黑红3个红色节点则为情况3,若为红红黑黑两个红色节点则为情况4;
十、红黑树的案例
在二叉树中依次插入节点:10,9,8,7,6,5,4,3,2,1 。
如果直接采用普通的二叉搜索树,节点全部插入后是这样的:
是一个严重的不平衡树,相当于一个链表,不能体现出二叉搜索树的高效率。而按照红黑树的五条规则插入节点就能最大程度保证搜索二叉树是一棵平衡树。以下为过程详解:为了方便解释省略了部分红黑树的叶子节点(NIL)
插入10
符合情况1:
- 插入节点10;
- 将节点10的颜色变为黑色;
插入9
符合情况2:
- 不需要任何变化;
插入8
符合情况4:
- 父节点9变成黑,祖父节点10变为红;
- 以祖父节点为根进行右旋转;
插入7
符合情况3:
- 父节点8和叔节点10变为黑,祖父节点9变为红;
- 此时会出现问题:不符合规则2,即根节点不为黑,此时可以把以9为根节点的二叉搜索树当作一个整体作为一个新插入的节点N,而此时又符合情况1,只需要把9变回黑色即可。
插入6
符合情况4:
- 父节点7变为黑,祖父节点8变为红;
- 以祖父节点8为根进行右旋转;
插入5
符合情况3:
- 父节点6和叔节点8变为黑,祖父节点7变为红;
插入4
符合情况4:
- 父节点5变为黑,祖父节点6变为红;
- 以祖父节点6为根进行右旋转;
插入3
第一次变换:符合情况3:
- 父节点4和叔节点6变为黑,祖父节点5变为红;
变换之后发现5和7为相连的两个红色节点,于是把以5为根的整个子树看成一个新插入的节点N1,再进行第二次变换。
第二次变换:符合情况4:
- 父节点7变为黑,祖父节点9变为红;
- 以祖父节点9为根进行右旋转;
最后复原N1得到变换后的红黑树:
插入2
符合情况4:
- 父节点3变为黑,祖父节点4变为红;
- 以祖父节点4为根进行右旋转;
插入1
第一次变换:符合情况3:
- 父节点2和叔节点4变为黑,祖父节点3变为红;
变换之后发现3和5为相连的两个红色节点,于是把以3为根的整个子树看成一个新插入的节点N1,再进行第二次变换。
第二次变换:符合情况3:
- 父节点5和叔节点9变为黑,祖父节点7变为红;即由图 b -> 图 c 。
变换之后发现根节点7为红色不符合规则2,所以把以7为根节点的红黑树看成一个新插入的节点N2,再进行第三次变换。
第三次变换:符合情况1:
- 直接将根节点7变为黑色即可。
由此,完成了1~10节点的插入,虽然没有遇到情况5,不过情况5经过左旋转的操作便可转换为情况4,原理一样。如下图所示,将这棵红黑树的叶子节点NIL补全之后,经检验满足红黑树的五条规则,并且基本属于平衡树,效率较高。
十一、红黑树的删除操作
红黑树的删除操作结合了复杂的二叉树的删除操作和复杂的红黑树的插入规则,整体来说难度非常大,篇幅较长,这里暂不进行探讨。