本节课我们来学习本章的第一个难点,就是B树。那么B树它其实是一种数据结构,我们设计出这种数据结构就是为了提高我们的查找效率的,提高我们在磁盘上的查找效率。那么什么是B树呢?了解B树之前,我们先来回忆一下第四章学习过的一种特殊二叉树,就是平衡二叉树。
平衡二叉树的定义是,任意结点的左右子树高度之差的绝对值均不超过1。这样特殊的二叉树我们称之为平衡二叉树。因为我们有了平衡二叉树这样一种特殊的二叉树,所以我们在平衡二叉树上的查找,不会退化为一种线性结构,也就是不会退化为在线性结构上的查找。所以,它的查找效率会比较的高。那么,我们根据平衡二叉树的特点,做出了一些改进,就设计出了B树。
在平衡二叉树当中,每个结点只有一个关键字一个数据元素。
那么在B树中,每个结点可以有多个数据元素,可以有多个关键字。好,我们简单地了解了一下什么是B树。接下来,我们就来看一下书中严格的定义。
书中是这样定义B树的,又称为多路平衡查找树。B树中所有结点孩子结点数的最大值称为B树的阶。这里也需要给大家强调的是,一定要记作为孩子结点数的最大值。因为有的时候我们会记错,会把它记错为关键字的数量的最大值。所以再次强调,一定是所有结点的孩子结点数的最大值为B树的阶。
好,除了这一个特点之后,我们再来看一下B树还有哪些要求?一棵m阶B树,或为空树,或为满足如下特性的m叉树。那么其实对B树的要求非常多,没关系,我们先来总览一下,然后再逐条地在例子当中看一下它有哪些要求。
首先第一条是树中每个结点至多有m棵子树,即至多含有m-1个关键字。大家发现了,这里关键字,也就是我们存储的数据元素的数量,是不是子树的数量、分支的数量减1啊,我们有这样的要求。
接下来,若根结点不是终端结点。也就是说,如果该B树当中,不仅仅只有一个终端结点的话,则它至少有两棵子树。根结点至少有两棵子树,也就是至少有一个关键字呗,我们根据性质1知道。
那么第三条则是除根结点外的所有非叶结点至少有[m/2](m/2取上阶)棵子树,这里我们对每一个非叶结点的子树数量是有要求的,它至少有[m/2](m/2取上阶)棵子树。那么接下来我们根据性质1,则它至少有[m/2]-1(m/2取上阶减1个关键字),这是我们根据性质1得到的。好,这就是B树的前三条要求。那么要求到这儿,同学们一定会有疑问。在每个结点上我们都会有多个关键字,也会有指向多棵子树根结点的指针,那么这些指针以及关键字是如何进行放置的呢?
接下来我们就来看一下,非叶结点的结构。这就是B树当中非叶结点的结构。我们来看一下,它其实存放在一个数组当中。这里数组的第一个值存放的是结点关键字的个数n,那么这个n它有范围是不是啊。因为我们对每个结点它是关键字是有要求的,它一定是小于等于m-1。为什么小于等于m-1啊,因为它是m阶的B树。如果它的关键字大于m-1的话,它就不再是一棵m阶的B树了。其次它还要大于等于[m/2]-1(m/2取上阶减1)。因为我们在要求3上对关键字的数量有这样一个最小值的要求。好,这就是第一个值n,它存放的是关键字的个数。接下来我们就来放置指向子树根节点的指针以及关键字了,我们是这样放置的。首先我们放置了一个P0,它是指向第一个子树根结点的指针,然后我们放置第二个值是一个关键字K1。其次我们又放置了一个指向子树根结点的指针P1,然后我们放置了关键字P2。这样的交错依次放置,最后一个放置的是指向一个子树根结点的指针Pn。除了有这样放置的要求以外,还有哪些要求呢?
那么还有这样的要求。对于关键字来讲,每一个关键字它是递增进行放置的。也就是K1<K2<k3<...Kn。那么对指针有哪些要求呢?如果Pi为子树根结点的指针,那么Pi-1这一个指针所指的这一棵子树的关键字均要小于关键字Ki。那么Pi所指子树的所有关键字,均要大于Ki,我们有这样的要求。也就是P0所指根结点上子树的所有关键字的值,它都要小于Ki。那么P1所指向根结点所代表的这一棵子树上所有关键字的值,均要大于K1。那么对于所有的关键字以及所有子树上的关键字的值,都有这样的要求,这是要求4。
接下来最后一个要求就是所有的叶结点都出现在同一层次上,并不带任何信息。也就是所有叶结点一定都在同一层,它一定是一棵平衡树。它的平衡因子都为0。好,这就是B树的所有的要求。我们先简单地总览了一下,接下来我们就来看一个例子,来看一下在例子当中它是如何满足这样的要求的。
好,这就是一个三阶B树的例子。我们来看一下它是如何满足各个要求的。首先我们先来回忆一下要求1。树中每个结点至多有m棵子树,即至多含有m-1个关键字。我们来观察,在这一棵三阶的B树上,是不是每一个结点都至多有3棵子树啊,也至多有两个关键字,所以它是满足3阶B树这样一个要求的。
接下来我们来看要求2。那么要求2是若根结点不是终端结点,则至少有两棵子树。观察这样一个三阶的B树,它的根结点是不是有三棵子树啊,它满足这样的要求2。
然后我们来看第三个要求。除根结点外的所有非叶结点至少有[m/2](m/2取上阶)棵子树,即[m/2]-1(m/2取上阶减1)个关键字。我们来观察每一个结点,是不是最少的结点它有一个关键字,有两棵子树,是不是满足[3/2]=2(3/2取上阶等于2)这样一个要求啊。所以说它满足这样的要求3。
接下来我们来看一下要求4。要求4则是关键字的排序它是有这样的递增的顺序的。我们来观察,每一个结点当中的关键字是不是都是这样的递增顺序啊,18,33,23,30等等等等。那么其次,对于指针有这样的要求。也就是18它的左边这棵子树的所有关键字的值是不是都要比18要小啊。那么18右边这棵子树上,所有关键字的值是不是都要比18要大呀。接着对于33这个关键字,也就有同样的这样一个规律。那么其他的所有关键字,大家发现是不是也有同样的这样一个规律啊。其实顺序这样的规律,是不是就是为了方便我们的查找操作的。为了实现我们查找的这样一个操作。
那么接下来我们再来看一下最后一个要求就是,所有的叶结点都出现在同一层次上,并不带任何信息。这样一个三阶的B树,是不是所有叶结点都在B4层上啊。这里需要强调一点是,那么这样的结点其实是,就是相当于我们上一节课所学习的那一种失败结点。那么对于有的教材有的题目来讲,它是作为一个实际的一层的存在。那么对于有的题目来讲,它也是一种虚拟的一种结点。它并不代表实际的一层,这里我们要具体问题具体分析。好,这就是所有B树的要求。我们通过一个例子来了解了这样一个5个要求。
接下来我们来看一个小知识点,小考点。就是n个关键字,阶数为m,高度为h的B树,它对应这样一个高度h有怎样的范围要求。也就是这样一个高度h,它最小可以取什么样的值,最大可以取什么样的值。首先我们来看它最小可以取哪些值。那么它最小取值是不是就是我们用每一个结点关键字的数量达到最大呀,这样我们就会令它的高度达到最小。所以我们列出了这样一个式子,关键字n要小于等于右边这样一个式子。我们来看一下右边这样一个式子有怎样的含义。首先来看括号,右边的这个括号。1+m+m^2+...+m^(h-1),大家观察是不是非常熟悉啊。它是不是就是我们在树的那一章计算m叉树为满的情况下它有多少个结点的式子啊。第一层有1个根结点,第二层有m个结点,第三层有m^2个结点,这样依次累加,就可以计算出这样一棵满m叉树有多少个结点。然后为什么要乘以(m-1)呢,是不是在B树当中,每一个结点最多有m-1个关键字啊。这样我们计算了如果它为满的情况下,最多有多少个关键字。那么n一定是小于等于它的。所以我们列出了这样一个不等式,接着我们只要通过化简,就可以计算出右边这个式子为m的h次幂减1。
然后移项就可以计算出它的最小取值就是logm为底n+1。好,我们计算出了它最小取值之后我们再来看右边,它的最大取值。
那么最大取值,它的方法是不是与最小取值刚好相反啊。就是我们用每个结点它的关键字数量达到最少,
我们来看一下它有怎样的规律。那么根结点我们根据要求知道它的关键字最少有1个,那么它有1个关键字的话,就会有两个分支,两个子树。这样我们就知道了第二层一定有两个结点,其中我们根据要求知道,每一个结点至少有[m/2]-1(m/2取上阶减1)个关键字。那么它就会有[m/2](m/2取上阶)个分支,[m/2](m/2取上阶)个子树。这样我们就可以通过第二层计算出第三层最少有多少个结点。
那么第三层最少则有2*[m/2](m/2取上阶)个结点。好,知道了它有多少个结点之后,那么我们根据这样的规律是不是就可以计算出那么第h+1层,也就是叶结点的那一层,有2*[m/2](m/2取上阶)h-1次幂个结点。我们为什么要计算最后那一层失败结点的最少取值呢?因为我们有这样的规律,在B树当中如果它的关键字个数为n的话,则叶结点也就是查找失败结点它的数量为n+1。为什么呢?其实非常好理解,那么我们对应的这些失败结点是不是就是对应着不存在在该B树上值的那一个区间啊。那么对应如果有n个关键字,它是不是就会有n+1个区间啊。所以它的失败结点数量为n+1。那么我们只要小于等于n+1,就可以计算出它的最大取值了。我们通过移项化简,就可以计算出最大取值。那么同学们可能会发现,这些式子是不是都是比较复杂的啊。它是不是如果不是特殊值的情况下,是很难计算出一个整值的,很难以手头计算的。所以在考研当中,我们直接利用这样式子进行计算其实情况是非常少的。更多时候会给出大家一个例子,大家手动地在草纸上进行构造就可以了。好,这就是第一个小考点。
好,了解了第一个小考点之后,我们来看有关B树的操作。那么B树最重要的操作一定是查找操作。在B树上是如何实现查找的呢?我们设计了这样一种特殊的数据结构,是如何实现查找。那么其实它的方式非常简单,首先我们则要找到对应这个关键字的那一个结点,然后在结点上依次地找寻关键字是不是就可以了。
我们来找一个例子试一下,例如查找32这样一个关键字。
那么我们首先要在这一棵树上找到对应存放32这个关键字的结点。我们通过根结点出发,
对应着比较,那么
本节课我们来学习B+树。什么是B+树呢?其实B+树就是对B树的一种变形。那么我们为了更好地用于数据库当中,将B树做了一些修改和调整,产生了B+树。接下来我们就来学习一下什么是B+树。
那么首先还是总览一下B+树有哪些要求。一棵m阶B+树需要满足如下特性:第一点是每个分支结点最多有m棵子树,它与B树是一样的。每个结点可以有多个子树。接着是若根结点不是终端结点,则至少有两棵子树。第三个就是除根结点外的所有非叶结点至少有[m/2](m/2取上阶)棵子树。并且这里我们注意一下,子树和关键字的个数是相等的。这里是不是与B树是不一样的了。那么子树和关键字的个数在B+树里是相等的。接着第四点是,所有叶结点包含全部关键字及指向相应记录的指针。什么意思呢?那么我们在B树当中,叶结点是不是不包含任何信息啊。但是在B+树当中,叶结点包含了全部的关键字以及相应对应每个关键字指向记录的这样一个指针。并且叶结点中相关关键字按大小顺序进行排列,并且相邻结点按大小顺序连接了起来。好,这就是叶结点的不同,我们对B树做了修改产生了这样的特点。那么最后一点则是,所有分支结点中包含它的各个子结点也就是下一级索引块中关键字的最大值及指向其子结点的指针。这里与分块查找是不是有一些类似啊,那么我们这里包含了它下一级到子结点中所有关键字当中的最大值。我们起了一个指示的作用,并且还要包含指向它的子结点的这样一个指针,为了查找到它的子结点。这就是最后一个要求。
那么接下来我们就来通过一个B+树的例子,来逐条看一看这些要求是怎样实现的。那么这就是一个B+树的例子。接着我们就来看一下第一个要求就是,每一个分支结点最多有m个子树、m个子结点,那么在这里是否满足这样的要求啊。
第二个要求是,若根结点不是终端结点,则至少有两棵子树。当中的根结点,是不是有两棵子树啊,
本节课我们来学习一种新的查找方式叫做散列查找。什么是散列查找呢?在学习散列查找之前,一定要介绍一个基本概念就是散列表。那么学习散列表之前我们先来回忆一下之前所学习过的所有查找方式,那么无论是顺序查找还是折半查找,还是之后学习的新的数据结构——B树、B+树,它们的查找方式都是基本比较的基础上的。我们都要通过比较来找到我们想要找到的元素的位置。那么本节课所学习的散列表、散列查找是一种全新的查找的概念,我们不用通过比较的方式就可以直接找到对应元素的位置。
那么我们先来看一个实际生活中的小例子。那么这是一群小朋友,我们让他站成了一排,根据我们之前的查找方式,那么它大体都是我们依次地比较,找到对应我们想要找的那个小朋友的位置,看一看哪一个位置是我们所要找的那一个小朋友。那么对应如果是散列查找呢,我们观察这些小朋友是不是按照彩虹的顺序,赤橙黄绿青蓝紫的顺序进行排列的。那么如果我们知道彩虹顺序,我们就一定知道小红一定是排在一号位置的,小黄应该是排在二号位置的,小橙应该是排在三号位置的,等等等等。那么这样我们是不是就不用通过一次比较来找到每个小朋友的位置了。我们通过一个基本常识直接让小红映射到了一号位置,小黄映射到了二号位置,依此类推。那么这样的方式,这样的概念,其实就有点类似我们今天所要学习的散列查找、散列表。那么散列查找就是通过了一种映射手段让我们每一个数据元素映射到存储空间上的一个特定的位置。
那么在散列表当中,我们是通过什么样的形式来表示这样的关系呢?我们是通过散列函数来表示的。那么这样一个把查找表中的关键字映射成该关键字对应的地址的这样一个函数就叫做散列函数。我们来看,如果现在有一个存储单元,它的地址为Addr。如果它存放的关键字为key的话,那么我们就用这样的散列函数表示了key与Addr的关系,也就是我们通过对key传入到函数Hash中进行计算,就可以算得Addr这样一个值。Addr恰好是key对应存储单元的地址。那么这就是散列函数的一个作用和表示。
接下来我们来看一个小例子,现在我们有三个存储单元。大家发现了,它对应的下标是不是0、1、2啊。这里我们并没有用地址,所以大家千万不要有局限性,就是散列函数计算出的最终结果,可以表示为存储单元的地址,也可以表示为数组的下标或者是记录的索引。它只要可以表示我们对应想要查找的这一个关键字,这一个数据元素的位置就可以了。那么这里我们把它表示为数组的下标,那么现在我们有三个数组的位置。
假设现在我们规定散列函数Hash为一个取余函数,它如何计算呢?就是通过关键字取余3来计算出对应数组的一个下标。
假设现在我们要存放的三个整数为,{6,13,26}。它是如何通过散列函数进行存放的呢?例如我们来看第一个数字6,6通过key取余,6现在是key,6取余3,它是不是得到0啊。那么6就存放在数组下标0的位置。那么13,取余3之后,得到的是1,所以它存放在数组下标1的位置。那么26取余之后得到的结果是2,所以它存放在数组下标2的位置。这样我们是不是就存放了6、13、26这样三个数字啊。我们是通过对应的规定的散列函数来将它们进行存放的。那么我们发现,如果我们想要找到关键字13的位置,是不是就可以通过13取余3,那么13存放的就是数组下标为1的这样一个位置啊。我们可以通过散列函数Hash这样一个散列函数直接计算出每一个数字对应存放的数组下标。这就是散列函数的使用过程以及我们拿到散列表这样一个目的。
那么接下来大家是不是就知道什么是散列表了?我们根据关键字而直接进行访问的数据结构,就如我们上一个举的这样一个例子的数据结构,它建立了关键字与存储地址之间的一种直接映射关系。我们称这样的一种结构就叫做散列表。那么我们观察,上一个例子是不是就是一个散列表的例子啊。我们将每一个关键字都与它对应的直接访问的地址建立了一种直接的映射关系。散列表的查找是不是直接可以通过散列函数直接找到对应关键字的存储单元,对应关键字的下标啊。那么这样的时间复杂度是不是就是大O1(O(1))啊。那么它既然比我们之前所学习的比较、基于比较的查找方式来的要更快,而且效率要更高的话,为什么没有得到广泛的应用呢?其实它存在一个问题,就是冲突的问题。什么是冲突的问题呢?我们来观察,现在我们把数字6存放在数组下标为0的位置,因为我们通过取余的这样一个散函数计算得到的它的对应下标。那么例如我们现在又要存放一个数字,存放3的话,我们通过3取余3,是不是也要存放在数组下标0的位置啊。这样我们就产生了冲突,因为我们把两个不同的数字、两个不同的关键字映射到了同一个存储单元下,它们的地址是相同的。
所以我们就有这样冲突的概念。散列函数可能会把多个不同的关键字映射到同一地址下的这样一种情况,就叫做冲突。所以为什么散列函数、散列表没有得到广泛的应用呢?因为它存在这样这样一个冲突的问题。虽然我们查找的效率可能在理想的情况下会非常高,但是如果产生冲突的话,它的查找效率也会降低下来。那么其实这样的冲突是无法避免的,所以在我们接下来的学习过程当中,主要学习的一点就是如何设计好散列函数来尽量减少冲突的发生。并且,如果冲突发生了,可以不让这些冲突影响我们对应的查找,这就是我们下一节课所要学习的主要内容。
上一节课我们学习了有关散列表的基础知识,了解了什么是散列表以及它是如何进行查找的。那么本节课我们就来学习散列表的其他重要的基本知识,就是散列函数的构造方法,冲突处理的方法以及散列表的性能分析。
首先我们先来学习一下散列函数的构造方法。如何构造一个散列函数呢?我们先来回忆一下散列函数的基本概念。它是一个把查找表中的关键字映射成该关键字对应的地址的这样一个函数。下面是我们的计测方法。Hash(key)我们最终求得的值是我们对应存储单元的地址Addr,那么散列函数我们无论是在构造散列表的过程当中还是在散列表中进行查找时,都要用到这样的函数。
那么如何构造它呢?我们先来看一下它的构造要求。第一个要求是散列函数的定义域必须包含全部需要存储的关键字,这一点是必然的,为什么呀。如果定义域不包含我们所要存储的关键字的话,那么这些关键字就无法通过散列函数映射到对应的存储单元上了。而下一句是,值域的范围则依赖于散列表的大小或地址范围。这一点也是非常好理解的。因为如果我们对应求得的这样一个值,无法求算出我们散列表中的每一个存储单元的地址的话,那么这些地址的存储单元是不是就浪费了?所以值域的范围则依赖于散列表的大小或者地址的这样一个范围。
那么第二个要求是什么呢?散列函数计算出来的地址,应该能等概率、均匀地分布在整个地址空间上,从而减少冲突的发生。因为,如果它不均匀地分布在地址空间上的话,如果我们所有求得的关键字,对应的映射到的地址,都是同一个的话,那么它们是不是都产生冲突了。所以我们要求它最好能够等概率、均匀地分布在地址空间上,这样可以减少对应冲突的发生。
而最后一点则是,散列函数应尽量简单,能够在较短时间内计算出任意关键字对应的这样一个散列地址。那么这一点大家也非常好理解。如果它非常难计算的话,我们对应的效率就会非常的低。好,讲解完了三个构造要求之后,接下来我们就来学习一下在散列函数当中会主要涉及哪些构造方法。