查找算法

851 阅读34分钟

前言

这篇文章是对《大话数据结构》的一个总结。这本书当时看的时候,感觉十分的震撼,国内难得有一本说人话的书。但是因为数据结构和算法本身内容就很枯燥,因此,部分内容表现出来仍然像天书一样。因此,本文竟可能对里面的“人话”提取出来。

内容总汇

首先我们要弄清楚查找表、记录、关键字、主关键字、静态查找表、动态查找表等这些概念。

顺序表查找,尽管很土(简单),但它却是后面很多查找的基础,注意设置“哨兵”的技巧,可以使得本已经很难提升的简单算法里还是提高了性能。

有序查找,我们着重讲了折半查找的思想,它在性能上比原来的顺序查找有了质的飞跃,由0(n)变成了00ogn)。之后我们又讲解了另外两种优秀的有序查找:插值查找和斐波那契查找,三者各有优缺点,望大家要仔细体会。

线性索引查找,我们讲解了稠密索引、分块索引和倒排索引。索引技术被广泛的用于文件检索、数据库和搜索引擎等技术领域,是进一步学习这些技术的基础。

二叉排序树是动态查找最重要的数据结构,它可以在兼顾查找性能的基础上,让插入和删除也变得效率较高。不过为了达到最优的状态,二叉排序树最好是构造成

平衡二叉树才最佳。因此我们就需要再学习关于平衡二叉树(AVL树)的数据结构,了解AVL树是如何处理平衡性的问题。这部分是本章重点,需要认真学习掌握。

B树这种数据结构是针对内存与外存之间的存取而专门设计的。由于内外存的查找性能更多取决于读取的次数,因此在设计中要考虑B树的平衡和层次。我们讲解时是先通过最最简单的B树(2-3树)来理解如何构建、插入、删除元素的操作,再通过2-3-4树的深化,最终来理解B树的原理。之后,我们还介绍了B+树的设计思想。

哈希表是一种非常高效的查找数据结构,在原理上也与前面的查找不尽相同,它回避了关键字之间反复比较的烦琐,而是直接一步到位查找结果。当然,这也就带来了记录之间没有任何关联的弊端。应该说,散列表对于那种查找性能要求高,记录之间关系无要求的数据有非常好的适用性。在学习中要注意的是散列函数的选择和处理冲突的方法。

1. 顺序查找

顺序查找(Sequential Search)又叫线性查找,是最基本的查找技术。

它的查找过程是:从表中第一个(或最后一个)记录开始,逐个进行记录的关键字和给定值比较,若某个记录的关键字和给定值相等,则查找成功,找到所查的记录;如果直到最后一个(或第一个)记录,其关键字和给定值比较都不等时,则表中没有所查的记录,查找不成功。

2. 有序表查找

我们如果仅仅是把书整理在书架上,要找到一本书还是比较困难的,也就是刚才讲的需要逐个顺序查找。但如果我们在整理书架时,将图书按照书名的拼音排序放置,那么要找到某一本书就相对容易了。说白了,就是对图书做了有序排列,一个线性表有序时,对于查找总是很有帮助的。

本节参考:zhuanlan.zhihu.com/p/31895830

(1) 二分查找

折半查找(Binary Search)技术,又称为二分查找。

它的前提是线性表中的记录必须是关键码有序(通常从小到大有序),线性表必须采用顺序存储。

折半查找的基本思想是:在有序表中,取中间记录作为比较对象,若给定值与中间记录的关键字相等,则查找成功;若给定值小于中间记录的关键字,则在中间记录的左半区继续查找;若给定值大于中间记录的关键字,则在中间记录的右半区继续查找。不断重复上述过程,直到查找成功,或所有查找区域无记录,查找失败为止。

(2)插值查找

插值查找(Interpolation Search)是根据要查找的关键字key与查找表中最大最小记录的关键字比较后的查找方法

其核心就在于插值的计算公式。

mid = low + (high - low)*(key - a[low])/ (a[high] - a[low])

