【大话数据结构】第八章-查找(2)

八、查找(二)

7.多路查找树(B树)

对于内存中的数据处理,将它写下来并时常阅读是内存数据对外存磁盘上的存取操作。

内存一般都是由硅制的存储芯片组成,这种技术的每一个存储单位代价都要比磁存储技术昂贵两个数量级,因此基于磁盘技术的外存,容量比内存的容量至少大两个数量级。这也就是目前PC通常内存几个G而已,而硬盘却可以成百上千G容量的原因。

我们前面讨论过的数据结构,处理数据都是在内存中,因此考虑的都是内存中的运算时间复杂度。

但如若我们操作的数据集非常大,大到内存已经没有办法处理了怎么办呢?如数据库中的上千万条记录的数据表、硬盘中的上万个文件等。在这种情况下,对数据的处理需要不断从硬盘等存储设备中调入或调出内存页面。

一旦涉及到这样的外部存储设备,关于时间复杂度的计算就会发生变化,访问该集合元素的时间已经不仅仅是寻找该元素所需比较次数的函数,我们必须考虑对硬盘等外部存储设备的访问时间以及将会对该设备做出多少次单独访问。

试想一下,为了要在一个拥有几十万个文件的磁盘中查找一个文本文件,你设计的算法需要读取磁盘上万次还是读取几十次,这是有本质差异的。此时,为了降低对外存设备的访问次数,我们就需要新的数据结构来处理这样的问题。

我们之前谈的树,都是一个结点可以有多个孩子,但是他自身只存储一个元素。二叉树限制更多,结点最多只能有两个孩子。

一个结点只能存储一个元素,在元素非常多的时候,就使得要么树的度非常大(结点拥有子树的个数的最大值),要么树的高度非常大,甚至两者都必须足够大才行。这就使得内存存取外存次数非常多,这显然成了时间效率上的瓶颈,迫使我们要打破每一个结点只存储一个元素的限制,为此引入了多路查找树的概念。

