Java中散列集的存储与查找机制详述 之 散列表、散列码、桶

《Java核心技术卷Ⅰ》这本大厚书确实编写得很好,但也确实很抽象,毕竟有些东西是不能事无巨细嘛,下文将围绕图 1 进行详述在这里插入图片描述

图 1

一、概述

1.1、什么是哈希表?

哈希表(Hash table,也叫散列表),是根据关键码值(Key value)而直接进行访问的数据结构(或者说散列表是以 Key-Value 的形式进行数据存取的映射(map)结构)。也就是说,它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫做散列函数,存放记录的数组叫做散列表。

或者说:散列表是一种通过与其所包含的元素相关联的对象来访问元素的容器,这种与元素相关联的对象在散列表中被称为“键”。也就是说,对散列表中元素的添加、查找、修改、删除等操作必须通过与元素所对应的键才能进行。因此,散列表中的元素所对应的键在同一个散列表对象中是唯一且不可变的,即同一个散列表中不会出现两个相同的键;并且在元素确定的情况下,其对应的键是不可变的。

散列表提供了一种高效的元素使用方式,在理想情况下 [1],访问元素的时间复杂度能够达到O(1)。

深入理解:用最基本的向量(数组)作为底层物理存储结构,通过适当的散列函数在词条的关键码与向量单元的秩(下标)之间建立映射关系。也就是说,开辟物理地址连续的桶数组ht[],借助散列函数hash(),将词条关键码key映射为桶地址(数组下标),从而快速地确定待操作词条的物理位置。

f(关键字)=记录的存储位置

这里的对应关系f称为散列函数,又称为哈希(Hash)函数,采用散列技术将记录存储在一块连续的存储空间中,这块连续存储空间称为散列表或哈希表(Hash table)。

哈希表hashtable(key,value) 就是把Key通过一个固定的算法函数即所谓的哈希函数转换成一个整型数字,然后就将该数字对数组长度进行取余,取余结果就当作数组的下标,将value存储在以该数字为下标的数组空间里。(或者:把任意长度的输入(又叫做预映射, pre-image),通过散列算法,变换成固定长度的输出,该输出就是散列值。这种转换是一种压缩映射,也就是,散列值的空间通常远小于输入的空间,不同的输入可能会散列成相同的输出,而不可能从散列值来唯一的确定输入值。简单的说就是一种将任意长度的消息压缩到某一固定长度的消息摘要的函数。)

而当使用哈希表进行查询的时候,就是再次使用哈希函数将key转换为对应的数组下标,并定位到该空间获取value,如此一来,就可以充分利用到数组的定位性能进行数据定位。

数组的特点是:寻址容易,插入和删除困难;

而链表的特点是:寻址困难,插入和删除容易。

那么我们能不能综合两者的特性,做出一种寻址容易,插入删除也容易的数据结构?
答案是肯定的,这就是我们要提起的哈希表,哈希表有多种不同的实现方法,我接下来解释的是最常用的一种方法——拉链法,我们可以理解为“链表的数组”,先简单了解一下吧,如图 2 所示:

在这里插入图片描述

图 2

左边很明显是个数组(也叫做桶),数组的每个成员包括一个指针,指向一个链表的头,当然这个链表可能为空,也可能元素很多。我们根据元素的一些特征把元素分配到不同的链表中去,也是根据这些特征,找到正确的链表,再从链表中找出这个元素。

1.2 部分概念

1、桶/桶单元(bucket):散列表的物理存储结构,在物理上连续排列的用于存放词条的单元。
2、桶数组(bucket array):用数组作为桶单元。
3、地址空间(address space):桶数组的合法秩区间,如容量为R 则地址空间为[0, R)。
4、散列函数(hash function):词条与桶地址之间的映射关系,即从关键码到桶数组地址空间的映射函数。
5、散列地址(hashing address):给定关键码所对应的桶的秩,即 若给定散列函数hash(),关键码key,则散列地址为hash(key)。
6、散列冲突(collision):关键码不同的词条被映射到统一散列地址的情况。
7、装填因子(load factor):散列表中非空桶数量与桶单元总数的比值。
8、完美散列(perfect hashing):时间与空间性能均最优的散列。即给定问题实例下,对于任意关键码,均可在O(1)时间查找确定,且每个桶恰好存放一个词条,无空余无重复。完美散列实际上并不常见。
9、词条的聚集(clustering):词条集中到散列表内少数若干桶中(或附近)的现象。

二、Hash的应用

1、Hash主要用于信息安全领域中加密算法,它把一些不同长度的信息转化成杂乱的128位的编码,这些编码值叫做Hash值. 也可以说,Hash就是找到一种数据内容和数据存放地址之间的映射关系。

