每日学习录(数据结构—查找)下

8.7多路查找树(B树)

多路查找树(multi-way search tree),其每一个结点的孩子树可以多于两个,且每一个结点处可以存储多个元素。由于它是查找树,所有元素之间存在某种特定的排序关系。

在这里,每一个结点可以存储多少个元素,以及它的孩子树的多少是非常关键的。为此,我们讲解它的4种特殊形式:2-3树、2-3-4树、B树和B+树。

8.7.1 2-3树

2-3树是这样的一棵多路查找树:其中的每一个结点都具有两个孩子(我们称它为2结点)或三个孩子(我们称它为3结点)。

一个2结点包含一个元素和两个孩子(或没有孩子),且与二叉排序树类似,左子树包含的元素小于该元素,右子树包含的元素大于该元素。不过,与二叉排序树不同的是,这个2结点要么没有孩子,要有就有两个,不能只有一个孩子。

一个3结点包含一小一大两个元素和三个孩子(或没有孩子),一个3结点要么没有孩子,要么具有3个孩子。如果某个3结点有孩子的话,左子树包含小于较小元素的元素,右子树包含大于较大元素的元素,中间子树包含介于两元素之间的元素。

并且2-3树中所有的叶子都在同一层次上,如下图,就是一颗有效的2-3树。

事实上,2-3树复杂的地方就在于新结点的插入和已有结点的删除。毕竟,每个结点可能是2结点也可能是3结点,要保证所有叶子都在同一层次,是需要进行一番复杂操作的。

8.7.2 2-3树的插入实现

对于2-3树的插入来说,与二叉排序树相同,插入操作一定是发生在叶子结点上。可与二叉排序树不同的是,2-3树插入一个元素的过程有可能会对该树的其余结构产生连锁反应。

2-3树插入可分为三种情况:

1.对于空树,插入一个2结点即可。

2。插入结点到一个2结点的叶子上。由于其本身就只有一个元素,所以只需要将其升级为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树插入的传播效应导致根结点的拆分,则树的高度就会增加。

8.7.3 2-3树的删除实现

2-3树的删除也分为三种情况:

一、所删除元素位于一个3结点的叶子结点上,只需要在该结点处删除该元素即可,不会影响到整棵树的其他结点结构,如下图。

二、所删除的元素位于一个2结点上,即要删除的是一个只有一个元素的结点。此时要分四种情况:

1.此结点的双亲也是2结点,且拥有一个3结点的右孩子。如下图,删除结点1,那么只需要左旋,即6成为双亲,4成为6的左孩子,7是6的右孩子。

2。此结点的双亲是2结点,它的右孩子也是2结点。如下图,此时删除结点4,如果直接左旋会造成没有右孩子,因此需要对整棵树变形,办法就是,我们目标是让结点7变成3结点,那就得让比7稍大的元素8下来,随即就得让比元素8稍大的元素9补充结点8的位置,于是就有了如下图的中间图,于是再用左旋的方式,变成下右图结果。

3.此结点的双亲是一个3结点。如下图,此时删除结点10,意味着双亲12、14这个结点不能成为3结点了,于是将此结点拆分,并将12和13合并成为左孩子。

4.如果当前树是一个满二叉树,此时删除任何一个叶子都会使得整棵树不能满足2-3树的定义,如下图,删除叶子结点8时,就不得不考虑将2-3的层数减少,办法是将8的双亲和其左子树6合并为一个3结点,再将14与9合并为3结点,最后成为下右图所示。

三、所删除的元素位于非叶子的分支结点。此时我们通常是将树按中序遍历后得到此元素的前驱或后继元素,考虑让他们来补位即可。

如果我们要删除的分支结点是2结点。如下图,我们要删除4结点,分析后得到它的前驱是1后继是6,显然,由于6、7是3结点,只需要用6来补位即可。

如果我们要删除的分支结点是3结点的某一元素,如下图,我们要删除12、14结点的12,此时,经过分析,显然应该是将3结点的左孩子的10上升到删除位置合适。

