深入浅出 哈希表与哈希函数(HashMap\HashSet\Map\Set)

1、初识哈希函数 和 哈希表

在线性表(数组、链表)和树结构中,记录存储的位置是随机的,也就是说在记录存储的位置和记录的关键字之间没有一 一对应的关系。

假设存在一个存储学生信息的数组,数组中存储着两个学生的信息,分别是“姓名:李雷,性别:男”, “姓名:韩梅梅,性别:女”。那么我们称 每一个学生的信息项 为 一条记录,记录所在数组中对应的下标为记录的存储位置。记录的关键字,可以是 姓名 也可以是 性别。

因此,在这样的结构中,要去查询某个记录,需要去进行一系列关键字的比较(遍历整个结构)。 时间复杂度为O(n)。

为了优化记录的查找效率,我们可以在记录的位置pos和记录的关键字k之间建立一个一一对应的映射关系f,即pos = f(k)。有了这个映射关系,在存入记录时,就可以根据记录的关键字k,得到记录的存储位置pos,然后将记录存储到这个位置中。在查找记录时,就可以直接根据给定的关键字k,来找到记录的存储位置pos,进而获取记录的内容,或进行相关的操作。这样的查找过程,时间复杂度为O(1)。在此,我们把映射关系f 称为哈希函数,把由这样的思想构建的存储记录的结构称为哈希表(其实也就是一个数组)。

2、哈希函数的构造,怎么去学习哈希函数

通过上面的讲解,相信大家也看出来了,想要构建一个查询 时间复杂度为O (1) 的哈希表结构,最关键 最重要的事情就是去求得这样的一个映射关系f(哈希函数)。看过数据结构课本的同学,可能有了解到,构造哈希函数的方法有什么 直接定址法、平方取中法、数字分析法等等。但在实际应用中的哈希函数的构造方法 是不可能这么简单的。一个成熟的哈希函数,它一定是要满足某几个特定的性质的,比如最重要的性质 离散性(均衡性)。

由于,哈希函数它已经有了很多成熟的实现方式,而它们的实现方式都是比较复杂的,再加上在java,javascript这些高级语言中,都有对应的实现好的哈希表结构,如java中的HashMap,js中的Map。因此,在实际开发中,我们一般很少自己去实现特定的哈希函数。也正因为如此,在刚开始学习的过程中,我们应该重点去关注哈希函数它有哪些性质,以及这些性质都有哪些应用,而不是去 细嗑 哈希函数的实现方式。

补充:哈希函数它有成百上千中实现方式,常见的成熟的哈希算法有 MD5、SHA1。

下面来讲一下哈希函数的性质。

3、哈希函数的性质

一个成熟的哈希函数都会满足下面这几个性质,其中最重要的性质是离散性(均衡性)。

性质1: 输入域无穷,输出域可能很大,但是有限。

如,哈希函数 hash(string),输入值类型是string,可以是任意字符的组合,所以说这样的输入值有无穷多个。而哈希函数的输出值,一般都是一个数字或者哈希码。如MD5算法输出的是一个 哈希码,范围是0-264。SHA1算法输出的也是一个哈希码,范围是0-2128。

性质2: 相同的输入值,对应一个确定的输出值。(它是一个函数,输入值和输出值之间存在一一对应的关系)

性质3: 不同的输入值,可能对应相同的输出值。(如 y = x^2 这个函数,当x取2 和 -2时,得到的都是4)

性质4: 离散性(均衡性 or 散列性)

离散性是哈希函数最重要的性质,它主要包含以下两点:

  1. 哈希函数的输出值,在输出域上几乎均匀分布。什么意思呢?举个例子。假设,存在一个哈希函数,它的输入域是所有的正整数(无穷),输出域为0-2。随机取100个不相同的正整数作为这个函数的输入值,那么得到的输出值,0 差不多是 33 个、1 差不多是33个、2差不多也是33个。

  2. 有序的输入值,得到的输出值是千差万别的(无序的)。如,joy1、joy2、joy3,三个只有后面数字不同的字符串作为输入,得到的输出值可能是 几十,也可是几万或者几千。但它不是随机的,它是通过固定的步骤计算出来的。

上面这两点综合起来,就叫做哈希函数的离散性。哈希函数的离散性越好,即输出值在输出域上分布越均匀,有序的输入值得到的输出值无序性越好,那么就说这个哈希函数越优良。在实际开发中,哈希函数的离散性,很多时候被用于去打破输入值的有序性(打破输入规律)。

离散性的延伸:

我们说,一个哈希函数它的输出值在S域上均匀分布,那么也在0 到 (S mod m - 1)范围内也均匀分布。

一个数mod(模)上m,得到结果的范围是 0 到 m-1

假如存在一个哈希函数,它的输入域是所有的正整数(无穷),输出域是0 - 9,随机取1000 个不同的正整数,那么它的输出值, 0 差不多是 100个、1差不多是100个、2差不多是100个……。对所有的输出值做模3运算,得 0 差不多是400个,1差不多是300个,2差不多是300个。得到的结果看起来似乎不是均匀的?上面是为了方便讲述,才说输出域是0-9,但实际上输出域一般都是很大的。当输出域很大时,再模3得到的值,在0-2上几乎就是均匀分布的了。

说明白了哈希函数,下面来 细suo一下 哈希表吧。

3、哈希表(散列表)