2、查找:哈希表,又称为散列,是一种更加快捷的查找技术。我们之前的查找,都是这样一种思路:集合中拿出来一个元素,看看是否与我们要找的相等,如果不等,缩小范围,继续查找。而哈希表是完全另外一种思路:当我知道key值以后,我就可以直接计算出这个元素在集合中的位置,根本不需要一次又一次的查找!
举一个例子,假如我的数组A中,第i个元素里面装的key就是i,那么数字3肯定是在第3个位置,数字10肯定是在第10个位置。哈希表就是利用利用这种基本的思想,建立一个从key到位置的函数,然后进行直接计算查找。

3、Hash表在海量数据处理中有着广泛应用。

Hash Table的查询速度非常的快,几乎是O(1)的时间复杂度。
hash就是找到一种数据内容和数据存放地址之间的映射关系。
散列法:元素特征转变为数组下标的方法。

我想大家都在想一个很严重的问题:“如果两个字符串在哈希表中对应的位置相同怎么办?”,毕竟一个数组容量是有限的,这种可能性很大。解决该问题的方法很多,我首先想到的就是用“链表”。我遇到的很多算法都可以转化成链表来解决,只要在哈希表的每个入口挂一个链表,保存所有对应的字符串就OK了。

散列表的查找步骤
当存储记录时,通过散列函数计算出记录的散列地址
当查找记录时,我们通过同样的是散列函数计算记录的散列地址,并按此散列地址访问该记录
关键字——散列函数(哈希函数)——散列地址
散列冲突:不同的关键字经过散列函数的计算得到了相同的散列地址。
好的散列函数=计算简单+分布均匀(计算得到的散列地址分布均匀)
哈希表是种数据结构,它可以提供快速的插入操作和查找操作。

三、优缺点

可以实现O(1)时间的数据项查找(注:给定关键码,通过散列函数可直接计算出所在地址)
能以节省空间的方式实现上述O(1)查找

优点:一对一的查找效率很高;不论哈希表中有多少数据,查找、插入、删除(有时包括删除)只需要接近常量的时间即0(1)的时间级。实际上,这只需要几条机器指令。
哈希表运算得非常快,在计算机程序中,如果需要在一秒种内查找上千条记录通常使用哈希表(例如拼写检查器)哈希表的速度明显比树快,树的操作通常需要O(N)的时间级。哈希表不仅速度快,编程实现也相对容易。
如果不需要有序遍历数据,并且可以提前预测数据量的大小。那么哈希表在速度和易用性方面是无与伦比的。

缺点:一个关键字可能对应多个散列地址;需要查找一个范围时,效果不好。它是基于数组的,数组创建后难于扩展,某些哈希表被基本填满时,性能下降得非常严重,所以程序员必须要清楚表中将要存储多少数据(或者准备好定期地把数据转移到更大的哈希表中,这是个费时的过程)。

四、存取访问机制

1、基本原理

散列表中存储的每一个元素都会有唯一一个与之对应的键,形成一一对应的关系 [2],散列表对某个元素的操作都是通过元素所对应的键进行,元素在散列表结构中存储的位置也是由其对应的键所决定的。

在添加元素时,散列表会先通过一个确定的散列函数 [3],将需要添加的元素所对应的键转化为一个整数值,这个数值可用于确定散列表结构内的某个位置,一旦存储的位置确定后,散列表将把这个需要添加的元素以及其对应的键以键值对的形式存储在这个位置。

元素添加后,若需要访问这个元素,就必须向散列表提供与需要访问的元素对应键,散列表仍然会通过那个确定的散列函数,将这个指定的键转化为一个整数值,即可根据这个数值找到散列元素所存储的位置。

散列表之所以能以这种方式访问元素,就是其散列函数能够保证:对于同一个指定的键,在确定的散列表结构中将总是得到同一个数值。

2、数据结构

为了能够高效存取,散列表使用了顺序结构与链式结构混合的方式来实现 [4]。散列表的数据结构大致如图 3 所示。其中黑色部分为链式结构,棕色部分为顺序结构。
在这里插入图片描述

图 3

图 3 中的棕色部分的顺序结构在每一个散列表对象有且仅有一个,它在散列表中被称为“表”或“桶”。

之所以采用这样的结构是有几个原因:
第一,元素的键通过散列函数所转化出的整数,经过计算后直接对应于顺序结构中的某个位置,可实现高效的随机存取;
第二,如果不同的键经过散列函数计算后得到了相同的值,则需要使用链式结构来表示这些元素在逻辑上被放置于同一个位置。

