散列表
散列表也叫哈希表,百科中给的定义是根据关键码值(Key value)而直接进行访问的数据结构。也就是说,它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫做散列函数,存放记录的数组叫做散列表。
给定表M,存在函数f(key),对任意给定的关键字值key,代入函数后若能得到包含该关键字的记录在表中的地址,则称表M为哈希(Hash)表,函数f(key)为哈希(Hash) 函数。
键(key)
key是哈希表的关键码值,根据关键码值可以映射到哈希表中的记录
槽(slot/bucket)
哈希函数
哈希函数就是可以将给定的关键之key,计算出该key对应的在表中的地址的函数。
哈希函数有以下三个要去:
- 散列函数计算得到的散列值是一个非负整数
- 如果key1=key2,那么hash(key1)=hash(key2)
- 如果key1!=key2,那么hash(key1)!=hash(key2)
前两条很好实现,但是想要找到一个不同的key对应的散列值不一样的散列函数,几乎是不可能的,即便像业界著名的MD5、SHA、CRC等哈希算法,也无法完全避免这种散列冲突。
散列冲突
通常我们解决散列冲突用到两种方案,开放寻址法和链表法;
开放寻址法
开放寻址法的核心思想是,如果出现了散列冲突,我们就重新探测一个空闲位置,将其插入。我们说一个比较简单的探测方法,线性探测。
当我们往散列表中插入数据时,如果某个数据经过散列函数散列之后,存储位置已经被占用了,我们就从当前位置开始,依次往后查找,看是否有空闲位置,直到找到为止。
在这种情况下的查找元素的过程有点类似于插入过程。我们通过散列函数求出要查找元素的键值对应的散列值,然后比较数组中下标为散列值的元素和要查找的元素。如果相等,则说明就是我们要找的元素;否则就顺序往后依次查找。如果遍历到数组中的空闲位置,还没有找到,就说明要查找的元素并没有在散列表中。
在这种情况下,我们的删除算法要稍微有些特别,如果我们单纯的把要删除的元素设置为空,那么当我们查找的时候,如果找到的空闲位置是后来删除的,就会导致原来的查找算法失效,本来存在的数据,会被认定为不存在。
我们可以将删除的元素,特殊标记为deleted。当我们线性探测查找的时候,遇到标记为deleted的空间,继续往下探测。
线性探测其实存在很大的问题。当散列表中插入的数据越来越多时,散列冲突的可能性就会越来越大,空闲位置就会越少,线性探测的时间就会越久。极端情况下,我们可能需要探测整个散列表,所以最坏情况下的时间复杂度为O(n)。同理,在删除和查找时,也有可能会线性探测整张散列表,才能找到要查找或者删除的数据。
对于开放寻址冲突解决方法,除了线性探测方法外,还有另外两种比较经典的方法,二次探测和双重散列
链表法
链表法是更加常用的散列冲突解决方法。在散列表中,每个“桶(bucket)”或者“槽(slot)”会对应一条链表,所有散列值相同的元素都放在相同槽位对应的链表中。
插入的时候,我们只需要通过散列函数计算出对应的散列槽位,将其插入到对应链表中即可,所以插入的时间复杂度为O(1)。当查找、删除一个元素时,我们同样通过散列函数计算出对应的槽,然后遍历链表查找或者删除。
装载因子
装载因子计算公式为:
散列表的转载因子 = 填入表中的元素个数/散列表的长度
装载因子越大,说明空闲位置越少,冲突越多,散列表的性能会下降
hash退化与拒绝服务攻击
hash退化是指当所有的key经过hash函数之后都映射到同一个槽中,hash表就会退化成链表。
当HashTable就会退化成链表,服务器有可能处理一次请求要花上十几分钟甚至几个小时的时间,一台PC机就可以搞定一台服务器,根本不用分布式攻击。如果Web应用框架采用的Hash机制存在漏洞。那么攻击者可以轻而易举的实施拒绝服务攻击。