应该说,对于表长较大,而关键字分布又比较均匀的查找表来说,插值查找算法的平均性能比折半查找要好得多。反之,数组中如果分布类似{0,1,2,2000,2001…. 999998,999999}这种极端不均匀的数据,用插值查找未必是很合适的选择。

(3)斐波那契查找

  1. 根据待查找数组长度确定装波那契数组的长度(或最大元素值)

  2. 根据1中长度创建该长度的装波那契数组,再通过F(0) = 1, F(1) = 1, F(n) = F(n-1) + F(n-2) 生成装波那契数列为数组赋值

  3. 以2中的裴波那契数组的最大值为长度创建填充数组,将原待排序数组元素拷贝到填充数组中来,如果有剩余的未赋值元素,用原待排序数组的最后一个元素值填充

  4. 针对填充数组进行关键字查找,查找成功后记得判断该元素是否来源于后来填充的那部分元素

3. 线性索引查找

前面讲的几种比较高效的查找方法都是基于有序的基础之上的,但事实上,很多数据集可能增长非常快,例如,某些微博网站或大型论坛的帖子和回复总数每天都是成百万上千万条,或者一些服务器的日志信息记录也可能是海量数据,要保证记录全部是按照当中的某个关键字有序,其时间代价是非常高昂的,所以这种数据通常都是按先后顺序存储。

那么对于这样的查找表,我们如何能够快速查找到需要的数据呢?办法就是索引。

数据结构的最终目的是提高数据的处理速度,索引是为了加快查找速度而设计的一种数据结构。索引就是把一个关键字与它对应的记录相关联的过程,一个索引由若干个索引项构成,每个索引项至少应包含关键字和其对应的记录在存储器中的位置等信息。索引技术是组织大型数据库以及磁盘文件的一种重要技术。

索引按照结构可以分为线性索引、树形索引和多级素引。我们这里就只介绍线性索引技术。所谓线性索引就是将索引项集合组织为线性结构,也称为索引表。我们重点介绍三种线性索引:稠密索引、分块索引和倒排素引。

(1) 稠密索引(本子记位置)

稠密索引是指在线性索引中,将数据集中的每个记录对应一个索引项

稠密索引要应对的可能是成千上万的数据,因此对于稠密索引这个索引表来说,索引项一定是按照关键码有序的排列。

索引项有序也就意味着,我们要查找关键字时,可以用到折半、插值、斐波那契等有序查找算法,大大提高了效率。

比如图中,我要查找关键字是18的记录,如果直接从右侧的数据表中查找,那只能顺序查找,需要查找6次才可以查到结果。而如果是从左侧的索引表中查找,只需两次折半查找就可以得到18对应的指针,最终查找到结果。

这显然是稠密索引优点,但是如果数据集非常大,比如上亿,那也就意味着索引也得同样的数据集长度规模,对于内存有限的计算机来说,可能就需要反复去访问磁盘,查找性能反而大大下降了。

(2) 分块索引(图书馆)

稠密索引因为索引项与数据集的记录个数相同,所以空间代价很大。为了减少索引项的个数,我们可以对数据集进行分块,使其分块有序,然后再对每一块建立一个索引项,从而减少索引项的个数。

分块有序,是把数据集的记录分成了若干块,并且这些块需要满足两个条件:

  • 块内无序,即每一块内的记录不要求有序。当然,你如果能够让块内有序对查找来说更理想,不过这就要付出大量时间和空间的代价,因此通常我们不要求块内有序。

  • 块间有序,例如,要求第二块所有记录的关键字均要大于第一块中所有记录的关键字,第三块的所有记录的关键字均要大于第二块的所有记录关键字……因为只有块间有序,才有可能在查找时带来效率。

对于分块有序的数据集,将每块对应一个索引项,这种索引方法叫做分块索引。如图所示,我们定义的分块索引的索引项结构分三个数据项:

  • 最大关键码,它存储每一块中的最大关键字,这样的好处就是可以使得在它之后的下一块中的最小关键字也能比这一块最大的关键字要大;

  • 存储了块中的记录个数,以便于循环时使用;

  • 用于指向块首数据元素的指针,便于开始对这一块中记录进行遍历。

