文章目录
九、外部排序
外部排序算法是跟外存有关的,之前所讲的排序算法都是将数据一次性读取到内存中再进行排序的。
(一)多路归并排序
之前所讲的二路归并排序,它的关键操作就是把两个有序的序列归并一个更长的有序序列。
三路归并排序,由并列的三个有序序列,每次从序列中挑出一个最值关键字,把它并入结果序列中。n路归并排序就会有n个有序序列。
对于三路归并排序,归并的过程中,每次都是三个有序序列中的第一个为待挑选的关键字,从这三个关键字中挑选出一个最值关键字,然后并入结果序列。这个挑选的过程是在内存中做的,因为我们需要一些比较操作才能从这三个关键字中挑选出最值关键字,而这些事情是CPU做的,并且CPU此时只需要作用于这三个关键字即可。
对于我们这个三路归并排序来说,需要CPU来处理的关键字也就三个,推广到一般情况,n路归并排序需要CPU同时处理的关键字也就是n个了。我们之前所讲的排序算法如果处理较大规模的关键字,大到内存无法将关键字全部装下,这些排序算法也就没有用了,因为它们都需要将数据全部读入内存再进行操作。而我们现在所讲的多路归并排序就是可以对这种情况进行操作的,因为CPU需要同时处理的关键字个数远小于关键字的总数。对于三路归并排序来说,我们只需要往内存中载入三个关键字,然后用CPU处理它们挑选出一个合适的关键字写入外村即可。
例子演示:假设有一块内存和一组外存中的关键字,内存无法读入一整组关键字。假设我们这里也使用三路归并排序。
首先读入三个关键字,按照归并操作的规则,每次从内存中挑出一个最值关键字,把它并入结果序列,并入的过程就是把关键字从内存写入到外存的过程。最后得到一个有序序列。
继续往内存中读入关键字,最后能得到3个有序序列。
然后把这三个有序序列的第一个关键字读入内存,从中挑选出一个最值关键字然后把它写回外存,并且用它原本所在的关键字序列的后一个关键字来填补它所空出来的位置
重复操作最后得到一个有序序列。这就是多路归并排序是如何在关键字的规模远大于内存所能容纳的规模来实现的排序。虽然实现了排序,但仍然存在许多问题,由于需要操作外存,也就是关键字的读入内存和写回外存的操作,这些操作是比在内存中做事情要慢的多的。因此对于外部排序,通过一些操作来减少IO操作是一件非常重要的事情。在我们的内部排序中,关键字的移动比较是比较耗时的操作,而外部排序中,关键字的读入内存和写回外存的操作是比较耗时的。
- 首先IO操作的次数是和初始归并段的长度有关,初始归并段也就是我们最开始拿来归并的有序序列,我们所演示的例子初始归并段的长度为1,开始的时候我们将每一个关键字视为一个有序序列,然后每三个关键字归并为一个长度为3的有序序列;后边我们又得到3个长度为3的有序序列,把它们归并为一个长度为9的有序序列,排序就完成了。每一个关键字都执行了4次IO操作,也就是两次读入内存和两次写回外存的操作。如果开始的时候我们的初始归并段长度为3的话,那我们把它们归并为长度为9的有序序列只需要一次归并即可,每一个关键字执行IO操作的次数也就变为2了。
- 也就是说初始归并段的长度越长,归并的次数可能就会越少,每一个关键字执行的IO操作的次数就越少。所以增加归并段的长度是减少IO操作的次数,提高归并效率的一种可行的方法。
- 所以在实际应用中,一般不会从长度为1的归并段开始归并,我们常常会选择一种排序方法,把初始序列给分段地排序成一个个的初始归并段,它的长度一般是远大于1的,并且排序成多个有序的初始归并段的过程中,每一个关键字的IO操作次数为2。当然你的排序算法不一定保证整个过程中每一个关键字的IO次数为2,但是肯定要比直接用归并排序来把它们归并成和你这个算法排序成长度相同的初始归并段的时候所出现的IO次数要少。
- 一种可行的方法就是把待排关键字分批读入内存,利用我们之前所讲过的任意的排序算法进行排序,然后把它们写回外存,就可以得到一系列的长度远大于1的有序的初始归并段,并且这个过程每个关键字只执行了两次IO操作。然后再从这些归并段开始归并,就会减少很多IO操作。
- 但是在实际应用中,我们要求的初始归并段的长度都是很长的,内存无法一次性装下的,这样之前所讲的内部排序算法又派不上用场了,因为它们要求一次性把待排序列读入内存,所以我们要用一种新的排序算法,它可以保证整个排序过程中只有两次IO操作并且不受内存限制。也就是说这种排序算法和归并排序类似,只需要部分读入内存即可完成整个序列的排序,我们用这种排序算法就可以构造一系列长度很长的初始归并段了,这些归并段甚至可以超出内存的容量,由这些归并段开始归并就可以大大减少IO操作次数了。
(二)置换-选择排序
前面所提到的只需要两次IO操作并且不受内存限制的排序方法就是置换-选择排序。
我们通过例子来理解这个排序算法:
这里有一块内存,和一堆存在于外存的待排关键字,关键字的规模是远大于内存容量的。
挨个将关键字读入内存:
从内存中挑出一个最小的关键字写入外存,代表并入了有序序列中,然后用外存的下一个关键字来填补这个空位。
然后再次挑选出内存中最小的关键字插入到有序序列中,这个有序序列是在外存中的。
重复之前的操作,但是这次如果按照之前的操作将内存中最小的关键字写入外存的话,这时候的序列就不是有序序列了。这种情况怎么办呢?其实在之前每一次将关键字写回外存的时候都要做一个判断,待写回的关键字是否比已经得到的有序序列的最后一个关键字还要大,如果大的话则写回,不大的话则暂时不写回,给它做上一个标记。然后从内存中其余的关键字中挑选出一个最小的,看看是否可以写回。
重复操作,直到内存中的关键字都被做了标记,说明内存中没有一个关键字比当前有序序列中最后一个关键字要大了。我们就将这个状态视为一趟置换-选择排序的结束了。得到一个长度可观的有序序列,甚至其规模已经超出内存的容纳范围为,把它作为归并排序的一个初始归并段是比较合适的,并且我们得到这一个有序序列的过程中,对每一个关键字只执行了2次IO操作。
将得到的有序序列作为一个初始归并段放在左边,把内存中的标记抹去,然后就可以开始重复之前的过程来继续求另外一个有序序列。
然后又来到了一个关键点,内存中所有的关键字都做上了标记,外存中已经没有可以读入的关键字了,内存中也没有关键字可以写出了,这种情况也可以作为一个有序序列求解完成的状态了,把得到的有序序列作为一个归并的初始归并段。
然后继续操作,得到最后一个初始归并段。
这就是置换-选择排序,求得了一系列的初始归并段之后,我们就可以进行多路归并排序来得到最终的有序序列了。
(三)最佳归并树
置换-选择排序得到的初始归并段的长度是不确定的,所产生的归并段的长度跟待排序列的关键字值有关系。
假设这里有一排初始归并段,用树的结点来表示,结点中的数字代表关键字的个数,也就是归并段的长度。
-
对这些长度不等的归并段进行多路归并,这里就做三路归并。我们得到一棵树。
现在我们想求某一个归并段中进行IO操作的次数,假如我们现在想求长度为9的归并段进行IO操作的次数。长度为9的归并段经过了两次归并并入了长度为32的归并段,每一次归并每个关键字都需要进行两次IO操作,因此长度为9的归并段在整个过程所需要的IO操作的次数是94=36次,每个关键字进行4次IO操作。如果把归并段的长度看作节点的权值,那每一个归并段的IO操作次数也就是它所在的节点的带权路径长度的二倍(92*2=36),那所有关键字的IO操作次数就是这棵树的带权路径长度的二倍。
也有其他的归并策略,此时求得的所有关键字的IO次数为86,所产生的IO次数比上一次还要少。也就是说给定一系列长度不等的初始归并段,可以采取多种归并策略来完成排序,不同的策略会导致不同的IO次数,显然也会导致不同的排序效率,显然我们应该找一种总是产生IO次数最少的归并策略。那就要用到最佳归并树了。
前面展示了两种归并策略,每一种我们都构建了一棵树,就是归并树,每一组序列都可以产生多组归并树,其中IO次数最少的就是最佳的。而IO次数就是树的带权路径长度,而带权路径长度最小的树就是哈夫曼树了。
我们用构造哈夫曼树的方法来构造最佳归并树。这里有一组初始归并段,我们进行三路归并。每次选择三个权值最小的结点构造一棵树。
这样我们就得到了一棵最佳归并树,按照这棵归并树所展示的策略去归并初始归并段所发生的IO操作次数是最少的。
到这里我们的多路归并排序有了很大的改进,通过选择-置换排序和最佳归并树在很大程度上减少了IO操作,提高了效率。但是还有一个问题,也就是在之前选择的归并排序是三路归并排序,归并过程中每次都从三个关键字中挑出一个,要是进行更多路的归并排序又该怎么操作呢?
(四)败者树
如何从一组关键字中选出最值关键字呢?那就需要用到败者树,败者树是一种数据结构,我们要利用败者树从一组关键字中选出最值关键字。显然它是一棵选择树,跟堆的功能类似,堆也能够选择出一组序列中的最值。
如何建树?
- 这里有一组关键字,等待我们从中找出最值。我们把它当成一组独立的树的叶子节点,并在下面编上编号。
- 用每一个节点的编号的值来构建一个新的节点,作为当前节点的父节点,这样我们就得到了五棵树,而构造败者树就是把这些树进行合并的过程。
- 任意选其中的两棵树,这里比较前两棵树,拿它们根结点存的编号所指的关键字的值进行比较,用较大的关键字的下标所在的根结点作为当前待合并的两棵树的根结点,然后将较小的关键字的下标所在的根结点连接在新的根结点上。
- 然后再合并任意两棵树,不断合并。我们就得到一棵败者树了,根结点中的编号所指的关键字就是最小的关键字了。
败者树是辅助我们进行归并排序的,它辅助从一组归并段的一端挑选出最值,等挑选出一个关键字之后,这个关键字所空出来的位置会被它所在的归并段后边的值所补上。
现在将根结点存的编号所指的关键字取出,其所在的归并段后边的值将会填补这个空缺。此时根结点存的编号所指的关键字不是最小的,不符合败者树的规则,需要进行调整。
我们将根结点取出,把当前根结点存的编号所指的关键字所在的子树也拿出来。观察这棵子树,它的根结点编号所指的关键字是17,小于另外它的另外一个孩子结点的值,不满足败者树的规则,我需要做一下调整,调整的过程就是局部重新建立败者树的过程。
将根结点和这棵子树还原为其初始的样子,得到两棵树,再把这两棵树按照之前建败者树的规则进行合并。
将以2为根结点的子树也摘出来,将它与刚合并得到的树进行合并:
将剩下的两棵树合并,又得到一棵完整的败者树:
把根结点存的编号所指的关键字取出,这样又得到了一个最值关键字,我们这一次从关键字中挑选出最小的关键字进行了3次比较(一次合并一次比较),比较的次数远小于扫描一整个关键字序列得到最小关键字所需要的次数。除了第一次建树比较慢之外,从第二次开始我们挑选出最小的关键字就比较快了,挑选的操作只是局部破坏了这棵树,只需要局部的修复这棵败者树即可,局部地修复一棵树的复杂度是远小于建一棵树的复杂度的。
败者树结合归并段的全景:
如果某一个归并段中的关键字先于其它的归并段归并完毕了,那就没有关键字能够填补刚挑选走的关键字的空缺了,那此时该如何调整败者树呢?
一般我们这么解决,在所有的归并段的末端补上一个值非常大的关键字,它们的值大于所有的待归并的关键字,
当某一归并段中的关键字先于其它的归并段归并完毕时,就可以用后来补上的值比较大的关键字来填补位置了,就可以继续调整败者树了,由于补上的关键字十分的大,所以它们不会在原先的归并段中的关键字被归并完之前被挑选出。
直到最后会来到这么一个状态:
当检测到有一个我们补上的关键字被挑选出的时候,就代表我们原先的归并段中的关键字已经归并完了,也就是归并排序结束了。
十、查找
在各种结构中对于不同的逻辑结构有不同的查找方法,同一种逻辑结构的不同存储结构也会有不同的查找方法。
先了解一些查找的相关概念。
记录就是一种数据的组织方式,把一些信息并列地放在一块作为一个整体就叫做记录。每个记录中都有一个关键字,它是唯一区分一个记录的标记。这些记录是在计算机中按照某种形式组织起来的,组织形式有线性表、树、图、集合等。
之前我们讲的逻辑结构的数据都是一种简化的形式,我们将它们的数据简化成一个数字或者一个字符,而实际情况是比较复杂的,它们的数据都是一个个的记录。
这里所讲的查找,就是找出我们想要查找的记录,每一个记录都有一条区别于其他记录的信息,就是关键字了,所以我们查找记录就转化为查找关键字了。
那我们又可以进行简化了,把记录中的其他信息扔掉,作为我们研究查找的方法,只看关键字。这里提记录的目的是在考试中有时候会出现记录这个概念,因此要知道记录这个概念的存在,要知道之前所讲的数据结构中的数据没有那么简单,它是多条数据合并的记录。
将每个记录简化成关键字,用它们来研究查找的方法。
(一)顺序查找
这里有一行关键字,上面的关键字代表我们所要查找的关键字。从左到右扫描关键字序列,进行判断查找。我们可能能找到,可能找不到,不同的关键字的查找次数可能不同。
这里需要插入讲解一个考点,那就是ASL(Average Search Length),也就是平均查找长度。
以下是利用顺序查找法来查找顺序表中的某个关键字所需要的比较次数,顺序表最后的F是一个标记,也就是查找失败的标记,表明查找失败的时候需要比较的次数。有的书中或者试卷中不把和标记的比较算进总的比较次数内,需要注意。我们利用平均查找长度来描述查找算法的查找效率,并且要进行分类,分别是查找成功的平均查找长度和查找失败的平均查找长度。
对于这个例子,我们算出其查找成功的平均查找长度:
也就是一个求期望的过程,ci代表对应的关键字的查找长度,pi就是这个关键字作为查找目标的概率:
对于这个例子,查找失败的平均查找长度是7,也就是每个查找失败的关键字都需要比较7次才能认为其查找失败。
代码实现:
int Search(int arr[], int n, int key)
{
int i;
for(i=0; i<n; i++)
if(arr[i] == key)
return i;
return -1;
}
LNode *Search(LNode *head,int key)
{
LNode *p = head->next;
while(p != NULL)
{
if(p->data == key)
return p;
p = p->next;
}
return NULL;
}
(二)折半查找
对于一个有序序列,如果使用顺序查找也能够查找到我们想要的关键字,但是有的时候并不需要这么做。
对于一个有序序列,我们用low和high两个标记来规定我们要查找的关键字的范围,然后取这两个标记中间的位置,用变量mid标记这个位置。
然后用mid所指的关键字与我们待查找的关键字进行比较,发现只做了一次比较就查找到我们想要查找的关键字了。
再看另一个例子,查找另外一个关键字。用low和high两个标记来规定我们要查找的关键字的范围,然后取这两个标记中间的位置,用变量mid标记这个位置。用mid所指的关键字与我们待查找的关键字进行比较,mid所指关键字小于我们待查找的关键字。
我们让low来到mid+1的位置,也就是说重新划定一个查找范围,除了这个范围之外的关键字就不用进行比较了,因为这个序列是升序的,因此mid以及mid之前的关键字都小于我们待查找的关键字。利用这种查找方法,我们每次都可以排除掉一半数量的关键字,因此它的查找效率要比顺序查找高多了。
再取low和high标记的中间位置mid,发现mid所指关键字仍然小于待查找的关键字。
继续让low移动到mid+1的位置,取中间位置mid,再让mid所指的关键字与待查找的关键字进行比较,发现恰好相等,也就是成功找到了。在这次查找中,我们只进行了三次比较,比进行顺序查找的次数要少的多。这种方法就是折半查找方法。
同样我们也需要来分析一下这种查找方法的平均查找长度。
首先划出待查找的范围,用low和high标出,然后求出中间位置mid。此时mid所指的关键字也就是第一次发生比较的关键字。把这个关键字拿出来,以这个关键字为分界线,把整个关键字序列分成两个部分。
此时取左边的部分为查找范围,也就是low指向0,high指向1,用mid标出中间位置。按照之前的步骤把mid所指的关键字挑出来,它把其所在的查找范围也划分为了两个部分,左边部分为空,右边部分有一个关键字,空关键字范围我们用蓝色圆点标出,并且对蓝色圆点标出的空关键字范围停止之前的划分关键字范围以及求中间位置的操作。
然后对关键字0所划分出的右边部分进行同样的操作。由于关键字0划分出来的右边部分只有一个关键字,对这个部分进行划分,得到的左右两部分都是空。
然后对关键字2划分出来的右边部分也进行同样的划分操作,最后能够得到下面的树,这棵树就是折半查找判定树了。这个过程就是构建折半查找判定树的过程了。这些红色节点就是由表中的关键字构成的,从根节点到任何一个红色节点路径上所经历的节点的个数,恰好就是利用折半查找找到这个红色节点所需要进行比较的次数。而蓝色的节点代表查找失败最后来到的位置,和顺序查找不同,这里查找失败的位置不是唯一的,原因是折半查找的特性,待查找的失败关键字所属的取值范围有可能影响最后的失败位置,也会影响查找次数。
我们标出所有查找成功的比较次数和查找失败的比较次数,黄色代表查找成功,红色代表查找失败。黄色的关键字有6个,对应于待查找的6个关键字,每个关键字被作为查找对象的概率是1/6;红色的关键字有7个,对应于7个查找失败的位置,也就是说每个查找失败的位置可能出现的概率是1/7。
求出查找成功和查找失败的平均查找长度。对于顺序查找,查找失败的位置只有一个,所以出现它的概率是1,因此顺序查找失败的平均查找长度就是那唯一的查找失败位置的查找长度。而对于有多个失败位置的,它的查找失败的平均查找长度就是每一个失败位置的查找长度的平均。
代码实现:
int BSearch(int arr[], int low, int high, int key)
{
while(low <= high)
{
int mid = (low + high)/2;
if(arr[mid] == key)
return mid;
else if(arr[mid > key)
high = mid - 1;
else
low = mid + 1;
}
return -1;
}
(三)分块查找(索引顺序查找)
对于顺序查找,查找序列是无序有序的都适用,而对于折半查找,要求查找序列是有序的。大多时候我们都不会遇到一个有序的序列或完全无序的序列。
这里有一个序列,将这个序列分为五块,每一块内大多都是无序的(这里规定升序才为有序)。这一排关键字在块与块之间是有序的,后一块中所有的关键字都大于前一块的关键字,这就是块内无序,块间有序。对于这种序列我们我们可以结合顺序查找和折半查找来完成查找某一个关键字的操作。
首先把划分出来的块中最大的关键字挑出:
然后给每一个关键字定义一个结构体变量,maxKey是每一个块中最大的关键字,low和high代表块在整个关键字序列中的范围,我们称这些结构体变量为索引元素,因为它含有一个块中最大的关键字,又描述了这个块在待查找关键字序列中的位置,具有一定的索引作用,所以叫索引元素。
我们得到了一个按照关键字值有序的索引元素数组,在这个较短的索引元素数组中,我们进行折半查找:
当折半查找结束时,在索引元素数组中仍没有找到我们想要的关键字,我们应该到low位置索引元素所代表的块范围内查找。low位置索引元素代表的块的范围是待查找关键字序列的第3到5个元素,由于这个块内的关键字是无序的,只能进行顺序查找。经过三次查找找到了我们想要查找的关键字。
这个查找的过程就是分块查找,这种查找方法有自己的存储结构,我们需要建立一个索引表,索引表就就是由索引元素所构成的表,由它可以把关键字数组划分称很多小的块,在索引表内我们进行折半查找,在块内进行顺序查找,这种方法就适用于这种含有一定有序性而不是严格有序的序列的一种较好的查找方法。
使用分块查找,对于整个关键字表,它的平均查找长度怎么求呢?显然是索引表中的折半查找平均查找长度+关键字表中的顺序查找平均查找长度,一般在考研中不会让你求出一个通用的公式,而是给出一张表,让你算平均查找长度。
看多一个例子,待查找的关键字是11,在索引表中进行折半查找:
在索引表中正好找到了我们所要查找的关键字,但是这样并不算找到了关键字,因为关键字本体是存在于原先的关键字表中的。这里索引表中的关键字等于我们待查找的关键字,只是说明我们找到了待查找关键字所在的块,必须到块中去找我们想要查找的关键字。
根据我们找到的索引元素划出来的块范围,我们确定查找范围为9到11,同样经过了3次比较我们找到了关键字。
看多一个例子,这次查找的关键字为14,直到折半查找结束的时候,low就指向我们要查找的块,范围是12到14。
到关键字表中进行查找,依然没有找到我们想要查找的关键字,这就是一种查找失败的情况。
看多一个例子,这次要查找的关键字是22。一样在索引表中进行折半查找。
与之前不同,折半查找结束时low所指的位置已经没有索引元素了,按照之前的规则,当折半查找结束的时候,如果low所指的位置有索引元素的话,我们需要到它所指的索引元素所代表的块的范围上去查找关键字,并且索引元素的关键字就是块中最大的关键字。也就是说,我们要找的关键字肯定落在当前low所指的索引元素所代表的块的范围内,并且我们要找的关键字是小于等于low所指索引元素中的关键字的。结束的时候high是在low前面的,high所指的索引元素所代表的块中的关键字肯定都小于low所指的索引元素所代表的块中的关键字。
而现在low指向了索引表的外部,这也是一种查找失败的情况,这种情况和之前的查找情况不同,我们没必要到关键字表中去查找了。
以上的例子就代表了分块查找中所有可能出现的情况了。
还有最后一个问题,为什么折半查找最后low所指的索引元素所代表的块的范围就是我们要查找的关键字所在的范围呢(如果关键字存在于表中)?
在折半查找失败的情况下,一定会出现以下这种情况,low、high、mid指向了同一个位置:
此时如果mid所指的关键字是大于我们要查找的关键字的,那么我们就让high指向mid-1的位置,这时候low和mid重合,mid所指的索引元素也就是low所指的索引元素,它其中的关键字代表这个块中最大的关键字,这个关键字也是大于我们待查找的关键字的。再看待查找的关键字有没有可能小于high所指的索引元素中的关键字呢,显然是不可能的,如果可能的话,high、low、mid重合的时候应该重合到B或者它之前的位置,所以我们就可以确定,待查找的关键字的值落在high所指的元素中的关键字到low所指的索引元素中的关键字的值之间,这个范围也就是low所指的索引元素所划出来的关键字的范围了。
看另外一种情况,此时如果mid所指的关键字是小于我们要查找的关键字,让low指向mid+1的位置。待查找的关键字显然是不可能大于low所指的关键字的,如果有可能大于low所指向的关键字的话,按照算法high、low、mid重合的时候应该重合到D或它之后的位置。所以我们待查找的关键字肯定是落在大于C小于等于D这个范围,那也就是low所指的索引元素所划出来的关键字的范围了。
综上,当折半查找结束的时候,low位置所指的索引元素所指的关键字范围就是我们将要在关键字表中查找的范围,当然除了那种low落在索引表之外的情况。
(四)二叉排序树
看一个例子,这就是一棵二叉排序树,树中的节点是关键字,如果树中的节点有左子树,则左子树的关键字全部都小于该节点的关键字的值;如果它有右子树,则右子树的关键字全部都大于该节点的关键字的值。
定义:
二叉排序树如何进行查找关键字呢?
假如要查找关键字4,用p指针指向二叉排序树的根节点,首先拿p所指的节点的关键字与待查找的关键字进行比较,如果相等则找到;如果不相等,如果待查找的关键字小于p所指的节点的关键字,就让p沿着左分支走,相反则往右分支走。这样p每来到一个新的节点,就拿p所指的节点的关键字与待查找的关键字进行比较,如果相等则说明找到了。如果最后p来到了空指针,依然没有找到我们想要找的关键字,就证明关键字不在这棵二叉排序树中,即查找失败。
代码实现:
这段代码考的几率比较少,但是如果在某个题目中需要用到查找二叉排序树中的某个关键字的时候,可以用这段代码。
BTNode *BSTSearch(BTNode *p, int key)
{
while(p != NULL)
{
if(key == p->key)
return p;
else if(key < p->key)
p = p->lChild;
else
p = p->rChild;
}
return NULL;
}
多数情况考的二叉排序树的查找代码实现是这种,递归实现的代码,这种代码使用的是二叉树遍历的递归框架,显然在二叉排序树查找关键字的过程也是某种程度上的遍历这棵树的过程。
BTNode *BSTSearch(BTNode *p, int key)
{
if(p == NULL)
return NULL;
else
{
if(p->key == key)
return p;
else if(key < p->key)
return BSTSearch(p->lChild, key);
else
return BSTSearch(p->rChild,key);
}
}
现在看二叉排序树的插入操作:
假如要插入一个关键字6,利用前面所讲的查找操作,找到合适的位置即可插入。
代码实现,这个过程也是二叉排序的建树过程,只要从一棵空树开始,逐个插入关键字,就能够建一棵二叉排序树:
//参数p用了指针引用型,原因是p可能找到一个空指针位置,需要对这个空指针做一些改变,也就是给它连接上我们要插入的节点
int BSTInsert(BTNode *&p, int key)
{
//p为空说明找到了插入的位置,直接构造一个节点,申请节点空间,填上待插入的关键字,把它挂在p指针位置即可。
if(p == NULL)
{
//由于p定义的是引用型,所以p这个位置的空指针就是指针的本体,也就是说这个指针是所要插入的位置的父节点的左孩子指针或右孩子指针
//做出的改变也是直接作用到左孩子指针或右孩子指针的
p = (BTNode*)malloc(sizeof(BTNode));
p>lChild = p->rChild = NULL;
p->key = key;
return 1;
}
else
{
//如果想插入的关键字已经存在于二叉排序树中,就没必要插入这个关键字了,返回插入失败标记
if(key == p->key)
return 0;
else if(key < p->key)
return BSTInsert(p->lChild,key);
else
return BSTInsert(p->rChild,key);
}
}
再看二叉排序树删除节点的操作,需要分情况讨论:
- 假如我们要删除叶子节点,就能直接删除。
- 如果要删除的节点不是叶子节点,要删除的节点只有一个孩子结点的话,则直接删除这个节点,然后让这个节点的父节点与这个节点的孩子节点相连即可。新增一个指针指向要删除节点的父节点,方便删除之后对树进行连接。
- 如果要删除的节点有两个孩子节点,有两种方法,第一种方法是,p指向要删除的节点,p往左走一步,再往右走到没有右孩子的节点,然后把这个节点的值赋值给我们要删除的节点,然后删除这个节点。
第二种方法是p指向要删除的节点,p往右走一步再往左走到没有左孩子的节点,然后把这个节点的值赋值给我们要删除的节点,然后删除这个节点。
前面两种方法找到的节点我们称之为A和B,假如A没有右孩子但是有左孩子,B没有左孩子但是有右孩子的情况,我们仍然将这个节点的值赋值给我们要删除的节点,然后删除这个节点,然后把其的左孩子或右孩子挂在其父节点上。
删除操作一般不考代码。
(五)平衡二叉树
平衡二叉树是由二叉排序树改进而来的。
现有一个关键字序列,我们利用这个序列建立一棵二叉排序树,标出每一个关键字的查找长度。对于这棵二叉排序树,它的查找成功的平均查找长度为(1+2+3+4+5)/ 5 = 3,我们发现,这个平均查找长度与在我们建树之前用顺序查找去查找关键字序列的平均查找长度是一样的,那我们建这棵二叉排序树有什么用呢?
建立另一棵二叉排序树,关键字与上一个序列是一样的,只不过是顺序不同。这棵二叉排序树的查找成功的平均查找长度是(1+2+2+3+3)/ 5 = 2.2,发现平均查找长度变小了,这是因为树变矮了,关键字的平均查找长度与树的高度有关。可见,同一组关键字,以不同的顺序建立二叉排序树,得到的树形状是不一样的,我们建树的目标是建立一棵比较矮的树,来使平均查找长度变小。那么给定一组关键字序列,怎么建立出一棵尽可能矮的树呢?
可以这么操作,按照原来的序列直接建树,在建树的过程中,如果发现树有不正常长高的趋势,也就是通过节点位置的调整可以使树变得不那么高时,进行调整。我们定义了一个叫平衡因子的东西,用它来检测二叉树是否有不正常长高的趋势,平衡因子是附着在每一个节点上的,它的值就是这个节点的左子树的高度减去右子树的高度,对于刚才建立的两棵树,我们求一下平衡因子。我们规定,在建立二叉排序树的时候,把每个节点的平衡因子控制在绝对值不大于一的范围内,并且称这种约束下的一棵二叉排序树为平衡二叉树。
那么如何把每个节点的平衡因子控制在绝对值不大于一的范围内呢?这就是我们要讲的平衡调整了。
这里有一棵二叉排序树,其中的三角号代表一棵高度为h的子树,这时候根结点的平衡因子是1。
在它的左孩子的左子树上插入一个节点,使得B的左子树高度加1,这时候A的平衡因子就变成了2,超出了要求的范围。这时候可以把B上提一个位置,A下移一个位置,由B原来是A的左孩子节点变成A是B的右孩子节点。这样的话B原先的右子树就给摘下来了,把它连接在A空出来的左分支上。
这样的话,节点A和B的平衡因子都变为了0。
这种调整方法有两个不同的名字,是从连个不同的视角来进行取名的
第一种是叫LL调整,就是一棵二叉排序树,在某个子树根的左孩子的左子树上插入了一个节点发生的不平衡,对这种状态进行平衡调整,名字就叫LL调整。
第二种是右单旋转调整,这棵子树未来的根节点右边的节点做了一次旋转, 在这个例子中B就是未来的根节点,右边的节点就是A。
对于刚才的不平衡的状态,存在一种对称的情况,对其进行调整。这种调整也有两种名字,也就是RR调整和左单旋转调整。
再看另外一种不平衡的情况,在左孩子的右子树上插入节点导致不平衡,这种情况使用LL调整显然是不行的,我们看另外一种调整方法。
把B的右子树拆分一下,把B的右孩子节点C显式地画出来,假设C有两棵高度为h-1的子树, 然后把在B的右子树上插入一个节点给具体化成给C的右子树插入一个节点的情况。
然后进行调整,先把以B为根的子树摘下来,对这个子树进行RR调整
然后把这棵子树挂在原来A节点的空指针上。满足LL调整的情况,对这棵树进行LL调整。
得到了一种平衡的状态。
刚才我们是在C的右子树上插入了一个节点,如果是在C的左子树上插入了节点,这种方法也是适用的,最终得到的平衡状态如下:
这种调整方法也有两种名字,LR的意思是在根节点的左孩子的右子树插入一个节点。先左后右双旋转的意思是先进行了左单旋转又进行了右单旋转。
这种调整也有一种对称的状态,在A的右孩子的左子树插入一个节点,进行调整。这种调整方法就叫RL调整,也叫先右后左双旋转调整。
有了这些调整方法之后,在构造二叉排序树的时候,每当出现平衡因子大于1的节点,就进行相应的调整方法即可,最终就能得到一棵平衡二叉树。
(1)平衡二叉树的扩展
- 设Nh表示高度为h的平衡二叉树中含有的最少结点数,则有N0=0,N1=1,N2=2,N3=4,N4=7,N5=12,N6=20…,Nh=Nh-1+Nh-2+1。即高度为h的平衡二叉树含有的结点数至少为Nh-1+Nh-2+1。
(六)B-树和B+树
(1)B-树
B-树可以理解为一种平衡二叉树的扩展,一个节点可以有多个关键字,有多个关键字就有多个分支。
这是一棵五阶B-树,白色的是叶子结点
B-树相关性质,如果是m阶B-树,每个节点最多有m个分支。
在实际应用中,叶子节点可能挂的是一些记录,而对于考研,可以将它们视为空指针,当成查找关键字失败的位置。
对于我们上面所讲的那棵五阶B-树,我们规定它的高度为3(不包括叶子节点那一层,具体还要看规定),而在计算整棵树有多少个节点,就需要把叶子节点包括进去了。
B-树的阶数是人为规定的,它不因当前B-树节点中的最大关键字数目而变化,或者说不因当前B-树节点中的最大分支数而变化。B-树的严格画法应该如下,相当于每一个节点都是一个固定长度的数组,并且这些所有的数组的长度都是一样的,B-树的阶数就是存储每一个节点的数组的长度加一,而不因为数组中存的关键字的个数而影响B-树的阶数。
比如将节点含4个关键字的数组中删去一个,这个节点的关键字数变为3,分支树变为4,我们仍然认为它是五阶B-树。
B-树节点结构体的设计,有两个数组,第一个数组的第一位上代表当前节点关键字的个数;第二个数组中有一排指针,这些指针指向当前节点的孩子节点。
然后补充一条B-树的性质:
接下来看B-树的一些相关操作:
如何在B-树中查找关键字:
先扫描根节点中所有的关键字,发现没有找到,而扫描的指针落到了最后一个关键字的后面。
沿着当前关键字右边的分支进行查找,新来到了一个节点,继续从左往右扫描这个节点中的关键字,来到了第一个比待查找关键字大的关键字,沿着它左边的分支进行查找。
又来到了一个新的节点,继续从左往右扫描节点中的关键字:
这次就找到了我们要查找的关键字。
我们的策略是,从左往右扫描节点中的关键字,如果找不到,就沿着适当的分支到下一层进行查找。对每一个节点,由于节点中的关键字是有序的,可以采用二分查找。
查找失败的情况,沿着分支一直走到空指针的位置,则为查找失败的情况。
如何在B-树中插入关键字:
通过例题来讲解,建立一棵树就是把关键字逐个插入的过程:
由于要建的是一棵五阶B-树,所以每个节点中最多的关键字数是4个,并且要按顺序排列。将前4个关键字插入的情况是这样的:
然后插入关键字11,此时阶数变为了6,不符合要求。
我们要进行一个叫节点拆分的操作,把当前节点中的阶数/2向上取整位置上的关键字提出来,把它拿出来作为一个节点,并且把它左边的关键字和右边的关键字分别装在两个新的节点中(也是一个插入过程)。
再来演示一下查找插入位置插入关键字。接下来要插入的关键字是4。首先在根节点中查找,没有找到这个关键字,并且来到了第一值比它大的关键字6,应该来到6前面的分支所值的节点找。
从左往右扫描仍然没有找到,并且扫描的指针落到了最后一个位置的后边,也就是应该到2的右边那个分支所指的节点中去查找,但是2右边是一个空分支,因此我们找到插入位置,也就是4要插入到2的右边。
按照同样的方法继续插入
直到出现这种情况,节点的关键字数超出了范围,要进行节点的拆分操作。将节点10挑出来并入上一个节点中,并且其左右的关键字被分别放在了两个节点中。
继续插入
当插入关键字20的时候,又需要执行拆分操作。
插入关键字3的时候,也需要进行拆分。
继续插入
当最后一个关键字被插入的时候,又要进行拆分。
拆分之后导致上一层的节点关键字数目超出了范围,又要进行拆分。
插入了一个关键字引起了两次拆分操作,就称之为连锁反应。
接下来看B-树删除关键字的操作:
在前面建好的树的基础上,我们进行删除操作。
由于关键字8在终端节点上,它所在节点的关键字数是3,并且这棵B-树是五阶的,除了根节点之外,其余节点的分支数至少是3,关键字数至少是两个,而8所在节点的关键字数有3个,因此可以直接删除。
也就是说,当我们要删除的关键字落在终端节点上,终端节点的个数大于(阶数/2向上取整)-1即可直接删除这个关键字。
接下来删除关键字16,它不在终端节点中。在二叉排序树中,对双分支非叶节点的删除操作是,沿着这个节点往右走一步再一直往左走或者是沿着这个节点往左走一步再一直往右走,用这种方法找到的节点来替换我们要删除的节点,换句话说就是用这个节点的右子树中的具有最小关键字值的节点或者是左子树中的具有最大关键字值的节点来替换我们要删除的节点即可。对于B-树非终端节点中的关键字的删除我们也采取和二叉排序树中删除非叶双分支节点类似的方法,也就是到我们要删除的关键字的左边的分支去查找最大的关键字或到右边的分支去查找最小的关键字来替换我们要删除的关键字即可,但是这样的话可能会出现一些问题。
我们要删除的关键字的左分支最大的关键字为15,但是我们不能把15用来替换我们要删除的关键字16,然后删除关键字15,因为15所在的节点的关键字数目已经是这个节点的下限了。所以我们只能到16的右分支查找最小的关键字,也就是17,显然可以拿来进行替换删除。
假如要删除的关键字是10,则有可能取代关键字10的位置则是9和11。
接下来删除关键字15,关键字15在终端节点上,但是它不可以直接删除,因为它所在节点的关键字数目已经达到了下限,我们采取一种向其兄弟节点借关键字的方法,此时显然应该借右边的节点的关键字。先让父节点的关键字17下移, 然后让它右兄弟节点的关键字18上移。
然后就可以删除关键字15了。
现在删除最后一个关键字4,4也处于终端节点上,它所在的节点关键字数目也达到了下限,并且它的左右兄弟节点的关键字数目都达到了下限,这时候就不借了,直接删除关键字4,然后来一次节点的合并操作。一般和其具有较少关键字的左或右兄弟节点合并,如果左右兄弟节点关键字数目一样,则任选一个。
现在我们将5和其右兄弟节点进行合并,具体操作是:将关键字6下移,然后将6左右两边的关键字合并起来。但是此时又有问题,它们的父节点关键字数目已经小于下限了,所以还需要进行一次合并。
这就是最后的结果了。
刚才所进行的合并是使5和其右兄弟节点进行合并,现在我们将5和其左兄弟节点合并。具体操作是:将关键字3下移,然后将3左右两边的关键字合并起来;然后让6和其右兄弟节点合并,最后的结果如下。这个例子告诉我们,删除一个关键字的时候有可能导致不同的结果。
(2)B+树
这种数据结构考的比较简单,只要知道B+树是什么即可。
这就是一棵四阶B+树:
在B+树中,那一排记录中上边的的节点称为叶子节点,在B-树中它叫终端节点。叶子节点中的每一个关键字引出一个指针,指向对应的记录,记录也就是存在磁盘上对应的一些信息了,找到对应的关键字,就相当于找到对应的记录了,就可以对记录进行一些读写操作了。
也就是说B+树是可以进行顺序查找的。
关于B+树的插入查找删除关键字考研中基本不涉及。
这里简单介绍一下查找操作:
要查找关键字71,首先在根节点中扫描,来到第一个比71大的关键字96
沿着96所引出的指针到下一层进行查找,同样从左往右扫描,来到第一个比71大的关键字78
沿着78所引出的指针到下一层进行查找,从左到右扫描,找到关键字71。
(七)散列表
散列表是一种不同于顺序存储结构和链式存储结构的一种新的数据结构。散列表最关键的特性就是:根据关键字的值来计算出关键字在表中的地址。在之前所说过的查找表中查找我们想要查找查找的关键字 ,都是通过一系列的比较,最后才能确定关键字在表中的位置,而在散列表不需要,在散列表中关键字的值和关键字在表中的位置是存在一定的关系的,也就是地址是关键字值的一个函数。我们引入一个相关的函数,也就是哈希函数,ad=H(key)。
看例题:
发现建出来的表有问题,不同的关键字经过哈希函数对应了同一个地址,这种情况是不合理的,在哈希表中这种情况就称为冲突。
如何解决冲突有不同的办法。
解决冲突的方法一:使用这个函数,这个函数的意思是,当某个位置发生冲突的时候,让i从1到len-1来计算一系列的地址,再看一下这些地址上是否已经存上了关键字,如果扫描到某个地址上没有存关键字,我们就把这个位置作为新的哈希地址。简单来说,就是如果某个地址上发生了冲突,我们就从这个地址后一个地址开始扫描到表尾,再从表头扫描到这个地址之前,直至找到一个空的地址。从这个例子来看,假如地址4发生了冲突,那么会扫描5到14的位置,再扫描0到3的位置。
加上冲突处理方法,将上面的例题再做一遍,这就是结合了冲突处理方法得出来的哈希表:
在这样的哈希表中如何进行查找呢?
- 用哈希函数求出索要查找的关键字的地址。
- 到这个地址上进行比较。
- 如果这个地址上的关键字与我们要查找的关键字一样,则说明找到了。
- 如果这个地址上的关键字与我们要查找的关键字不一样,就说明这个地址是一个冲突地址,就按照冲突处理方法来查找这个关键字。扫描它后面的地址,边扫描边比较,看是否和我们要查找的关键字是一样的,如果在扫描的过程中发现了空位置,并且依然没有找到要找的关键字,就证明查找失败了。如果在扫描的过程中没有遇到空位置,而遇到了表尾,那就折回第一个位置,进行扫描比较,如果第二次扫描到了我们最初的用哈希函数计算出来的地址,这种情况也说明查找失败了。
在这个哈希表中查找成功的平均查找长度是:
如何求这个哈希表查找失败的平均查找长度呢?我们看这个哈希函数,H(key) = key Mod 13,也就是说不管在key位置填上什么关键字,这个哈希函数都会把这个关键字映射到0到12上,对于不存在于这个哈希表中的关键字也会映射到0到12上。对于不存在与哈希表中的关键字,查找失败的查找次数就是从通过哈希表计算出来的地址,一直比较到空位置或本身时比较的次数,空位置的比较也算作一次比较(大多数学校考卷空位置算作一次比较,少部分不算)。这样的话这个哈希表查找失败的平均查找长度为:
常考的哈希函数:
直接定址法:
数字分析法,关键字就是数字,从中取出关键字的某些位来作为关键字的地址,取关键字的某些位作为关键字的地址就是分析的过程,分析的标准就是挑出来的位数上的数字作为哈希地址的话要不容易产生冲突。比如电话号码就可以取后四位作为地址。这种方法适用于关键字位数较多且表中可能的关键字都是已知的情况。
平方取中法,这种方法要求关键字的位数尽可能少一点,太长的话对其进行平方会得到一个很长的关键字,不利于处理。
除留余数法,这种方法在综合题中比较常见。前面三种经常出现在选择题中,知道它们的区别和优缺点即可。
常见的冲突处理方法:
开放定址法:在散列表中某个位置发生冲突,就去找其他位置。
前面解决例题使用的方法就属于开放定址法,它的名字是线性探查法。线性探查法的优点就是可以探测表中的所有位置,缺点就是容易发生堆积现象。堆积现象就是在冲突处理过程中,某些关键字落在了本不属于它的地址上,而导致本应该拥有这个地址的关键字没办法落在这个地址上。
另外一种属于开放定址法的方法是平方探查法,d±i²不能超出表的范围。
另外一类冲突处理方法为链地址法:把冲突位置上的关键字全部串到一个链表中:
还有一个概念通过例题来看一下,这个例题也是考研中对于这一块最常考的形式:
装填因子的求法是a=n/len,n是关键字个数,len是表长,可见这个东西反应了表中关键字装满的程度。知道装填因子,又知道关键字个数,很容易求出表长为16。哈希函数的p一般去不大于表长的最大素数。
确定哈希函数开始构造哈希表:
从这个哈希表中查找关键字的方法也是比较简单的,先通过哈希函数找到链表的地址,再在链表中去查找关键字。如果在某个链表中查找关键字失败,比较的次数是这个链表的长度,一般是不算最后的空指针作为一次比较的。