散列表(中)
Ⅰ 前言
在散列表(上)中我介绍了散列表的思想和基本内容,我们知道,散列表的查询效率并不能笼统地说成是 O(1),它和散列函数、装载因子、散列冲突等都有关系。如果散列函数设计得不好,或者装载因子过高,都可能导致散列冲突发生的概率升高,查询效率下降。
在极端情况下,有些恶意的攻击者,还有可能通过精心构造的数据,使得所有的数据经过散列函数之后,都散列到同一个槽里。如果我们使用的是基于链表的冲突解决方法,这个时候散列表就会退化成链表,查询的时间复杂度就从 O(1) 退化到 O(n)。
如果散列表中有 10 万个数据,退化后的散列表查询的效率就下降了 10 万倍。如果原来运行 100 次查询只需要 0.1 秒,现在就需要 1 万秒。这样就有可能因为查询操作消耗大量 CPU 或者线程资源,导致系统无法响应其他请求,从而达到拒绝服务攻击(DoS)的目的,这也就是散列表碰撞攻击的基本原理。
所以这篇文章就主要讲解一下工业级散列表的设计,避免在散列冲突的情况下,散列表性能的急剧下降,并且抵抗散列碰撞攻击。
【数据结构与算法】->数据结构->散列表(上)->散列表的思想&散列冲突的解决
Ⅱ 如何设计散列函数
散列函数的好坏,决定了散列表冲突的概率大小,也直接决定了散列表的性能。那什么才是好的散列表?主要有以下几点。
第一,散列函数的设计不能太复杂。过于复杂的散列函数,势必会消耗很多计算时间,也间接地影响到散列表的性能。
第二,散列函数生成的值要尽可能随机并且均匀分布,这样才能避免或者最小化散列冲突,而且即便出现散列冲突,散列到每个槽里的数据也会比较平均,不会出现某个槽内数据特别多的情况。
在实际开发中,我们还需要综合考虑各种因素。这些因素有关键字的长度、特点、分布、还有散列表的大小等。散列函数各式各样,我举几个比较常用的简单散列函数的设计方法。
第一种就是第一节里我写过的学生运动会的例子,我们通过分析参赛编号的特征,把编号后两位作为散列值。我们还可以用类似的散列函数处理手机号码,因为手机号码前几位重复的可能性很大,但是后几位比较随机,所以我们可以取手机号的后四位作为散列值。这种散列函数的设计方法,我们一般叫作 数据分析法。
第二种就是 Word 的单词拼写检查功能,这里面的散列函数,我们可以这样设计:将单词中每个字母的 ASCII 码值进位相加,然后再和散列表的大小求余、取模,作为散列值。比如,英语单词 “one”,我们转化出来的散列值就是下面这样👇
实际上,散列函数的设计方法还有很多,比如直接寻址法、平方取中法、折叠法、随机数法等,大家可以自行去了解。
Ⅲ 如何处理装载因子过大
装载因子越大,说明散列表中的元素越多,空闲位置越少,散列冲突的概率就越大。不仅插入数据的过程要多次寻址或者拉很长的链,查找的过程也会因此变得很慢。
对于没有频繁插入和删除的静态数据集合来说,我们很容易根据数据的特点、分布等,设计出完美的、极少冲突的散列函数,因为毕竟之前数据都是已知的。
对于动态散列表来说,数据集合是频繁变动的,我们事先无法预估将要加入的数据个数,所以我们也无法事先申请一个足够大的散列表。随着数据慢慢加入,装载因子就会慢慢变大。当装载因子大到一定程度之后,散列冲突就会变得不可接受。这个时候,我们就需要进行动态扩容,申请一个更大的散列表,将数据都搬移到这个新散列表中。
假设每次扩容我们都申请一个原来散列表大小两倍的空间,如果原来散列表的装载因子是 0.8,那经过扩容以后,新散列表的装载因子就下降为原来的一半,变成了 0.4。
针对数组的扩容,数据搬移操作比较简单。但是,针对散列表的扩容,数据搬移操作要复杂很多。因为散列表的大小变了。数据的存储位置也变了,所以我们需要通过散列函数重新计算每个数