一旦明白散列表的数据结构后,也就不难明白散列表访问元素时的流程了:
1、将指定的键通过散列函数转化为一个整数值 n。
2、使用这个得到的数值 n 计算出一个表中的合法位置 x ,访问这个 x 的位置。
3、如果表中的 x 位置没有任何对象,则说明散列表中目前并没有指定键所对应的元素。
4、如果表中的 x 位置有指针指向某个对象,那么这个对象一定是一个链式结构的起始。依次检查链式结构的每一个节点,如果有某一个节点的键与指定的键一致,则说明找到元素;如果没有找到,则说明散列表中目前并没有指定键所对应的元素。

3、元素记录

散列表中的每一个元素必须拥有一个与之对应的键,并且在发生散列冲突的时候,这些元素还要能够自发组织为链式结构。为了满足这些要求,散列表的元素以及其对应的键通常被放置于一种结构体中,这种结构体在散列中被称作“记录” [5],如图 4。
在这里插入图片描述

图 4

其中,value 为散列表的元素,key 为这个元素对应的键,next 指向下一个发生散列冲突的记录。如果将图 4 与图 3 联系起来看的话,就是图 3 中的每一个黑色矩形,其细节就如图 4 所示。

4、散列值与索引

现在已经知道了散列表中数据结构的形式,那么接下来思考这么两个问题:
1、散列表如何将指定的键转化为整数 ?
2、如果将散列表的表视作一个数组,散列表是如果将这个整数转化为其合法的索引下标 ?

明白了第一个问题,也就明白了什么是散列值。通常,某个对象一旦创建之后,它在内存中的地址将是不变的,如果某些语言的内存模型并不是这样,那么它一定会提供某种标识一个对象的方式。这种标识对象的方式可以将之视为一串二进制值,不论其原本表示的意义是什么,二进制值都可以按照整数的方式去解析它,最终,总是能够得到一个整数值。

明白了第二个问题,也就明白了如何通过散列值得到散列表的表索引 [6]。散列表在设计上巧妙的利用了按位与运算。二进制的按位与运算是在相同的位上如果两个值同为 1,则得到 1,否则得到 0 。例如 1010 & 0011 = 0010、111 & 101 = 101。如果以十进制书写,就是 10 & 3 = 2、7 & 5 = 5,也就是有这样一个特点: A & B 如果将A、B视为无符号整数,其结果一定大于等于 0 且小于等于 A 且小于等于 B。一个在区间 [0, n]的整数,可作为长度为 n+1 的数组的合法索引。推出:一个长度为 n+1 的数组,用其长度减 1 的值(n + 1 - 1 = n)去和任意一个二进制数进行与运算,得到的结果一定是这个数组的合法索引。同时,为了能够最大限度的利用二进制的每一位,最好的方式就是 n 的结果转化为二进制后每一位都是 1 ,这样的 n 就是 1, 3, 7, 15, 31…,那么 n+1 就是 2, 4, 8, 16, 32…。[7]

5、散列冲突

前面章节中,已经知道了散列表根据元素的键获取散列值的表索引的原理。现在假设有一个散列表的表长为 8,现在需要添加两个元素,散列值分别为 0010 和 1010。使用表长减 1 的值分别与键的散列值进行按位与运算,得到的结果都是二进制的 0010,也就是十进制的 2。此时,对于散列值分别为 0010 和 1010 的两个键来说,就出现了相同的索引,称为“散列冲突”。

一旦发生了散列冲突,也就是散列表尝试将多个元素放置于同一个位置,此时,链式结构就发挥作用了。散列表添加元素具体过程如下:

1、将指定的键通过散列函数转化为一个整数值 n。
2、使用这个得到的数值 n 计算出一个表中的合法位置 x ,尝试将元素放置于这个 x 的位置。
3 、如果表中的 x 位置没有任何对象,则根据指定的元素和对应的键创建一个新的记录,然后将表中的 x 位置的指针指向这个新创建的记录。
4、如果表中的 x 位置有指向某个已有记录的指针,则根据指定的元素和对应的键创建一个新的记录,使这个新记录的 next 指向当前表中 x 位置上已有的指针所指向的记录,然后将表中的 x 位置的指针指向这个新创建的记录。
(第4步的详细过程如下:
a、遍历该位置上的所有旧元素,依次比较每个旧元素的哈希值和新元素的哈希值是否相同。  
如果有哈希值相同的旧元素,则执行第 b 步。  
如果没有哈希值相同的旧元素,则执行第 c 步。  
b、比较新元素和旧元素的地址是否相同  
如果地址值相同则用新的元素替换老的元素。停止比较。  
如果地址值不同,则新元素调用equals方法与旧元素比较内容是否相同。  
如果返回true,用新的元素替换老的元素,停止比较。  
如果返回false,则回到第 a 步继续遍历下一个旧元素。  
c、说明没有重复,则将新元素存放到该位置上并让新元素记住之前该位置的元素。)
: 往链式结构里插入元素时,是在链首添加元素。