8.7.4 2-3-4树

2-3-4树是2-3树的概念扩展,包括了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。

8.7.5 B树

B树(B-tree)是一种平衡的多路查找树,2-3树和2-3-4树都是B树的特例。结点最大的孩子数目称为B树的阶(order),因此2-3树是3阶B树,2-3-4树是4阶B树。

一个m阶的B树具有如下属性:

1.如果根结点不是叶结点,则其至少有两棵子树。
2.每一个非根的分支结点都有k-1个元素和k个孩子,其中[m/2]<=k<=m。每一个叶子结点n都有k-1个元素,其中[m/2]<=k<=m。
3.所有叶子结点都位于同一层次。
4.所有分支结点包含下列信息数据(n,A0,K1,A1,K2,A1,···,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树就如下图所示。左侧灰色方块表示当前结点的元素个数。

第一层至少有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。

8.7.6B+树

为了能够解决所有元素遍历等基本问题,我们在原有的B树结构基础上,加上了新的元素组织方式,这就是B+树。

B+树是应文件系统所需而出的一种B树的变形树,注意严格意义上讲,它其实已经不是第六章定义的树了。在B树中,每一个元素在该树中只出现一次,有可能在叶子结点上,也有可能在分支结点上。而在B+树中,出现在分支结点中的元素会被当作它们在该分支结点位置的中序后继者(叶子结点)中再次列出。另外,每一个叶子结点都会保存一个指向后一叶子结点的指针。

如下图,就是一棵B+树,灰色关键字即是根结点中的关键字在叶子结点再次列出,并且所有叶子结点都链接在一起。

一棵m阶的B+树和m阶的B树的差异在于:

有n棵子树的结点中包含有n个关键字;
所有的叶子结点包含全部关键字的信息,及指向含这些关键字记录的指针,叶子结点本身依关键字的大小自小而大顺序链接;
所有分支结点可以看成是索引,结点中仅含有其子树中的最大(或最小)关键字。
这样的数据结构最大的好处在于,如果是要随机查找,我们就从根结点出发,与B树的查找方式相同,只不过即使在分支结点找到了待查找的关键字,它也只是用来索引的,不能提供实际记录的访问,还是需要到达包含此关键字的终端结点。

如果我们是需要从最小关键字进行从小到达的顺序查找,我们就可以从最左侧的叶子结点出发,不经过分支结点,而是延着指向下一叶子的指针就可遍历所有的关键字。

B+树的结构特别适合带有范围的查找。比如查找我们学校18~22岁的学生人数,我们可以通过从根结点出发找到第一个18岁的学生,然后再在叶子结点按顺序查找到符合范围的所有记录。

B+树的插入、删除过程也都与B树类似,只不过插入和删除的元素都是在叶子结点上进行而已。

8.8散列表查找(哈希表)概述

8.8.1 散列表查找定义

如果我们只需要通过某个函数f,就可以得到:

存储位置 = f(关键字)

那样我们查找关键字不需要比较就可获得需要的记录的存储位置。这就是一种新的存储技术——散列技术。

散列技术是在记录的存储位置和它的关键字之间建立一个确定的对应关系f,使得每个关键字key对应一个存储位置f(key)。查找时,根据这个确定的对应关系找到给定值key的映射f(key),若查找集合中存在这个记录,则必定在f(key)的位置上。

这里我们把这种对应关系f称为散列函数,又称为哈希(Hash)函数。按这个思想,采用散列技术将记录存储在一块连续的存储空间中,这块连续存储空间称为散列表或哈希表(Hash table)。那么关键字对应的记录存储位置我们称为散列地址。

8.8.2散列表查找步骤

整个散列过程其实就是两步。

(1)在存储时,通过散列函数计算记录的散列地址,并按此散列地址存储该记录。例如下图。

(2)当查找记录时,我们通过同样的散列函数计算记录的散列地址,按此散列地址访问该记录。说起来很简单,在哪存的,上哪去找,由于存取用的是同一个散列函数,因此结果当然也是相同的。

所以说,散列技术既是一种存储方法,也是一种查找方法。然而他与线性表、树、图等结构不同的是,前面几种结构,数据元素之间都存在某种逻辑关系,可以用连续图示表示出来,而散列技术的记录之间不存在什么逻辑关系,它只与关键字有关。因此,散列主要是面向查找的存储结构。

散列技术最适合的求解问题是查找与给定值相等的记录。对于查找来说,简化了比较的过程,效率就会大大提高。但万事有利就有弊,散列技术不具备很多常规数据结构的能力。

在理想的情况下,每一个关键字,通过散列函数计算出来的地址都是不一样的,可现实中,这只是一个理想。我们时常会碰到两个关键字key1≠key2,但是却有f(key1)=f(key2),这种现象我们称为冲突(collision),并把key1和key2称为这个散列函数的同义词(synonym)。出现了冲突当然非常糟糕,那将造成数据查找错误,尽管我们可以通过精心设计的散列函数让冲突尽可能的少,但是不能完全避免。于是如何处理冲突就成了一个很重要的课题,这在我们后面也需要详细讲解。

8.9散列函数的构造方法

关于好的散列函数的两个原则:

1.计算简单

你说设计一个算法可以保证所有的关键字都不会产生冲突,但是这个算法需要很复杂的计算,会耗费很多时间,这对于需要频繁地查找来说,就会大大降低查找的效率了。因此散列函数的计算时间不应该超过其他查找技术与关键字比较的时间。

2.散列地址分布均匀

我们刚才也提到冲突带来的问题,最好的办法就是尽量让散列地址均匀地分布在存储空间中,这样可以保证存储空间的有效利用,并减少为处理冲突而耗费的时间。

接下来我们就介绍几种常用的散列函数的构造方法。

8.9.1直接定址法

如果我们现在要对0~100岁的人口统计表,如下表,那么我们对年龄这个关键字就可以直接用年龄的数字作为地址。此时f(key)=key。

如果我们现在要统计的是80后出生年份的人口数,如下表,那么我们对出生年份这个关键字可以用年份减去1980来作为地址。此时f(key)=key-1980。

也就是说,我们可以取关键字的某个线性函数值为散列地址,即

f(key) = a×key+b(a、b为常数)

这样的散列函数有点就是简单、均匀,也不会产生冲突,但问题是这需要事先知道关键字的分布情况,适合查找表较小且连续的情况。由于这样的限制,在现实应用中,此方法虽然简单,但却并不常用。

8.10处理散列冲突的方法

所谓开放定址法就是一旦发生冲突,就去寻找下一个空的散列地址,只要散列表足够大,空的散列地址总能找到,并将记录存入。

它的公式是:

比如说,我们的关键字集合为{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的位置。

后面的关键字同上面方法存入。

我们把这种解决冲突的开发定址法称为线性探测法。

从这个例子我们可以看到,在解决冲突时,还会碰如48和37这种本来不是同义词却需要争夺一个地址的情况,我们称这种现象为堆积。很显然,堆积的出现,使得我们需要不断处理冲突,无论是存入还是查找效率都会大大降低。

考虑深一步,如果发生这样的情况,当最后一个key=34,f(key)=10,与22所在的位置冲突,可是22后面没有空位置了,反而它的前面有一个空位置,尽管可以不断地求余数后得到结果,但效率很差。因此我们可以改进di=12,-12,22,-22,······,q2,-q2,(q≤m/2),这样就等于是可以双向寻找到可能的空位置。对于34来说,我们取di=-1即可找到空位置了。另外增加平方运算的目的是为了不让关键字都聚集在某一块区域,我们称这种方法为二次探测法。

此时一定有人问,既然是随机,那么查找的时候不也随机生成di吗?如何可以获得相同的地址呢?这是个问题。这里的随机其实是伪随机数,伪随机数是说,如果我们设置随机种子相同,则不断调用随机函数可以生成不会重复的数列,我们在查找时,用同样的随机种子,它每次得到的数列是相同的,相同的di当然可以得到相同的散列地址。(对于随机种子概念不清楚的,自行上网查资料)

总之,开发定址法只要在散列表未填满时,总是能找到不发生冲突的地址,是我们常用的捷径冲突的办法。

8.10.1再散列函数法

我们还可以换一种思维,对于散列表来说,我们可以事先准备多个散列函数。

这里RHi就是不同的散列函数,你可以把我们前说的什么除留余数、折叠、平方取中全部用上。每当发生散列地址冲突时,就换一个散列函数计算,相信总会有一个可以把冲突解决的。这种方法能够使得关键字不产生聚集,当然,相应地也增加了计算的时间。

8.10.2 链地址法

将所有关键字为同义词的记录存储在一个单链表中,我们称这种表为同义词子表,在散列表中只存储所有同义词子表的头指针。对于关键字集合{12,67,56,16,25,37,22,29,15,47,48,34},我们用前面同样的12为除数,进行除留余数法,可得到如下图结构,此时,已经不存在什么冲突换址的问题,无论有多少个冲突,都只是在当前位置给单链表增加结点的问题。

链地址法对于可能会造成很多冲突的散列函数来说,提供了绝不会出现找不到地址的保障。当然,这也就带来了查找时需要遍历单链表的性能损耗。

8.11散列表查找实现

8.11.1 散列表查找算法实现

首先定义一个散列表的结构以及一些相关的常数,其中HashTable就是散列表结构,结构当中的elem为一个动态数组。

有了结构的定义,我们可以对散列表进行初始化。

为了插入时计算地址,我们需要定义散列函数,散列函数可以根据不同情况更改算法。

初始化完成后,我们可以对散列表进行插入操作。假设我们插入的关键字集合就是前面的{12,67,56,16,25,37,22,29,15,47,48,34}

散列表存在后,我们在需要是就可以通过散列表查找要的记录。

8.11.2散列表查找性能分析

最后,我们对散列表查找的性能作一个简单的分析。如果没有冲突,散列查找是我们本章介绍的所有查找中效率最高的,因为它的时间复杂度为O(1)。但冲突是不可避免的,那么散列查找的平均查找长度取决于那些因素呢?

1.散列函数是否均匀

散列函数的好坏直接影响着出现冲突的频繁程度,不过,由于不同的散列函数对同一组随机的关键字,产生冲突的可能性是相同的,因此我们可以不考虑它对平均查找长度的影响。

2.处理冲突的方法

相同的关键字、相同的散列

目录

8.7多路查找树(B树)

8.7.1 2-3树

8.7.2 2-3树的插入实现

8.7.3 2-3树的删除实现

8.7.4 2-3-4树

8.7.5 B树

8.7.6B+树

8.8散列表查找(哈希表)概述

8.8.1 散列表查找定义

8.8.2散列表查找步骤

8.9散列函数的构造方法

8.9.1直接定址法

8.10处理散列冲突的方法

8.10.1再散列函数法

8.10.2 链地址法

8.11散列表查找实现

8.11.1 散列表查找算法实现

8.11.2散列表查找性能分析


表函数,但处理冲突的方法不同,会使得平均查找长度不同。比如线性探测处理冲突可能会产生堆积,显然没有二次探测法好,而链地址法处理冲突不会产生任何堆积,因而具有更佳的平均查找性能。

3.散列表的装填因子

所谓的装填因子α \alphaα=填入表中的记录个数/散列表长度。α \alphaα标志着散列表的装满的程度。当填入表中的记录越多,α \alphaα就越大,产生冲突的可能性就越大。也就是说,散列表的平均查找长度取决于装填因子,而不是取决于查找集合中的记录个数。

不管记录个数n有多大,我们总可以选择一个合适的装填因子以便将平均查找长度限定在一个范围之内,此时我们散列查找的时间复杂度就真的是O(1)了。为了做到这一点,通常我们都是将散列表的空间设置得比查找集合大,此时虽然是浪费了一定的空间,但换来的是查找效率的大大提升,总的来说,还是非常值得的。

  • 14
    点赞
  • 23
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值