散列表(哈希表)
散列表,顾名思义,就是数据分散在列表中,也叫哈希表,散列表依赖于数组随机下标访问的特性,本质上是数组的一种拓展,有数组演化而来,可以说,没有数组就没有散列表。
散列思想
散列思想其实就是映射思想,即用键值对来保存信息,键(key)和值(value)之间的映射法则叫做散列函数(也叫Hash函数或哈希函数),通过散列函数得到的值叫散列值(Hash值,哈希值)。下图的这个table就是散列表。
散列函数
散列函数这样工作的,把key交给散列函数,然后散列函数就为这个key生成一个对应的散列值(数组下标),然后将key的信息保存到该数组下标对应的内存空间。
构造散列函数三个基本要求
①通过散列函数得到的散列值是一个非负整数,因为散列值是数组下标
②如果key1 == key2,则hash(key1) == hash(key2),因为要保证同样的key只对应一个散列值
③如果key1 != key2,则hash(key1) != hash(key2),还是因为一个key最好是对应的唯一的数组下标(value)
但是第三点在实际中很难做到,几乎都会出现,某些不同的key经过散列函数散列后,得出了相同的散列值,这叫做散列冲突,我们常用的散列冲突的解决方法有两类
1.开放寻址法
开放寻址的核心思想:当出现了一个重复散列值,那就寻找空闲位置,将其插入
探测空闲位置的方法
①线性探测法
当我们们往散列表插入数据时,如果该key对应的散列值的对应内存已被占用,那么从当前位置开始,寻找下一个空闲位置,直到找到为止
线性探测法其实存在很大问题。当散列表中插入的数据越来越多时,散列冲突发生的可能性就会越来越大,空闲位置会越来越少,线性探测的时间就会越来越久。极端情况下,我们可能需要探测整个散列表,所以最坏情况下的时间复杂度为 O(n)。同理,在删除和查找时,也有可能会线性探测整张散列表,才能找到要查找或者删除的数据。
这里还要说一下,哈希表依赖于数组,所以同样可以进行查找,修改,插入,删除的操作,我们要特别注意查找和删除这两个操作
散列表查找元素的步骤是:将想要查找的元素a的key传给散列函数,得到散列值,然后将该散列值对应的元素b与a比较,如果两者相等,那b就是我们要找的元素,否则,从当前位置开始找,一直到下个空闲位置都没找到的话,就证明a不存在于此散列表中,为什么是下个空闲位置都找不到就判断a不在列表中呢,因为如果b和a不相等,那就证明出现了散列冲突,按照线性检测法,a的信息就会应当保存从b开始找,找到的第一个空闲位置 c 保存下a,所以如果保存了a,那么找到下个空闲位置 d 前,必能找到a
然后我们线性探测法查找元素时,是以空闲位置为标准的,所以在删除元素时,不能简单地把要删的的元素的值设为null,比如以上面的a和b为例,如果a和b散列冲突,然后a的信息在b的下下个位置进行了保存,而我们随后删除了a和b夹着的那个元素,如果把删除的元素的值设为null,那么,在查找a时,会先检测到这个null,于是就会错误地得出a不在散列表的结果,所以我们用特殊的标识---“deleted”来标识已删除的元素,线性检测遇到deleted时会继续往下检测,而不是停下。
②二次探测
所谓二次探测,跟线性探测很像,线性探测每次探测的步长是 1,那它探测的下标序列就是 hash(key)+0,hash(key)+1,hash(key)+2……而二次探测探测的步长就变成了原来的“二次方”,也就是说,它探测的下标序列就是 hash(key)+0,hash(key)+12,hash(key)+22……
③双重散列
所谓双重散列,意思就是不仅要使用一个散列函数。我们使用一组散列函数 hash1(key),hash2(key),hash3(key)……我们先用第一个散列函数,如果计算得到的存储位置已经被占用,再用第二个散列函数,依次类推,直到找到空闲的存储位置。
不管采用哪种探测方法,当散列表中空闲位置不多的时候,散列冲突的概率就会大大提高。为了尽可能保证散列表的操作效率,一般情况下,我们会尽可能保证散列表中有一定比例的空闲槽位。我们用装载因子(load factor)来表示空位的多少。
散列表的装载因子=填入表中的元素个数/散列表的长度
装载因子越大,说明table数组的空闲位置越少,冲突越多,散列表的性能会下降。
2.链表法
链表法比开放寻址法更常用,也更加简单,散列表中,每个“桶(bucket)”或者“槽(slot)”会对应一条链表,所有散列值相同的元素我们都放到相同槽位对应的链表中。本质是数组加链表,
数组保存的是每个槽的头链表
java的HashMap就是用的这个
当插入的时候,我们只需要通过散列函数计算出对应的散列槽位,将其插入到对应链表中即可,所以插入的时间复杂度是 O(1)。当查找、删除一个元素时,我们同样通过散列函数计算出对应的槽,然后遍历链表查找或者删除。那查找或删除操作的时间复杂度是多少呢?实际上,这两个操作的时间复杂度跟链表的长度 k 成正比,也就是 O(k)。对于散列比较均匀的散列函数来说,理论上讲,k=n/m,其中 n 表示散列中数据的个数,m 表示散列表中“槽”的个数。
开放寻址法和链表法各自的优缺点
开放寻址法
优点:不需要像链表法拉那么多的链表,所有信息完全保存在数组中,由于数组是连续的内存空间,所以可以利用cpu缓存加快查询速度,而且这种方法能让散列表的序列化变得比较简单,而链表包含指针,序列化起来就没那么简单。
缺点:由于开放寻址法的信息都保存在一个数组中,所以散列冲突造成的影响会更大,因此装载因子上限不能很高,所以会比链表法浪费更多的内存空间,而且开放寻址法删除某个元素时,只能用特殊标记来标记删除的数据。
总结来说就是,当数据量小时,才适合开放寻址法,这也是 Java 中的ThreadLocalMap使用开放寻址法解决散列冲突的原因。
链表法
优点:首先,链表法内存利用率更高,因为每个槽是很多个结点连接的,所以可以需要时才创建,而不用像数组一样预先申请一大块内存空间。同时,链表法对装载因子过大的容忍度更高,毕竟,只要散列函数能使散列值尽量地随机且分布均匀,即便装载因子过大,只要不是过大得很多,那顶多就是链表长度变长点,虽然查找效率会下降,但比起顺序查找,还是快很多。
缺点:链表要保存指针,所以如果每个节点保留的是内存占用很小的对象,那么这些指针造成的额外内存占用就显得很多了,甚至翻倍,而且,链表不连续,对cpu缓存技术不友好,影响了链表的查询速度。
实际上,我们对链表法稍加改造,可以实现一个更加高效的散列表。那就是,我们将链表法中的链表改造为其他高效的动态数据结构,比如跳表、红黑树。这样,即便出现散列冲突,极端情况下,所有的数据都散列到同一个桶内,那最终退化成的散列表的查找时间也只不过是 O(logn)。这样也就有效避免了前面讲到的散列碰撞攻击。
总结一下,基于链表的散列冲突处理方法比较适合存储大对象、大数据量的散列表,而且,比起开放寻址法,它更加灵活,支持更多的优化策略,比如用红黑树代替链表。
如何设计散列函数
散列函数决定了散列冲突的概率大小,也直接决定了散列表的性能,所以我们要怎样设计呢?
①散列函数不能太复杂,因为复杂的的散列函数,势必会消耗很多计算时间,就间接影响了散列表的性能。
②散列函数生成的散列值要尽量随机且均匀分布,这样可以最大化避免散列冲突以和防止很多个元素集中在某一个槽,导致查找等操作的效率大大降低
装载因子过大时怎么办
如果数据是静态的,后续基本没有插入和删除操作的话,我们直接量身定做散列函数的,装载因子的大小不需要在意
但如果是动态的数据集合,我们无法事先预估数据的个数,所以无法申请一个足够大的散列表,当数据越来越多时,空闲位置就越来越少,散列冲突就越来越容易发生,所以我们要进行动态扩容
针对散列表,当装载因子过大时,可以进行动态扩容,重新申请一个更大的散列表,将数据搬移到这个新散列表中。假设每次扩容我们都申请一个原来散列表大小两倍的空间。如果原来散列表的装载因子是 0.8,那经过扩容之后,新散列表的装载因子就下降为原来的一半,变成了 0.4。
对数组的扩容,数据搬移操作比较简单。但是,针对散列表的扩容,数据搬移操作要复杂很多。因为散列表的大小变了,数据的存储位置也变了,所以我们需要通过散列函数重新计算每个数据的存储位置。
插入一个数据,最好情况下,不需要扩容,最好时间复杂度是 O(1)。最坏情况下,散列表装载因子过高,启动扩容,我们需要重新申请内存空间,重新计算哈希位置,并且搬移数据,所以时间复杂度是 O(n)。用摊还分析法,均摊情况下,时间复杂度接近最好情况,就是 O(1)。
实际上,对于动态散列表,随着数据的删除,散列表中的数据会越来越少,空闲空间会越来越多。如果我们对空间消耗非常敏感,我们可以在装载因子小于某个值之后,启动动态缩容。当然,如果我们更加在意执行效率,能够容忍多消耗一点内存空间,那就可以不用费劲来缩容了。
总之,装载因子阈值需要选择得当。如果太大,会导致冲突过多;如果太小,会导致内存浪费严重。同时,装载因子阈值的设置还要权衡时间、空间复杂度。如果内存空间不紧张,对执行效率要求很高,可以降低负载因子的阈值;相反,如果内存空间紧张,对执行效率要求又不高,可以增加负载因子的值,甚至可以大于 1。
如何避免低效的扩容
当散列表的装载因子到达了阈值,这时如果我们要进行插入操作,那么就要先扩容再插入,这就导致了本次插入操作非常慢,虽然这只是很多次插入后才会由于扩容而出现的一次低效插入,但对用户来说,始终还是体验不好的,所以我们要采取这样的方式:当要扩容时,我们先申请一个新散列表,但不进行数据迁移,而是每次插入一个数据时迁移一个,这样一点一点地把数据迁移到新列表,就可以让每次的插入操作都比较快了
然后这其间,进行查询操作时,要兼顾新老散列表的数据,所以我们的原则是:先在新散列表中查找,然后再去老列表查找。
这种方法可以把一次性扩容带来的时间成本均摊到各个插入操作中,使得任何情况下,插入操作都接近于O(1)的时间复杂度。
怎么样去创造一个工业级的散列表?
首先要了解什么是工业级的散列表,有什么特性
①支持快速地增删改查操作
②内存使用合理,不浪费过多的内存空间
③性能稳定,即使在极端情况下也不会退化到无法接受的情况
如何实现?
①有一个合适的散列函数,尽可能随机且均匀分布
②对于装载因子,要设立合适的阈值,设立合适的动态扩容策略
③选择合适的散列冲突解决方法。
为什么哈希表经常和链表一起使用
散列表这种数据结构虽然能非常高效地进行查询增加删除操作,但是散列表的数据都是通过散列函数打乱后无规则地存储,所以我们如果希望某种顺序地遍历数据时,要使用其他的数据结构来辅助,而由于散列表是动态数据结构,经常会进行增加,删除等操作,所以如果是将散列表的数据拷贝到一个数组,然后排序输出的话,那么每当我们希望安某种顺序遍历时,都会先进行排序,这效率就低了,于是我们是使用链表或跳表