根据以上过程可以作出假设,如果不停的向散列表中添加散列冲突的键,最终结果就是这个散列表的表中除那个特定的位置有记录外,其他位置都是空,并且所有记录都以链式结构的方式排列在一起。此时散列表访问元素的时间复杂度变为 O(n),到达最坏情况。[8]

6、扩容

如果散列表中放置了很多的元素,即使添加的元素的键之间发生最少次数的散列冲突 [9],也会使得散列表访问元素的时间复杂度增加。此时 [10],散列表将会扩大表的长度以尝试减少各个链式结构的长度,从而改善访问元素的时间复杂度。扩容前后的结构对比如图 5。

在这里插入图片描述

图 5

由图 5 可以看出,扩容后的散列表中各个元素所处的链式结构的长度都减少了,这就使得散列表的时间复杂度下级降,趋近于O(1)。

[1] 理想情况是指:添加元素时,不出现散列冲突、不出现表扩容;得到元素时,已有的所有元素不存在散列冲突。
[2] 一种特殊的情况是:如果有多个键,它们对应的值都是指针(引用),并且这些指针都指向同一个对象。那么,站在对象的层面上来看,就会出现多个键对应同一个值。
[3] 散列函数是一种用于将一个任意类型的对象(包括 Null),根据代码中定义的某种标识对象的特征,获取一个具体数值的函数,得到的数值被称为这个对象的“散列值”。散列值与对应的对象的之间的关系通常为:如果两个对象相同,对应的散列值一定相同;如果两个对象不相同,对应的散列值不一定不相同;如果两个散列值不同,对应的对象一定不相同;如果两个散列值相同,对应的对象不一定相同。
[4] 散列表的表的数据结构有多种实现方式,在这里我们仅讨论其中一种比较典型的方式。
[5] 散列表的记录的数据结构有多种实现方式,在这里我们仍然仅讨论其中一种比较典型的方式。
[6] 散列表将对象散列值转化为表的索引的方式有多种实现方式,在这里我们还是仅讨论其中一种比较典型的方式。
[7] 这也就是散列表的表长度一定是 2 的 n 次幂的原因。(基于我们上面所讨论的实现方式)(呼应图1中红框内的内容)
[8] 这种散列表的最坏情况在我们上面所讨论的实现方式中是不可避免的,这种情况是所有键对外透露的特征都是相同的,但他们却彼此不相等。比这种情况稍微好一点的情况是所有键对外透露的特征虽然不同,但是在计算索引的函数中都返回了相同结果,此时可将链式结构由链列转为查找树即可有所改善。
[9] 最少散列冲突是指:当添加的键的散列值足够离散,但由于散列表的表不够长,迫使某些元素的键产生散列冲突。例如,一个表长为 8 的散列表,向其中添加 16 个元素,即使在最好情况下,也就是表中每一个位置都放置了记录,但这些记录所处的链式结构长度都为 2。
[10] 通常,散列表不会在元素被放满才进行扩容,而是会有一个阈值,当散列表添加元素时,散列表中的元素数量大于这个阈值,并且这个新添加的元素的键发生了散列冲突,就会进行扩容,以最大化利用我们上面所讨论的结构。

参考:
《Java核心技术卷Ⅰ》

https://blog.csdn.net/duan19920101/article/details/51579136?utm_medium=distribute.pc_relevant.none-task-blog-BlogCommendFromBaidu-1.channel_param&depth_1-utm_source=distribute.pc_relevant.none-task-blog-BlogCommendFromBaidu-1.channel_param

https://www.cnblogs.com/simpleito/p/10720107.html

https://www.jianshu.com/p/4b60b5740432

https://www.cnblogs.com/z-b-q/p/11641991.html

。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。

如果大家喜欢这篇文章的话,希望大家收藏、转发、关注、评论、点赞,转载请注明出自这里。 PS:本随笔属个人学习小结,文中内容有参考互联网上的相关文章。如果您博文的链接被我引用,我承诺不会参杂经济利益;如果有版权纠纷,请私信留言。其中如果发现文中有不正确的认知或遗漏的地方请评论告知,谢谢! 还是那句话:不是我喜欢copy,是站在巨人的肩膀上~~

  • 2
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值