由于,哈希函数的输入域是几乎无穷的,输出域是有限的,所以就必然会存在“不同的输入值,得到相同的输出值”这种情况,当出现这种的情况时,我们就说发生了哈希地址冲突。而由于,哈希表的查找效率又受这种冲突的影响,并且这种冲突只能减少,不能避免,所以就有了很多处理冲突的办法,如开放地址法、再哈希法、连地址法等等。下面我会以链地址法为例来讲解一下哈希表的构建过程 和 记录的查询过程。其它的方法留给大家自己去搜索哈。

假设,我们现在有一个存储学生信息(姓名:xxx,性别:x,班级:xxx……)的数组,在这样的结构中去查询一个学生,时间复杂度为O(n)。为了优化学生信息的查询效率,咱们把这个结构,转换为一个哈希表结构。先是申请一个合适长度的数组,假设它的长度为17,初始数组的每个元素为一个空链表。然后去寻找一个合适的哈希函数,假设咱们想要通过学生的姓名来直接去查找到一个学生,因此,假设这个哈希函数的输入域为任意汉字组成的字符串,输出域为一个0-2^64范围内的值。现在咱们取出学生信息数组中一个学生的信息,假设这个学生的姓名叫 ’李雷‘,将’李雷‘做为关键字输入哈希函数中,得到一个0-2^64范围内的一个数,给这个数做模17运算。以得到的值作为数组的下标(记录的存储位置),假设这个值为10,那么就将’李雷‘同学的信息存储 到 数组下标10对应位置的链表中。如下:

对原来数组中所有的学生信息都进行这样的操作后,可能会到得到这样的一个结构:

想要查询一个学生的信息的话。比如咱们想查询’李雷‘同学的信息,和构建过程一样,将’李雷’作为关键字经过哈希函数处理,然后模(mod)上数组的长度17,得到10。

注意它得到的一定是10,因为“相同的输入 对应着确定的唯一的输出”。

然后去遍历数组下标为10的位置 对应的链表,经过 关键字的匹配,就可以找到‘李雷’同学的信息了。
在这里插入图片描述

提问1:有的友友可能会问了,以这样的方式来构建哈希表,如果学生的数量很多的话,那么必然就会到导致,数组中链表的长度过长,这样一来查找效率,不是就变的不是那么高效了吗?

对的,确实是这样的,所以在采用链地址法处理冲突 实现哈希表结构的 高级语言中(如java),它会为链表设置一个长度阈值m。在存入数据的过程中,当任意一个链表长度大于m时,哈希表就会扩容。具体操作就是,申请长度为当前哈希表长度两倍的内存空间(数组),将表中的所有记录都 重新经过哈希函数计算,紧接着取模后,存入新的内存空间(数组)中,形成新的哈希表。(17 , 第一次扩容是 34,第二次扩容是68,…)

一般这个m值应该都不会取的很大,假设它取为6。那么要去查询一个同学的信息,在关键字经过哈希取模运算后,用得到的下标去数组中查找,最多需要比较6次,就可以知道哈希表中,是否存在这个同学的信息了。查询 时间复杂度是O(1),只不过是 常数项比较大而已。

提问2:有的友友可能又会问了,当任意一个链表长度大于m时,就去扩容,难道不浪费内存空间吗?为什么不等等,在大部分链表都大于m时,去扩容呢?

不会浪费内存空间的,因为上面我们讲过,哈希函数的输出值在输出域上几乎均匀分布,如果给所有 输出值进行 模 m运算,那么得到的值在 0 到(m-1)范围内,也几乎均匀分布。也就是说,在向哈希表的构建过程中,对应的所有链表的长度,几乎是均匀变长的。有一个链表长度为6,那么也就是说明,其它链表的长度几乎差不多也是6。所以说这样的操作是不会浪费内存空间的。

提问3:有的友友说,我还有问一个问题,扩容操作看起那么麻烦,它会不会影响到查询效率呢?

不会。因为每一次扩容操作,都是扩容为原来的容量的两倍。这样的指数级操作,在扩容一次之后,很久都不会再次扩容。平均扩容代价的时间复杂度,可以认为是O(1)。而且这个扩容操作是可以离线的。也就是说,在存入一个数据时,发现容量超了,可以将扩容操作放到后台去执行(想一下多进程或多线程),暂时可以将这个数据存入当前未扩容的哈希表中。在查询的时候,可以先去旧的哈希表中的查询,等到新的哈希表构建好了,再让后面的查询去新的哈希表中查询。

4、Java中HashMap 和 HashSet的区别

HashMapHashSet底层都是基于哈希表结构来实现的,都采用链地址法来处理哈希冲突。

在jdk1.7还是1.8之前,数组中的元素都是链表,之后为了优化查找效率,改成了 红黑树。

只不过,在HashSet结构中,存入的是 Key,而在HashMap结构中存入的是一个键值对,key 和 value。在HashSet中,数据的查询过程和上面所讲的一样。在HashMap中,可以根据给定的键值key,在哈希表中查找到对应的数据项,然后将数据项中的value返回。这样给人的感觉就是:在key 和 value之前建立一种一一对应的映射关系。

通过下图应该可以清楚的看出它俩的区别。
在这里插入图片描述

5、javascript中Map 和 Set的区别

Map和Set,在底层也都是基于哈希表实现的。采用什么方式来处理的 哈希冲突的,这个就要给大家说声抱歉了,我在internet上逛了老半天,也没有找到确切的说是哪一种方式。估计要去读一下V8的原码就知道了(读不读不知道,但是读了我一定来补充)

它俩的区别跟上面所讲的HashMap 和 HashSet一样。Map存入的是键值对,Set存入只有键。

补充:

无论是Java中的HashMapHashSet ,还是 javascript中的 mapSet,由于它们都是基于哈希表实现的,所以它们的 增删改查 操作的**时间复杂度都可以做到 O(1)。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值