多路查找树(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树中所有的叶子都在同一层次上,如下图,就是一颗有效的2-3树。

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

在这里插入图片描述

7.1.1 2-3树的插入实现

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

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

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

  • 插入结点到一个2结点的叶子上。由于其本身就只有一个元素,所以只需要将其升级为3结点即可,如下图。

在这里插入图片描述

  • 插入结点到一个3结点的叶子上。因为3结点本身已经是2-3树的结点最大容量,因此就需要将其拆分,且将树中两元素或插入元素的三者中选择其一向上移动一层。这里也分三种情况:

    1. 如下图,需要向左图中插入元素5。经过遍历可得到元素5比8小比4大,因此它应该是需要插入在拥有6、7元素的3结点位置。问题在于,6和7结点已经是3结点,不能再加。此时发现它的双亲结点4是个2结点,因此考虑让它升级为3结点,这样它就得有三个孩子,于是就想,将6、7结点拆分,让6与4结成3结点,将5成为它的中间孩子,将7成为它的右孩子,如下右图所示。

      在这里插入图片描述

    2. 如下图,需要向左图中插入元素11。经过遍历可得到元素11比12、14小比9、10大,因此它应该是需要插入在拥有9、10元素的3结点位置。同样道理,9和10结点不能再增加结点。此时发现它的双亲结点12、14也是一个3结点,也不能在插入元素了。再往上看,12、14结点的双亲,结点8是个2结点。于是就想到,将9、10拆分,12、14也拆分,让根结点8升级为3结点,最终形成如下右图所示。

      在这里插入图片描述

    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树插入的传播效应导致根结点的拆分,则树的高度就会增加。

7.1.2 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上升到删除位置合适。

    在这里插入图片描述

7.2 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。

在这里插入图片描述

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,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树就如下图所示。左侧灰色方块表示当前结点的元素个数。

在这里插入图片描述

在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树结构中,我们往返于每个结点之间也就意味着,我们必须得在硬盘的页面之间进行多次访问,如下图,我们希望遍历这棵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树类似,只不过插入和删除的元素都是在叶子结点上进行而已。

8.散列表查找(哈希表)概述

在本章前面的顺序表查找时,我们曾经说过,如果你要查找某个关键字的记录,就是从表头开始,挨个的比较记录a[i]与key的值是“=”还是“≠”,直到有相等才算是查找成功,返回i。到了有序表查找时,我们可以利用a[i]与key的“<”或“>”来折半查找,直到相等时查找成功返回i。最终我们的目的都是为了找到那个i,其实也就是相对应的下标,再通过顺序存储的存储位置计算方法,LOC(ai) = LOC(a1) + (i-1)×c,也就是通过第一个元素内存存储位置加上i-1个单元位置,得到最后的内存地址。

此时我们发现,为了查找到结果,之前的方法“比较”都是不可避免的,但这是否真的有必要?能否直接通过关键字key得到要查找的记录内存存储位置呢?

8.1 散列表查找定义

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

存储位置 = f(关键字)

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

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

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

8.2 散列表查找步骤

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

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

在这里插入图片描述

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

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

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

比如那种同样的关键字,它能对应很多记录的情况,却不适合用散列技术。一个班级几十个学生,他们的性别有男有女,你用关键字“男”去查找,对应的有许多学生的记录,这显然是不合适的。只有如用班级学生的学号或者身份证号来散列存储,此时一个号码唯一对应一个学生才比较合适。

同样散列表也不适合范围查找,比如查找一个班级18~22岁的同学,在散列表中没法进行。想获得表中记录的排序也不可能,像最大值、最小值等结果也都无法从散列表中计算出来。

我们说了这么多,散列函数应该如何设计?这个我们需要重点来讲解,总之设计一个简单、均匀、存储利用率高的散列函数是散列技术中最关键的问题。

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

9.散列函数的构造方法

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

  • 计算简单

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

  • 散列地址分布均匀

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

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

9.1 直接定址法

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

在这里插入图片描述

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

在这里插入图片描述

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

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

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

9.2 数字分析法

如果我们的关键字是位数较多的数字,比如我们的11位手机号“130xxxx1234”,其中前三位是接入号,一般对应不同运营商公司的子品牌,如130是联通如意通、136是移动神州行、153是电信等;中间四位是HLR识别号,表示用户号的归属地;后四位才是真正的用户号,如下表。

在这里插入图片描述

若我们现在要存储某家公司员工登记表,如果用手机号作为关键字,那么极有可能前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,然后将它们叠加求和的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如果选得不好,就可能会容易产生同义词。

例如下表,我们对于有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因子的合数。

9.6 随机数法

选择一个随机数,取关键字的随机函数值为它的散列表,也就是f(key)=random(key),这里random是随机函数。当关键字的长度不等时,采用这个方法构造散列函数是比较合适的。

而对于关键字是字符串的情况,其实无论是英文字符,还是中文字符,也包括各种各样的符号,它们都可以转化为某种数字来对待,比如ASCII码或者Unicode码等。

总之,现实中,应该视不同的情况采用不同的散列函数。我们只能给出一些考虑的因素来提供参考:

  1. 计算散列地址所需的时间。
  2. 关键字的长度。
  3. 散列表的大小。
  4. 关键字的分布情况。
  5. 记录查找的频率。

综合这些因素,才能决策选择哪种散列函数更合适。

10.处理散列冲突的方法

从刚才除留余数法的例子可以看出,我们设计再好的散列函数也不可能完全避免冲突,所以我们要考虑如何处理它。

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}时,都没有冲突,直接存入,如下表。

在这里插入图片描述

计算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吗?如何可以获得相同的地址呢?这是个问题。这里的随机其实是伪随机数,伪随机数是说,如果我们设置随机种子相同,则不断调用随机函数可以生成不会重复的数列,我们在查找时,用同样的随机种子,它每次得到的数列是相同的,相同的di当然可以得到相同的散列地址。(对于随机种子概念不清楚的,自行上网查资料)

在这里插入图片描述

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

10.2 再散列函数法

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

在这里插入图片描述

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

10.3 链地址法

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

在这里插入图片描述

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

10.4 公共溢出区法

这种方法更容易理解,将冲突的关键字存储到溢出表中,如下图。

在这里插入图片描述

在查找时,对给定值通过散列函数计算出散列地址后,先与基本表的相应位置进行比对,如果相等,则查找成功;如果不相等,则到溢出表去进行顺序查找。如果相对于基本表而言,有冲突的数据很少的情况下,公共溢出区的结构对查找性能来说还是非常高的。

11.散列表查找实现

11.1 散列表查找算法实现

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

#define SUCCESS 1
#define UNSECCESS 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. 散列表的装填因子

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

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

猜你喜欢

转载自blog.csdn.net/m0_50833438/article/details/114489557