在分块索引表中查找,就是分两步进行:

  1. 在分块索引表中查找要查关键字所在的块。由于分块索引表是块间有序的,因此很容易利用折半、插值等算法得到结果。例如,图中的数据集中查找62,我们可以很快可以从左上角的索引表中由57<62<96得到62在第三个块中。
  2. 根据块首指针找到相应的块,并在块中顺序查找关键码。因为块中可以是无序的,因此只能顺序查找。

(3) 倒排索引(搜索引擎)

在这里这张单词表就是索引表,索引项的通用结构是:

  • 次关键码,例如上面的“英文单词”;
  • 记录号表,例如上面的“文章编号”。

其中记录号表存储具有相同次关键字的所有记录的记录号(可以是指向记录的指针或者是该记录的主关键字)。这样的索引方法就是倒排索引(inverted index)。

倒排索引源于实际应用中需要根据属性(或字段、次关键码)的值来查找记录。这种索引表中的每一项都包括一个属性值和具有该属性值的各记录的地址。由于不是由记录来确定属性值,而是由属性值来确定记录的位置,因而称为倒排索引。

当然,现实中的搜索技术非常复杂,比如我们不仅要知道某篇文章有要搜索的关键字,还想知道这个关键字在文章中的哪些地方出现,这就需要我们对记录号表做一些改良。再比如,文章编号上亿,如果都用长数字也没必要,可以进行压缩,比如三篇文章的编号是“112,115,119°,我们可以记录成“112,+3,+4”,即只记录差值,这样每个关键字就只占用一两个字节。甚至关键字也可以压缩,比如前一条记录的关键字是“and”而后一条是“android”,那么后面这个可以改成“<3,roid>”,这样也可以起到压缩数据的作用。再比如搜索时,尽管告诉你有几千几万条查找到的记录,但其实真正显示给你看的,就只是当中的前10或者20条左右数据,只有在点击下一页时才会获得后面的部分索引记录,这也可以大大提高了整体搜索的效率。

4. 二叉排序树

二叉排序树(Binary Sort Tree),又称为二又查找树。

它或者是一棵空树,或者是具有下列性质的二又树:

  • 若它的左子树不空,则左子树上所有结点的值均小于它的根结构的值;
  • 若它的右子树不空,则右子树上所有结点的值均大于它的根结点的值;
  • 它的左、右子树也分别为二叉排序树。

构造一棵二叉排序树的目的,其实并不是为了排序,而是为了提高查找和插入删除关键字的速度。不管怎么说,在一个有序数据集上的查找,速度总是要快于无序的数据集的,而二又排序树这种非线性的结构,也有利于插入和删除的实现。

(1) 二叉排序树的查询

对于二叉排序树的查找,走的就是从根结点到要查找的结点的路径,其比较次数等于给定值的结点在二叉排序树的层数。极端情况,最少为1次,即根结点就是要找的结点,最多也不会超过树的深度。也就是说,二又排序树的查找性能取决于二叉排序树的形状。可问题就在于,二叉排序树的形状是不确定的。

(2) 二叉排序树的插入

二叉排序树是以链接的方式存储,保持了链接存储结构在执行插入或删除操作时不用移动元素的优点,只要找到合适的插入和删除位置后,仅需修改链接指针即可。插入删除的时间性能比较好。

(3) 二叉排序树的删除

根据我们对删除结点三种情况的分析:

  • 叶子结点:直接删除

  • 仅有左或右子树的结点:独子继承父业

  • 左右子树都有的结点,我们来看代码,下面这个算法是递归方式对二又排序树T查找key,查找到时删除。

(4)平衡二叉树(AVL树)及其实现

平衡二叉树是一种二叉排序树,其中每一个节点的左子树和右子树的高度差至多等于1。它是一种高度平衡的二又排序树。

那什么叫做高度平衡呢?意思是说,要么它是一棵空树,要么它的左子树和右子树都是平衡二叉树,且左子树和右子树的深度之差的绝对值不超过1。

