如何设计散列表(哈希表)
可以获取到什么
通过本章可以了解散列表是什么数据结构,为什么叫做散列表?他的特点是什么?以及如何去设计一个散列表?为什么要这么设计?
会介绍散列表中三个重要的核心点:散列函数,处理冲突,查找效率。并且会从是什么,到为什么的去剖析散列表的设计。
基本概念
对于散列表的概念这里我想先放上百科的概念,可能会比较生涩,但是却是相对准确且描述非常到位的一种说明。在学习完之后再回头看,会发现百科的定义是非常到位的。
散列表(Hash table,也叫哈希表),是根据键(Key)而直接访问在内存储存位置的数据结构。也就是说,它通过计算出一个键值的函数,将所需查询的数据映射到表中一个位置来让人访问,这加快了查找速度。这个映射函数称做散列函数,存放记录的数组称做散列表。
简单总结一下就是,散列表是一个数组,这个数组存储的是一个个的键值对。
但是普通的数组存储键值对并不能根据key迅速的查找到这个键值对存储在数组中的具体位置。因此需要对这个存储剑指对的数组进行改造,让其能够可以快速的根据key定位到对应的key在数组中所在的位置。
最后设计出来的这个数据结构就叫做散列表。也称为哈希表。
他可以通过key在 O(1) 的时间复杂度内获取到对应key-value在散列表中对应的存储位置。从而获取到对应的value值。
使用场景
像这样一种数据结构,在程序和生活中是非常常见和经常要用到的。
举一个通俗的例子:电话簿
对于电话簿的设计,我们需要可以通过用户名快速定位到对应的手机号码。其中用户名和手机号码就是key-value的形式,而所谓的电话簿就是存储这些key-value的集合数组。那么如果是普通的数组的话,就算已知用户名,要寻找到对应的号码,也需要在电话簿这个数组中遍历一遍(O(n)的复杂度)找到对应的存储位置,才能获取到对应的手机号码。而如果将电话簿设计成散列表的形式那么就可以根据用户名以O(1)的时间复杂度获取到对应的电话号码。
而类似于我们编程语言中的变量。和变量存储的对象引用之间的关系也是key-value的形式。因为程序帮我们维护了这样一个key-value的表,我们才可以随意的根据变量名取到对应的对象引用。
像key-value这样形式的使用情况还有很多。因此如何设计出能够高效根据key获取到value的数据结构就变得非常的重要。
现在我们已经知道了散列表的优点和他的应用场景。那么现在就让我们来看看如何设计出这样的数据结构。
分析问题
现在抛弃掉你之前对于散列表哈希表的理解。就当做完全不了解这个东西。
来分析一下我们上面所提到的场景:电话簿
现在就要让你设计这个电话簿,你要怎么设计。跟着一起分析,我们现在所面临的问题和情况。
- 如果使用普通的数组存储,会让我们查找的效率为O(n).是为什么,是什么导致的呢
- 查找的时候key是已知的,并且要根据这个唯一的key获取到对应的key-value内容。
- 存储的每一个用户名肯定是不一样的。也就是key是唯一的。
从上面三点仔细的分析。有没有什么头绪?
先看第一个问题:为什么使用普通的数组进行存储查找的时候效率为O(n)?
这是因为存储的地址跟存储的内容没有关联。使用普通数组存储的时候,拿到对应的数据,一般情况下是往后面的位置插入。但是往后面插入的位置跟插入的内容本身没有什么关系,和任何规则的,那么查找的时候也无法根据存储内容推算出任何关于存储位置有关的信息。因此只能通过遍历的方式进行查找。
再好好想想,当使用普通数组存储的时候,对于要存入的数据具体存在数组中什么位置,我们并不关心,对于我们来说就是未知的。那么查找跟存储其实是相对的。存储的时候未知,查找的时候也是未知,因此就需要使用笨办法——遍历
现在已经知道了问题的原因,那就是存储的地址随机的没有任何意义。那么解决的办法就是让存储的地址变得有意义。但是要怎么定义存储的地址?
上面提到的,存储和读取是相对的。读取的时候是根据什么进行读取的?也就是上面的第二个问题。查找的时候是根据key进行查找的。那么我们存储的时候如果让存储的地址跟key关联上,那么存储的规则就使用与查找的规则了。那么就可以有另一个种方式去找到对应的存储位置,而不是使用遍历。
这时看上面第三个问题,每一个用户名(key)都是唯一的。并且存储的位置对于数据结构来说是可以掌控的。那么如果让key的唯一性对应与数据结构中存储位置的的唯一性。那么就可以根据key找到对应的存储位置了。
从上面我们对问题进行分析,剖析了问题的关键,以及解决问题的方向。那么接下来就要学习如何让key和存储位置对应的方法——hash函数
hash函数
🍁什么是函数
在聊这个之前,先回忆一下什么是函数
函数(英语:Function)是数学描述对应关系的一种特殊集合。
上面是百科中对函数的定义,函数是描述对应关系的一种集合。
举一个简单的例子:
1
∗
7
X
=
Y
1*7X = Y
1∗7X=Y
其中X为不同值时,得到的Y也是不同的。并且X的值为相同时得到的Y也是相同的。并且如果X想要知道对应的Y是什么只需要将X的值带入到上面的函数式中计算一下就可以得到。
🍁使用函数解决映射问题
经过上面的对于函数的说明,就会发现函数这个定义和例子非常适用于我们现在的需要的 key 和存储位置的对应关系。
也可以让key和存储位置的关系表达成一个函数式,输入key通过函数式得到存储的位置。那么就可以从原来的需要遍历才能得到key的存储位置到现在,将key带入到函数式就可以得到key的存储位置。时间效率从最坏 O(N) 降低到平均O(1)的时间复杂度。
而根据key计算出存储位置的函数,我们称其为 hash函数(哈希函数)
散列函数能使对一个数据序列的访问过程更加迅速有效,通过散列函数,数据元素将被更快定位。
🍁常见hash方法
常见的构造散列表的散列函数有:
- 直接定址法:取关键字或关键字的某个线性函数值为散列地址。即 h a s h ( k ) = k hash(k)=k hash(k)=k或 h a s h ( k ) = a ⋅ k + b hash(k)=a \cdot k+b hash(k)=a⋅k+b,其中 a , b a,b a,b为常数(这种散列函数叫做自身函数)
- 数字分析法:假设关键字是以r为基的数,并且哈希表中可能出现的关键字都是事先知道的,则可取关键字的若干数位组成哈希地址。
- 平方取中法:取关键字平方后的中间几位为哈希地址。通常在选定哈希函数时不一定能知道关键字的全部情况,取其中的哪几位也不一定合适,而一个数平方后的中间几位数和数的每一位都相关,由此使随机分布的关键字得到的哈希地址也是随机的。取的位数由表长决定。
- 折叠法:将关键字分割成位数相同的几部分(最后一部分的位数可以不同),然后取这几部分的叠加和(舍去进位)作为哈希地址。
- 随机数法
- 除留余数法:取关键字被某个不大于散列表表长m的数p除后所得的余数为散列地址。即
h
a
s
h
(
k
)
=
k
m
o
d
p
,
p
≤
m
hash(k)\ =\ k\ mod\ p,\ p\ \leq\ m
hash(k) = k mod p, p ≤ m。不仅可以对关键字直接取模,也可在折叠法、平方取中法等运算之后取模。对p的选择很重要,一般取素数或m,若p选择不好,容易产生冲突。
这些方法都是比较常见的,也可以自己进行构造。但是要遵循几个原则。
- 随机性(散列均匀)
- 尽量减少冲突
- 能够控制范围(一般控制在表长范围内)
其实总体来说就是减少冲突,以及可以控制在表长以内。至于什么是冲突接下来就会讲到
冲突以及解决冲突
冲突: 不同的key通过计算得到同一个存储地址。
🍁为什么叫散列表
由于存储的地址跟key是有关联的。而每一个key对应的存储地址都是不同的,彼此之间并没有什么联系,因此这些元素之间是分散的而不是聚集的。也就是这些元素是分散的存储在散列表中的各个位置,而非原本的普通的数组存储那般一个个的排列存储。这也是为什么称之为这样的数据结构为散列表的原因。
🍁产生冲突的根本原因
散列函数有很多设计的方式,但是都要遵循的一个原则就是尽量减少冲突。但是难免会存在冲突,并不单单是指哈希函数的原因,因为线性函数是百分百不会出现冲突情况的。而是需要考虑到真实的存储环境。
数组的存储空间是连续的,那么可能存储在这种情况,散列表中存储了两个元素,有key经过哈希函数得出的地址是1,100.那么就将这里两元素分别存储在数组下标1,100的位置。那么这个散列表的所使用的空间是多大,是2?还是100?
结果是100,因为数组的空间是连续的,也就是开辟的是1-100的空间,但是只使用了1,100两个位置,剩余的空间都浪费了。
这还是只是100,假设有hash函数计算出来的结果是1亿呢?那应该怎么处理?
🍁如何处理冲突
那么这里有点数据结构基础的可能就会提出一个方案,使用链表存储,而不使用数组。因为链表的空间不是连续的。可以根据需要进行开辟。
但是如果了解了数组和链表的区别就会发现,数组由于元素之间的地址是连续的,因此可以做到以O(1)的时间复杂度做到随机访问。而链表是不行的。链表之间的联系只有元素之间的关联,因此要想访问一个元素需要根据元素的引用一个个去遍历。
那么这就不符合散列表一开始的设定:以O(1)的复杂度根据key获取到对应的value
因此使用链表替代数组是不可取的一种方式。
再回来看上面的问题。有1-100的空间,其中有98个是空的,那么是否可以将计算出来的hash内容超出去的key通过一种处理方式将其存到这98个空位中?
想想上面所提到的 除留余数法 的hash函数方法。其可以保证计算出来的所有存储地址都是在散列表长度之内的。但同时也会出现冲突的情况。因此对于hash函数的设定需要为散列表的实际长度做一定的妥协。这也就导致了一定会出现冲突的情况。
🍁处理冲突的思路
可以分析冲突是因为不同的key得到同一个存储地址,但是其key本身还是不相同的。就例如: 8 % 7 == 1,1%7 == 1.这两key经过hash函数计算出来地址是一样的,但是1和8本身是不相等的。
因此当出现冲突的情况,就可以使用特定的规则将冲突的元素存储到一个新的地方即可。但是在查找的时候也需要根据这个特点的规则去查找。
例如当1 % 7 == 1.找到下标1这个位置的时候,需要多一步:判断下标1存储的key跟当前元素key是否相等,如果是不相等说明产生了冲突,就要根据特定的规则寻找到下一个存储点。然后继续判断,直到找到key相等的存储地址进行返回。
因此这里也说明了一个问题,就是因为冲突的存在,导致哈希表在查找的时候还要进行equal判断key是否相等。如果不相等需要根据冲突的处理方法继续查找。
🍁常见的处理冲突方法
那么处理冲突的的方法有什么?常见的有:开放寻址法,拉链法,再次散列法
这里介绍第一种方式,就是当存储的时候,发现hash函数计算出来的存储位置已经有存储数据了,寻找当前位置的下一个位置进行存储,如果下一个位置还有有数据的那就继续往下找,直到找到空的位置。
需要注意的是,存储的时候是这样处理,查找的时候同样如此。
效率问题
上面的两个点,hash函数,冲突处理就是散列表中的核心点。理解了上面两点就能够设计能用的散列表了。
但是效率却是没有办法保证的。
🍁影响查找效率的因素
现在说明一下散列表的效率问题,在理想状态下,散列表的读取时间是通过哈希函数计算出存储地址的时间,也就是O(1)的复杂度。但是由于存储列表长度的限制,并不能达到理想状态,会出现冲突的情况,而冲突的出现,就会让查找的时候不仅仅只是计算哈希函数计算的时间,还需要计算处理冲突的时间。
因此散列表的读取复杂度取决于 哈希函数的计算 + 冲突的处理时间。其中前者是固定的,而后者是随着冲突的情况决定的。
因此本节如果要提高效率问题就是要从减少冲突上下手。
🍁产生冲突的原因
在上面的分析中可以得知,冲突的问题取决于两个方面。
- 哈希函数本身存在计算冲突。也就是不同key通过哈希函数计算得出相同的存储地址。
- 散列表的表长限制。当然这个问题也会影响到哈希函数的设计。
🍁负载因子
因此说到底可以归根结底是表长的原因导致的冲突。既然现在可以确定的是散列表的长度是不可能任意长的,但同时如果太短的话很显然冲突的几率也会随之提高。太长的话又有可能出现内存浪费的情况。
因此散列表的长度应该取什么长度合适?取怎样的长度能够达到内存和效率之间的均衡?
或者说表的长度应该受什么关联比较合适?那肯定是表中元素的个数。
因此有一个公式描述表中元素与表长的联系:负载因子
散列表负载因子定义为:
α
\alpha
α = 填入表中的元素个数 / 散列表的长度
因此可以通过这个负载因子的大小间接的判断目前表的长度是过长还是过短。而这个负载因子对于开放寻址法来说是特别重要的因素,应严格限制在0.7-0.8以下,超过0.8查表时的CPU缓存不命中按照指数曲线上升,因此一些采用开放寻址法的hash库,如Java的系统库限制负载因子为0.75。
也就是说这个负载因子的的大小设置通过统计测试的方式计算出应该严苛限制在0.7-0.8以下。因此可以对表长进行监控,当负载因此超过这个限制就进行resize(扩容)
实现
到现在相当于已经从头到尾过了一遍散列表的设计。从问题分析到数据结构设计,了解了问题是什么, 为什么这么设计,为什么会有冲突,冲突如何解决,如何提高效率。
但是这些都是理论上的,具体实现呢,如何将理论落实到实践中?
对于实践应该考虑的有
- 散列表存储的类型有很多,如何讲这些类型进行哈希成数字?
- 散列表的存与取如何实现
🍁如何哈希
我们先来看一下如何进行hash。其实很多编程语言,都已经封装好了几种基本类型的hash算法,例如:数字,字符串,浮点数等。但在很多面向对象的编程语言中,其基类都会有一个hash方法要求子类进行重写。这是因为对于自己构造的对象hash函数并不知道怎么去进行计算。因此需要你自己去重写你这个类的hash方法。
举个例子,在python中有一个方法hash() 他是公有方法可以直接调用。如果直接传入基本类型会直接进行计算。这是因为底层对这些就基本类型有了实现。但是如果传入自定义构造的类对象,那么他就会调用你这个对象的_hash()_
方法进行输出。如果你没有重写的话就会调用基类的默认实现。
该
hash()
方法返回一个对象的哈希值(如果它有一个)。哈希值只是用于在字典快速查找期间比较字典键的整数。
🍁重写equal方法
并且还需要实现的方法有:equal方法,因为遇到冲突时,可能出现key不同但是计算出来的hash值是相等的。那么这个时候就需要调用equal方法来判断是否是目的元素。
这两个方法是构造自定义数据类的时候需要重写的方法。如果使用散列表的话。
就只需要根据对应的逻辑实现散列表的存与取即可。
🍁 存(set)
冲突处理方法:拉链法
- 第一步根据 HashCode() 获取要存储对象的 HashCode
- 根据 HashCode 调用 hash() 映射到x 位置
- 遍历链表,
判断是否重复,使用 equals() 方法进行判断。相等则覆盖并返回,否则继续遍历 - 直到覆盖或者遍历到链表的链尾为空时进行存储。
🍁取(get)
与存同理,遍历链表,相等时返回该节点的值
不相等继续遍历。
因此可以发现,同一个链表上的哈希值都是相等的。
总结
本篇文章,我们从设计者的角度,一步一步的去分析问题,发现问题,并去解决这个问题。了解了散列表的设计原理,以及为什么这么设计。从根源上了解散列表这个数据结构。了解了每一个核心的解决的问题,因此就可以根据我们实际的情况去改造,优化散列表。
知道了解了散列表的三个核心,hash函数,冲突,负载因子。
因为要让key和存储位置产生关联,制作一个新的元素查找方式而不是使用遍历的方式,设计了hash函数。
而因为实际情况达不到理想情况的无限表长,从而会产生冲突,进而去了解解决冲突的方式,以及冲突对于hash函数设计的影响。
冲突还会影响到散列表元素的查找效率。因此为了减少冲突以及不浪费内存空间之间做了一个取舍,使用负载因子来衡量表容量的合理性。尽可能的提高效率减少冲突。
最后还说明了散列表在实际实现中会遇到的问题,hash函数的实现问题,以及自定义构造类要实现hash方法,和equal方法的必要性。简单说明了散列表存与取的原理。