1. 二叉查找树
参考:深入学习理解二叉搜索树(附详细讲解与实例分析)
1.1 基本概念
二叉查找树,也称二叉搜索树,或二叉排序树。其要么是一颗空树,要么就是具有如下性质的二叉树:
(1)若任意节点的左子树不空,则左子树上所有结点的值均不大于它的根结点的值;
(2)若任意节点的右子树不空,则右子树上所有结点的值均不小于它的根结点的值;
(3)任意节点的左、右子树也分别为二叉查找树;
1.2 基本操作
对于一棵二叉搜索树来说,它支持许多动态集合操作,包括**WALK(遍历)、SEARCH(查找)、MINIMUM(最小关键字)、MAXIMUM(最大关键字)、SUCCESSOR(后继)、PREDECESSOR(前驱)、INSERT(插入)、DELETE(删除)**等。下面将依次讲解这些操作。
1.2.1 Walk(遍历)
得益于二叉搜索树的性质,当使用中序遍历来访问一棵二叉搜索树上的所有结点时,最后得到的访问序列恰好是所有结点关键字的升序序列。
/* 中序遍历 */
void inOrder_Tree_Walk(BSTNode x)
{
if (x != NULL)
{
inOrder_Tree_Walk(x.lchild);
System.out.println(x.getKey())
inOrder_Tree_Walk(x.rchild);
}
}
树的先序遍历和后序遍历也类似。
1.2.2 Search(查找)
在二叉搜索树中查找一个具有给定关键字key的结点,需要输入一个指向树根的指针x和一个关键字k,如果这个结点存在,则TREE-SEARCH返回一个指向关键字为k的结点的指针;否则返回NULL。
具体查找过程为:
① 从树根开始查找,并沿着这棵树中的一条简单路径向下进行;
② 若树为空树,则查找失败,返回NULL;
③ 对于遇到的每个结点x,若关键字k等于结点x的关键字,查找终止,返回指向结点x的指针;
④ 若关键字k小于结点x的关键字,则查找在x的左子树中继续(根据二叉搜索树的性质,k此时不可能在右子树中);
⑤ 对称地,若关键字k大于结点x的关键字,则查找在x的右子树中继续(k此时不可能在左子树中);
⑥ 若查找至叶子结点后仍未匹配到相等的关键字,则关键字为k的结点不存在,返回NULL。
/**
* 查找(递归实现)
* 输入:一个指向根节点的指针x,和待查找的关键字k
* 输出:指向关键字为k的节点的指针(若存在,否则输出NULL)
*/
BSTNode tree_Search(BSTNode x, double k)
{
if (x == NULL || k == x.getKey()) /* 如果找不着就返回NULL,找到了则返回对应节点的指针 */
return x;
if (k < x.getKey()) /* 关键字小于当前节点的关键字,查找就在左子树中继续 */
return tree_Search(x.lchild, k);
else /* 关键字大于当前节点的关键字,查找就在右子树中继续 */
return tree_Search(x.rchild, k);
}
或者不使用递归,采用迭代实现
/**
* 查找(迭代实现)
* 输入:一个指向根节点的指针x,和待查找的关键字k
* 输出:指向关键字为k的节点的指针(若存在,否则输出NIL)
*/
BSTNode iterative_Tree_Search(BSTNode x, double k)
{
while (x != NULL && k != x.getKey())
{
if (k < x.getKey()) /* 关键字小于当前节点的关键字,查找就在左子树中继续 */
x = x.lchild;
else /* 关键字大于当前节点的关键字,查找就在右子树中继续 */
x = x.rchild;
}
return x; /* 如果找不着就返回NULL,找到了则返回对应节点的指针 */
}
二叉搜索树的查找的时间复杂度为O(h),其中h为二叉搜索树的高度。
1.2.3 Minimum(最小关键字)
根据二叉搜索树的性质,对于非叶子结点来说,其左子树的关键字总是不大于该结点的关键字。从一棵子树的树根开始,沿着lchild指针直到遇到一个NULL,我们总能在一棵二叉搜索树中找到一个指针,这个指针指向该子树中的最小元素。
查询二叉搜索树的最小关键字的时间复杂度为O(h),其中h为二叉搜索树的高度。
1.2.4 Maximum(最大关键字)
最大关键字的查询过程与最小关键字的十分类似。根据二叉搜索树的性质,对于非叶子结点来说,其右子树的关键字总是不小于该结点的关键字。从一棵子树的树根开始,沿着rchild指针直到遇到一个NULL,我们总能在一棵二叉搜索树中找到一个指针,这个指针指向该子树中的最大元素。
查询二叉搜索树的最大关键字的时间复杂度为O(h),其中h为二叉搜索树的高度。
1.2.5 Successor(后继)
给定一棵二叉搜索树中的一个结点,有时候需要按中序遍历的次序查找它的后继,即中序遍历顺序下的后面一位。一棵二叉搜索树的结构允许我们通过没有任何关键字的比较来确定一个结点的后继。如果后继存在,则返回指向结点x的后继的指针。倘若结点x的关键字是这棵树的最大关键字,则说明它没有后继了,返回NULL。
求后继的过程可以分以下两种情况讨论:
① 如果结点x的右子树非空,那么x的后继恰是x右子树中的最左结点(右子树中的最小关键字);
② 如果结点x的右子树为空,则有以下两种可能:
a. 结点x是其父结点的左孩子,则结点x的后继结点为它的父结点;
b. 结点x是其父结点的右孩子,则结点x的后继结点为x的最底层祖先,同时满足“这个最底层祖先的左孩子也是结点x的祖先”的条件。
举个例子:
对于情况①,假设我们要求结点5的后继。因为结点5的右子树非空,所以5的后继就是它的右子树中的最小关键字,即为6。
对于情况②a,假设我们要求结点2的后继。因为结点2的右子树为空,且结点2是其父结点3的左孩子,所以2的后继就是它的父结点,即为3。
对于情况②b,假设我们要求结点4的后继。因为结点4的右子树为空,且结点4是其父结点3的右孩子。此时我们要找的后继是4的最底层祖先,而且这个最底层祖先的左孩子也是结点4的祖先之一。我们可以这样看,首先结点4的祖先有3、5;按理说最底层结点应该是3。但是我们需要注意,这个最底层是有前提条件的。前提条件就是这个祖先要有左孩子,并且这个左孩子也是结点4的祖先之一。在祖先3、5中,有左孩子的有3和5,但是结点3左孩子2并不是结点4的祖先之一;结点5的左孩子3正是结点4的另一个祖先。因此,符合前提条件的最底层祖先为5。所以4的后继就是5。
1.2.6 Predecessor(前驱)
给定一棵二叉搜索树中的一个结点查找它的前驱的情况跟求后继的情况是对称的。一棵二叉搜索树的结构也允许我们通过没有任何关键字的比较来确定一个结点的前驱。如果前驱存在,则返回指向结点x的前驱的指针。倘若结点x的关键字是这棵树的最小关键字,则说明它没有前驱了,返回NULL。
求前驱的过程同样可以分以下两种情况讨论:
① 如果结点x的左子树非空,那么x的前驱恰是x左子树中的最右结点(左子树中的最大关键字);
② 如果结点x的左子树为空,则有以下两种可能:
a. 结点x是其父结点的右孩子,则结点x的前驱结点为它的父结点;
b. 结点x是其父结点的左孩子,则结点x的前驱结点为x的最底层祖先,同时满足“这个最底层祖先的右孩子也是结点x的祖先”的条件。
举个例子:
对于情况①,假设我们要求结点5的前驱。因为结点5的左子树非空,所以5的前驱就是它的左子树中的最大关键字,即为4。
对于情况②a,假设我们要求结点7的前驱。因为结点7的左子树为空,且结点4是其父结点3的右孩子,所以4的前驱就是它的父结点,即为3。
对于情况②b,假设我们要求结点6的前驱。因为结点6的左子树为空,且结点6是其父结点7的左孩子。此时我们要找的前驱是6的最底层祖先,而且这个最底层祖先的右孩子也是结点6的祖先之一。
我们可以这样看,首先结点6的祖先有7、5,;按理说最底层结点应该是7。但是我们需要注意,这个最底层是有前提条件的。前提条件就是这个祖先要有右孩子,并且这个右孩子也是结点17的祖先之一。在祖先7、5中,结点5的右孩子7正是结点6的另一个祖先。因此,符合前提条件的最底层祖先为5。所以6的前驱就是5。
1.2.7 Insert(插入)
插入操作会引起由二叉搜索树表示的动态集合的变化。我们需要修改数据结构来反映这个变化,但要保证修改后二叉搜索树的性质不被破坏。
插入的过程首先从树根开始遍历,沿树向下移动。指针x记录了一条向下的简单路径,并查找要替换的输入项z的NIL。同时,保持遍历指针y指向x的双亲。两个指针沿树向下移动时,通过比较当前结点x的关键字与待插入结点z的关键字大小,来决定向左或向右移动。直到x指向NIL时,这个NIL占据的位置就是输入项z要放置的位置。前面我们提到在x移动过程中还需要保持y指向x的父结点,原因是当我们找到可插入的NIL位置时,我们需要知道z属于哪个结点。
1.2.8 Delete(删除)
相对于插入操作,删除操作会更加复杂一些。从一棵二叉搜索树中删除某个特定结点z可以分为以下三种情况,其中前两种情况较为简单,最后一种情况则复杂一点。
① 如果z没有孩子结点,那么只是简单地将它删除,并修改它的父结点,用NIL作为孩子来替换z;
② 如果z只有一个孩子,那么将这个孩子提升到树中z的位置上,并修改z的父结点,用z的孩子来替换z;
③ 如果z有两个孩子,那么找z的后继y,并让y占据树中z的位置。z的原来右子树部分成为y的新的右子树,z的原来左子树部分成为y新的左子树。这里要注意,z的后继y一定在z的右子树中,并且没有左孩子(详情见上文2.7前的证明)。利用z的后继y替换z,又细分为以下两种情况:
a. 如果y是z的右孩子,那么直接用y替换z,并保留y的右子树(y没有左子树);
b. 如果y不是z的右孩子,那么先用y的右孩子替换y(y没有左孩子),然后再用y替换z。
2. AVL树(二叉平衡树)
2.1 AVL树概述
参考:AVL树(一)之 图文解析 和 C语言的实现
AVL树是根据它的发明者G.M.Adelson-Velsky和E.M.Landis命名的。
上面介绍的二叉查找树在不断插入的情况下,有可能出现一侧不断生长,最后退化成线性结构的情形,查找插入等操作的效率就会退化为线性查找的效率。
而AVL树是最先发明的自平衡二叉查找树,也被称为高度平衡树。相比于"二叉查找树",它的特点是:AVL树中任何节点的两个子树的高度最大差别为1。
AVL树的查找、插入和删除在平均和最坏情况下都是O(logn)。
如果在AVL树中插入或删除节点后,使得高度之差大于1。此时,AVL树的平衡状态就被破坏,它就不再是一棵二叉树;为了让它重新维持在一个平衡状态,就需要对其进行旋转处理。学AVL树,重点的地方也就是它的旋转算法。
树的高度为最大层次。即空的二叉树的高度是0,非空树的高度等于它的最大层次(根的层次为1,根的子节点为第2层,依次类推)。
2.2 AVL树失去平衡的四种情况
如果在AVL树中进行插入或删除节点后,可能导致AVL树失去平衡。这种失去平衡的可以概括为4种姿态:LL(左左),LR(左右),RR(右右)和RL(右左)。
① LL:LeftLeft,也称为"左左"。插入或删除一个节点后,根节点的左子树的左子树还有非空子节点,导致"根的左子树的高度"比"根的右子树的高度"大2,导致AVL树失去了平衡。
例如,在上面LL情况中,由于"根节点(8)的左子树(4)的左子树(2)还有非空子节点",而"根节点(8)的右子树(12)没有子节点";导致"根节点(8)的左子树(4)高度"比"根节点(8)的右子树(12)"高2。
② LR:LeftRight,也称为"左右"。插入或删除一个节点后,根节点的左子树的右子树还有非空子节点,导致"根的左子树的高度"比"根的右子树的高度"大2,导致AVL树失去了平衡。
例如,在上面LR情况中,由于"根节点(8)的左子树(4)的左子树(6)还有非空子节点",而"根节点(8)的右子树(12)没有子节点";导致"根节点(8)的左子树(4)高度"比"根节点(8)的右子树(12)"高2。
③ RL:RightLeft,称为"右左"。插入或删除一个节点后,根节点的右子树的左子树还有非空子节点,导致"根的右子树的高度"比"根的左子树的高度"大2,导致AVL树失去了平衡。
例如,在上面RL情况中,由于"根节点(8)的右子树(12)的左子树(10)还有非空子节点",而"根节点(8)的左子树(4)没有子节点";导致"根节点(8)的右子树(12)高度"比"根节点(8)的左子树(4)"高2。
(4)RR:RightRight,称为"右右"。插入或删除一个节点后,根节点的右子树的右子树还有非空子节点,导致"根的右子树的高度"比"根的左子树的高度"大2,导致AVL树失去了平衡。
例如,在上面RR情况中,由于"根节点(8)的右子树(12)的右子树(14)还有非空子节点",而"根节点(8)的左子树(4)没有子节点";导致"根节点(8)的右子树(12)高度"比"根节点(8)的左子树(4)"高2。
2.3 AVL树的旋转
2.3.1 LL的旋转
图中左边是旋转之前的树,右边是旋转之后的树。从中可以发现,旋转之后的树又变成了AVL树,而且该旋转只需要一次即可完成。
k2结点不一定是整棵AVL树的根结点,而是开始失去平衡的那个根结点,至于k1则是高度过高的那棵子树的根结点。将k1变成根节点,k2变成k1的右子树,“k1的右子树"变成"k2的左子树”。
2.3.2 RR的旋转
图中左边是旋转之前的树,右边是旋转之后的树。RR旋转也只需要一次即可完成。
RR旋转操作即LL旋转的镜像操作。
2.3.3 LR的旋转
LR失去平衡的情况,需要经过两次旋转才能让AVL树恢复平衡。第一次旋转是围绕"k1"进行的"RR旋转",第二次是围绕"k3"进行的"LL旋转"。
2.3.4 RL的旋转
RL是LR的镜像操作。第一次旋转是围绕"k3"进行的"LL旋转",第二次是围绕"k1"进行的"RR旋转"。
3. B树、B+树和B*树
3.1 B树
3.1.1 概述
在现代计算机中通常采用分级存储系统,以最简单的二级分级存储策略为例,就是由内存储器与外存储器(磁盘)组成二级存储系统.这一策略的思想是:将最常用的数据副本存放于内存中,而大量的数据存放于外存中,借助有效的算法可以将外存的大存储量与内存高速度的优点结合起来。
一般的,在分级存储系统中,各级存储器的速度有着巨大的差异,仍然以磁盘和内存为 例,前者的平均访问速度为 10ms左右,而内存储器的平均访问时间为ns级,通常在 10~100ns 左右,二者之间差异大约为 10的6次方.因此,为了节省一次外存储器的访问,我们宁愿多访问内存储器一百次,一千次甚至一万次. 当问题规模太大时,以至于内存储器无法容纳时,即使是前面介绍的 AVL 树,在时间上也会大打折扣。
B树是对二叉查找树的改进。它的设计思想是,将相关数据尽量集中在一起,以便一次读取多个数据,减少硬盘操作次数。B树为系统最优化大块数据的读和写操作。B树算法减少定位记录时所经历的中间过程,从而加快存取速度。普遍运用在数据库和文件系统。
假定一个节点可以容纳100个值,那么3层的B树可以容纳100万个数据,如果换成二叉查找树,则需要20层!假定操作系统一次读取一个节点,并且根节点保留在内存中,那么B树在100万个数据中查找目标值,只需要读取两次硬盘,相对于平衡二叉树,查询比较的次数可能没有减少,但是磁盘IO次数会大大减少。
3.1.2 B树的结构
所谓 m 阶B-树或为空树,或为满足下列特性的 m叉树:
① 树中每个结点至多有 m 棵子树;
② 若根结点不是叶子结点,则至少有两棵子树;
③ 除根结点之外的所有非终端结点至少有 ⎡m/2⎤ 棵子树;
④ 所有的非终端结点的结构为:(n , A0 , K1 , A1 , K2 , … , Kn , An) 其中: Ki(i=1,2,…,n)为关键字,且Ki<Ki+1,Ai为指向子树根结点的指针(i=0,1,…,n), 且指针A(i-1)所指子树中所有结点的关键字均小于Ki (i=1,2,…,n),An所指子树中所有结点的关键字均大于Kn,n为关键码的个数,⎡m/2⎤ −1≤ n ≤ m −1;
⑤ 所有的叶子结点都出现在同一层次上,并且不带信息(可以看作是外部结点或查找 失败的结点,实际上这些结点不存在,指向这些结点的指针为空);
3.1.3 B树的查找操作
B-树的查找类似二叉排序树的查找,所不同的是B-树每个结点上是多关键码的有序表, 在到达某个结点时,先在有序表中查找,若找到,则查找成功;否则,到按照对应的指针信 息指向的子树中去查找,当到达叶子结点时,则说明树中没有对应的关键码,查找失败.即在 B-树上的查找过程是一个顺指针查找结点和在结点中查找关键码交叉进行的过程.
正如前面所指出的,B-树的查找适合于大规模的数据.实际的做法是**将大量数据组织为一棵 B-树,并存于外存储器,B-树的根结点常驻内存.**一旦需要查找,则按照上述过程, 首先将根结点作为当前结点,在当前结点中顺序查找;如果当前结点不存在需要查找的关键 字,则根据相应的引用,找到外存中的某一个下层结点,将其读入内存,作为新的当前结点继续查找.如此进行下去,直到找到相应关键字或查找失败.
由此可见,**在B-树中进行查找所需的时间,无外乎两类操作的时间消耗:一种是在 B-树上找结点,即将外存中的结点读入内存;另一种是,在结点中找关键字.**在前面曾经提 到,内外存储器的平均访问时间存在巨大的差异,所以在这两部分时间中,前者必然是主要部分,后一部分时间则可以忽略.因此,B-树的查找效率取决于外存的访问次数.
由推导可知,对于具有N个关键字的m阶B-树的每次查找操作,都可以在Ο(logm N)时间内完成.
3.1.4 B树的插入操作
为了在 m 阶B-树中插入一个新关键字 key,首先要找到该关键字应该插入的位置,该过程实际是在B-树中查找关键字的过程.倘若查找成功,则不再插入重复的关键字,若查找不成功,则在此查找过程中遇到的最后一个非叶子结点p,即为关键字 key 的插入位置.然后,在结点 p 中,按照关键字有序的顺序将 key 插入.若插入后结点 p 上关键字个数不超过 m−1个,则可直接插入到该结点上;否则,要进行调整,即结点的“分裂”.
分裂操作按如下规则进行:
① 假设结点 p 中已有 m-1 个关键字,当插入一个关键字之后,结点中包含的信息为:(m , A0 , K1 , A1 , K2 , … , Km , Am)
② 此时,可以将结点 p 分裂为两个结点 u和v,其中结点 u 包含的信息为 (⎡m/2⎤ −1 , A0 , K1 , A1 , … , K⎡m/2⎤ −1 , A⎡m/2⎤ −1)结点 v 中包含的信息为(m− ⎡m/2⎤ , A⎡m/2⎤ , K⎡m/2⎤+1 , A⎡m/2⎤+1 , … , Km , Am)
③ 而关键字K⎡m/2⎤则插入到p的父结点中去.如果p的父结点不存在(已经到根结点),则新建一个只包含关键字 K⎡m/2⎤的结点;
④ 如果p的父结点关键字个数由于关键字K⎡m/2⎤的插入而超过m−1,则分裂过程继续下去,直到p的某个祖先结点g,在插入关键字后g的关键字个数不超过m−1.
◆ 举例如下所示5阶B-树:
① 插入7
② 插入 3
③ 插入 38
3.1.5 B树的删除操作
与插入关键字相反,若在 B-树上删除一个关键字,则首先应该找到该关键字所在的结点u,并从中删除。B-树的删除操作可以分为以下三种情况:
一、若待删关键字所在结点并非最下层的非终端结点
假设待删关键字为Ki,此时,可以用Ai所指子树中的最小关键字X替代,然后问题转换成了删除关键字X,若关键字X仍不是最下层的非终端结点,则继续向下,直到问题转换为删除最下层结点。
二、 若该结点 u 为最下层的非终端结点,且其中关键字的数目不少于 ⎡m/2⎤
直接删除即可。
三、 若该结点 u 为最下层的非终端结点,且其中关键字的数目少于 ⎡m/2⎤(即关键字数目为⎡m/2⎤-1)
在u中删除关键字后需要进行“合并”结点的操作。
“合并”操作可以分成以下三种情况分别进行处理:
① 结点 u 的左兄弟 v 包含至少 ⎡m/2⎤ 个关键字
如下图所示,可以从结点v中取出最大的关键字kmax,将u的父结点p中介于v 和u之间的关键字kmid替换为kmax,然后将kmid插至u的最左侧.(即从结点u中删除一个关键字之后,从左兄弟取出最右关键字放入父节点,将父节点关键字放入结点u)
② 结点 u 的右兄弟 v 包含至少 ⎡m/2⎤ 个关键字
与第一种情况类似,如下图所示,可以从结点v中取出最小的关键字kmin,将u的父结点p中介于v 和u之间的关键字kmid替换为kmin,然后将kmid插至u的最右侧.
③ 结点 u 没有一个兄弟包含至少 ⎡m/2⎤ 个关键字
此时,结点u和其相邻的兄弟结点中的关键字数目均等于 ⎡m/2⎤ −1,但是结点u至少会有一个兄弟结点.如下图所示,设u有右兄弟v,这时可以取出父结点p中介于u,v之间 的关键字kmid,然后将kmid与u,v中关键字合并为一个结点.
综合以上三种情况,在“合并”结点之后,结点 u 的父结点中关键字有可能会减少,若“合并”操作之后,父结点关键字数目小于 ⎡m/2⎤ −1,则需要对父结点进行修复(即对父节点进行以上的合并操作),该过程一直进行下去,直到 u 的某个祖先结点 g,g 的关键字数目不少于 ⎡m/2⎤ −1,或直到根结点为止.
3.2 B+树
B+树是B-树的变体,也是一种多路搜索树,其定义基本与B树相同,除了:
① 非叶子结点的子树指针与关键字个数相同;
② 非叶子结点的子树指针P[i],指向关键字值属于(K[i], K[i+1])的子树(B-树是开区间);
③ 为所有叶子结点增加一个链指针;
④ 所有关键字都在叶子结点出现;