我们将二叉树上任一结点的左子树深度减去右子树深度的值称为平衡因子BF(Balance Factor),那么平衡二又树上所有结点的平衡因子只可能是一1、0和1。只要二叉树上有一个结点的平衡因子的绝对值大于1,则该二叉树就是不平衡的。

看图8-7-2,为什么图1是平衡二叉树,而图2却不是呢?这里就是考查我们对平衡二叉树的定义的理解,它的前提首先是一棵二叉排序树,右上图的59比58大,却是58的左子树,这是不符合二又排序树的定义的。图3不是平衡二又树的原因就在于,结点58的左子树高度为2,而右子树为空,二者差大于了绝对值1,因此它也不是平衡的。而经过适当的调整后的图4,它就符合了定义,因此它是平衡二叉树。

平衡二又树构建的基本思想就是在构建二又排序树的过程中,每当插入一个结点时,先检查是否因插入而破坏了树的平衡性,若是,则找出最小不平衡子树。

在保持二叉排序树特性的前提下,调整最小不平衡子树中各结点之间的链接关系,进行相应的旋转,使之成为新的平衡子树。

哪个点BF(左减右)大于1或者小于-1,该点就旋转,大于1右转,小于-1左转

5. 多路查找树(B树)

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

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

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

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

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

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

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

(1)2-3树

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

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

一个3结点包含一小一大两个元素和三个孩子(或没有孩子),一个3结点要么没有孩子,要么具有3个孩子。如果某个3结点有孩子的话,左子树包含小于较小元素的元素,右子树包含大于较大元素的元素,中间子树包含介于两元素之间的元素。并且2-3树中所有的叶子都在同一层次上。如图8-8-2所示,就是一棵有效的2-3树。

a、2-3树的插入

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

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

1)对于空树,插入一个2结点即可,这很容易理解。

2)插入结点到一个2结点的叶子上。应该说,由于其本身就只有一个元素,所以只需要将其升级为3结点即可。如图8-8-3所示20。我们希望从左图的2-3树中插入元素3,根据遍历可知,3比8小、比4小,于是就只能考虑插入到叶子结点1所在的位置,因此很自然的想法就是将此结点变成一个3结点,即右图这样完成插入操作。当然,要视插入的元素与当前叶子结点的元素比较大小后,决定谁在左谁在右。例如,若插入的是0,则此结点就是“0”在左“1”在右了。

3)要往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的右图样子。

b、2-3树的删除

对于2-3树的删除来说,如果对前面插入的理解足够到位的话,应该不是难事了。2-3树的删除也分为三种情况。与插入相反,我们从3结点开始说起。

1)所删除元素位于一个3结点的叶子结点上,这非常简单,只需要在该结点处删除该元素即可,不会影响到整棵树的其他结点结构。如图8-8-7所示,删除元素9,只需要将此结点改成只有元素10的2结点即可。

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所示,此时删除结点1,如果直接左旋会造成没有右孩子,因此需要对整棵树变形,办法就是,我们目标是让结点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结点,最后成为右图的样子。

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)2-3-4树

2-3-4树其实就是2-3树的概念扩展,包括了4结点的使用。一个4结点包含小中大三个元素和四个孩子(或没有孩子),一个4结点要么没有孩子,要么具有4个孩子。如果某个4结点有孩子的话,左子树包含小于最小元素的元素;第二子树包含大于最小元素,小于第二元素的元素;第三子树包含大于第二元素,小于最大元素的元素;右子树包含大于最大元素的元素。

