本文参考文献:https://www.cnblogs.com/AhuntSun-blog/p/12446656.html
配套视频教程:https://www.bilibili.com/video/BV1r7411n7Pw?p=1&spm_id_from=pageDriver
目录
数据结构-三
树
简介
树的特点:
- 树一般都有一个根,连接着根的是树干;
- 树干会发生分叉,形成许多树枝,树枝会继续分化成更小的树枝;
- 树枝的最后是叶子;
现实生活中很多结构都是树的抽象,模拟的树结构相当于旋转180°的树。
树结构的优势
数组:
- 优点:可以通过下标值访问,效率高;
- 缺点:查找数据时需要先对数据进行排序,生成有序数组,才能提高查找效率(二分查找法);并且在插入和删除元素时,需要大量的位移操作,降低了效率;
链表:
- 优点:数据的插入和删除操作效率都很高;
- 缺点:查找效率低,需要从头开始依次查找,直到找到目标数据为止(线性查找);当需要在链表中间位置插入或删除数据时,插入或删除的效率都不高,因为首先要从头开始线性查找到该元素。
哈希表:
- 优点:哈希表的插入/查询/删除效率都非常高;
- 缺点:空间利用率不高,底层使用的数组中很多单元没有被利用(装载因子);并且哈希表中的元素是无序的,不能按照固定顺序遍历哈希表中的元素;而且不能快速找出哈希表中最大值或最小值这些特殊值(因为要两次循环嵌套遍历)。
树结构:
优点:
- 树结构综合了上述三种结构的优点,同时也弥补了它们存在的缺点(虽然效率不一定都比它们高)
- 比如树结构中数据都是有序的,查找效率高;空间利用率高;并且可以快速获取最大值和最小值
- 为了模拟某些场景,使用树结构会更加方便:树的非线性结构可以表示一对多的关系,如文件的目录结构等
总的来说:每种数据结构都有自己特定的应用场景。
树结构
树(tree):由 n(n ≥ 0)个节点构成的有限集合,当n = 0时,称为空树。
对于任意一棵非空树(n>0),它具备以下性质:
- 数有一个**根(root)**的特殊节点,用
r
表示 - 其余节点可分为m (m > 0) 个互不相交的有限集合,其中每个集合本身又是一棵树,称为原来这个数的子树(SubTree)
树的常用术语
- 节点的度(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(即四层);
树结构的表示方式
普通的表示方式:
如图,树结构的组成方式类似于链表,都是由一个个节点连接构成。不过,根据每个父节点子节点数量的不同,每一个父节点需要的引用数量也不同。比如节点A需要3个引用,分别指向子节点B,C,D;B节点需要2个引用,分别指向子节点E和F;K节点由于没有子节点,所以不需要引用。
这种方法缺点在于我们无法确定某一结点的引用数。
儿子-兄弟表示法:
我们对每个节点都记录它的左子节点和右兄弟节点,这样的表示方式的优点在于每一个节点中引用的数量都是确定的。
这种方法可以完整地记录每一个节点的数据,如下例:
//节点A
Node{
//存储数据
this.data = data
//统一只记录左边的子节点
this.leftChild = B
//统一只记录右边的第一个兄弟节点
this.rightSibling = null
}
//节点B
Node{
this.data = data
this.leftChild = E
this.rightSibling = C
}
//节点F
Node{
this.data = data
this.leftChild = null
this.rightSibling = null
}
儿子-兄弟表示法 + 旋转:
将上图的儿子-兄弟表示法的 树结构顺时针旋转45°后:
这样就成为了一棵二叉树,由此我们可以得出结论:任何树都可以通过二叉树进行模拟。
可能你会有一个问题:但是这样父节点不是变了吗?
- 其实,父节点的设置只是为了方便指向子节点,在代码实现中谁是父节点并没有关系,只要能正确找到对应节点即可。
二叉树
简介
二叉树的概念:如果树中的每一个节点最多只能由两个子节点组成,这样的树就称为二叉树;
二叉树十分重要,不仅仅是因为简单,更是因为几乎所有的树都可以表示成二叉树形式。
二叉树的组成:
- 二叉树可以为空,也就是没有节点;
- 若二叉树不为空,则它由根节点和称为其左子树TL和右子树TR的两个不相交的二叉树组成;
二叉树的五种形态:
上图分别表示:a 空二叉树,b 仅一个根节点的二叉树, c 只有左子树TL的二叉树,d 只有右子树TR的二叉树,有左右两个子树的二叉树。
二叉树的三个重要特性:
- 一个二叉树的第 i 层的最大节点数为:2(i-1),i >= 1;
- 深度为k的二叉树的最大节点总数(该数最多共有多少个节点)为:2k - 1 ,k >= 1;
- 对任何非空二叉树,若 n0 表示叶子节点的个数,n2表示度为2的非叶子节点个数,那么两者满足关系:n0 = n2 + 1;
- 如下图所示:H,E,I,J,G为叶子节点,总数为5;A,B,C,F为度为2的非叶子节点,总数为4;满足n0 = n2 + 1的规律。
特殊二叉树
完全二叉树
完全二叉树(Complete Binary Tree):
- 除了二叉树最后一层外,其他各层的节点数都达到了最大值;
- 并且,最后一层的叶子节点从左向右是连续存在,只缺失右侧若干叶子节点;
- 完美二叉树是特殊的完全二叉树;
在上图中,由于D缺少了右子节点,所以它不是完全二叉树。
完美二叉树
完美二叉树(Perfect Binary Tree)也成为满二叉树(Full Binary Tree)。
在二叉树中,除了最下一层的叶子节点外,每层节点都有2个子节点,这就构成了完美二叉树。
二叉树的数据存储
常见的二叉树存储方式为数组和链表:
使用数组:
使用数组来存储二叉树结构时,只能对完全二叉树进行存储,如对一般二叉树存储,需要将其转换为完全二叉树才能再进行存储。
-
完全二叉树:按从上到下,从左到右的方式存储数据。
根据表格,直接按顺序从左到右从上往下依次存储数据至数组中即可,取数据的时候也十分方便:左子节点的序号等于父节点序号 * 2
,右子节点的序号等于父节点序号 * 2 + 1
。 -
非完全二叉树:非完全二叉树需要转换成完全二叉树才能按照上面的方案存储,这样会浪费存储空间。
从表格可知,虽然该树仅有共五个有数据的节点,但是却需要一个长度为13的数组来存储,极大浪费了存储空间。
使用链表:
相对于数组来说,二叉树最常见的存储方式为链表:每一个节点封装成一个Node,Node中包含存储的数据、左节点的引用和右节点的引用。
二叉搜索树
简介
二叉搜索树(BST,Binary Search Tree),也称为二叉排序树和二叉查找树。
二叉搜索树是一棵二叉树,可以为空;
如果不为空,则满足以下性质:
- 条件1:非空左子树的所有键值小于其根节点的键值。比如树三中节点6的所有非空左子树的键值都小于6;
- 条件2:非空右子树的所有键值大于其根节点的键值;比如树三中节点6的所有非空右子树的键值都大于6;
- 条件3:左、右子树本身也都是二叉搜索树;
如上图所示,树二和树三符合3个条件属于二叉树,树一不满足条件3所以不是二叉树。
总结:二叉搜索树的特点主要是较小的值总是保存在左节点上,相对较大的值总是保存在右节点上。这种特点使得二叉搜索树的查询效率非常高(类似二分查找法),这也就是二叉搜索树中"搜索"的来源。
应用举例
若想在其中查找数据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 。
封装和实现
基本类的封装
二叉树搜索树的基本属性:
如图所示:
二叉搜索树有四个最基本的属性:指向节点的根(root),节点中的键(key)、左指针(right)、右指针(right),实际上,节点可再有一个value属性,存放key的值,成为一个键值对。
所以,二叉搜索树中除了定义root属性外,还应定义一个节点内部类,里面包含每个节点中的left、right和key三个属性:
实现代码:
class Node {
constructor(key) {
this.key = key;
this.left = null;
this.right = null;
}
}
class BinarySearchTree {
constructor() {
this.root = null
}
}
二叉搜索树的常用操作
insert(key)
:向树中插入一个新的键;search(key)
:在树中查找一个键,如果节点存在,则返回true;如果不存在,则返回false;inOrderTraverse
:通过中序遍历方式遍历所有节点;preOrderTraverse
:通过先序遍历方式遍历所有节点;postOrderTraverse
:通过后序遍历方式遍历所有节点;min
:返回树中最小的值/键;max
:返回树中最大的值/键;remove(key)
:从树中移除某个键;
插入数据 insert()方法
insert()
方法的实现思路:
- 首先根据传入的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为止;
实现代码:
// 递归方法
insertNode(node, newNode) {
// 如果要插入的节点key已在树中存在,则返回
// 如果树的节点有value属性,此处应该对重复的key的value进行修改操作,将新传入的value替换旧value
if (newNode.key === node.key) {
return
}
// 判断新节点的key是否大于当前比较节点的key
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);
}
}
}
insert(key) {
let newNode = new Node(key);
if (this.root === null) {
this.root = newNode
} else {
this.insertNode(this.root, newNode);
}
}
插入方法的过程详解:
想要上述的二叉搜索树(蓝色)中插入数据10(红色节点原先不存在):
- 先把key = 10 传入insert方法,由于存在根节点 9,所以直接调用insertNode方法,传入的参数: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方法,递归停止。
max() & min() 查找最值方法
在二叉搜索树中查找最值非常简单,最小值在二叉搜索树的最左边,最大值在二叉搜索树的最右边。只需要一直向左/右查找就能得到最值,如下图所示:
实现代码:
max() {
// 从根节点开始向下
let node = this.root;
// 如果右子节点为空,则证明已经到了最右下角了
while (node.right !== null) {
node = node.right
}
return node.key
}
min() {
let node = this.root;
// 如果左子节点为空,则证明已经到了最左下角了
while (node.left !== null) {
node = node.left
}
return node.key
}
search() 判断是否有特定值方法
查找二叉搜索树当中的特定值效率也非常高。只需要从根节点开始将需要查找节点的key值与之比较,若node.key < roo
t则向左查找,若node.key > root
就向右查找,直到找到或查找到null
为止。这里可以使用递归实现,也可以采用循环来实现。
循环方式实现代码:
search(key) {
var node = this.root;
// 循环向下搜索
while (node !== null) {
if (key < node.key) { // 如果待查节点的key小于当前节点,继续往左下节点查找
node = node.left
} else if (key > node.key) { // 如果待查节点的key大于当前节点,继续往右下节点查找
node = node.right
} else { // 如果相等了,证明已经找到这个节点,返回true
return true
}
}
// 循环流程结束,证明没有找到该节点,返回false
return false
}
remove() 删除节点方法
该方法接受一个key,删除树中的这个key节点。
实现思路:
第一步:先找到需要删除的节点,若没找到,则不需要删除;
第二步:找到待删除的节点后,判断其位置,分成了三种情况:
- 情况一:该节点是叶子节点(没有子节点)
- 情况二:该节点仅有一个子节点(或有左子节点,或有右子节点),其子节点可以再有子节点,不影响
- 情况三:该节点有两个子节点,其子节点可以再有子节点,需细分考虑
情况一:没有子节点
1.1 该子节点是根节点:此时这个树仅有这一个节点,通过this.root = null
删除根节点即可
1.2 该子节点在parent节点的左侧或右侧
- 若current = 8,可以通过:parent.left = null,删除节点8;
- 若current = 10,可以通过:parent.right = null,删除节点10;
代码实现:
// 1.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;
实现代码: -
} else if (current.right === null) {// 2.2.1 待删除节点仅有一个子节点(左子节点)
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) {// 2.2.2 待删除节点仅有一个子节点(右子节点)
if (current === this.root) {
this.root = current.right
} else if (isLeftChild) { // 如果当前被删除节点是上一级节点(parent)的左子节点,就让parent节点的左节点(原来指向被删除节点)指向被删除节点的子节点
parent.left = current.right
} else { // 如果当前被删除节点是上一级节点的右子节点,同理
parent.right = current.right
}
情况三:有两个子节点
首先通过三个删除例子,找到其规律所在:
删除节点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后继的情况,查找前驱的原理相同,这里暂不讨论。
- 因此,我们需要先实现一个查找后继的函数
getSuccessor()
,再去实现删除功能
删除方法的完整实现
// 获取node节点的后继节点(即node右子树的最小节点)
getSuccessor(node) {
// 获取当前节点,当前后继节点和后继节点的根节点
var successor = node;
var current = node.right;
var successorParent = node;
// 循环向左下角查找,直至找到最左下角的最小的节点
while (current !== null) {
successorParent = successor
successor = current
current = current.left
}
// 此时的后继节点需要替代被删除节点,因此将后继节点的左子节点设定为被删除节点的左子节点
successor.left = node.left
// 当被删除节点的右子节点有且仅有一个节点,则此时后继节点的根节点就是被删除节点(即 后继节点 等于 被删除节点的右子节点)
// 如果 后继节点不等于被删除节点的右子节点,则需要对后继节点、后继节点的根节点进行指向上的调整
if (successor !== node.right) {
// 后继节点的根节点的左节点 指向 后继节点的右节点(此时后继节点已经没有左子节点了,它本身就是最左下角的节点)
successorParent.left = successor.right
// 后继节点的右节点 指向 被删除节点的右节点(接管被删除节点的原有指向)
successor.right = node.right
}
return successor
}
remove(key) {
// 保存需要用到的信息,current:当前节点,parent:当前节点的根节点,isLeftChild:当前节点是否是parent节点的左子节点
var current = this.root
var parent = null
var isLeftChild = null
// 1. 搜索待删除的节点
while (current.key !== key) {
// 1.1 key小于当前节点的key,向左搜索
if (key < current.key) {
isLeftChild = true
parent = current
current = current.left
} else {
// 1.2 key大于当前节点的key,向右搜索
isLeftChild = false
parent = current
current = current.right
}
// 1.3 当前节点为空了,证明搜索到最后也没找到key节点,返回false
if (current === null) return false
}
// 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
}
}
} else if (current.right === null) {// 2.2.1 待删除节点仅有一个子节点(左子节点)
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) {// 2.2.2 待删除节点仅有一个子节点(右子节点)
if (current === this.root) {
this.root = current.right
} else if (isLeftChild) { // 如果当前被删除节点是上一级节点(parent)的左子节点,就让parent节点的左节点(原来指向被删除节点)指向被删除节点的子节点
parent.left = current.right
} else { // 如果当前被删除节点是上一级节点的右子节点,同理
parent.right = current.right
}
} else { // 2.3 待删除节点有两个子节点
// 先获取后继节点(即被删除节点右子树中值最小的节点)
var successor = this.getSuccessor(current);
// 如果待删除节点是树的根节点,特殊处理
if (current == this.root) {
this.root = successor
} else if (isLeftChild) { // 如果当前被删除节点是上一级节点的左子节点,用后继节点来替换待删除节点
parent.left = successor
} else { // 如果当前被删除节点是上一级节点的右子节点,用后继节点来替换待删除节点
parent.right = successor
}
}
}
树的遍历(先序、中序、后序遍历)
这里所说的树的遍历不仅仅针对二叉搜索树,而是适用于所有的二叉树。由于树结构不是线性结构,所以遍历方式有多种选择,常见的二叉树遍历方式为:
- 先序遍历;
- 中序遍历;
- 后序遍历;
- 还有层序遍历,使用较少。
以遍历根(父)节点的顺序来区分三种遍历方式。比如:先序遍历先遍历根节点、中序遍历第二遍历根节点、后续遍历最后遍历根节点。
先序遍历
对于每个节点来说,其先序遍历的过程都可以看作为:
- 首先,遍历根节点;
- 然后,遍历其左子树;
- 最后,遍历其右子树;
如上图所示,二叉树的节点遍历顺序为:A -> B -> D -> H -> I -> E -> C -> F -> G。
实现代码:
// 递归函数:对一个节点进行先序遍历
preOrderTraversalNode(node, s) {
// 如果该节点不为空,则开始遍历
if (node !== null) {
// 保存当前节点的值
s += node.key + " -> "
console.log(s);
// 传递该节点的左子节点,继续重复执行先序遍历
s = this.preOrderTraversalNode(node.left, s);
// 传递该节点的右子节点,继续重复执行先序遍历
s = this.preOrderTraversalNode(node.right, s);
}
// 把保存了的值传回去
return s
}
// 主调用函数
preOrderTraversal() {
let s = "";
// 传入根节点,开始第一次的先序遍历
s = this.preOrderTraversalNode(this.root, s);
// 返回遍历结果字符串
return s
}
先序遍历函数的具体执行过程:
以遍历下方的二叉树的部分执行过程为例:
首先调用preOrderTraversal方法,在方法里再调用preOrderTraversalNode方法用于遍历二叉搜索树。在preOrderTraversalNode方法中,递归1负责遍历左子节点,递归2负责遍历右子节点。先执行递归1,执行过程如下图所示:
下文中,preOrderTraversalNode() 简写为A(),遍历左子节点的A()方法称为递归1,遍历右子节点的A()方法称为递归2。
可以看到一共递归调用了4次方法A,分别传入的节点key是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)层都要经历上述循环,直到将二叉搜索树中的节点全部遍历完为止。
具体过程如下图所示:
中序遍历
实现思路:与先序遍历原理相同,只不过是遍历的顺序不一样了。(从左下角开始找)
- 首先,遍历其左子树;
- 然后,遍历根(父)节点;
- 最后,遍历其右子树;
(图中右下角路径有误)
对该树进行中序遍历,结果是3->5->6->7->8->9->10->11->12->13->14->15->18->20->25
。
实现代码:
midOrderTraversalNode(node, s) {
if (node !== null) {
// 先去找左子节点,一直找到最左下角找不到为止
s = this.midOrderTraversalNode(node.left, s);
// 找完了第一个左子节点,返回到这一行开始读取中间节点
s += node.key + "->";
// 读取完后,再去找右节点(同理,此时再在右节点中去往左下角找左子节点)
s = this.midOrderTraversalNode(node.right, s);
}
return s
}
midOrderTraversal() {
let s = "";
s = this.midOrderTraversalNode(this.root, s)
return s
}
其执行流程与前序遍历类似,先用递归1遍历左节点,再用s保存中间的根节点,最后再用递归2遍历右节点。
后序遍历
实现思路:与先序遍历原理相反,只不过是遍历的顺序不一样了。(从左下角开始找)
- 首先,遍历其左子树;
- 然后,遍历其右子树;
- 最后,遍历根(父)节点;
对该树进行后序遍历,结果是3->6->5->8->10->9->7->12->14->13->18->25->20->15->11
。
实现代码:
postOrderTraversalNode(node, s) {
if (node !== null) {
s = this.postOrderTraversalNode(node.left, s);
s = this.postOrderTraversalNode(node.right, s);
s += node.key + "->"
}
return s
}
postOrderTraversal() {
let s = "";
s = this.postOrderTraversalNode(this.root, s);
return s
}
其执行流程与前序遍历相反,先用递归1遍历左节点,再用递归2遍历右节点,最后再用s保存中间的根节点。
平衡树
二叉搜索树的缺陷
当插入的数据是有序的数据,就会造成二叉搜索树的深度过大。比如原二叉搜索树由 11 7 15
组成,如下图所示:
当插入一组有序数据:6 5 4 3 2
就会变成深度过大的搜索二叉树,会严重影响二叉搜索树的性能。
此时,这个数就可以被称为非平衡树。
非平衡树
- 比较好的二叉搜索树,它的数据应该是左右均匀分布的;
- 但是插入连续数据后,二叉搜索树中的数据分布就变得不均匀了,我们称这种树为非平衡树;
- 对于一棵平衡二叉树来说,插入/查找等操作的效率是O(logN);
- 而对于一棵非平衡二叉树来说,相当于编写了一个链表,查找效率变成了O(N);
树的平衡性
为了能以较快的时间O(logN)来操作一棵树,我们需要保证树总是平衡的:
- 起码大部分是平衡的,此时的时间复杂度也是接近O(logN)的;
- 这就要求树中每个节点左边的子孙节点的个数,应该尽可能地等于右边的子孙节点的个数;
常见的平衡树
- AVL树:是最早的一种平衡树,它通过在每个节点多存储一个额外的数据来保持树的平衡。由于AVL树是平衡树,所以它的时间复杂度也是O(logN)。但是它的整体效率不如红黑树,开发中比较少用。
- 红黑树:同样通过一些特性来保持树的平衡,时间复杂度也是O(logN)。进行插入/删除等操作时,性能优于AVL树,所以平衡树的应用基本都是红黑树。
红黑树
简介 & 规则
红黑树就是一种常见的平衡树,它除了符合二叉搜索树的基本规则外,还添加了以下规则:
红黑树五个规则:
- 规则1:节点是红色或黑色的;
- 规则2:根节点是黑色的;
- 规则3:每个叶子节点都是黑色的空节点(NIL节点),即红黑树的最后一次必然是由黑色的空节点构成的;
- 规则4:每个红色节点的两个子节点都是黑色的(从每个叶子到根的所有路径上不可能有两个连续的红色节点);
- 规则5:从任一节点到其每个叶子节点的所有路径都包含相同数目的黑色节点(无论从哪个节点开始往下数到叶子节点,其路过的节点中黑色节点的个数是相同的);
红黑树的特性:相对平衡:
前面5条规则的约束确保了以下红黑树的关键特性:
- 从根到叶子节点的最长路径,不会超过最短路径的两倍;
- 结果就是这棵树基本是平衡的;
- 虽然没有做到绝对的平衡,但是可以保证在最坏的情况下,该树依然是高效的;
为什么可以做到最长路径不超过最短路径的两倍呢?
- 性质4决定了路径上不能有两个相连的红色节点;
- 所以,最长路径一定是红色节点和黑色节点交替而成的;
- 由于根节点和叶子节点都是黑色的,最短路径可能都是黑色节点,并且最长路径中一定是黑色节点多于红色节点(黑比红多一个);
- 性质5决定了所有路径上都有相同数目的黑色节点;
- 这就表明了没有路径能多于其他任何路径两倍长。
红黑树的三种变换
插入一个新节点时,有可能树不再平衡,可以通过三种方式的变换使树保持平衡:
- 变色;
- 左旋转;
- 右旋转;
变色:
为了重新符合红黑树的规则,需要把 红色节点变为黑色,或者把黑色节点变为红色;
一般来说,插入的新节点应是红色节点:
- 当插入的节点为红色的时候,大多数情况不违反红黑树的任何规则;
- 而插入黑色节点,必然会导致一条路径上多了一个黑色节点,这是很难调整的;
- 红色节点虽然可能导致红红相连的情况,但是这种情况可以通过颜色调换和旋转来调整;
左旋转:
以节点X为根逆时针旋转二叉搜索树,使得父节点原来的位置被自己的右子节点替代,左子节点的位置被父节点替代;
详解:
如上图所示,左旋转之后:
- 节点X取代了节点a原来的位置;
- 节点Y取代了节点X原来的位置;
- 节点X的左子树 a 仍然是节点X的左子树(这里X的左子树只有一个节点,有多个节点时同样适用,以下同理);
- 节点Y的右子树 c 仍然是节点Y的右子树;
- 节点Y的左子树 b 向左平移成为了节点X的右子树;
除此之外,二叉搜索树左旋转之后仍为二叉搜索树,下图为实例:
右旋转:
以节点X为根 顺时针旋转二叉搜索树,使得父节点原来的位置被自己的左子节点替代,右子节点的位置被父节点替代;
详解:
如上图所示,右旋转之后:
- 节点X取代了节点a原来的位置;
- 节点Y取代了节点X原来的位置;
- 节点X的右子树 a 仍然是节点X的右子树(这里X的右子树虽然只有一个节点,但是多个节点时同样适用,以下同理);
- 节点Y的左子树 b 仍然是节点Y的左子树;
- 节点Y的右子树 c 向右平移成为了节点X的左子树;
除此之外,二叉搜索树右旋转之后仍为二叉搜索树,下图为实例: