大话数据结构学习笔记(八)——查找
查找(Searching)就是根据给定的某个值,在查找表中确定一个其关键字等于给定值的数据元素(或记录)。
1 查找概论
只要你打开电脑,就会涉及到查找技术。如炒股软件中查股票信息、硬盘文件中找照片、在光盘中搜DVD,甚至玩游戏时在内存中查找攻击力、魅力值等数据修改用来作弊等,都要涉及到查找。当然, 在互联网上查找信息就更加是家常便饭。所有这些需要被查的数据所在的集合,我们给它一个统称叫查找表。
查找表(Search Table)是由同一类型的数据元素(或记录)构成的集合。例如图8-2-1就是一个查找表。
关键字(Key)是数据元素中某个数据项的值,又称为键值,用它可以标识一个数据元素。也可以标识一个记录的某个数据项(字段),我们称为关键码,如图8-2-1中①和②所示。
若此关键字可以唯一地标识一个记录,则称此关键字为主关键字(Primary Key)。注意这也就意味着,对不同的记录,其主关键字均不相同。主关键字所在的数据项称为主关键码,如图8-2-1中③和④所示。
那么对于那些可以识别多个数据元素(或记录)的关键字,我们称为次关键字(Secondary Key), 如图8-2-1中⑤所示。次关键字也可以理解为是不以唯一标识一个数据元素(或记录)的关键字,它对应的数据项就是次关键码。
查找(Searching)就是根据给定的某个值,在查找表中确定一个其关键字等于给定值的数据元素
(或记录)。
若表中不存在关键字等于给定值的记录,则称查找不成功,此时查找的结果可给出一个“空”记录或“空”指针。
查找表按照操作方式来分有两大种:静态查找表和动态查找表。
静态查找表(Static Search Table):只作查找操作的查找表。它的主要操作有:
- 询某个“特定的”数据元素是否在查找表中。
- 检索某个“特定的”数据元素和各种属性。
动态查找表(Dynamic Search Table):在查找过程中同时插入查找表中不存在的数据元素,或者从查找表中删除已经存在的某个数据元素。显然动态查找表的操作就是两个:
- 查找时插入数据元素。
- 查找时删除数据元素。
了提高查找的效率,我们需要专门为查找操作设置数据结构,这种面向查找操作的数据结构称为查找结构。
2 顺序表查找
顺序查找(Sequential Search)又叫线性查找,是最基本的查找技术,它的查找过程是: 从表中第一个(或最后一个)记录开始,逐个进行记录的关键字和给定值比较,若某个记录的关键字和给定值相等,则查找成功,找到所查的记录;如果直到最后一个(或第一个)记录,其关键字和给定值比较都不等时,则表中没有所查的记录,查找不成功。
2.1 顺序表查找算法
顺序查找的算法实现如下。
// 顺序查找,a为数组,n为要查找的数组长度,key为要查找的关键字
int Sequential_Search(int *a, int n, int key)
{
int i;
for (int i = 1; i < n; i ++)
{
if (a[i] == key)
return i;
}
return 0;
}
这段代码非常简单,就是在数组a
(注意元素值从下标1开始)中查看有没有关键字(key),当你需要查找复杂表结构的记录时,只需要把数组a
与关键字key
定义成你需要的表结构和数据类型即可。
2.2 顺序表查找优化
到这里并非足够完美,因为每次循环时都需要对i
是否越界,即是否小于等于n
作判断。事实上,还可以有更好一点的办法,设置一个哨兵,可以解决不需要每次让i
与n
作比较。看下面的改进后的顺序查找算法代码。
// 有哨兵的顺序查找
int Sequential_Search2(int *a, int n, int key)
{
int i;
// 设置a[0]为关键字值,我们称之为“哨兵”
a[0] = key;
// 循环从数组尾部开始
i = n;
while (a[i] != key)
{
i --;
}
// 返回0则说明查找失败
return i;
}
此时代码是从尾部开始查找,由于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
很大时,查找效率极为低下,不过优点也是有的,这个算法非常简单,对静态查找表的记录没有任何要求,在一些小型数据的查找时,是可以适用的。
另外,也正由于查找概率的不同,我们完全可以将容易查找到的记录放在前面,而不常用的记录放置在后面,效率就可以有大幅提高。
3 有序表查找
我们如果仅仅是把书整理在书架上,要找到一本书还是比较困难的,也就是刚才讲的需要逐个顺序查找。但如果我们在整理书架时,将图书按照书名的拼音排序放置,那么要找到某一本书就相对容易了。说白了,就是对图书做了有序排列,一个线性表有序时,对于查找总是很有帮助的。
3.1 折半查找(二分查找)
折半查找(Binary Search) 技术,又称为二分查找。它的前提是线性表中的记录必须是关键码有序(通常从小到大有序),线性表必须采用顺序存储。折半查找的基本思想是:在有序表中,取中间记录作为比较对象,若给定值与中间记录的关键字相等,则查找成功;若给定值小于中间记录的关键字,则在中间记录的左半区继续查找;若给定值大于中间记录的关键字,则在中间记录的右半区继续查找。不断重复上述过程,直到查找成功,或所有查找区域无记录,查找失败为止。
假设我们现在有这样一个有序表数组{0,1,16,24,35,47,59,62,73,88,99}
,除0下标外共10个数字。对它进行查找是否存在62这个数。我们来看折半查找的算法是如何工作的。
// 折半查找
int Binary_Search(int *a, int n, int key)
{
int low, high, mid;
// 定义最低下标为记录首位
low = 1;
// 定义最高下标为记录末位
high = n;
while (low <= high)
{
// 折半
mid = (low + high) / 2;
// 若查找值比中值小
if (key < a[mid])
// 最高下标调整到中位下标小一位
high = mid - 1;
// 若查找值比中值大
else if (key > a[mid])
// 最低下标调整到中位下标大一位
low = mid + 1;
// 若相等则说明mid即为查找到的位置
else
return mid;
}
return 0;
}
-
程序开始运行, 参数
a={0,1,16,24,35,47,59,62,73,88,99}
,n=10
,key=62
,第4-8行,此时low=1
,high=10
,如图8-4-2所示。 -
第9到24行循环,进行查找。
-
第12行,mid计算得5,由于
a[5]=47<key
,所以执行了第20行,low=5+1=6
,如图8-4-3所示。 -
再次循环,
mid=(6+10)/2=8
,此时a[8]=73>key
,所以执行第16行,high=8-1=7
,如图8-4-4所示。 -
再次循环,
mid=(6+7)/2=6
,此时a[6]=59<key
,所以执行第20行,low=6+1=7
,如图8-4-5所示。 -
再次循环,
mid=(7+7)/2=7
,此时a[7]=62=key
,查找成功,返回7。
首先,我们将这个数组的查找过程绘制成一棵二叉树,如图8-4-6所示,从图上就可以理解,如果查找的关键字不是中间记录47的话,折半查找等于是把静态有序查找表分成了两棵子树,即查找结果只需要找其中的一半数据记录即可,等于工作量少了一半,然后继续折半查找,效率当然是非常高了。
我们之前讲的二叉树的性质4,有过对“具有n个结点的完全二叉树的深度为|log2(n)|+1
。”性质的推导过程。在这里尽管折半查找判定二叉树并不是完全二叉树,但同样相同的推导可以得出,最坏情况是查找到关键字或查找失败的次数为|log2(n)|+1
,而最好的情况当然是1。
因此最终我们折半算法的时间复杂度为O(log2(n))
,它显然远远好于顺序查找的O(n)
时间复杂度了。
不过由于折半查找的前提条件是需要有序表顺序存储,对于静态查找表,一次排序后不再变化,这样的算法已经比较好了。但对于需要频繁执行插入或删除操作的数据集来说,维护有序的排序会带来不小的工作量,那就不建议使用。
3.2 插值查找
现在我们的新问题是,为什么一定要折半,而不是折四分之一或者折更多呢?
比如要在取值范围0~10000之间100个元素从小到大均匀分布的数组中查找5,我们自然会考虑从数组下标较小的开始查找。
所以,我们的折半查找,还是有改进空间的。
折半查找代码的第12行,我们略微等式变换后得到:
也就是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
时,按原来折半的做法,我们需要四次( 如图8-4-6)才可以得到结果,但如果用新办法,(key-a[low])/(a[high]-a[low])=(16-1)/(99-1)≈0.153
,即mid≈1+0.153×(10-1)=2.377
取整得到mid=2
,我们只需要二次就查找到结果了,显然大大提高了查找的效率。
换句话说,我们只需要在折半查找算法的代码中更改一下第12行代码如下:
// 插值
mid=low + (high - low) * (key - a[low]) / (a[high] - a[low]);
就得到了另一种有序表查找算法,插值查找法。 插值查找( Interpolation Search)是根据要查找的关键字key与查找表中最大最小记录的关键字比较后的查找方法,其核心就在于插值的计算公式(key-a[low])/(a[high]-a[low])
。应该说,从时间复杂度来看,它也是O(log2(n))
,但对于表长较大,而关键字分布又比较均匀的查找表来说,插值查找算法的平均性能比折半查找要好得多。反之,数组中如果分布类似{0,1,2,2000,2001,......,999998,999999}
这种极端不均匀的数据,用插值查找未必是很合适的选择。
3.3 斐波那契查找
还有没有其他办法?我们折半查找是从中间分,也就是说,每一次查找总是一分为二,无论数据偏大还是偏小,很多时候这都未必就是最合理的做法。除了插值查找,我们再介绍一种有序查找,斐波那契查找(Fibonacci Search),它是利用了黄金分割原理来实现的。
为了能够介绍清楚这个查找算法,我们先需要有一个斐波那契数列的数组,如图8-4-8所示。
下面我们根据代码来看程序是如何运行的。
// 斐波那契查找
int Fibonacci_Search(int *a, int n, int key)
{
int low, high, mid, i, k;
// 定义最低下标为记录首位
low = 1;
// 定义最高下标为记录末位
high = n;
k = 0;
// 计算n位于斐波那契数列的位置
while (n > F[k] - 1)
k ++;
// 将不满的数值补全
for (i = n; i < F[k] - 1; i ++)
a[i] = a[n];
while (low <= high)
{
// 计算当前分隔的下标
mid = low + F[k - 1] - 1;
// 若查找记录小于当前分隔记录
if (key < a[mid])
{
// 最高下标调整到分隔下标mid-1处
high = mid - 1;
// 斐波那契数列下标减一位
k = k - 1;
}
// 若查找记录大于当前分隔记录
else if (key > a[mid])
{
// 最低下标调整到分隔下标mid+1处
low = mid + 1;
// 斐波那契数列下标减两位
k = k - 2;
}
else
{
// 若相等则说明mid即为查找到的位置
if (mid <= n)
return mid;
// 若mid>n说明是补全数值,返回n
else
return n;
}
}
return 0;
}
-
程序开始运行,参数
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,......}
。 -
第9-12行是计算当前的
n
处于斐波那契数列的位置。现在n=10
,F[6]<n<F[7]
,所以计算得出k=7
。 -
第13-15行,由于
k=7
,计算时是以F[7]=13
为基础,而a中最大的仅是a[10]
,后面的a[11]
,a[12]
均未赋值,这不能构成有序数列,因此将它们都赋值为最大的数组值,所以此时a[11]=a[12]=a[10]=99
( 此段代码作用后面还有解释)。 -
第16-45行,查找正式开始。
-
第19行,
mid=1+F[7-1]-1=8
,也就是说,我们第一个要对比的数值是从下标为8开始的。 -
由于此时
key=59
而a[8]=73
,因此执行第23-26行,得到high=7
,k=6
。 -
再次循环,
mid=1+F[6-1]-1=5
。此时a[5]=47<key
,因此执行第31-34行,得到low=6
,k=6-2=4
。注意此时k
下调2个单位。 -
再次循环,
mid=6+F[4-1]-1=7
。此时a[7]=62>key
,因此执行第23-26行,得到high=6
,k=4-1=3
。 -
再次循环,
mid=6+F[3-1]-1=6
。此时a[6]=59=key
,因此执行第38-40行,得到返回值为6。程序运行结束。
如果key=99
,此时查找循环第一次时,mid=8
与上例是相同的,第二次循环时,mid=11
,如果a[11]
没有值就会使得与key
的比较失败,为了避免这样的情况出现, 第13-15行的代码就起到这样的作用。
斐波那契查找算法的核心在于:
- 当
key=a[mid]
时,查找就成功; - 当
key<a[mid]
时,新范围是第low
个到第mid-1
个,此时范围个数为F[k-1]-1
个; - 当
key>a[mid]
时,新范围是第mid+1
到第high
个,此时范围个数为F[k-2]-1
个。
也就是说,如果要查找的记录在右侧,则左侧的数据都不用再判断了,不断反复进行下去,对处于当中的大部分数据,其工作效率要高一些。所以尽管斐波那契查找的时间复杂也为O(log2(n))
,但就平均性能来说,斐波那契查找要优于折半查找。可惜如果是最坏情况,比如这里key=1
,那么始终都处于左侧长半区在查找,则查找效率要低于折半查找。
还有比较关键的一点,折半查找是进行加法与除法运算(mid=(low+high)/2
),插值查找进行复杂的四则运算(mid=low+(high-low)*(key-a[low])/(a[high]-a[low])
),而斐波那契查找只是最简单加减法运算(mid=low+F[k-1]-1
),在海量数据的查找过程中,这种细微的差别可能会影响最终的查找效率。
应该说,三种有序表的查找本质上是分隔点的选择不同,各有优劣,实际开发时可根据数据的特点综合考虑再做出选择。
4 线性索引查找
我们前面讲的几种比较高效的查找方法都是基于有序的基础之上的,但事实上,很多数据集可能增长非常快,例如,某些微博网站或大型论坛的帖子和回复总数每天都是成百万上千万条,或者一
些服务器的日志信息记录也可能是海量数据,要保证记录全部是按照当中的某个关键字有序,其时间代价是非常高昂的,所以这种数据通常都是按先后顺序存储。
那么对于这样的查找表,我们如何能够快速查找到需要的数据呢?办法就是——索引。
数据结构的最终目的是提高数据的处理速度,索引是为了加快查找速度而设计的一种数据结构。索引就是把一个关键字与它对应的记录相关联的过程,一个索引由若干个索引项构成,每个索引项至少应包含关键字和其对应的记录在存储器中的位置等信息。索引技术是组织大型数据库以及磁盘文件的一种重要技术。
索引按照结构可以分为线性索引、树形索引和多级索引。我们这里就只介绍线性索引技术。所谓线性索引就是将索引项集合组织为线性结构,也称为索引表。我们重点介绍三种线性索引:稠密索引、分块索引和倒排索引。
4.1 稠密索引
稠密索引是指在线性索引中,将数据集中的每个记录对应一个索引项,如图8-5-2所示。
密索引要应对的可能是成千上万的数据,因此对于稠密索引这个索引表来说,索引项一定是按照关键码有序的排列。
索引项有序也就意味着,我们要查找关键字时,可以用到折半、插值、斐波那契等有序查找算法,大大提高了效率。比如图8-5-2中, 我要查找关键字是18的记录,如果直接从右侧的数据表中查找,那只能顺序查找,需要查找6次才可以查到结果。而如果是从左侧的索引表中查找,只需两次折半查找就可以得到18对应的指针,最终查找到结果。
这显然是稠密索引优点,但是如果数据集非常大,比如上亿,那也就意味着索引也得同样的数据集长度规模,对于内存有限的计算机来说,可能就需要反复去访问磁盘,查找性能反而大大下降了。
4.2 分块索引
稠密索引因为索引项与数据集的记录个数相同,所以空间代价很大。为了减少索引项的个数,我们可以对数据集进行分块,使其分块有序,然后再对每一块建立一个索引项,从而减少索引项的数。
分块有序,是把数据集的记录分成了若干块,并且这些块需要满足两个条件:
- 块内无序,即每一块内的记录不要求有序。当然,你如果能够让块内有序对查找来说更理想,不过这就要付出大量时间和空间的代价,因此通常我们不要求块内有序。
- 块间有序,例如,要求第二块所有记录的关键字均要大于第一块中所有记录的关键字,第三块的所有记录的关键字均要大于第二块的所有记录关键字……因为只有块间有序,才有可能在查找时带来效率。
对于分块有序的数据集,将每块对应一个索引项,这种索引方法叫做分块索引。如图8-5-4所示, 我们定义的分块索引的索引项结构分三个数据项:
- 最大关键码,它存储每一块中的最大关键字,这样的好处就是可以使得在它之后的下一块中的最小关键字也能比这一块最大的关键字要大;
- 存储了块中的记录个数,以便于循环时使用;
- 用于指向块首数据元素的指针,便于开始对这一块中记录进行遍历。
在分块索引表中查找,就是分两步进行:
- 在分块索引表中查找要查关键字所在的块。由于分块索引表是块间有序的,因此很容易利用折半、插值等算法得到结果。例如,在图8-5-4的数据集中查找62,我们可以很快可以从左上角的索引表中由57<62<96得到62在第三个块中。
- 根据块首指针找到相应的块,并在块中顺序查找关键码。因为块中可以是无序的, 因此只能顺序查找。
我们再来分析一下分块索引的平均查找长度。设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=t^2
,即:
可见,分块索引的效率比之顺序查找的O(n)
是高了不少,不过显然它与折半查找的O(log2(n))
相比还有不小的差距。因此在确定所在块的过程中,由于块间有序,所以可以应用折半、插值等手段来提高效率。
总的来说,分块索引在兼顾了对细分块不需要有序的情况下,大大增加了整体查找的速度,所以普遍被用于数据库表查找等技术的应用当中。
4.3 倒排索引
我们在这里介绍最简单的,也算是最基础的搜索技术——倒排索引。
我们来看样例,现在有两篇极短的英文“文章”——其实只能算是句子,我们暂认为它是文章,编号分别是1和2。
- Books and friends should be few but good.( 读书如交友,应求少而精。)
- A good book is a good friend.( 好书如挚友。)
假设我们忽略掉如"books"
、"friends"
中的复数"s"
以及如"A"
这样的大小写差异。我们可以整理出这样一张单词表,如下表所示,并将单词做了排序,也就是表格显示了每个不同的单词分别出现在哪篇文章中,比如"good"
它在两篇文章中都有出现,而"is"
只是在文章2中才有。
英文单词 | 文章编号 |
---|---|
a | 2 |
and | 1 |
be | 1 |
book | 1,2 |
but | 1 |
few | 1 |
friend | 1,2 |
good | 1,2 |
is | 2 |
should | 1 |
有了这样一张单词表,我们要搜索文章,就非常方便了。如果你在搜索框中填写"book"
关键字。系统就先在这张单词表中有序查找"book"
,找到后将它对应的文章编号1和2的文章地址( 通常在搜索引擎中就是网页的标题和链接)返回,并告诉你,查找到两条记录,用时0.0001秒。由于单词表是有序的,查找效率很高,返回的又只是文章的编号,所以整体速度都非常快。
在这里这张单词表就是索引表,索引项的通用结构是:
- 次关键码,例如上面的“英文单词”。
- 记录号表,例如上面的“文章编号”。
其中记录号表存储具有相同次关键字的所有记录的记录号(可以是指向记录的指针或者是该记录的主关键字)。 这样的索引方法就是倒排索引(inverted index)。倒排索引源于实际应用中需要根据属性(或字段、次关键码)的值来查找记录。这种索引表中的每一项都包括一个属性值和具有该属性值的各记录的地址。由于不是由记录来确定属性值,而是由属性值来确定记录的位置,因而称为倒排索引。
倒排索引的优点显然就是查找记录非常快,基本等于生成索引表后,查找时都不用去读取记录,就可以得到结果。但它的缺点是这个记录号不定长, 比如上例有7个单词的文章编号只有一个,而“book”、“friend”、“good”有两个文章编号,若是对多篇文章所有单词建立倒排索引,那每个单词都将对应相当多的文章编号,维护比较困难,插入和删除操作都需要作相应的处理。
5 二叉排序树
二叉排序树(Binary Sort Tree),又称为二叉查找树。它或者是一棵空树,或者是具有下列性质的二叉树。
- 若它的左子树不空,则左子树上所有结点的值均小于它的根结构的值;
- 若它的右子树不空,则右子树上所有结点的值均大于它的根结点的值;、
- 它的左、右子树也分别为二叉排序树。
从二叉排序树的定义也可以知道,它前提是二叉树,然后它采用了递归的定义方法,再者,它的结点间满足一定的次序关系,左子树结点一定比其双亲结点小,右子树结点一定比其双亲结点大。
构造一棵二叉排序树的目的,其实并不是为了排序,而是为了提高查找和插入删除关键字的速度。 不管怎么说,在一个有序数据集上的查找,速度总是要快于无序的数据集的,而二叉排序树这种非线性的结构,也有利于插入和删除的实现。
5.1 二叉排序树查找操作
首先我们提供一个二叉树的结构。
// 二叉树的二叉链表结点结构定义
// 结点结构
typedef struct BiTNode
{
// 结点数据
int data;
// 左右孩子指针
struct BiTNode *lchild, *rchild;
} BiTNode, *BiTree;
然后我们来看看二叉排序树的查找是如何实现的。
// 递归查找二叉排序树T中是否存在key,指针f指向T的双亲,其初始调用值为NULL。若查找成功,则指针p指向该数据元素结点,并返回TRUE;否则指针p指向查找路径上访问的最后一个结点并返回FALSE。
Status SearchBST(BiTree T, int key, BiTree f, BiTree *p)
{
// 查找不成功
if (!T)
{
*p = f;
return FALSE;
}
//查找成功
else if (key == T->data)
{
*p = T;
return TRUE;
}
else if (key < T->data)
// 在左子树继续查找
return SearchBST(T->lchild, key, T, p);
else
// 在右子树继续查找
return SearchBST(T->rchild, key, T, p);
}
-
SearchBST
函数是一个可递归运行的函数,函数调用时的语句为SearchBST(T,93,NULL,p)
,参数T
是一个二叉链表, 其中数据如图8-6-3所示,key
代表要查找的关键字,目前我们打算查找93,二叉树f
指向T
的双亲,当T
指向根结点时,f
的初值就为NULL
,它在递归时有用,最后的参数p
是为了查找成功后可以得到查找到的结点位置。 -
第4-9行,是用来判断当前二叉树是否到叶子结点,显然图8-6-3告诉我们当前
T
指向根结点62的位置,T
不为空,第7-8行不执行。 -
第10-15行是查找到相匹配的关键字时执行语句,显然93≠62,第13-14行不执行。
-
第16-18行是当要查找关键字小于当前结点值时执行语句,由于93>62,第18行不执行。
-
第19-21行是当要查找关键字大于当前结点值时执行语句,由于93>62,所以递归调用
SearchBST(T->rchild,key,T,p)
。此时T
指向了62的右孩子88,如图8-6-4所示。 -
此时第二层
SearchBST
,因93比88大,所以执行第21行,再次递归调用SearchBST(T->rchild,key,T,p)
。此时T
指向了88的右孩子99,如图8-6-5所示。 -
第三层的
SearchBST
,因93比99小,所以执行第18行,递归调用SearchBST(T->lchild,key,T,p)
。此时T
指向了99的左孩子93,如图8-6-6所示。 -
第四层
SearchBST
,因key
等于T->data
,所以执行第13-14行,此时指针p
指向93所在的结点,并返回TRUE
到第三层、第二层、第一层,最终函数返回TRUE
。
5.2 二叉排序树插入操作
有了二叉排序树的查找函数,那么所谓的二叉排序树的插入,其实也就是将关键字放到树中的合适位置而已,来看代码。
// 当二叉排序树T中不存在关键字等于key的数据元素时,插入key并返回TRUE,否则返回FALSE
Status InsertBST(BiTree *T, int key)
{
BiTree p, s;
// 查找不成功
if (!SearchBST(*T, key, NULL, &p))
{
s = (BiTree) malloc(sizeof(BiTNode));
s->data = key;
s->lchild = s->rchild = NULL;
if (!p)
// 插入s为新的根结点
*T = s;
else if (key < p->data)
// 插入s为左孩子
p->lchild = s;
else
// 插入s为右孩子
p->rchild = s;
return TRUE;
}
else
// 树中已有关键字相同的结点,不再插入
return FALSE;
}
这段代码非常简单。如果你调用函数是InsertBST(&T,93)
,那么结果就是FALSE
,如果是InsertBST(&T,95)
,那么一定就是在93的结点增加一个右孩子95,并且返回TRUE
。如图8-6-7所示。
有了二叉排序树的插入代码,我们要实现二叉排序树的构建就非常容易了。下面的代码就可以创建一棵图8-6-3这样的树。
int i;
int a[10] = {62, 88, 58, 47, 35, 73, 51, 99, 37, 93};
BiTree T = NULL;
for (i = 0; i < 10; i++)
{
InsertBST(&T, a[i]);
}
5.3 二叉排序树删除操作
我们对删除结点三种情况的分析:
- 叶子结点;
- 仅有左或右子树的结点;
- 左右子树都有的结点。
我们来看代码,下面这个算法是递归方式对二叉排序树T
查找key
,查找到时删除。
// 若二叉排序树T中存在关键字等于key的数据元素时,则删除该数据元素结点,并返回TRUE;否则返回FALSE。
Status DeleteBST(BiTree *T, int key)
{
// 不存在关键字等于key的数据元素
if (!*T)
return FALSE;
else
{
// 找到关键字等于key的数据元素
if (key == (*T)->data)
return Delete(T);
else if (key < (*T)->data)
return DeleteBST(&(*T)->lchild, key);
else
return DeleteBST(&(*T)->rchild, key);
}
}
这段代码和前面的二叉排序树查找几乎完全相同,唯一的区别就在于执行的是Delete方法,对当前结点进行删除操作。我们来看Delete的代码。
// 从二叉排序树中删除结点p,并重接它的左或右子树。
Status Delete(BiTree *p)
{
BiTree q, s;
// 右子树空则只需重接它的左子树
if ((*p)->rchild == NULL)
{
q = *p;
*p = (*p)->lchild;
free(q);
}
// 左子树空只需重接它的右子树
else if ((*p)->lchild == NULL)
{
q = *p;
*p = (*p)->rchild;
free(q);
}
// 左右子树均不空
else
{
q = *p;
s = (*p)->lchild;
// 转左,然后向右到尽头(找待删结点的前驱)
while (s->rchild)
{
q = s;
s = s->rchild;
}
// s指向被删结点的直接前驱
(*p)->data = s->data;
if (q != *p)
// 重接q的右子树
q->rchild = s->lchild;
else
// 重接q的左子树
q->lchild = s->lchild;
free(s);
}
return TRUE;
}
-
程序开始执行,代码第5-11行目的是为了删除没有右子树只有左子树的结点。此时只需将此结点的左孩子替换它自己,然后释放此结点内存,就等于删除了。
-
代码第12-18行是同样的道理,处理只有右子树没有左子树的结点删除问题。
-
第19-39行处理复杂的左右子树均存在的问题。
-
第22-23行,将要删除的结点
p
赋值给临时的变量q
,再将p
的左孩子p->lchild
赋值给临时的变量s
。此时q
指向47结点,s指向35结点,如图8-6-13所示。 -
第24-29行,循环找到左子树的右结点,直到右侧尽头。就当前例子来说就是让
q
指向35,而s
指向了37这个再没有右子树的结点,如图8-6-14所示。 -
第31行,此时让要删除的结点
p
的位置的数据被赋值为s->data
,即让p->data=37
,如图8-6-15所示。 -
第32-37行,如果
p
和q
指向不同,则将s->lchild
赋值给q->rchild
,否则就是将s->lchild
赋值给q->lchild
。 显然这个例子p
不等于q
,将s->lchild
指向的36赋值给q->rchild
,也就是让q->rchild
指向36结点,如图8-6-16所示。 -
第38行,
free(s)
,就非常好理解了,将37结点删除,如图8-6-17所示。
从这段代码也可以看出,我们其实是在找删除结点的前驱结点替换的方法,对于用后继结点来替换,方法上是一样的。
5.4 二叉排序树总结
总之,二叉排序树是以链接的方式存储,保持了链接存储结构在执行插入或删除操作时不用移动元素的优点,只要找到合适的插入和删除位置后,仅需修改链接指针即可。插入删除的时间性能比较好。而对于二叉排序树的查找,走的就是从根结点到要查找的结点的路径,其比较次数等于给定值的结点在二叉排序树的层数。极端情况,最少为1次,即根结点就是要找的结点,最多也不会超过树的深度。也就是说,二叉排序树的查找性能取决于二叉排序树的形状。可问题就在于,二叉排序树的形状是不确定的。
例如{62,88,58,47,35,73,51,99,37,93}
这样的数组,我们可以构建如图8-6-18左图的二叉排序树。但如果数组元素的次序是从小到大有序,如{35,37,47,51,58,62,73,88,93,99}
,则二叉排序树就成了极端的右斜树,注意它依然是一棵二叉排序树,如图8-6-18的右图。此时,同样是查找结点99,左图只需要两次比较,而右图就需要10次比较才可以得到结果,二者差异很大。
也就是说,我们希望二叉排序树是比较平衡的,即其深度与完全二叉树相同,均为|log2(n)|+1
,那么查找的时间复杂也就为O(log2(n))
,近似于折半查找,事实上,图8-6-18的左图也不够平衡,明显的左重右轻。
不平衡的最坏情况就是像图8-6-18右图的斜树,查找时间复杂度为O(n)
,这等同于顺序查找。
因此,如果我们希望对一个集合按二叉排序树查找,最好是把它构建成一棵平衡的二叉排序树。这样我们就引申出另一个问题,如何让二叉排序树平衡的问题。
6 平衡二叉树(AVL树)
平衡二叉树(Self-Balancing Binary SearchTree或Height-Balanced Binary Search Tree),是一种二叉排序树,其中每一个节点的左子树和右子树的高度差至多等于1。
从平衡二叉树的英文名字,你也可以体会到,它是一种高度平衡的二叉排序树。那什么叫做高度平衡呢?意思是说,要么它是一棵空树,要么它的左子树和右子树都是平衡二叉树,且左子树和右子树的深度之差的绝对值不超过1。我们将二叉树上结点的左子树深度减去右子树深度的值称为平衡因子BF(Balance Factor),那么平衡二叉树上所有结点的平衡因子只可能是-1、0和1。只要二叉树上有一个结点的平衡因子的绝对值大于1,则该二叉树就是不平衡的。
看图8-7-2,为什么图1是平衡二叉树,而图2却不是呢? 这里就是考查我们对平衡二叉树的定义的理解,它的前提首先是一棵二叉排序树,右上图的59比58大,却是58的左子树,这是不符合二叉排序树的定义的。图3不是平衡二叉树的原因就在于,结点58的左子树高度为3,而右子树为空,二者差大于了绝对值1,因此它也不是平衡的。而经过适当的调整后的图4,它就符合了定义,因此它是平衡二叉树。
距离插入结点最近的,且平衡因子的绝对值大于1的结点为根的子树,我们称为最小不平衡子树。图8-7-3,当新插入结点37时,距离它最近的平衡因子绝对值超过1的结点是58(即它的左子树高度3减去右子树高度1),所以从58开始以下的子树为最小不平衡子树。
6.1 平衡二叉树实现原理
平衡二叉树构建的基本思想就是在构建二叉排序树的过程中,每当插入一个结点时,先检查是否因插入而破坏了树的平衡性,若是,则找出最小不平衡子树。在保持二叉排序树特性的前提下,调整最小不平衡子树中各结点之间的链接关系,进行相应的旋转,使之成为新的平衡子树。
为了能在讲解算法时轻松一些,我们先讲一个平衡二叉树构建过程的例子。假设我们现在有一个数组a[10]={3,2,1,4,5,6,7,10,9,8}
需要构建二叉排序树。在没有学习平衡二叉树之前,根据二叉排序树的特性,我们通常会将它构建成如图8-7-4的图1所示的样子。虽然它完全符合二叉排序树的定义,但是对这样高度达到8的二叉树来说,查找是非常不利的。我们更期望能构建成如图8-7-4的图2的样子,高度为4的二叉排序树才可以提供高效的查找效率。那么现在我们就来研究如何将一个数组构建出图2的树结构。
对于数组a[10]={3,2,1,4,5,6,7,10,9,8}
的前两位3和2,我们很正常地构建,到了第3个数“1”时,发现此时根结点“3”的平衡因子变成了2,此时整棵树都成了最小不平衡子树,因此需要调整,如图8-7-5的图1(结点左上角数字为平衡因子BF值)。因为BF值为正,因此我们将整个树进
行右旋(顺时针旋转),此时结点2成了根结点,3成了2的右孩子,这样三个结点的BF值均为0,非常的平衡,如图8-7-5的图2所示。
然后我们再增加结点4,平衡因子没有超出限定范围(-1, 0, 1),如图3。增加结点5时,结点3的BF值为-2,说明要旋转了。由于BF是负值,所以我们对这棵最小平衡子树进行左旋(逆时针旋转),如图4,此时我们整个树又达到了平衡。
继续,增加结点6时,发现根结点2的BF值变了-2,如图8-7-6的图6。所以我们对根结点进行了左旋,注意此时本来结点3是4的左孩子,由于旋转后需要满足二叉排序树特性,因此它成了结点2的右孩子,如图7。增加结点7,同样的左旋转,使得整棵树达到平衡,如图8和图9所示。
当增加结点10时,结构无变化,如图8-7-7的图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值符号统一了,如图8-7-7的图12所示。
这样我们再以结点7为最小不平衡子树进行左旋,得到图8-7-8的图13。接着插入8,情况与刚才类似,结点6的BF是-2,而它的右孩子9的BF是1,如图14,因此首先以9为根结点,进行右旋,得到图15,此时结点6和结点7的符号都是负,再以6为根结点左旋,最终得到最后的平衡二叉树,如图8-7-8的图16所示。
6.2 平衡二叉树实现算法
好了,有这么多的准备工作,我们可以来讲解代码了。首先是需要改进二叉排序树的结点结构,增加一个bf
,用来存储平衡因子。
// 二叉树的二叉链表结点结构定义
// 结点结构
typedef struct BiTNode
{
// 结点数据
int data;
// 结点的平衡因子
int bf;
// 左右孩子指针
struct BiTNode *lchild, *rchild;
} BiTNode, *BiTree;
然后,对于右旋操作,我们的代码如下。
// 对以P为根的二叉排序树作右旋处理,处理之后P指向新的树根结点,即旋转处理之前的左子树的根结点
void R_Rotate(BiTree *P)
{
BiTree L;
// L指向P的左子树根结点
L = (*P)->lchild;
// L的右子树挂接为P的左子树
(*P)->lchild = L->rchild;
L->rchild = (*P);
// P指向新的根结点
*P = L;
}
此函数代码的意思是说,当传入一个二叉排序树P
,将它的左孩子结点定义为L
,将L
的右子树变成P
的左子树,再将P
改成L
的右子树,最后将L
替换P
成为根结点。这样就完成了一次右旋操作,如图8-7-9所示。图中三角形代表子树,N
代表新增结点。
上面例子中的新增加结点N
(如图8-7-5的图1和图2),就是右旋操作。
左旋操作代码如下。
// 对以P为根的二叉排序树作左旋处理,处理之后P指向新的树根结点,即旋转处理之前的右子树的根结点
void L_Rotate(BiTree *P)
{
BiTree R;
// R指向P的右子树根结点
R = (*P)->rchild;
// R的左子树挂接为P的右子树
(*P)->rchild = R->lchild;
R->lchild = (*P);
// P指向新的根结点
*P = R;
}
这段代码与右旋代码是对称的,在此不做解释了。上面例子中的新增结点5、6、7(如图8-7-5的图4、5,图8-7-6的图6、7、8、9),都是左旋操作。
现在我们来看左平衡旋转处理的函数代码。
// 左高
#define LH +1
// 等高
#define EH 0
// 右高
#define RH -1
// 对以指针T所指结点为根的二叉树作左平衡旋转处理
// 本算法结束时,指针T指向新的根结点
void LeftBalance(BiTree *T)
{
BiTree L, Lr;
// L指向T的左子树根结点
L = (*T)->lchild;
switch (L->bf)
{
// 检查T的左子树的平衡度,并作相应平衡处理
// 新结点插入在T的左孩子的左子树上,要作单右旋处理
case LH:
(*T)->bf = L->bf = EH;
R_Rotate(T);
break;
// 新结点插入在T的左孩子的右子树上,要作双旋处理
case RH:
// Lr指向T的左孩子的右子树根
Lr = L->rchild;
// 修改T及其左孩子的平衡因子
switch (Lr->bf)
{
case LH:
(*T)->bf = RH;
L->bf = EH;
break;
case EH:
(*T)->bf = L->bf = EH;
break;
case RH:
(*T)->bf = EH;
L->bf = LH;
break;
}
Lr->bf = EH;
// 对T的左子树作左旋平衡处理
L_Rotate(&(*T)->lchild);
// 对T作右旋平衡处理
R_Rotate(T);
}
}
首先,我们定义了三个常数变量,分别代表1、0、-1。
-
函数被调用,传入一个需调整平衡性的子树
T
。由于LeftBalance
函数被调用时,其实是已经确认当前子树是不平衡状态,且左子树的高度大于右子树的高度。换句话说,此时T
的根结点应该是平衡因子BF
的值大于1的数。 -
第13行,我们将
T
的左孩子赋值给L
。 -
第14-46行是分支判断。
-
当
L
的平衡因子为LH
,即为1时,表明它与根结点的BF
值符号相同,因此,第19行,将它们的BF
值都改为0,并且第20行,进行右旋操作。操作的方式如图8-7-9所示。 -
当
L
的平衡因子为RH
,即为-1时,表明它与根结点的BF
值符号相反,此时需要做双旋处理。第26-40行,针对L
的右孩子Lr的BF
作判断,修改根结点T
和L
的BF
值。第41行将当前Lr的BF
改为0。 -
第43行,对根结点的左子树进行左旋,如图8-7-10第二图所示。
-
第45行,对根结点进行右旋,如图8-7-10的第三图所示,完成平衡操作。
同样的,右平衡旋转处理的函数代码非常类似,直接看代码,不做讲解了。
我们前面例子中的新增结点9和8就是典型的右平衡旋转,并且双旋完成平衡的例子(如图8-7-7的图11、12,图8-7-8的图14、15、16所示)。
有了这些准备,我们的主函数才算是正式登场了。
// 若在平衡的二叉排序树T中不存在和e有相同关键字的结点,则插入一个数据元素为e的新结点并返回1,否则返回0。若因插入而使二叉排序树失去平衡,则作平衡旋转处理,布尔变量taller反映T长高与否
Status InsertAVL(BiTree *T, int e, Status *taller)
{
if (!*T)
{
// 插入新结点,树“长高”,置taller为TRUE
*T = (BiTree) malloc(sizeof(BiTNode));
(*T)->data = e;
(*T)->lchild = (*T)->rchild = NULL;
(*T)->bf = EH;
*taller = TRUE;
}
else
{
if (e == (*T)->data)
{
// 树中已存在和e有相同关键字的结点则不再插入
*taller = FALSE;
return FALSE;
}
if (e < (*T)->data)
{
// 应继续在T左子树进行搜索
// 未插入
if (!InsertAVL(&(*T)->lchild, e, taller))
return FALSE;
// 已插入到T的左子树且左子树“长高”
if (*taller)
{
// 检查T的平衡度
switch((*T)->bf)
{
// 原本左子树比右子树高,需要作左平衡处理
case LH:
LeftBalance(T);
*taller = FALSE;
break;
// 原本左右子树等高,现因左子树增高而树增高
case EH:
(*T)->bf = LH;
*taller = TRUE;
break;
// 原本右子树比左子树高,现左右子树等高
case RH:
(*T)->bf = EH;
*taller = FALSE;
break;
}
}
}
else
{
// 应继续在T的右子树中进行搜索
// 未插入
if (!InsertAVL(&(*T)->rchild, e, taller))
return FALSE;
// 已插入到T的右子树且右子树“长高”
if (*taller)
{
// 检查T的平衡度
switch((*T)->bf)
{
// 原本左子树比右子树高,现左右子树等高
case LH:
(*T)->bf = EH;
*taller = FALSE;
break;
// 原本左右子树等高,现因右子树增高而树增高
case EH:
(*T)->bf = RH;
*taller = TRUE;
break;
// 原本右子树比左子树高,需要作右平衡处理
case RH:
RightBalance(T);
*taller = FALSE;
break;
}
}
}
}
}
- 程序开始执行时,第4-12行是指当前
T
为空时,则申请内存新增一个结点。 - 第15-20行表示当存在相同结点,则不需要插入。
- 第21-50行,当新结点
e
小于T
的根结点值时,则在T
的左子树查找。 - 第25-26行,递归调用本函数,直到找到则返回
FALSE
,否则说明插入结点成功,执行下面语句。 - 第27-49行,当
taller
为TRUE
时,说明插入了结点,此时需要判断T
的平衡因子,如果是1,说明左子树高于右子树,需要调用LeftBalance
函数进行左平衡旋转处理。如果为0或-1,则说明新插入结点没有让整棵二叉排序树失去平衡性,只需要修改相关的BF
值即可。 - 第51-80行,说明新结点
e
大于T
的根结点的值,在T
的右子树查找。代码上述类似,不再详述。
对于这段代码来说,我们只需要在需要构建平衡二叉树的时候执行如下列代码即可在内存中生成一棵与图8-7-4的图2相同的平衡的二叉树。
int i;
int a[10] = { 3, 2, 1, 4, 5, 6, 7, 10, 9, 8 };
BiTree T = NULL;
Status taller;
for (i = 0; i < 10; i ++)
{
InsertAVL(&T, a[i], &taller);
}
如果我们需要查找的集合本身没有顺序,在频繁查找的同时也需要经常的插入和删除操作,显然我们需要构建一棵二叉排序树,但是不平衡的二叉排序树,查找效率是非常低的,因此我们需要在构建时,就让这棵二叉排序树是平衡二叉树,此时我们的查找时间复杂度就为O(log2(n))
,而插入和删除也为O(log2(n))
。这显然是比较理想的一种动态查找表算法。
7 多路查找树(B树)
我们之前谈的树,都是一个结点可以有多个孩子,但是它自身只存储一个元素。二叉树限制更多,结点最多只能有两个孩子。
一个结点只能存储一个元素,在元素非常多的时候,就使得要么树的度非常大(结点拥有子树的个数的最大值),要么树的高度非常大,甚至两者都必须足够大才行。这就使得内存存取外存次数非常多,这显然成了时间效率上的瓶颈,这迫使我们要打破每一个结点只存储一个元素的限制,为此引入了多路查找树的概念。
多路查找树(multi-way search tree),其每一个结点的孩子数可以多于两个,且每一个结点处可以存储多个元素。由于它是查找树,所有元素之间存在某种特定的排序关系。
在这里,每一个结点可以存储多少个元素,以及它的孩子数的多少是非常关键的。为此,我们讲解它的4种特殊形式:2-3树、2-3-4树、B树和B+树。
7.1 2-3树
2-3树是这样的一棵多路查找树:其中的每一个结点都具有两个孩子(我们称它为2结点)或三个孩子(我们称它为3结点)。
一个2结点包含一个元素和两个孩子(或没有孩子),且与二叉排序树类似,左子树包含的元素小于该元素,右子树包含的元素大于该元素。不过,与二叉排序树不同的是,这个2结点要么没有孩子, 要有就有两个,不能只有一个孩子。
一个3结点包含一小一大两个元素和三个孩子(或没有孩子),一个3结点要么没有孩子,要么具有3个孩子。如果某个3结点有孩子的话,左子树包含小于较小元素的元素,右子树包含大于较大元素的元素,中间子树包含介于两元素之间的元素。
并且2-3树中所有的叶子都在同一层次上。如图8-8-2所示,就是一棵有效的2-3树。
事实上,2-3树复杂的地方就在于新结点的插入和已有结点的删除。毕竟,每个结点可能是2结点也可能是3结点,要保证所有叶子都在同一层次,是需要进行一番复杂操作的。
7.1.1 2-3树的插入实现
对于2-3树的插入来说,与二叉排序树相同,插入操作一定是发生在叶子结点上。可与二叉排序树不同的是,2-3树插入一个元素的过程有可能会对该树的其余结构产生连锁反应。
2-3树插入可分为三种情况。
-
对于空树,插入一个2结点即可,这很容易理解。
-
插入结点到一个2结点的叶子上。应该说,由于其本身就只有一个元素,所以只需要将其升级为3结点即可。如图8-8-3所示。我们希望从左图的2-3树中插入元素3,根据遍历可知,3比8小、比4小,于是就只能考虑插入到叶子结点1所在的位置,因此很自然的想法就是将此结点变成一个3结点,即右图这样完成插入操作。当然,要视插入的元素与当前叶子结点的元素比较大小后,决定谁在左谁在右。例如,若插入的是0,则此结点就是“0”在左“1”在右了。
-
要往3结点中插入一个新元素。因为3结点本身已经是2-3树的结点最大容量(已经有两个元素),因此就需要将其拆分,且将树中两元素或插入元素的三者中选择其一向上移动一层。复杂的情况也正在于此。
第一种情况,见图8-8-4,需要向左图中插入元素5。经过遍历可得到元素5比8小比4大,因此它应该是需要插入在拥有6、7元素的3结点位置。问题就在于,6和7结点已经是3结点,不能再加。此时发现它的双亲结点4是个2结点,因此考虑让它升级为3结点,这样它就得有三个孩子,于是就想到,将6、7结点拆分,让6与4结成3结点,将5成为它的中间孩子,将7成为它的右孩子,如图8-8-4的右图所示。另一种情况,如图8-8-5所示,需要向左图中插入元素11。经过遍历可得到元素11比12、14小比9、10大,因此它应该是需要插入在拥有9、10元素的3结点位置。同样道理,9和10结点不能再增加结点。此时发现它的双亲结点12、14也是一个3结点,也不能再插入元素了。再往上
看,12、14结点的双亲,结点8是个2结点。于是就想到,将9、10拆分,12、14也拆分,让根结点8升级为3结点,最终形成如图8-8-5的右图样子。
再来看个例子,如图8-8-6所示,需要在左图中插入元素2。经过遍历可得到元素2比4、6小,比1大,因此它应该是需要插入在拥有1、3元素的3结点位置。与上例一样,你会发现,1、3结点,4、 6结点都是3结点,都不能再插入元素了,再往上看,8、12结点还是一个3结点,那就意味着,当前我们的树结构是三层已经不能满足当前结点增加的需要了。
于是将1、3拆分,4、6拆分,连根结点8、12也拆分,最终形成如图8-8-6的右图样子。
通过这个例子,也让我们发现,如果2-3树插入的传播效应导致了根结点的拆分,则树的高度就会增加。
7.1.2 2-3树的删除实现
对于2-3树的删除来说,如果对前面插入的理解足够到位的话,应该不是难事了。2-3树的删除也分为三种情况。与插入相反,我们从3结点开始说起。
-
所删除元素位于一个3结点的叶子结点上,这非常简单,只需要在该结点处删除该元素即可,不会影响到整棵树的其他结点结构。如图8-8-7所示,删除元素9,只需要将此结点改成只有元素10的2结点即可。
-
所删除的元素位于一个2结点上,即要删除的是一个只有一个元素的结点。如果按照以前树的理解,删除即可,可现在的2-3树的定义告诉我们这样做是不可以的。比如图8-8-8所示,如果我们删除了结点1,那么结点4本来是一个2结点(它拥有两个孩子),此时它就不满足定义了。
因此,对于删除叶子是2结点的情况,我们需要分四种情形来处理。
情形一,此结点的双亲也是2结点,且拥有一个3结点的右孩子。如图8-8-9所示,删除结点1,那么只需要左旋,即6成为双亲,4成为6的左孩子,7是6的右孩子。
情形二,此结点的双亲是2结点,它的右孩子也是2结点。如图8-8-10所示,此时删除结点4, 如果直接左旋会造成没有右孩子,因此需要对整棵树变形,办法就是,我们目标是让结点7变成3结点,那就得让比7稍大的元素8下来,随即就得让比元素8稍大的元素9补充结点8的位置,于是就有了图8-8-10的中间图,于是再用左旋的方式,变成右图结果。
情形三,此结点的双亲是一个3结点。如图8-8-11所示,此时删除结点10,意味着双亲12、14这个结点不能成为3结点了,于是将此结点拆分,并将12与13合并成为左孩子。
情形四,如果当前树是一个满二叉树的情况,此时删除任何一个叶子都会使得整棵树不能满足2-3树的定义。如图8-8-12所示,删除叶子结点8时(其实删除任何一个结点都一样),就不得不考虑要将2-3的层数减少,办法是将8的双亲和其左子树6合并为一3个结点,再将14与9合并
为3结点,最后成为右图的样子。 -
所删除的元素位于非叶子的分支结点。此时我们通常是将树按中序遍历后得到此元素的前驱或后继元素,考虑让它们来补位即可。
如果我们要删除的分支结点是2结点。如图8-8-13所示我们要删除4结点,分析后得到它的前驱是1后继是6,显然,由于6、7是3结点,只需要用6来补位即可,如图8-8-13右图所示。
如果我们要删除的分支结点是3结点的某一元素,如图8-8-14所示,我们要删除12、14结点中的12,此时,经过分析,显然应该是将是3结点的左孩子的10上升到删除位置合适。
当然,如果对2-3树的插入和删除等所有的情况进行讲解,既占篇幅,又没必要,总的来说它是有规律的,需要你们在上面的这些例子中多去体会后掌握。
7.2 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树的过程,如图8-8-15所示。图1是在分别插入7、1、2时的结果图,因为3个元素满足2-3-4树的单个4结点定义,因此此时不需要拆分,接着插入元素5,因为已经超过了4结
点的定义,因此拆分为图2的形状。之后的图其实就是在元素不断插入时最后形成了图7的2-3-4树。
图8-8-16是对一个2-3-4树的删除结点的演变过程,删除顺序是1、6、3、4、5、2、9。
7.3 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
个孩子,其中⌈m/2⌉≤k≤m
。每一个叶子结点n
都有k-1
个元素,其中⌈m/2⌉≤k≤m
。 -
所有叶子结点都位于同一层次。
-
所有分支结点包含下列信息数据:
(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(⌈m/2⌉-1≤n≤m-1)为关键字的个数(或n+1为子树的个数)。
例如,在讲2-3-4树时插入9个数后的图转成B树示意就如图8-8-17的右图所示。左侧灰色方块表示当前结点的元素个数。
在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。
7.4 B+树
尽管前面我们已经讲了B树的诸多好处,但其实它还是有缺陷的。对于树结构来说,我们都可以通过中序遍历来顺序查找树中的元素,这一切都是在内存中进行。
可是在B树结构中,我们往返于每个结点之间也就意味着,我们必须得在硬盘的页面之间进行多次访问,如图8-8-18所示,我们希望遍历这棵B树,假设每个结点都属于硬盘的不同页面,我们为了中序遍历所有的元素,页面2→页面1→页面3→页面1→页面4→页面1→页面5。而且我们每次经过结点遍历时,都会对结点中的元素进行一次遍历,这就非常糟糕。有没有可能让遍历时每个元素只访问一次呢?
为了能够解决所有元素遍历等基本问题,我们在原有的B树结构基础上,加上了新的元素组织方式, 这就是B+树。
B+树是应文件系统所需而出的一种B树的变形树,注意严格意义上讲,它其实已经不是第六章定义的树了。在B树中,每一个元素在该树中只出现一次,有可能在叶子结点上,也有可能在分支结点上。而在B+树中,出现在分支结点中的元素会被当作它们在该分支结点位置的中序后继者(叶子结点)中再次列出。另外,每一个叶子结点都会保存一个指向后一叶子结点的指针。
例如图8-8-19所示,就是一棵B+树的示意图,灰色关键字即是根结点中的关键字在叶子结点再次列出,并且所有叶子结点都链接在一起。
一棵m
阶的B+树和m
阶的B树的差异在于:
- 有
n
棵子树的结点中包含有n
个关键字; - 所有的叶子结点包含全部关键字的信息,及指向含这些关键字记录的指针,叶子结点本身依关键字的大小自小而大顺序链接;
- 所有分支结点可以看成是索引,结点中仅含有其子树中的最大(或最小)关键字。
这样的数据结构最大的好处就在于,如果是要随机查找,我们就从根结点出发,与B树的查找方式相同,只不过即使在分支结点找到了待查找的关键字,它也只是用来索引的,不能提供实际记录的访问,还是需要到达包含此关键字的终端结点。
如果我们是需要从最小关键字进行从小到大的顺序查找,我们就可以从最左侧的叶子结点出发,不经过分支结点,而是延着指向下一叶子的指针就可遍历所有的关键字。
B+树的结构特别适合带有范围的查找。比如查找我们学校18~22岁的学生人数,我们可以通过从根结点出发找到第一个18岁的学生,然后再在叶子结点按顺序查找到符合范围的所有记录。
B+树的插入、删除过程也都与B树类似,只不过插入和删除的元素都是在叶子结点上进行而已。
8 散列表(哈希表)查找概述
我们可能会发现,为了查找到结果,之前的方法“比较”都是不可避免的,但这是否真的有必要?能否直接通过关键字key得到要查找的记录内存存储位置呢?
8.1 散列表查找定义
我们可以通过查找关键字不需要比较就可获得需要的记录的存储位置。这就是一种新的存储技术——散列技术。
散列技术是在记录的存储位置和它的关键字之间建立一个确定的对应关系f
,使得每个关键字key
对应一个存储位置f(key)
。查找时,根据这个确定的对应关系找到给定值key
的映射f(key)
,若查找集合中存在这个记录,则必定在f(key)
的位置上。
这里我们把这种对应关系f称为散列函数,又称为哈希(Hash)函数。按这个思想,采用散列技术将记录存储在一块连续的存储空间中,这块连续存储空间称为散列表或哈希表(Hash table)。那么关键字对应的记录存储位置我们称为散列地址。
8.2 散列表查找步骤
整个散列过程其实就是两步。
-
在存储时,通过散列函数计算记录的散列地址,并按此散列地址存储该记录。就像"张三丰"我们就让他在体育馆,那如果是‘爱因斯坦’我们让他在图书馆,如果是"居里夫人",那就让她在化学实验室,如果是"巴顿将军",这个打仗的将军——我们可以让他到网吧。总之,不管什么记录, 我们都需要用同一个散列函数计算出地址再存储。
-
当查找记录时,我们通过同样的散列函数计算记录的散列地址,按此散列地址访问该记录。说起来很简单,在哪存的,上哪去找,由于存取用的是同一个散列函数,因此结果当然也是相同的。
所以说,散列技术既是一种存储方法,也是一种查找方法。然而它与线性表、树、图等结构不同的是,前面几种结构,数据元素之间都存在某种逻辑关系,可以用连线图示表示出来,而散列技术的记录之间不存在什么逻辑关系,它只与关键字有关联。因此,散列主要是面向查找的存储结构。
散列技术最适合的求解问题是查找与给定值相等的记录。对于查找来说,简化了比较过程,效率就会大大提高。但万事有利就有弊,散列技术不具备很多常规数据结构的能力。比如那种同样的关键字,它能对应很多记录的情况,却不适合用散列技术。
同样散列表也不适合范围查找,比如查找一个班级18~22岁的同学,在散列表中没法进行。想获得表中记录的排序也不可能,像最大值、最小值等结果也都无法从散列表中计算出来。
我们说了这么多,散列函数应该如何设计?这个我们需要重点来讲解,总之设计一个简单、均匀、存储利用率高的散列函数是散列技术中最关键的问题。
另一个问题是冲突。在理想的情况下,每一个关键字,通过散列函数计算出来的地址都是不一样的,可现实中,这只是一个理想。我们时常会碰到两个关键字key1≠key2
,但是却有f(key1)=f(key2)
, 这种现象我们称为冲突(collision),并把key1
和key2
称为这个散列函数的同义词(synonym)。 出现了冲突当然非常糟糕,那将造成数据查找错误。尽管我们可以通过精心设计的散列函数让冲突尽可能的少,但是不能完全避免。于是如何处理冲突就成了一个很重要的课题,这在我们后面也需要详细讲解。
9 散列函数的构造方法
什么才算是好的散列函数呢?这里我们有两个原则可以参考。
-
设计简单
你说设计一个算法可以保证所有的关键字都不会产生冲突,但是这个算法需要很复杂的计算, 会耗费很多时间,这对于需要频繁地查找来说,就会大大降低查找的效率了。因此散列函数的计算时间不应该超过其他查找技术与关键字比较的时间。
-
散列地址分布均匀
我们刚才也提到冲突带来的问题,最好的办法就是尽量让散列地址均匀地分布在存储空间中, 这样可以保证存储空间的有效利用,并减少为处理冲突而耗费的时间。
接下来我们就要介绍几种常用的散列函数构造方法。
9.1 直接定址法
如果我们现在要对0~100岁的人口数字统计表,如表8-10-1所示,那么我们对年龄这个关键字就可以直接用年龄的数字作为地址。此时f(key)=key
。
如果我们现在要统计的是80后出生年份的人口数,如表8-10-2所示,那么我们对出生年份这个关键字可以用年份减去1980来作为地址。此时f(key)=key-1980
。
也就是说,我们可以取关键字的某个线性函数值为散列地址,即
f(key)=a × key + b // a、b为常数
这样的散列函数优点就是简单、均匀,也不会产生冲突,但问题是这需要事先知道关键字的分布情况,适合查找表较小且连续的情况。由于这样的限制,在现实应用中,此方法虽然简单,但却并不常用。
9.2 数字分析法
如果我们的关键字是位数较多的数字,比如我们的11位手机号“130xxxx1234”,其中前三位是接入号,一般对应不同运营商公司的子品牌,如130是联通如意通、136是移动神州行、153是电信等; 中间四位是HLR识别号,表示用户号的归属地;后四位才是真正的用户号,如表8-10-3所示。
若我们现在要存储某家公司员工登记表,如果用手机号作为关键字,那么极有可能前7位都是相同的。那么我们选择后面的四位成为散列地址就是不错的选择。如果这样的抽取工作还是容易出现冲突问题,还可以对抽取出来的数字再进行反转(如1234改成4321)、右环位移(如1234改成4123)、左环位移、甚至前两数与后两数叠加(如1234改成12+34=46)等方法。总的目的就是为了提供一个散列函数,能够合理地将关键字分配到散列表的各位置。
这里我们提到了一个关键词——抽取。抽取方法是使用关键字的一部分来计算散列存储位置的方法, 这在散列函数中是常常用到的手段。
数字分析法通常适合处理关键字位数比较大的情况,如果事先知道关键字的分布且关键字的若干位分布较均匀,就可以考虑用这个方法。
9.3 平方取中法
这个方法计算很简单,假设关键字是1234,那么它的平方就是1522756,再抽取中间的3位就是227,用做散列地址。再比如关键字是4321,那么它的平方就是18671041,抽取中间的3位就可以是671,也可以是710,用做散列地址。平方取中法比较适合于不知道关键字的分布,而位数又不是很大的情况。
9.4 折叠法
折叠法是将关键字从左到右分割成位数相等的几部分(注意最后一部分位数不够时可以短些),然后将这几部分叠加求和,并按散列表表长,取后几位作为散列地址。
比如我们的关键字是9876543210,散列表表长为三位,我们将它分为四组,987|654|321|0,然后将它们叠加求和987+654+321+0=1962,再求后3位得到散列地址为962。
有时可能这还不能够保证分布均匀,不妨从一端向另一端来回折叠后对齐相加。比如我们将987和321反转,再与654和0相加,变成789+654+123+0=1566,此时散列地址为566。
折叠法事先不需要知道关键字的分布,适合关键字位数较多的情况。
9.5 除留余数法
此方法为最常用的构造散列函数方法。对于散列表长为m的散列函数公式为:
f(key) = key mod p; // p≤m
mod是取模(求余数)的意思。事实上,这方法不仅可以对关键字直接取模,也可在折叠、平方取中后再取模。
很显然,本方法的关键就在于选择合适的p
,p
如果选得不好,就可能会容易产生同义词。
例如表8-10-4,我们对于有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所对应的下标位置冲突了。
甚至极端一些,对于表8-10-5的关键字,如果我们让p
为12的话,就可能出现下面的情况,所有的关键字都得到了0这个地址数,这未免也太糟糕了点。
我们不选用p=12
来做除留余数法,而选用p=11
,如表8-10-6所示。
此就只有12和144有冲突,相对来说,就要好很多。
因此根据前辈们的经验,若散列表表长为m
,通常p
为小于或等于表长(最好接近m
)的最小质数或不包含小于20质因子的合数。
9.6 随机数法
选择一个随机数,取关键字的随机函数值为它的散列地址。也就是f(key)=random(key)
。这里random
是随机函数。当关键字的长度不等时,采用这个方法构造散列函数是比较合适的。
总之,现实中,应该视不同的情况采用不同的散列函数。我们只能给出
一些考虑的因素来提供参考:
- 计算散列地址所需的时间。
- 关键字的长度。
- 散列表的大小。
- 关键字的分布情况。
- 记录查找的频率。
综合这些因素,才能决策选择哪种散列函数更合适。
10 处理散列冲突的方法
当我们在使用散列函数后发现两个关键字key1≠key2
,但是却有f(key1)=f(key2)
,即有冲突时,怎么办呢?我们可以从生活中找寻思路。
试想一下,当你观望很久很久,终于看上一套房打算要买了,正准备下订金,人家告诉你,这房子已经被人买走了,你怎么办?
对呀,再找别的房子呗!这其实就是一种处理冲突的方法——开放定址法。
10.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}
时,都是没有冲突的散列地址,直接存入,如表8-11-1所示。
计算key=37
时,发现f(37)=1
,此时就与25所在的位置冲突。于是我们应用上面的公式f(37) = (f(37) + 1) mod 12 = 2
。于是将37存入下标为2的位置。这其实就是房子被人买了于是买下一间的作法,如表8-11-2所示。
接下来22,29,15,47都没有冲突,正常的存入,如表8-11-3所示。
到了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
时,才有空位,机不可失,赶快存入,如表8-11-4所示。
我们把这种解决冲突的开放定址法称为线性探测法。
从这个例子我们也看到,我们在解决冲突的时候,还会碰到如48和37这种本来都不是同义词却需要争夺一个地址的情况,我们称这种现象为堆积。很显然,堆积的出现,使得我们需要不断处理冲突,无论是存入还是查找效率都会大大降低。
考虑深一步,如果发生这样的情况,当最后一个key=34
,f(key)=10
,与22所在的位置冲突,可是22后面没有空位置了,反而它的前面有一个空位置,尽管可以不断地求余数后得到结果,但效率很差。因此我们可以改进di=12,-12,22,-22,......,q2,-q2,(q≤m/2), 这样就等于是可以双向寻找到可能的空位置。对于34来说,我们取di=-1即可找到空位置了。另外增加平方运算的目的是为了不让关键字都聚集在某一块区域。我们称这种方法为二次探测法。
还有一种方法是,在冲突时,对于位移量di采用随机函数计算得到,我们称之为随机探测法。
总之,开放定址法只要在散列表未填满时,总是能找到不发生冲突的地址,是我们常用的解决冲突的办法。
10.2 再散列函数法
对于我们的散列表来说,我们事先准备多个散列函数。
这里RHi就是不同的散列函数,你可以把我们前面说的什么除留余数、折叠、平方取中全部用上。 每当发生散列地址冲突时,就换一个散列函数计算,相信总会有一个可以把冲突解决掉。这种方法能够使得关键字不产生聚集,当然,相应地也增加了计算的时间。
10.3 链地址法
思路还可以再换一换,为什么有冲突就要换地方呢,我们直接就在原地想办法不可以吗?于是我们就有了链地址法。
将所有关键字为同义词的记录存储在一个单链表中,我们称这种表为同义词子表,在散列表中只存储所有同义词子表的头指针。对于关键字集合{12,67,56,16,25,37,22,29,15,47,48,34}
, 我们用前面同样的12为除数,进行除留余数法,可得到如图8-11-1结构,此时,已经不存在什么冲突换址的问题,无论有多少个冲突,都只是在当前位置给单链表增加结点的问题。
链地址法对于可能会造成很多冲突的散列函数来说,提供了绝不会出现找不到地址的保障。当然,这也就带来了查找时需要遍历单链表的性能损耗。
10.4 公共溢出区法
这个方法其实就更加好理解,你不是冲突吗?好吧,凡是冲突的都跟我走,我给你们这些冲突找个地儿待着。这就如同孤儿院收留所有无家可归的孩子一样,我们为所有冲突的关键字建立了一个公共的溢出区来存放。
就前面的例子而言,我们共有三个关键字{37,48,34}
与之前的关键字位置有冲突,那么就将它们存储到溢出表中,如图8-11-2所示。
在查找时,对给定值通过散列函数计算出散列地址后,先与基本表的相应位置进行比对,如果相等,则查找成功;如果不相等,则到溢出表去进行顺序查找。如果相对于基本表而言,有冲突的数据很少的情况下,公共溢出区的结构对查找性能来说还是非常高的。
11 散列表查找实现
说了这么多散列表查找的思想,我们就来看看查找的实现代码。
11.1 散列表查找算法实现
首先是需要定义一个散列表的结构以及一些相关的常数。其中HashTable
就是散列表结构。结构当中的elem
为一个动态数组。
#define SUCCESS 1
#define UNSUCCESS 0
// 定义散列表长为数组的长度
#define HASHSIZE 12
#define NULLKEY -32768
typedef struct
{
// 数据元素存储基址,动态分配数组
int *elem;
// 当前数据元素个数
int count;
} HashTable;
// 散列表表长,全局变量
int m = 0;
有了结构的定义,我们可以对散列表进行初始化。
// 初始化散列表
Status InitHashTable(HashTable *H)
{
int i;
m = HASHSIZE;
H->count = m;
H->elem = (int *) malloc(m * sizeof(int));
for (i = 0; i < m; i ++)
H->elem[i] = NULLKEY;
return OK;
}
为了插入时计算地址,我们需要定义散列函数,散列函数可以根据不同情况更改算法。
// 散列函数
int Hash(int key)
{
// 除留余数法
return key % m;
}
初始化完成后,我们可以对散列表进行插入操作。假设我们插入的关键字集合就是前面的{12,67,56,16,25,37,22,29,15,47,48,34}
。
// 插入关键字进散列表
void InsertHash(HashTable *H, int key)
{
// 求散列地址
int addr = Hash(key);
// 如果不为空,则冲突
while (H->elem[addr] != NULLKEY)
// 开放定址法的线性探测
addr = (addr + 1) % m;
// 直到有空位后插入关键字
H->elem[addr] = key;
}
代码中插入关键字时,首先算出散列地址,如果当前地址不为空关键字,则说明有冲突。此时我们应用开放定址法的线性探测进行重新寻址,此处也可更改为链地址法等其他解决冲突的办法。
散列表存在后,我们在需要时就可以通过散列表查找要的记录。
// 散列表查找关键字
Status SearchHash(HashTable H, int key, int *addr)
{
// 求散列地址
*addr = Hash(key);
// 如果不为空,则冲突
while (H.elem[*addr] != key)
{
// 开放定址法的线性探测
*addr = (*addr + 1) % m;
if (H.elem[*addr] == NULLKEY || *addr == Hash(key))
{
// 如果循环回到原点,则说明关键字不存在
return UNSUCCESS;
}
}
return SUCCESS;
}
查找的代码与插入的代码非常类似,只需做一个不存在关键字的判断而已。
11.2 散列表查找性能分析
最后,我们对散列表查找的性能作一个简单分析。如果没有冲突,散列查找是我们本章介绍的所有查找中效率最高的,因为它的时间复杂度为O(1)
。可惜,我说的只是“如果”,没有冲突的散列只是一种理想,在实际的应用中,冲突是不可避免的。那么散列查找的平均查找长度取决于哪些因素呢?
1.散列函数是否均匀
散列函数的好坏直接影响着出现冲突的频繁程度,不过,由于不同的散列函数对同一组随机的关键字,产生冲突的可能性是相同的,因此我们可以不考虑它对平均查找长度的影响。
2.处理冲突的方法
相同的关键字、相同的散列函数,但处理冲突的方法不同,会使得平均查找长度不同。比如线性探测处理冲突可能会产生堆积,显然就没有二次探测法好,而链地址法处理冲突不会产生任何堆积, 因而具有更佳的平均查找性能。
3.散列表的装填因子
所谓的装填因子α=填入表中的记录个数/散列表长度
。α
标志着散列表的装满的程度。当填入表中的记录越多,α
就越大,产生冲突的可能性就越大。比如我们前面的例子,如图8-11-5所示,如果你的散列表长度是12,而填入表中的记录个数为11,那么此时的装填因子α=11/12=0.9167
,再填入最后一个关键字产生冲突的可能性就非常之大。也就是说,散列表的平均查找长度取决于装填因子, 而不是取决于查找集合中的记录个数。
不管记录个数n
有多大,我们总可以选择一个合适的装填因子以便将平均查找长度限定在一个范围之内,此时我们散列查找的时间复杂度就真的是O(1)
了。为了做到这一点,通常我们都是将散列表的空间设置得比查找集合大,此时虽然是浪费了一定的空间,但换来的是查找效率的大大提升,总的来说,还是非常值得的。
12 总结回顾
我们这一章全都是围绕一个主题“查找”来作文章的。
首先我们要弄清楚查找表、记录、关键字、主关键字、静态查找表、动态查找表等这些概念。
然后,对于顺序表查找来说,尽管很土(简单),但它却是后面很多查找的基础,注意设置“哨兵”的技巧,可以使得本已经很难提升的简单算法里还是提高了性能。
有序查找,我们着重讲了折半查找的思想,它在性能上比原来的顺序查找有了质的飞跃,由O(n)
变成了O(log2(n))
。之后我们又讲解了另外两种优秀的有序查找:插值查找和斐波那契查找, 三者各有优缺点,望大家要仔细体会。
线性索引查找,我们讲解了稠密索引、分块索引和倒排索引。索引技术被广泛的用于文件检索、数据库和搜索引擎等技术领域,是进一步学习这些技术的基础。
二叉排序树是动态查找最重要的数据结构,它可以在兼顾查找性能的基础上,让插入和删除也变得效率较高。不过为了达到最优的状态,二叉排序树最好是构造成平衡的二叉树才最佳。 因此我们就需要再学习关于平衡二叉树(AVL树)的数据结构,了解AVL树是如何处理平衡性的问题。 这部分是本章重点,需要认真学习掌握。
B树这种数据结构是针对内存与外存之间的存取而专门设计的。由于内外存的查找性能更多取决于读取的次数,因此在设计中要考虑B树的平衡和层次。我们讲解时是先通过最最简单的B树(2-3树)来理解如何构建、插入、删除元素的操作,再通过2-3-4树的深化,最终来理解B树的原理。之后,我们还介绍了B+树的设计思想。
散列表是一种非常高效的查找数据结构,在原理上也与前面的查找不尽相同,它回避了关键字之间反复比较的烦琐,而是直接一步到位查找结果。当然,这也就带来了记录之间没有任何关联的弊端。应该说,散列表对于那种查找性能要求高,记录之间关系无要求的数据有非常好的适用性。在学习中要注意的是散列函数的选择和处理冲突的方法。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· 单线程的Redis速度为什么快?
· SQL Server 2025 AI相关能力初探
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 展开说说关于C#中ORM框架的用法!