如果我们构建一个数组为(7,1,2,5,6,9,84,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。

(3)B树

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

例如,在讲2-3-4树时插入9个数后的图转成B树示意就如图8-8-17的右图所示。左侧灰色方块表示当前结点的元素个数。

在B树上查找的过程是一个顺指针查找结点和在结点中查找关键字的交叉过程。

比方说,我们要查找数字7,首先从外存(比如硬盘中)读取得到根结点3、5、8三个元素,发现7不在当中,但在5和8之间,因此就通过Az再读取外存的6、7结点,查找到所要的元素。

至于B树的插入和删除,方式是与2-3树和2-3-4树相类似的,只不过阶数可能会很大而已。

我们在本节的开头提到,如果内存与外存交换数据次数频繁,会造成了时间效率上的瓶颈,那么B树结构怎么就可以做到减少次数呢?

我们的外存,比如硬盘,是将所有的信息分割成相等大小的页面,每次硬盘读写的都是一个或多个完整的页面,对于一个硬盘来说,一页的长度可能是211到214个字节。

在一个典型的B树应用中,要处理的硬盘数据量很大,因此无法一次全部装入内存。因此我们会对B树进行调整,使得B树的阶数(或结点的元素)与硬盘存储的页面大小相匹配。

比如说一棵B树的阶为1001(即1个结点包含1000个关键字),高度为2,它可以储存超过10亿个关键字,我们只要让根结点持久地保留在内存中,那么在这棵树上,寻找某一个关键字至多需要两次硬盘的读取即可。这就好比我们普通人数钱都是一张一张的数,而银行职员数钱则是五张、十张,甚至几十张一数,速度当然是比常人快了不少。

通过这种方式,在有限内存的情况下,每一次磁盘的访问我们都可以获得最大数量的数据。由于B树每结点可以具有比二又树多得多的元素,所以与二又树的操作不同,它们减少了必须访问结点和数据块的数量,从而提高了性能。可以说,B树的数据结构就是为内外存的数据交互准备的。

(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+树的示意,灰色关键字即是根结点中的关键字在叶子结点再次列出,并且所有叶子结点都链接在一起

6. 哈希表

在顺序表查找时,我们曾经说过,如果你要查找某个关键字的记录,就是从表头开始,挨个的比较记录a[i]与key的值是“=”还是“≠”,直到有相等才算是查找成功,返回i。

到了有序表查找时,我们可以利用a[i]与key的“<”或“>”,来折半查找,直到相等时查找成功返回i。最终我们的目的都是为了找到那个i,其实也就是相对的下标,再通过顺序存储的存储位置计算方法,LOC(al)=loC(a1)+(i一1)×c,也就是通过第一个元素内存存储位置加上i-1个单元位置,得到最后的内存地址。

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

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

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

(1)哈希表存储

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

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

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

(2)哈希表查找

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

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

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

(3)哈希函数

**设计散列函数的两个原则**:

    1、计算简单,节省时间(你要是用个算法算地址算半天,我用你干嘛)
    
    2、散列地址分布均匀,保证存储空间的有效利用(减少处理冲突的时间)

**选择散列函数的五个因素**:
    
    1、计算散列地址所需的时间。
    
    2、关键字的长度。
    
    3、散列表的大小。

    4、关键字的分布情况。

    5、记录查找的频率。

常见散列函数构造方法

(4)处理散列冲突的方法

那么当我们在使用散列函数后发现两个关键字key1≠keyz,但是却有f(key1)=f (keyz),即有冲突时,怎么办呢?

(5)哈希表的查找

我们对散列表查找的性能作一个简单分析。如果没有冲突,散列查找是我们本章介绍的所有查找中效率最高的,因为它的时间复杂度为0(1]。可惜,我说的只是“如果”,没有冲突的散列只是一种理想,在实际的应用中,冲突是不可避免的。那么散列查找的平均查找长度取决于哪些因素呢?

1.散列函数是否均匀

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

2.处理冲突的方法

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

3.散列表的装填因子

所谓的装填因子x=填入表中的记录个数/散列表长度。a标志着散列表的装满的程度。当填入表中的记录越多,《就越大,产生冲突的可能性就越大。如果你的散列表长度是12,而填入表中的记录个数为11,那么此时的装填因子x=11/12=0.9167,再填入最后一个关键字产生冲突的可能性就非常之大。也就是说,散列表的平均查找长度取决于装填因子,而不是取决于查找集合中的记录个数。

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