《大话数据结构》(查找)
查找
查找(Searching)就是根据给定的某个值,在查找表中确定一个其关键字等于给定值的数据元素(或记录)。
查找概述
所有这些需要被查的数据所在的集合,我们给它一个统称叫查找表。
查找表(Search Table)是由同一类型的数据元素(或记录)构成的集合。
关键字(Key)是数据元素中某个数据项的值,又称为键值,用它可以标识一个数据元素。
也可以标识一个记录的某个数据项(字段),我们称为关键码。
若此关键字可以唯一地标识一个记录,则称此关键字为主关键字(Primary Key)。
注意这也就意味着,对不同的记录,其主关键字均不相同。主关键字所在的数据项称为主关键码。
那么对于那些可以识别多个数据元素(或记录)的关键字,我们称为次关键字(SecondaryKey),如图8-2-1中⑤所示。次关键字也可以理解为是不以唯一标识一个数据元素(或记录)的关键字,它对应的数据项就是次关键码。
查找(Searching)就是根据给定的某个值,在查找表中确定一个其关键字等于给定值的数据元素(或记录)。
若表中存在这样的一个记录,则称查找是成功的,此时查找的结果给出整个记录的信息,或指示该记录在查找表中的位置。
若表中不存在关键字等于给定值的记录,则称查找不成功,此时查找的结果可给出一个“空”记录或“空”指针。
查找表按照操作方式来分有两大种:静态查找表和动态查找表。
静态查找表(Static Search Table):只作查找操作的查找表。它的主要操作有:(1)查询某个“特定的”数据元素是否在查找表中。(2)检索某个“特定的”数据元素和各种属性。
动态查找表(Dynamic Search Table):在查找过程中同时插入查找表中不存在的数据元素,或者从查找表中删除已经存在的某个数据元素。显然动态查找表的操作就是两个:(1)查找时插入数据元素。(2)查找时删除数据元素。
为了提高查找的效率,我们需要专门为查找操作设置数据结构,这种面向查找操作的数据结构称为查找结构。
从逻辑上来说,查找所基于的数据结构是集合,集合中的记录之间没有本质关系。可是要想获得较高的查找性能,我们就不能不改变数据元素之间的关系,在存储时可以将查找集合组织成表、树等结构。
例如,对于静态查找表来说,我们不妨应用线性表结构来组织数据,这样可以使用顺序查找算法,如果再对主关键字排序,则可以应用折半查找等技术进行高效的查找。
如果是需要动态查找,则会复杂一些,可以考虑二叉排序树的查找技术。
另外,还可以用散列表结构来解决一些查找问题,这些技术都将在后面的讲解中说明。
顺序表查找
顺序查找(Sequential Search)又叫线性查找,是最基本的查找技术,它的查找过程是:从表中第一个(或最后一个)记录开始,逐个进行记录的关键字和给定值比较,若某个记录的关键字和给定值相等,则查找成功,找到所查的记录;如果直到最后一个(或第一个)记录,其关键字和给定值比较都不等时,则表中没有所查的记录,查找不成功。
顺序表查找算法
1 | /* 顺序查找,a为数组,n为要查找的数组长度,key为要查找的关键字 */ |
这段代码非常简单,就是在数组a(注意元素值从下标1开始)中查看有没有关键字(key),当你需要查找复杂表结构的记录时,只需要把数组a与关键字key定义成你需要的表结构和数据类型即可。
顺序表查找优化
到这里并非足够完美,因为每次循环时都需要对i是否越界,即是否小于等于n作判断。事实上,还可以有更好一点的办法,设置一个哨兵,可以解决不需要每次让i与n作比较。看下面的改进后的顺序查找算法代码。
1 | /* 有哨兵顺序查找 */ |
此时代码是从尾部开始查找,由于a[0]=key,也就是说,如果在a[i]中有key则返回i值,查找成功。否则一定在最终的a[0]处等于key,此时返回的是0,即说明a[1]~a[n]中没有关键字key,查找失败。
这种在查找方向的尽头放置“哨兵”免去了在查找过程中每一次比较后都要判断查找位置是否越界的小技巧,看似与原先差别不大,但在总数据较多时,效率提高很大,是非常好的编码技巧。当然,“哨兵”也不一定就一定要在数组开始,也可以在末端。
对于这种顺序查找算法来说,查找成功最好的情况就是在第一个位置就找到了,算法时间复杂度为O(1),最坏的情况是在最后一位置才找到,需要n次比较,时间复杂度为O(n),当查找不成功时,需要n+1次比较,时间复杂度为O(n)。我们之前推导过,关键字在任何一位置的概率是相同的,所以平均查找次数为(n+1)/2,所以最终时间复杂度还是O(n)。
很显然,顺序查找技术是有很大缺点的,n很大时,查找效率极为低下,不过优点也是有的,这个算法非常简单,对静态查找表的记录没有任何要求,在一些小型数据的查找时,是可以适用的。
另外,也正由于查找概率的不同,我们完全可以将容易查找到的记录放在前面,而不常用的记录放置在后面,效率就可以有大幅提高。
有序表查找
折半查找
折半查找(Binary Search)技术,又称为二分查找。
它的前提是线性表中的记录必须是关键码有序(通常从小到大有序),线性表必须采用顺序存储。
折半查找的基本思想是:在有序表中,取中间记录作为比较对象,若给定值与中间记录的关键字相等,则查找成功;若给定值小于中间记录的关键字,则在中间记录的左半区继续查找;若给定值大于中间记录的关键字,则在中间记录的右半区继续查找。不断重复上述过程,直到查找成功,或所有查找区域无记录,查找失败为止。
假设我们现在有这样一个有序表数组{0,1,16,24,35,47,59,62,73,88,99},除0下标外共10个数字。对它进行查找是否存在62这个数。我们来看折半查找的算法是如何工作的。
1 | /* 折半查找 */ |
1.
程序开始运行,参数a={0,1,16,24,35,47,59,62,73,88,99},n=10,key=62,第3~5行,此时low=1,high=10。
2.
第6~15行循环,进行查找。
3.
第8行,mid计算得5,由于a[5]=47<key,所以执行了第12行,low=5+1=6.
4.
再次循环,mid=(6+10)/2=8,此时a[8]=73>key,所以执行第10行,high=8-1=7。
5.
再次循环,mid=(6+7)/2=6,此时a[6]=59<key,所以执行12行,low=6+1=7。
6.
再次循环,mid=(7+7)/2=7,此时a[7]=62=key,查找成功,返回7。
该算法还是比较容易理解的,同时我们也能感觉到它的效率非常高。但到底高多少?关键在于此算法的时间复杂度分析。
首先,我们将这个数组的查找过程绘制成一棵二叉树,如图所示,从图上就可以理解,如果查找的关键字不是中间记录47的话,折半查找等于是把静态有序查找表分成了两棵子树,即查找结果只需要找其中的一半数据记录即可,等于工作量少了一半,然后继续折半查找,效率当然是非常高了。
我们之前6.6节讲的二叉树的性质4,有过对“具有n个结点的完全二叉树的深度为「log2n」+1。”性质的推导过程。在这里尽管折半查找判定二叉树并不是完全二叉树,但同样相同的推导可以得出,最坏情况是查找到关键字或查找失败的次数为「log2n」+1。
有人还在问最好的情况?那还用说吗,当然是1次了。
因此最终我们折半算法的时间复杂度为O(logn),它显然远远好于顺序查找的O(n)时间复杂度了。
不过由于折半查找的前提条件是需要有序表顺序存储,对于静态查找表,一次排序后不再变化,这样的算法已经比较好了。但对于需要频繁执行插入或删除操作的数据集来说,维护有序的排序会带来不小的工作量,那就不建议使用。
插值查找
现在我们的新问题是,为什么一定要折半,而不是折四分之一或者折更多呢?
看来,我们的折半查找,还是有改进空间的。
折半查找代码的第8句,我们略微等式变换后得到:
也就是mid等于最低下标low加上最高下标high与low的差的一半。
算法科学家们考虑的就是将这个1/2进行改进,改进为下面的计算方案:
将1/2改成了(key-a[low])/(a[high]-a[low])有什么道理呢?
假设a[11]={0,1,16,24,35,47,59,62,73,88,99},low=1,high=10,则a[low]=1,a[high]=99,如果我们要找的是key=16时,按原来折半的做法,我们需要四次才可以得到结果,但如果用新办法,(key-a[low])/(a[high]-a[low])=(16-1)/(99-1)≈0.153,即mid≈1+0.153×(10-1)=2.377取整得到mid=2,我们只需要二次就查找到结果了,显然大大提高了查找的效率。
换句话说,我们只需要在折半查找算法的代码中更改一下第8行代码如下:
1 | mid=low+ (high-low)*(key-a[low])/(a[high]-a[low]); /* 插值 */ |
就得到了另一种有序表查找算法,插值查找法。
插值查找(Interpolation Search)是根据要查找的关键字key与查找表中最大最小记录的关键字比较后的查找方法,其核心就在于插值的计算公式(key-a[low])/(a[high]-a[low])。
应该说,从时间复杂度来看,它也是O(logn),但对于表长较大,而关键字分布又比较均匀的查找表来说,插值查找算法的平均性能比折半查找要好得多。
反之,数组中如果分布类似{0,1,2,2000,2001,……,999998,999999}这种极端不均匀的数据,用插值查找未必是很合适的选择。
斐波那契查找
还有没有其他办法?我们折半查找是从中间分,也就是说,每一次查找总是一分为二,无论数据偏大还是偏小,很多时候这都未必就是最合理的做法。
除了插值查找,我们再介绍一种有序查找,斐波那契查找(Fibonacci Search),它是利用了黄金分割原理来实现的。
为了能够介绍清楚这个查找算法,我们先需要有一个斐波那契数列的数组,如图所示。
下面我们根据代码来看程序是如何运行的。
1 | /* 斐波那契查找 */ |
1.
程序开始运行,参数a={0,1,16,24,35,47,59,62,73,88,99},n=10,要查找的关键字key=59。
注意此时我们已经有了事先计算好的全局变量数组F的具体数据,它是斐波那契数列,F={0,1,1,2,3,5,8,13,21,……}。
2.
第6~8行是计算当前的n处于斐波那契数列的位置。现在n=10,F[6]<n<F[7],所以计算得出k=7。
3.
第9~10行,由于k=7,计算时是以F[7]=13为基础,而a中最大的仅是a[10],后面的a[11],a[12]均未赋值,这不能构成有序数列,因此将它们都赋值为最大的数组值,所以此时a[11]=a[12]=a[10]=99(此段代码作用后面还有解释)。
4.
第11~31行查找正式开始。
5.
第13行,mid=1+F[7-1]-1=8,也就是说,我们第一个要对比的数值是从下标为8开始的。
6.
由于此时key=59而a[8]=73,因此执行第16~17行,得到high=7,k=6。
7.
再次循环,mid=1+F[6-1]-1=5。此时a[5]=47<key,因此执行第21~22行,得到low=6,k=6-2=4。注意此时k下调2个单位。
8.
再次循环,mid=6+F[4-1]-1=7。此时a[7]=62>key,因此执行第16~17行,得到high=6,k=4-1=3。
9.
再次循环,mid=6+F[3-1]-1=6。此时a[6]=59=key,因此执行第26~27行,得到返回值为6。程序运行结束。
如果key=99,此时查找循环第一次时,mid=8与上例是相同的,第二次循环时,mid=11,如果a[11]没有值就会使得与key的比较失败,为了避免这样的情况出现,第9~10行的代码就起到这样的作用。
斐波那契查找算法的核心在于:
1)当key=a[mid]时,查找就成功;
2)当key<a[mid]时,新范围是第low个到第mid-1个,此时范围个数为F[k-1]-1个;
3)当key>a[mid]时,新范围是第m+1个到第high个,此时范围个数为F[k-2]-1个。
也就是说,如果要查找的记录在右侧,则左侧的数据都不用再判断了,不断反复进行下去,对处于当中的大部分数据,其工作效率要高一些。
所以尽管斐波那契查找的时间复杂也为O(logn),但就平均性能来说,斐波那契查找要优于折半查找。
可惜如果是最坏情况,比如这里key=1,那么始终都处于左侧长半区在查找,则查找效率要低于折半查找。
还有比较关键的一点,折半查找是进行加法与除法运算(mid=(low+high)/2),插值查找进行复杂的四则运算(mid=low+(high-low)*(key-a[low])/(a[high]-a[low])),而斐波那契查找只是最简单加减法运算(mid=low+F[k-1]-1),在海量数据的查找过程中,这种细微的差别可能会影响最终的查找效率。
应该说,三种有序表的查找本质上是分隔点的选择不同,各有优劣,实际开发时可根据数据的特点综合考虑再做出选择。
线性索引查找
我们前面讲的几种比较高效的查找方法都是基于有序的基础之上的,但事实上,很多数据集可能增长非常快,例如,某些微博网站或大型论坛的帖子和回复总数每天都是成百万上千万条,如图所示,或者一些服务器的日志信息记录也可能是海量数据,要保证记录全部是按照当中的某个关键字有序,其时间代价是非常高昂的,所以这种数据通常都是按先后顺序存储。
那么对于这样的查找表,我们如何能够快速查找到需要的数据呢?办法就是——索引。
数据结构的最终目的是提高数据的处理速度,索引是为了加快查找速度而设计的一种数据结构。索引就是把一个关键字与它对应的记录相关联的过程,一个索引由若干个索引项构成,每个索引项至少应包含关键字和其对应的记录在存储器中的位置等信息。索引技术是组织大型数据库以及磁盘文件的一种重要技术。
索引按照结构可以分为线性索引、树形索引和多级索引。我们这里就只介绍线性索引技术。
所谓线性索引就是将索引项集合组织为线性结构,也称为索引表。
我们重点介绍三种线性索引:稠密索引、分块索引和倒排索引。
稠密索引
稠密索引是指在线性索引中,将数据集中的每个记录对应一个索引项,如图所示。
稠密索引要应对的可能是成千上万的数据,因此对于稠密索引这个索引表来说,索引项一定是按照关键码有序的排列。
索引项有序也就意味着,我们要查找关键字时,可以用到折半、插值、斐波那契等有序查找算法,大大提高了效率。
比如上图中,我要查找关键字是18的记录,如果直接从右侧的数据表中查找,那只能顺序查找,需要查找6次才可以查到结果。
而如果是从左侧的索引表中查找,只需两次折半查找就可以得到18对应的指针,最终查找到结果。
这显然是稠密索引优点,但是如果数据集非常大,比如上亿,那也就意味着索引也得同样的数据集长度规模,对于内存有限的计算机来说,可能就需要反复去访问磁盘,查找性能反而大大下降了。
分块索引
稠密索引因为索引项与数据集的记录个数相同,所以空间代价很大。
为了减少索引项的个数,我们可以对数据集进行分块,使其分块有序,然后再对每一块建立一个索引项,从而减少索引项的个数。
分块有序,是把数据集的记录分成了若干块,并且这些块需要满足两个条件:
块内无序,即每一块内的记录不要求有序。当然,你如果能够让块内有序对查找来说更理想,不过这就要付出大量时间和空间的代价,因此通常我们不要求块内有序。
块间有序,例如,要求第二块所有记录的关键字均要大于第一块中所有记录的关键字,第三块的所有记录的关键字均要大于第二块的所有记录关键字……因为只有块间有序,才有可能在 查找时带来效率。
对于分块有序的数据集,将每块对应一个索引项,这种索引方法叫做分块索引。
如图所示,我们定义的分块索引的索引项结构分三个数据项:
最大关键码,它存储每一块中的最大关键字,这样的好处就是可以使得在它之后的下一块中的最小关键字也能比这一块最大的关键字要大;
存储了块中的记录个数,以便于循环时使用;
用于指向块首数据元素的指针,便于开始对这一块中记录进行遍历。
在分块索引表中查找,就是分两步进行:
1.
在分块索引表中查找要查关键字所在的块。由于分块索引表是块间有序的,因此很容易利用折半、插值等算法得到结果。
例如,在上图的数据集中查找62,我们可以很快可以从左上角的索引表中由57<62<96得到62在第三个块中。
2.
根据块首指针找到相应的块,并在块中顺序查找关键码。
因为块中可以是无序的,因此只能顺序查找。
我们再来分析一下分块索引的平均查找长度。
设n个记录的数据集被平均分成m块,每个块中有t条记录,显然n=m×t,或者说m=n/t。
再假设Lb为查找索引表的平均查找长度,因最好与最差的等概率原则,所以Lb的平均长度为(m+1)/2。
Lw为块中查找记录的平均查找长度,同理可知它的平均查找长度为(t+1)/2。
这样分块索引查找的平均查找长度为:
注意上面这个式子的推导是为了让整个分块索引查找长度依赖n和t两个变量。
从这里了我们也就得到,平均长度不仅仅取决于数据集的总记录数n,还和每一个块的记录个数t相关。
最佳的情况就是分的块数m与块中的记录数t相同,此时意味着n=m×t=t2,即ASLw=1/2·(n/t+t)+1=t+1=sqrt(n)+1。
可见,分块索引的效率比之顺序查找的O(n)是高了不少,不过显然它与折半查找的O(logn)相比还有不小的差距。
因此在确定所在块的过程中,由于块间有序,所以可以应用折半、插值等手段来提高效率。
总的来说,分块索引在兼顾了对细分块不需要有序的情况下,大大增加了整体查找的速度,所以普遍被用于数据库表查找等技术的应用当中。
倒排索引
1.Books and friends should be few but good.(读书如交友,应求少而精。)
2.A good book is a good friend.(好书如挚友。)
英文单词 | 文章编号 |
---|---|
a | 2 |
and | 1 |
be | 1 |
book | 1,2 |
but | 1 |
few | 1 |
friend | 1,2 |
good | 1,2 |
is | 2 |
should | 1 |
在这里这张单词表就是索引表,索引项的通用结构是:
次关键码,例如上面的“英文单词”;
记录号表,例如上面的“文章编号”。
其中记录号表存储具有相同次关键字的所有记录的记录号(可以是指向记录的指针或者是该记录的主关键字)。
这样的索引方法就是倒排索引(in-verted index)。
倒排索引源于实际应用中需要根据属性(或字段、次关键码)的值来查找记录。
这种索引表中的每一项都包括一个属性值和具有该属性值的各记录的地址。
由于不是由记录来确定属性值,而是由属性值来确定记录的位置,因而称为倒排索引。
倒排索引的优点显然就是查找记录非常快,基本等于生成索引表后,查找时都不用去读取记录,就可以得到结果。
但它的缺点是这个记录号不定长,比如上例有7个单词的文章编号只有一个,而“book”、“friend”、“good”有两个文章编号,若是对多篇文章所有单词建立倒排索引,那每个单词都将对应相当多的文章编号,维护比较困难,插入和删除操作都需要作相应的处理。
当然,现实中的搜索技术非常复杂,比如我们不仅要知道某篇文章有要搜索的关键字,还想知道这个关键字在文章中的哪些地方出现,这就需要我们对记录号表做一些改良。
再比如,文章编号上亿,如果都用长数字也没必要,可以进行压缩,比如三篇文章的编号是“112,115,119”,我们可以记录成“112,+3,+4”,即只记录差值,这样每个关键字就只占用一两个字节。
甚至关键字也可以压缩,比如前一条记录的关键字是“and”而后一条是“an-droid”,那么后面这个可以改成“<3,roid>”,这样也可以起到压缩数据的作用。
再比如搜索时,尽管告诉你有几千几万条查找到的记录,但其实真正显示给你看的,就只是当中的前10或者20条左右数据,只有在点击下一页时才会获得后面的部分索引记录,这也可以大大提高了整体搜索的效率。
二叉排序树
假设查找的数据集是普通的顺序存储,那么插入操作就是将记录放在表的末端,给表记录数加一即可,删除操作可以是删除后,后面的记录向前移,也可以是要删除的元素与最后一个元素互换,表记录数减一,反正整个数据集也没有什么顺序,这样的效率也不错。
应该说,插入和删除对于顺序存储结构来说,效率是可以接受的,但这样的表由于无序造成查找的效率很低,前面我们有讲解,这就不在啰嗦。
如果查找的数据集是有序线性表,并且是顺序存储的,查找可以用折半、插值、斐波那契等查找算法来实现,可惜,因为有序,在插入和删除操作上,就需要耗费大量的时间。
有没有一种即可以使得插入和删除效率不错,又可以比较高效率地实现查找的算法呢?
还真有。
我们在8.2节把这种需要在查找时插入或删除的查找表称为动态查找表。
我们现在就来看看什么样的结构可以实现动态查找表的高效率。
如果在复杂的问题面前,我们束手无策的话,不妨先从最最简单的情况入手。
现在我们的目标是插入和查找同样高效。
假设我们的数据集开始只有一个数{62},然后现在需要将88插入数据集,于是数据集成了{62,88},还保持着从小到大有序。
再查找有没有58,没有则插入,可此时要想在线性表的顺序存储中有序,就得移动62和88的位置,如图左图,可不可以不移动呢?
嗯,当然是可以,那就是二叉树结构。
当我们用二叉树的方式时,首先我们将第一个数62定为根结点,88因为比62大,因此让它做62的右子树,58因比62小,所以成为它的左子树。
此时58的插入并没有影响到62与88的关系,如图右图所示。
也就是说,若我们现在需要对集合{62,88,58,47,35,73,51,99,37,93}做查找,在我们打算创建此集合时就考虑用二叉树结构,而且是排好序的二叉树来创建。
如图所示,62、88、58创建好后,下一个数47因比58小,是它的左子树(见③),35是47的左子树(见④),73比62大,但却比88小,是88的左子树(见⑤),51比62小、比58小、比47大,是47的右子树(见⑥),99比62、88都大,是88的右子树(见⑦),37比62、58、47都小,但却比35大,是35的右子树(见⑧),93则因比62、88大是99的左子树(见⑨)。
这样我们就得到了一棵二叉树,并且当我们对它进行中序遍历时,就可以得到一个有序的序列{35,37,47,51,58,62,73,88,93,99},所以我们通常称它为二叉排序树。
二叉排序树(Binary Sort Tree),又称为二叉查找树。它或者是一棵空树,或者是具有下列性质的二叉树。
若它的左子树不空,则左子树上所有结点的值均小于它的根结构的值;
若它的右子树不空,则右子树上所有结点的值均大于它的根结点的值;
它的左、右子树也分别为二叉排序树。
从二叉排序树的定义也可以知道,它前提是二叉树,然后它采用了递归的定义方法,再者,它的结点间满足一定的次序关系,左子树结点一定比其双亲结点小,右子树结点一定比其双亲结点大。
构造一棵二叉排序树的目的,其实并不是为了排序,而是为了提高查找和插入删除关键字的速度。
不管怎么说,在一个有序数据集上的查找,速度总是要快于无序的数据集的,而二叉排序树这种非线性的结构,也有利于插入和删除的实现。
二叉排序树查找操作
首先我们提供一个二叉树的结构。
1 | /* 二叉树的二叉链表结点结构定义 */ |
然后我们来看看二叉排序树的查找是如何实现的。
1 | /* 递归查找二叉排序树T中是否存在key, */ |
1.
SearchBST函数是一个可递归运行的函数,函数调用时的语句为SearchBST(T,93,NULL,p),参数T是一个二叉链表,其中数据如图所示,key代表要查找的关键字,目前我们打算查找93,二叉树f指向T的双亲,当T指向根结点时,f的初值就为NULL,它在递归时有用,最后的参数p是为了查找成功后可以得到查找到的结点位置。
2.第3~7行,是用来判断当前二叉树是否到叶子结点,显然图中告诉我们当前T指向根结点62的位置,T不为空,第5~6行不执行。
3.第8~12行是查找到相匹配的关键字时执行语句,显然93≠62,第10~11行不执行。
4.第13~14行是当要查找关键字小于当前结点值时执行语句,由于93>62,第14行不执行。
5.第15~16行是当要查找关键字大于当前结点值时执行语句,由于93>62,所以递归调用SearchBST(T->rchild,key,T,p)。此时T指向了62的右孩子88,如图所示。
6.此时第二层SearchBST,因93比88大,所以执行第16行,再次递归调用SearchBST(T->rchild,key,T,p)。此时T指向了88的右孩子99,如图所示。
7.第三层的SearchBST,因93比99小,所以执行第14行,递归调用SearchBST(T->lchild,key,T,p)。此时T指向了99的左孩子93,如图所示。
8.第四层SearchBST,因key等于T->data,所以执行第10~11行,此时指针p指向93所在的结点,并返回True到第三层、第二层、第一层,最终函数返回True。
二叉排序树插入操作
1 | /* 当二叉排序树T中不存在关键字等于key的数据元素时, */ |
这段代码非常简单。
如果你调用函数是“InsertBST(&T,93);”,那么结果就是FALSE,如果是“InsertBST(&T,95);”,那么一定就是在93的结点增加一个右孩子95,并且返回True。
如图所示。
二叉排序树删除操作
如果需要查找并删除如37、51、73、93这些在二叉排序树中是叶子的结点,那是很容易的,毕竟删除它们对整棵树来说,其他结点的结构并未受到影响,如图所示。
对于要删除的结点只有左子树或只有右子树的情况,相对也比较好解决。那就是结点删除后,将它的左子树或右子树整个移动到删除结点的位置即可,可以理解为独子继承父业。
比如图,就是先删除35和99结点,再删除58结点的变化图,最终,整个结构还是一个二叉排序树。
但是对于要删除的结点既有左子树又有右子树的情况怎么办呢?
比较好的办法就是,找到需要删除的结点p的直接前驱(或直接后继)s,用s来替换结点p,然后再删除此结点s,如图所示。
根据我们对删除结点三种情况的分析:
叶子结点;
仅有左或右子树的结点;
左右子树都有的结点;
我们来看代码,下面这个算法是递归方式对二叉排序树T查找key,查找到时删除。
1 | /* 若二叉排序树T中存在关键字等于key的数据元素时,则删除该数据元素结点, */ |
这段代码和前面的二叉排序树查找几乎完全相同,唯一的区别就在于第8行,此时执行的是Delete方法,对当前结点进行删除操作。
我们来看Delete的代码。
1 | /* 从二叉排序树中删除结点p,并重接它的左或右子树。 */ |
1.
程序开始执行,代码第4~7行目的是为了删除没有右子树只有左子树的结点。此时只需将此结点的左孩子替换它自己,然后释放此结点内存,就等于删除了。
2.
代码第8~11行是同样的道理处理只有右子树没有左子树的结点删除问题。
3.
第12~25行处理复杂的左右子树均存在的问题。
4.
第14行,将要删除的结点p赋值给临时的变量q,再将p的左孩子p->lchild赋值给临时的变量s。
此时q指向47结点,s指向35结点,如图所示。
5.
第15~18行,循环找到左子树的右结点,直到右侧尽头。
就当前例子来说就是让q指向35,而s指向了37这个再没有右子树的结点,如图所示。
6.第19行,此时让要删除的结点p的位置的数据被赋值为s->data,即让p->data=37,如图所示。
7.
第20~23行,如果p和q指向不同,则将s->lchild赋值给q->rchild,否则就是将s->lchild赋值给q->lchild。
显然这个例子p不等于q,将s->lchild指向的36赋值给q->rchild,也就是让q->rchild指向36结点,如图所示。
8.
第24行,free(s),就非常好理解了,将37结点删除,如图所示。
从这段代码也可以看出,我们其实是在找删除结点的前驱结点替换的方法,对于用后继结点来替换,方法上是一样的。
二叉排序树总结
总之,二叉排序树是以链接的方式存储,保持了链接存储结构在执行插入或删除操作时不用移动元素的优点,只要找到合适的插入和删除位置后,仅需修改链接指针即可。插入删除的时间性能比较好。
而对于二叉排序树的查找,走的就是从根结点到要查找的结点的路径,其比较次数等于给定值的结点在二叉排序树的层数。
极端情况,最少为1次,即根结点就是要找的结点,最多也不会超过树的深度。
也就是说,二叉排序树的查找性能取决于二叉排序树的形状。可问题就在于,二叉排序树的形状是不确定的。
例如{62,88,58,47,35,73,51,99,37,93}这样的数组,我们可以构建如图左图的二叉排序树。
但如果数组元素的次序是从小到大有序,如{35,37,47,51,58,62,73,88,93,99},则二叉排序树就成了极端的右斜树,注意它依然是一棵二叉排序树,如图的右图。
此时,同样是查找结点99,左图只需要两次比较,而右图就需要10次比较才可以得到结果,二者差异很大。
也就是说,我们希望二叉排序树是比较平衡的,即其深度与完全二叉树相同,均为,那么查找的时间复杂也就为O(logn),近似于折半查找,事实上,图的左图也不够平衡,明显的左重右轻。
不平衡的最坏情况就是像图右图的斜树,查找时间复杂度为O(n),这等同于顺序查找。
因此,如果我们希望对一个集合按二叉排序树查找,最好是把它构建成一棵平衡的二叉排序树。
这样我们就引申出另一个问题,如何让二叉排序树平衡的问题。
平衡二叉树(AVL树)
平衡二叉树(Self-Balancing Binary SearchTree或Height-Balanced Binary Search Tree),是一种二叉排序树,其中每一个节点的左子树和右子树的高度差至多等于1。
从平衡二叉树的英文名字,你也可以体会到,它是一种高度平衡的二叉排序树。
那什么叫做高度平衡呢?意思是说,要么它是一棵空树,要么它的左子树和右子树都是平衡二叉树,且左子树和右子树的深度之差的绝对值不超过1。
我们将二叉树上结点的左子树深度减去右子树深度的值称为平衡因子BF(Balance Factor),那么平衡二叉树上所有结点的平衡因子只可能是-1、0和1。
只要二叉树上有一个结点的平衡因子的绝对值大于1,则该二叉树就是不平衡的。
看图,为什么图1是平衡二叉树,而图2却不是呢?
这里就是考查我们对平衡二叉树的定义的理解,它的前提首先是一棵二叉排序树,右上图的59比58大,却是58的左子树,这是不符合二叉排序树的定义的。
图3不是平衡二叉树的原因就在于,结点58的左子树高度为3,而右子树为空,二者差大于了绝对值1,因此它也不是平衡的。
而经过适当的调整后的图4,它就符合了定义,因此它是平衡二叉树。
距离插入结点最近的,且平衡因子的绝对值大于1的结点为根的子树,我们称为最小不平衡子树。
下图中,当新插入结点37时,距离它最近的平衡因子绝对值超过1的结点是58(即它的左子树高度3减去右子树高度1),所以从58开始以下的子树为最小不平衡子树。
平衡二叉树实现原理
平衡二叉树构建的基本思想就是在构建二叉排序树的过程中,每当插入一个结点时,先检查是否因插入而破坏了树的平衡性,若是,则找出最小不平衡子树。
在保持二叉排序树特性的前提下,调整最小不平衡子树中各结点之间的链接关系,进行相应的旋转,使之成为新的平衡子树。
为了能在讲解算法时轻松一些,我们先讲一个平衡二叉树构建过程的例子。
假设我们现在有一个数组a[10]={3,2,1,4,5,6,7,10,9,8}需要构建二叉排序树。
在没有学习平衡二叉树之前,根据二叉排序树的特性,我们通常会将它构建成如图的图1所示的样子。虽然它完全符合二叉排序树的定义,但是对这样高度达到8的二叉树来说,查找是非常不利的。
我们更期望能构建成如图图2的样子,高度为4的二叉排序树才可以提供高效的查找效率。
那么现在我们就来研究如何将一个数组构建出图2的树结构。
对于数组a[10]={3,2,1,4,5,6,7,10,9,8}的前两位3和2,我们很正常地构建,到了第3个数“1”时,发现此时根结点“3”的平衡因子变成了2,此时整棵树都成了最小不平衡子树,因此需要调整,如图的图1(结点左上角数字为平衡因子BF值)。
因为BF值为正,因此我们将整个树进行右旋(顺时针旋转),此时结点2成了根结点,3成了2的右孩子,这样三个结点的BF值均为0,非常的平衡,如图的图2所示。
然后我们再增加结点4,平衡因子没有超出限定范围(-1,0,1),如图3。
增加结点5时,结点3的BF值为-2,说明要旋转了。
由于BF是负值,所以我们对这棵最小平衡子树进行左旋(逆时针旋转),如图4,此时我们整个树又达到了平衡。
继续,增加结点6时,发现根结点2的BF值变成了-2,如图的图6。
所以我们对根结点进行了左旋,注意此时本来结点3是4的左孩子,由于旋转后需要满足二叉排序树特性,因此它成了结点2的右孩子,如图7。
增加结点7,同样的左旋转,使得整棵树达到平衡,如图8和图9所示。
当增加结点10时,结构无变化,如图的图10。
再增加结点9,此时结点7的BF变成了-2,理论上我们只需要旋转最小不平衡子树7、9、10即可,但是如果左旋转后,结点9就成了10的右孩子,这是不符合二叉排序树的特性的,此时不能简单的左旋,如图11所示。
仔细观察图11,发现根本原因在于结点7的BF是-2,而结点10的BF是1,也就是说,它们俩一正一负,符号并不统一,而前面的几次旋转,无论左还是右旋,最小不平衡子树的根结点与它的子结点符号都是相同的。这就是不能直接旋转的关键。那怎么办呢?
不统一,不统一就把它们先转到符号统一再说,于是我们先对结点9和结点10进行右旋,使得结点10成了9的右子树,结点9的BF为-1,此时就与结点7的BF值符号统一了,如图的图12所示。
这样我们再以结点7为最小不平衡子树进行左旋,得到图的图13。
接着插入8,情况与刚才类似,结点6的BF是-2,而它的右孩子9的BF是1,如图14,因此首先以9为根结点,进行右旋,得到图15,此时结点6和结点7的符号都是负,再以6为根结点左旋,最终得到最后的平衡二叉树,如图8-7-8的图16所示。
通过刚才这个例子,你会发现,当最小不平衡子树根结点的平衡因子BF是大于1时,就右旋,小于-1时就左旋,如上例中结点1、5、6、7的插入等。
插入结点后,最小不平衡子树的BF与它的子树的BF符号相反时,就需要对结点先进行一次旋转以使得符号相同后,再反向旋转一次才能够完成平衡操作,如上例中结点9、8的插入时。
平衡二叉树实现算法
好了,有这么多的准备工作,我们可以来讲解代码了。
首先是需要改进二叉排序树的结点结构,增加一个bf,用来存储平衡因子。
1 | /* 二叉树的二叉链表结点结构定义 */ |
然后,对于右旋操作,我们的代码如下。
1 | /* 对以p为根的二叉排序树作右旋处理, */ |
此函数代码的意思是说,当传入一个二叉排序树P,将它的左孩子结点定义为L,将L的右子树变成P的左子树,再将P改成L的右子树,最后将L替换P成为根结点。
这样就完成了一次右旋操作,如图所示。
图中三角形代表子树,N代表新增结点。
左旋操作代码如下。
1 | /* 对以P为根的二叉排序树作左旋处理, */ |
现在我们来看左平衡旋转处理的函数代码。
1 |
|
首先,我们定义了三个常数变量,分别代表1、0、-1。
1.
函数被调用,传入一个需调整平衡性的子树T。
由于LeftBalance函数被调用时,其实是已经确认当前子树是不平衡状态,且左子树的高度大于右子树的高度。
换句话说,此时T的根结点应该是平衡因子BF的值大于1的数。
2.
第4行,我们将T的左孩子赋值给L。
3.
第5~27行是分支判断。
4.
当L的平衡因子为LH,即为1时,表明它与根结点的BF值符号相同,因此,第8行,将它们的BF值都改为0,并且第9行,进行右旋操作。
操作的方式如上图所示。
5.
当L的平衡因子为RH,即为-1时,表明它与根结点的BF值符号相反,此时需要做双旋处理。
第13~22行,针对L的右孩子Lr的BF作判断,修改根结点T和L的BF值。
第24行将当前Lr的BF改为0。
6.
第25行,对根结点的左子树进行左旋,如下图第二图所示。
7.
第26行,对根结点进行右旋,如下图的第三图所示,完成平衡操作。
同样的,右平衡旋转处理的函数代码非常类似,直接看代码,不做讲解了。
有了这些准备,我们的主函数才算是正式登场了。
1 | /* 若在平衡的二叉排序树T中不存在和e有相同关键字的结点,则插入一个 */ |
1.
程序开始执行时,第3~10行是指当前T为空时,则申请内存新增一个结点。
2.
第13~17行表示当存在相同结点,则不需要插入。
3.
第18~40行,当新结点e小于T的根结点值时,则在T的左子树查找。
4.
第20~21行,递归调用本函数,直到找到则返回false,否则说明插入结点成功,执行下面语句。
5.
第22~39行,当taller为TRUE时,说明插入了结点,此时需要判断T的平衡因子,如果是1,说明左子树高于右子树,需要调用LeftBalance函数进行左平衡旋转处理。
如果为0或-1,则说明新插入结点没有让整棵二叉排序树失去平衡性,只需要修改相关的BF值即可。
6.
第41~63行,说明新结点e大于T的根结点的值,在T的右子树查找。
代码上述类似,不再详述。
对于这段代码来说,我们只需要在需要构建平衡二叉树的时候执行如下列代码即可在内存中生成一棵与上图的图2相同的平衡的二叉树。
1 | int i; |
如果我们需要查找的集合本身没有顺序,在频繁查找的同时也需要经常的插入和删除操作,显然我们需要构建一棵二叉排序树,但是不平衡的二叉排序树,查找效率是非常低的,因此我们需要在构建时,就让这棵二叉排序树是平衡二叉树,此时我们的查找时间复杂度就为O(logn),而插入和删除也为O(logn)。
这显然是比较理想的一种动态查找表算法。
多路查找树(B树)
我们前面讨论过的数据结构,处理数据都是在内存中,因此考虑的都是内存中的运算时间复杂度。
但如若我们要操作的数据集非常大,大到内存已经没办法处理了怎么办呢?如数据库中的上千万条记录的数据表、硬盘中的上万个文件等。
在这种情况下,对数据的处理需要不断从硬盘等存储设备中调入或调出内存页面。
一旦涉及到这样的外部存储设备,关于时间复杂度的计算就会发生变化,访问该集合元素的时间已经不仅仅是寻找该元素所需比较次数的函数,我们必须考虑对硬盘等外部存储设备的访问时间以及将会对该设备做出多少次单独访问。
试想一下,为了要在一个拥有几十万个文件的磁盘中查找一个文本文件,你设计的算法需要读取磁盘上万次还是读取几十次,这是有本质差异的。
此时,为了降低对外存设备的访问次数,我们就需要新的数据结构来处理这样的问题。
我们之前谈的树,都是一个结点可以有多个孩子,但是它自身只存储一个元素。二叉树限制更多,结点最多只能有两个孩子。
一个结点只能存储一个元素,在元素非常多的时候,就使得要么树的度非常大(结点拥有子树的个数的最大值),要么树的高度非常大,甚至两者都必须足够大才行。
这就使得内存存取外存次数非常多,这显然成了时间效率上的瓶颈,这迫使我们要打破每一个结点只存储一个元素的限制,为此引入了多路查找树的概念。
多路查找树(muitl-way search tree),其每一个结点的孩子数可以多于两个,且每一个结点处可以存储多个元素。
由于它是查找树,所有元素之间存在某种特定的排序关系。
在这里,每一个结点可以存储多少个元素,以及它的孩子数的多少是非常关键的。
为此,我们讲解它的4种特殊形式:2-3树、2-3-4树、B树和B+树。
2-3树
2-3树是这样的一棵多路查找树:其中的每一个结点都具有两个孩子(我们称它为2结点)或三个孩子(我们称它为3结点)。
一个2结点包含一个元素和两个孩子(或没有孩子),且与二叉排序树类似,左子树包含的元素小于该元素,右子树包含的元素大于该元素。
不过,与二叉排序树不同的是,这个2结点要么没有孩子,要有就有两个,不能只有一个孩子。
一个3结点包含一小一大两个元素和三个孩子(或没有孩子),一个3结点要么没有孩子,要么具有3个孩子。如果某个3结点有孩子的话,左子树包含小于较小元素的元素,右子树包含大于较大元素的元素,中间子树包含介于两元素之间的元素。
并且2-3树中所有的叶子都在同一层次上。如图所示,就是一棵有效的2-3树。
事实上,2-3树复杂的地方就在于新结点的插入和已有结点的删除。
毕竟,每个结点可能是2结点也可能是3结点,要保证所有叶子都在同一层次,是需要进行一番复杂操作的。
2-3树的插入实现
对于2-3树的插入来说,与二叉排序树相同,插入操作一定是发生在叶子结点上。可与二叉排序树不同的是,2-3树插入一个元素的过程有可能会对该树的其余结构产生连锁反应。
2-3树插入可分为三种情况。
1)
对于空树,插入一个2结点即可,这很容易理解。
2)
插入结点到一个2结点的叶子上。
应该说,由于其本身就只有一个元素,所以只需要将其升级为3结点即可。
如图所示。我们希望从左图的2-3树中插入元素3,根据遍历可知,3比8小、比4小,于是就只能考虑插入到叶子结点1所在的位置,因此很自然的想法就是将此结点变成一个3结点,即右图这样完成插入操作。
当然,要视插入的元素与当前叶子结点的元素比较大小后,决定谁在左谁在右。例如,若插入的是0,则此结点就是“0”在左“1”在右了。
3)
要往3结点中插入一个新元素。
因为3结点本身已经是2-3树的结点最大容量(已经有两个元素),因此就需要将其拆分,且将树中两元素或插入元素的三者中选择其一向上移动一层。
复杂的情况也正在于此。
第一种情况,见下图,需要向左图中插入元素5。
经过遍历可得到元素5比8小比4大,因此它应该是需要插入在拥有6、7元素的3结点位置。问题就在于,6和7结点已经是3结点,不能再加。
此时发现它的双亲结点4是个2结点,因此考虑让它升级为3结点,这样它就得有三个孩子,于是就想到,将6、7结点拆分,让6与4结成3结点,将5成为它的中间孩子,将7成为它的右孩子,如图的右图所示。
另一种情况,如下图所示,需要向左图中插入元素11。
经过遍历可得到元素11比12、14小比9、10大,因此它应该是需要插入在拥有9、10元素的3结点位置。
同样道理,9和10结点不能再增加结点。此时发现它的双亲结点12、14也是一个3结点,也不能再插入元素了。
再往上看,12、14结点的双亲,结点8是个2结点。
于是就想到,将9、10拆分,12、14也拆分,让根结点8升级为3结点,最终形成如图的右图样子。
再来看个例子,如图所示,需要在左图中插入元素2。
经过遍历可得到元素2比4小、6比1大,因此它应该是需要插入在拥有1、3元素的3结点位置。
与上例一样,你会发现,1、3结点,4、6结点都是3结点,都不能再插入元素了,再往上看,8、12结点还是一个3结点,那就意味着,当前我们的树结构是三层已经不能满足当前结点增加的需要了。
于是将1、3拆分,4、6拆分,连根结点8、12也拆分,最终形成如图的右图样子。
通过这个例子,也让我们发现,如果2-3树插入的传播效应导致了根结点的拆分,则树的高度就会增加。
2-3树的删除实现
对于2-3树的删除来说,如果对前面插入的理解足够到位的话,应该不是难事了。
2-3树的删除也分为三种情况。
与插入相反,我们从3结点开始说起。
1)
所删除元素位于一个3结点的叶子结点上, 这非常简单,只需要在该结点处删除该元素即可,不会影响到整棵树的其他结点结构。
如图所示,删除元素9,只需要将此结点改成只有元素10的2结点即可。
2)
所删除的元素位于一个2结点上,即要删除的是一个只有一个元素的结点。
如果按照以前树的理解,删除即可,可现在的2-3树的定义告诉我们这样做是不可以的。
比如图所示,如果我们删除了结点1,那么结点4本来是一个2结点(它拥有两个孩子),此时它就不满足定义了。
因此,对于删除叶子是2结点的情况,我们需要分四种情形来处理。
情形一,此结点的双亲也是2结点,且拥有一个3结点的右孩子。
如图所示,删除结点1,那么只需要左旋,即6成为双亲,4成为6的左孩子,7是6的右孩子。
情形二,此结点的双亲是2结点,它的右孩子也是2结点。
如图所示,此时删除结点4,如果直接左旋会造成没有右孩子,因此需要对整棵树变形,办法就是,我们目标是让结点7变成3结点,那就得让比7稍大的元素8下来,随即就得让比元素8稍大的元素9补充结点8的位置,于是就有了图的中间图,于是再用左旋的方式,变成右图结果。
情形三,此结点的双亲是一个3结点。
如图所示,此时删除结点10,意味着双亲12、14这个结点不能成为3结点了,于是将此结点拆分,并将12与13合并成为左孩子。
情形四,如果当前树是一个满二叉树的情况,此时删除任何一个叶子都会使得整棵树不能满足2-3树的定义。
如图所示,删除叶子结点8时(其实删除任何一个结点都一样),就不得不考虑要将2-3的层数减少,办法是将8的双亲和其左子树6合并为一3个结点,再将14与9合并为3结点,最后成为右图的样子。
3)
所删除的元素位于非叶子的分支结点。此时我们通常是将树按中序遍历后得到此元素的前驱或后继元素,考虑让它们来补位即可。
如果我们要删除的分支结点是2结点。
如图所示我们要删除4结点,分析后得到它的前驱是1后继是6,显然,由于6、7是3结点,只需要用6来补位即可,如图右图所示。
如果我们要删除的分支结点是3结点的某一元素,如图所示我们要删除12、14结点的12,此时,经过分析,显然应该是将是3结点的左孩子的10上升到删除位置合适。
当然,如果对2-3树的插入和删除等所有的情况进行讲解,既占篇幅,又没必要,总的来说它是有规律的,需要你们在上面的这些例子中多去体会后掌握。
2-3-4树
有了2-3树的讲解,2-3-4树就很好理解了,它其实就是2-3树的概念扩展,包括了4结点的使用。
一个4结点包含小中大三个元素和四个孩子(或没有孩子),一个4结点要么没有孩子,要么具有4个孩子。
如果某个4结点有孩子的话,左子树包含小于最小元素的元素;第二子树包含大于最小元素,小于第二元素的元素;第三子树包含大于第二元素,小于最大元素的元素;右子树包含大于最大元素的元素。
由于2-3-4树和2-3树是类似的,我们这里就简单介绍一下,如果我们构建一个数组为{7,1,2,5,6,9,8,4,3}的2-3-4树的过程,如图所示。
图1是在分别插入7、1、2时的结果图,因为3个元素满足2-3-4树的单个4结点定义,因此此时不需要拆分,接着插入元素5,因为已经超过了4结点的定义,因此拆分为图2的形状。
之后的图其实就是在元素不断插入时最后形成了图7的2-3-4树。
下图是对一个2-3-4树的删除结点的演变过程,删除顺序是1、6、3、4、5、2、9。
B树
B树(B-tree)是一种平衡的多路查找树,2-3树和2-3-4树都是B树的特例。
结点最大的孩子数目称为B树的阶(order),因此,2-3树是3阶B树,2-3-4树是4阶B树。
一个m阶的B树具有如下属性:
如果根结点不是叶结点,则其至少有两棵子树。
每一个非根的分支结点都有k-1个元素和k个孩子,其中。每一个叶子结点n都有k-1个元素,其中。
所有叶子结点都位于同一层次。
所有分支结点包含下列信息数据
(n,A0,K1,A1,K2,A2,…,Kn,An),其中:Ki(i=1,2,…,n)为关键字,且Ki<Ki+1(i=1,2,…,n-1);Ai(i=0,2,…,n)为指向子树根结点的指针,且指针Ai-1所指子树中所有结点的关键字均小于Ki(i=1,2,…,n),An所指子树中所有结点的关键字均大于Kn,n(≤n≤m-1)为关键字的个数(或n+1为子树的个数)。
例如,在讲2-3-4树时插入9个数后的图转成B树示意就如图的右图所示。左侧灰色方块表示当前结点的元素个数。
在B树上查找的过程是一个顺指针查找结点和在结点中查找关键字的交叉过程。
比方说,我们要查找数字7,首先从外存(比如硬盘中)读取得到根结点3、5、8三个元素,发现7不在当中,但在5和8之间,因此就通过A2再读取外存的6、7结点,查找到所要的元素。
至于B树的插入和删除,方式是与2-3树和2-3-4树相类似的,只不过阶数可能会很大而已。
我们在本节的开头提到,如果内存与外存交换数据次数频繁,会造成了时间效率上的瓶颈,那么B树结构怎么就可以做到减少次数呢?
我们的外存,比如硬盘,是将所有的信息分割成相等大小的页面,每次硬盘读写的都是一个或多个完整的页面,对于一个硬盘来说,一页的长度可能是211到214个字节。
在一个典型的B树应用中,要处理的硬盘数据量很大,因此无法一次全部装入内存。因此我们会对B树进行调整,使得B树的阶数(或结点的元素)与硬盘存储的页面大小相匹配。
比如说一棵B树的阶为1001(即1个结点包含1000个关键字),高度为2,它可以储存超过10亿个关键字,我们只要让根结点持久地保留在内存中,那么在这棵树上,寻找某一个关键字至多需要两次硬盘的读取即可。
这就好比我们普通人数钱都是一张一张的数,而银行职员数钱则是五张、十张,甚至几十张一数,速度当然是比常人快了不少。
通过这种方式,在有限内存的情况下,每一次磁盘的访问我们都可以获得最大数量的数据。
由于B树每结点可以具有比二叉树多得多的元素,所以与二叉树的操作不同,它们减少了必须访问结点和数据块的数量,从而提高了性能。
可以说,B树的数据结构就是为内外存的数据交互准备的。
那么对于n个关键字的m阶B树,最坏情况是要查找几次呢?我们来作一分析。
第一层至少有1个结点,第二层至少有2个结点,由于除根结点外每个分支结点至少有|m/2|棵子树,则第三层至少有2×|m/2|个结点,……,这样第k+1层至少有2×(|m/2|)k-1个结点,而实际上,k+1层的结点就是叶子结点。若m阶B树有n个关键字,那么当你找到了叶子结点,其实也就等于查找不成功的结点为n+1,因此n+1≥2×(|m/2|)k-1,即:
也就是说,在含有n个关键字的B树上查找时,从根结点到关键字结点的路径上涉及的结点数不超过log|m/2|((n+1)/2)+1。
B+树
尽管前面我们已经讲了B树的诸多好处,但其实它还是有缺陷的。
对于树结构来说,我们都可以通过中序遍历来顺序查找树中的元素,这一切都是在内存中进行。
可是在B树结构中,我们往返于每个结点之间也就意味着,我们必须得在硬盘的页面之间进行多次访问,如图所示,我们希望遍历这棵B树,假设每个结点都属于硬盘的不同页面,我们为了中序遍历所有的元素,页面2→页面1→页面3→页面1→页面4→页面1→页面5。
而且我们每次经过结点遍历时,都会对结点中的元素进行一次遍历,这就非常糟糕。
有没有可能让遍历时每个元素只访问一次呢?
为了能够解决所有元素遍历等基本问题,我们在原有的B树结构基础上,加上了新的元素组织方式,这就是B+树。
B+树是应文件系统所需而出的一种B树的变形树,注意严格意义上讲,它其实已经不是第六章定义的树了。
在B树中,每一个元素在该树中只出现一次,有可能在叶子结点上,也有可能在分支结点上。
而在B+树中,出现在分支结点中的元素会被当作它们在该分支结点位置的中序后继者(叶子结点)中再次列出。
另外,每一个叶子结点都会保存一个指向后一叶子结点的指针。
例如图所示,就是一棵B+树的示意,灰色关键字即是根结点中的关键字在叶子结点再次列出,并且所有叶子结点都链接在一起。
一棵m阶的B+树和m阶的B树的差异在于:
有n棵子树的结点中包含有n个关键字;
所有的叶子结点包含全部关键字的信息,及指向含这些关键字记录的指针,叶子结点本身依关键字的大小自小而大顺序链接;
所有分支结点可以看成是索引,结点中仅含有其子树中的最大(或最小)关键字。
这样的数据结构最大的好处就在于,如果是要随机查找,我们就从根结点出发,与B树的查找方式相同,只不过即使在分支结点找到了待查找的关键字,它也只是用来索引的,不能提供实际记录的访问,还是需要到达包含此关键字的终端结点。
如果我们是需要从最小关键字进行从小到大的顺序查找,我们就可以从最左侧的叶子结点出发,不经过分支结点,而是延着指向下一叶子的指针就可遍历所有的关键字。
B+树的结构特别适合带有范围的查找。比如查找我们学校18~22岁的学生人数,我们可以通过从根结点出发找到第一个18岁的学生,然后再在叶子结点按顺序查找到符合范围的所有记录。
B+树的插入、删除过程也都与B树类似,只不过插入和删除的元素都是在叶子结点上进行而已。
散列表查找(哈希表)概述
在本章前面的顺序表查找时,我们曾经说过,如果你要查找某个关键字的记录,就是从表头开始,挨个的比较记录a[i]与key的值是“=”还是“≠”,直到有相等才算是查找成功,返回i。
到了有序表查找时,我们可以利用a[i]与key的“<”或“>”来折半查找,直到相等时查找成功返回i。
最终我们的目的都是为了找到那个i,其实也就是相对的下标,再通过顺序存储的存储位置计算方法,LOC(ai)=LOC(a1)+(i-1)×c,也就是通过第一个元素内存存储位置加上i-1个单元位置,得到最后的内存地址。
此时我们发现,为了查找到结果,之前的方法“比较”都是不可避免的,但这是否真的有必要?能否直接通过关键字key得到要查找的记录内存存储位置呢?
散列表查找定义
也就是说,我们只需要通过某个函数f,使得
存储位置=f(关键字)
那样我们可以通过查找关键字不需要比较就可获得需要的记录的存储位置。这就是一种新的存储技术——散列技术。
散列技术是在记录的存储位置和它的关键字之间建立一个确定的对应关系f,使得每个关键字key对应一个存储位置f(key)。
查找时,根据这个确定的对应关系找到给定值key的映射f(key),若查找集合中存在这个记录,则必定在f(key)的位置上。
这里我们把这种对应关系f称为散列函数,又称为哈希(Hash)函数。
按这个思想,采用散列技术将记录存储在一块连续的存储空间中,这块连续存储空间称为散列表或哈希表(Hash table)。
那么关键字对应的记录存储位置我们称为散列地址。
散列表查找步骤
整个散列过程其实就是两步。
(1)
在存储时,通过散列函数计算记录的散列地址,并按此散列地址存储该记录。
总之,不管什么记录,我们都需要用同一个散列函数计算出地址再存储。
(2)
当查找记录时,我们通过同样的散列函数计算记录的散列地址,按此散列地址访问该记录。
说起来很简单,在哪存的,上哪去找,由于存取用的是同一个散列函数,因此结果当然也是相同的。
所以说,散列技术既是一种存储方法,也是一种查找方法。
然而它与线性表、树、图等结构不同的是,前面几种结构,数据元素之间都存在某种逻辑关系,可以用连线图示表示出来,而散列技术的记录之间不存在什么逻辑关系,它只与关键字有关联。
因此,散列主要是面向查找的存储结构。
散列技术最适合的求解问题是查找与给定值相等的记录。
对于查找来说,简化了比较过程,效率就会大大提高。
但万事有利就有弊,散列技术不具备很多常规数据结构的能力。
我们说了这么多,散列函数应该如何设计?这个我们需要重点来讲解,总之设计一个简单、均匀、存储利用率高的散列函数是散列技术中最关键的问题。
另一个问题是冲突。
在理想的情况下,每一个关键字,通过散列函数计算出来的地址都是不一样的,可现实中,这只是一个理想。
我们时常会碰到两个关键字key1≠key2,但是却有f(key1)=f(key2),这种现象我们称为冲突(colli-sion),并把key1和key2称为这个散列函数的同义词(synonym)。
出现了冲突当然非常糟糕,那将造成数据查找错误。尽管我们可以通过精心设计的散列函数让冲突尽可能的少,但是不能完全避免。
于是如何处理冲突就成了一个很重要的课题,这在我们后面也需要详细讲解。
散列函数的构造方法
不管做什么事要达到最优都不容易,既要付出尽可能的少,又要得到最大化的多。那么什么才算是好的散列函数呢?这里我们有两个原则可以参考。
1.计算简单
你说设计一个算法可以保证所有的关键字都不会产生冲突,但是这个算法需要很复杂的计算,会耗费很多时间,这对于需要频繁地查找来说,就会大大降低查找的效率了。因此散列函数的计算时间不应该超过其他查找技术与关键字比较的时间。
2.散列地址分布均匀
我们刚才也提到冲突带来的问题,最好的办法就是尽量让散列地址均匀地分布在存储空间中,这样可以保证存储空间的有效利用,并减少为处理冲突而耗费的时间。
直接定址法
如果我们现在要对0~100岁的人口数字统计表,如下表所示,那么我们对年龄这个关键字就可以直接用年龄的数字作为地址。此时f(key)=key。
地址 | 年龄人数 |
---|---|
0 | 0500万 |
1 | 1600万 |
2 | 2450万 |
…… | …… |
2020 | 1500万 |
…… | …… |
如果我们现在要统计的是80后出生年份的人口数,如下表所示,那么我们对出生年份这个关键字可以用年份减去1980来作为地址。此时f(key)=key-1980。
地址 | 出生 | 年份人数 |
---|---|---|
0 | 1980 | 1500万 |
1 | 1981 | 1600万 |
2 | 1982 | 1300万 |
…… | …… | …… |
2000 | 2000 | 800万 |
…… | …… | …… |
也就是说,我们可以取关键字的某个线性函数值为散列地址,即
f(key)=a×key+b(a、b为常数)
这样的散列函数优点就是简单、均匀,也不会产生冲突,但问题是这需要事先知道关键字的分布情况,适合查找表较小且连续的情况。
由于这样的限制,在现实应用中,此方法虽然简单,但却并不常用。
数字分析法
如果我们的关键字是位数较多的数字,比如我们的11位手机号“130xxxx1234”,其中前三位是接入号,一般对应不同运营商公司的子品牌,如130是联通如意通、136是移动神州行、153是电信等;中间四位是HLR识别号,表示用户号的归属地;后四位才是真正的用户号,如表所示。
若我们现在要存储某家公司员工登记表,如果用手机号作为关键字,那么极有可能前7位都是相同的。
那么我们选择后面的四位成为散列地址就是不错的选择。
如果这样的抽取工作还是容易出现冲突问题,还可以对抽取出来的数字再进行反转(如1234改成4321)、右环位移(如1234改成4123)、左环位移、甚至前两数与后两数叠加(如1234改成12+34=46)等方法。
总的目的就是为了提供一个散列函数,能够合理地将关键字分配到散列表的各位置。
这里我们提到了一个关键词——抽取。抽取方法是使用关键字的一部分来计算散列存储位置的方法,这在散列函数中是常常用到的手段。
数字分析法通常适合处理关键字位数比较大的情况,如果事先知道关键字的分布且关键字的若干位分布较均匀,就可以考虑用这个方法。
平方取中法
这个方法计算很简单,假设关键字是1234,那么它的平方就是1522756,再抽取中间的3位就是227,用做散列地址。
再比如关键字是4321,那么它的平方就是18671041,抽取中间的3位就可以是671,也可以是710,用做散列地址。
平方取中法比较适合于不知道关键字的分布,而位数又不是很大的情况。
折叠法
折叠法是将关键字从左到右分割成位数相等的几部分(注意最后一部分位数不够时可以短些),然后将这几部分叠加求和,并按散列表表长,取后几位作为散列地址。
比如我们的关键字是9876543210,散列表表长为三位,我们将它分为四组,987|654|321|0,然后将它们叠加求和987+654+321+0=1962,再求后3位得到散列地址为962。
有时可能这还不能够保证分布均匀,不妨从一端向另一端来回折叠后对齐相加。
比如我们将987和321反转,再与654和0相加,变成789+654+123+0=1566,此时散列地址为566。
折叠法事先不需要知道关键字的分布,适合关键字位数较多的情况。
除留余数法
此方法为最常用的构造散列函数方法。
对于散列表长为m的散列函数公式为:
f(key)=key mod p(p≤m)
mod是取模(求余数)的意思。事实上,这方法不仅可以对关键字直接取模,也可在折叠、平方取中后再取模。
很显然,本方法的关键就在于选择合适的p,p如果选得不好,就可能会容易产生同义词。
例如下表,我们对于有12个记录的关键字构造散列表时,就用了f(key)=key mod 12的方法。比如29 mod 12=5,所以它存储在下标为5的位置。
不过这也是存在冲突的可能的,因为12=2×6=3×4。如果关键字中有像18(3×6)、30(5×6)、42(7×6)等数字,它们的余数都为6,这就和78所对应的下标位置冲突了。
甚至极端一些,对于下表的关键字,如果我们让p为12的话,就可能出现下面的情况,所有的关键字都得到了0这个地址数,这未免也太糟糕了点。
我们不选用p=12来做除留余数法,而选用p=11,如下表所示。
此就只有12和144有冲突,相对来说,就要好很多。
因此根据前辈们的经验,若散列表表长为m,通常p为小于或等于表长(最好接近m)的最小质数或不包含小于20质因子的合数。
随机数法
选择一个随机数,取关键字的随机函数值为它的散列地址。
也就是f(key)=random(key)。
这里random是随机函数。
当关键字的长度不等时,采用这个方法构造散列函数是比较合适的。
有同学问,那如果关键字是字符串如何处理?其实无论是英文字符,还是中文字符,也包括各种各样的符号,它们都可以转化为某种数字来对待,比如ASCII码或者Unicode码等,因此也就可以使用上面的这些方法。
总之,现实中,应该视不同的情况采用不同的散列函数。
我们只能给出一些考虑的因素来提供参考:
1.计算散列地址所需的时间。
2.关键字的长度。
3.散列表的大小。
4.关键字的分布情况。
5.记录查找的频率。
综合这些因素,才能决策选择哪种散列函数更合适。
处理散列冲突的方法
那么当我们在使用散列函数后发现两个关键字key1≠key2,但是却有f(key1)=f(key2),即有冲突时,怎么办呢?我们可以从生活中找寻思路。
试想一下,当你观望很久很久,终于看上一套房打算要买了,正准备下订金,人家告诉你,这房子已经被人买走了,你怎么办?
对呀,再找别的房子呗!这其实就是一种处理冲突的方法——开放定址法。
开放定址法
所谓的开放定址法就是一旦发生了冲突,就去寻找下一个空的散列地址,只要散列表足够大,空的散列地址总能找到,并将记录存入。
它的公式是:
fi(key)=(f(key)+di)MOD m(di=1,2,3,……,m-1)
比如说,我们的关键字集合为{12,67,56,16,25,37,22,29,15,47,48,34},表长为12。我们用散列函数f(key)=key mod 12。
当计算前5个数{12,67,56,16,25}时,都是没有冲突的散列地址,直接存入,如下表所示。
计算key=37时,发现f(37)=1,此时就与25所在的位置冲突。于是我们应用上面的公式f(37)=(f(37)+1)mod 12=2。于是将37存入下标为2的位置。这其实就是房子被人买了于是买下一间的作法,如表所示。
接下来22,29,15,47都没有冲突,正常的存入,如表所示。
到了key=48,我们计算得到f(48)=0,与12所在的0位置冲突了,不要紧,我们f(48)=(f(48)+1)mod 12=1,此时又与25所在的位置冲突。
于是f(48)=(f(48)+2)mod 12=2,还是冲突……一直到f(48)=(f(48)+6)mod 12=6时,才有空位,机不可失,赶快存入,如表所示。
我们把这种解决冲突的开放定址法称为线性探测法。
从这个例子我们也看到,我们在解决冲突的时候,还会碰到如48和37这种本来都不是同义词却需要争夺一个地址的情况,我们称这种现象为堆积。
很显然,堆积的出现,使得我们需要不断处理冲突,无论是存入还是查找效率都会大大降低。
考虑深一步,如果发生这样的情况,当最后一个key=34,f(key)=10,与22所在的位置冲突,可是22后面没有空位置了,反而它的前面有一个空位置,尽管可以不断地求余数后得到结果,但效率很差。
因此我们可以改进di=12,-12,22,-22,……,q2,-q2,(q≤m/2),这样就等于是可以双向寻找到可能的空位置。
对于34来说,我们取di=-1即可找到空位置了。
另外增加平方运算的目的是为了不让关键字都聚集在某一块区域。我们称这种方法为二次探测法。
fi(key)=(f(key)+di)MOD m(di=12,-12,22,-22,…,q2,-q2,q≤m/2)
还有一种方法是,在冲突时,对于位移量di采用随机函数计算得到,我们称之为随机探测法。
此时一定有人问,既然是随机,那么查找的时候不也随机生成di吗?如何可以获得相同的地址呢?这是个问题。这里的随机其实是伪随机数。伪随机数是说,如果我们设置随机种子相同,则不断调用随机函数可以生成不会重复的数列,我们在查找时,用同样的随机种子,它每次得到的数列是相同的,相同的di当然可以得到相同的散列地址。
fi(key)=(f(key)+di)MOD m(di是一个随机数列)
总之,开放定址法只要在散列表未填满时,总是能找到不发生冲突的地址,是我们常用的解决冲突的办法。
再散列函数法
对于我们的散列表来说,我们事先准备多个散列函数。
fi(key)=RHi(key)(i=1,2,…,k)
这里RHi就是不同的散列函数,你可以把我们前面说的什么除留余数、折叠、平方取中全部用上。
每当发生散列地址冲突时,就换一个散列函数计算,相信总会有一个可以把冲突解决掉。
这种方法能够使得关键字不产生聚集,当然,相应地也增加了计算的时间。
链地址法
思路还可以再换一换,为什么有冲突就要换地方呢,我们直接就在原地想办法不可以吗?于是我们就有了链地址法。
将所有关键字为同义词的记录存储在一个单链表中,我们称这种表为同义词子表,在散列表中只存储所有同义词子表的头指针。
对于关键字集合{12,67,56,16,25,37,22,29,15,47,48,34},我们用前面同样的12为除数,进行除留余数法,可得到如下图结构,此时,已经不存在什么冲突换址的问题,无论有多少个冲突,都只是在当前位置给单链表增加结点的问题。
链地址法对于可能会造成很多冲突的散列函数来说,提供了绝不会出现找不到地址的保障。
当然,这也就带来了查找时需要遍历单链表的性能损耗。
公共溢出区法
这个方法其实就更加好理解,你不是冲突吗?好吧,凡是冲突的都跟我走,我给你们这些冲突找个地儿待着。这就如同孤儿院收留所有无家可归的孩子一样,我们为所有冲突的关键字建立了一个公共的溢出区来存放。
就前面的例子而言,我们共有三个关键字{37,48,34}与之前的关键字位置有冲突,那么就将它们存储到溢出表中,如图8-11-2所示。
在查找时,对给定值通过散列函数计算出散列地址后,先与基本表的相应位置进行比对,如果相等,则查找成功;
如果不相等,则到溢出表去进行顺序查找。
如果相对于基本表而言,有冲突的数据很少的情况下,公共溢出区的结构对查找性能来说还是非常高的。
散列表查找实现
说了这么多散列表查找的思想,我们就来看看查找的实现代码。
散列表查找算法实现
首先是需要定义一个散列表的结构以及一些相关的常数。
其中HashTable就是散列表结构。
结构当中的elem为一个动态数组。
1 |
|
有了结构的定义,我们可以对散列表进行初始化。
1 | /* 初始化散列表 */ |
为了插入时计算地址,我们需要定义散列函数,散列函数可以根据不同情况更改算法。
1 | /* 散列函数 */ |
初始化完成后,我们可以对散列表进行插入操作。
假设我们插入的关键字集合就是前面的{12,67,56,16,25,37,22,29,15,47,48,34}。
1 | /* 插入关键字进散列表 */ |
代码中插入关键字时,首先算出散列地址,如果当前地址不为空关键字,则说明有冲突。
此时我们应用开放定址法的线性探测进行重新寻址,此处也可更改为链地址法等其他解决冲突的办法。
散列表存在后,我们在需要时就可以通过散列表查找要的记录。
1 | /* 散列表查找关键字 */ |
查找的代码与插入的代码非常类似,只需做一个不存在关键字的判断而已。
散列表查找性能分析
最后,我们对散列表查找的性能作一个简单分析。
如果没有冲突,散列查找是我们本章介绍的所有查找中效率最高的,因为它的时间复杂度为O(1)。
可惜,我说的只是“如果”,没有冲突的散列只是一种理想,在实际的应用中,冲突是不可避免的。
那么散列查找的平均查找长度取决于哪些因素呢?
1.散列函数是否均匀
散列函数的好坏直接影响着出现冲突的频繁程度,不过,由于不同的散列函数对同一组随机的关键字,产生冲突的可能性是相同的,因此我们可以不考虑它对平均查找长度的影响。
2.处理冲突的方法
相同的关键字、相同的散列函数,但处理冲突的方法不同,会使得平均查找长度不同。
比如线性探测处理冲突可能会产生堆积,显然就没有二次探测法好,而链地址法处理冲突不会产生任何堆积,因而具有更佳的平均查找性能。
3.散列表的装填因子
所谓的装填因子α=填入表中的记录个数/散列表长度。α标志着散列表的装满的程度。当填入表中的记录越多,α就越大,产生冲突的可能性就越大。比如我们前面的例子,如果你的散列表长度是12,而填入表中的记录个数为11,那么此时的装填因子α=11/12=0.9167,再填入最后一个关键字产生冲突的可能性就非常之大。也就是说,散列表的平均查找长度取决于装填因子,而不是取决于查找集合中的记录个数。
不管记录个数n有多大,我们总可以选择一个合适的装填因子以便将平均查找长度限定在一个范围之内,此时我们散列查找的时间复杂度就真的是O(1)了。
为了做到这一点,通常我们都是将散列表的空间设置得比查找集合大,此时虽然是浪费了一定的空间,但换来的是查找效率的大大提升,总的来说,还是非常值得的。