散列表那些事
基本概念
在登录QQ的时候,QQ服务器是如何核对你的身份?面对庞大的用户群,如何快速找到用户信息?
我们已经知道的几种查找方法包括:顺序查找,二分查找(静态查找),二叉搜索树(动态查找)。在这个场景下,如果使用二分查找的话就会面对插入和删除一个新号码要移动大量数据的问题。
这里我们要用到散列查找的方法,散列(Hashing)的基本思想是:
- 以关键字 key 为自变量,通过一个确定的**函数 h (散列函数)**计算出对应的函数值h(key),作为数据对象的存储地址
- 可能不同的关键字会映射到同一个散列地址上,称为**“冲突”**,发生冲突后需要某种冲突解决策略来解决冲突。
散列查找的时间复杂度为 O(1),即查找时间与问题规模无关。
一般情况下,设散列表空间大小为m,填入表中的元素个数是n,则称α=n/m为散列表的”装填因子“(Loading Factor)。实用时,通常将散列表大小设计为 0.5-0.8 为宜。
散列映射法的关键问题有两个:
- 如何设计散列函数,使得发生冲突的概率尽可能小;
- 当冲突或溢出不可避免的时候,如何处理使得表中没有空单元被浪费,同时插入、删除、查找等操作都正确完成。
散列函数的构造方法
一个好的散列函数一般考虑下列两个因素:
- 计算简单,以便提高转换速度
- 关键字对应的地址空间分不均匀,以尽量减少冲突。即对于关键字集合中的任何一个关键字,经散列函数映射到地址集合中任何一个地址的概率是基本相等的。实际应用过程中,严格的均匀分布也是不可能的,只是不要过于聚集就行了。
关键字又分为数字关键字和字符串关键字两种类型,分别有不同的散列函数的构造方法:
数字关键字的散列函数构造
直接定址法
取关键字的某个线性函数值为散列地址,即
h(key) = a*key + b (a,b 为常数)
除留余数法
散列函数为
h(key)=key mod p
假设散列表长为 TableSize (TableSize 的选取通常由关键字集合的大小 n 和允许最大的装填因子 α 决定,一般 TableSize=n/α),选择一个正整数 p <= TableSize。一般选取 p 为小于或者等于散列表表长 TableSize 的某个最大素数比较好。用素数求得得余数作为散列地址,比较均匀地分布在整个地址空间上的可能性比较大,具体证明可以参考为什么一般hashtable的桶数会取一个素数。例如,TableSize=8,p=7;TableSize=16, p=13。
数字分析法
分析数字关键字在各位上的变化情况,取比较随机的位作为散列地址。散列函数可以取为:
h(key)=atoi(key+7)
折叠法
把关键字分割成位数相同的几个部分,然后叠加取部分值。例如:
折叠法是希望每一位对最后的结果都能产生影响。
平方取中法
如:56793542
平方取中法和折叠法的目的都是一样的,为了让关键字的每一位都对最后的结果产生影响。
字符关键字的散列函数构造
一个简单的散列函数:ASCII 码加和法。对字符型关键字key定义散列函数如下:
$\sum key[i]$ mod TableSize
这种方法会有严重的冲突
简单的改进 - 前3个字符移位法
$h(key)=(key[0]*27^2+key[1]*27+key[2])$ mod TableSize
这个方法会造成空间的浪费
好的散列函数:移位法
涉及关键字所有n个字符,并且分布的很好:
$h(key)=(\displaystyle \sum^{n-1}_{i=0}{key[n-i-1]*32^i})$ mod TableSize
该函数用于处理长度位 n 的字符串关键字,每个字符占5位(即 $2^5=32$),具体实现时并不需要做乘法运算,而是通过一次左移 5 位来完成。
public int hashString(String key, int tableSize) { int h = 0; //散列函数值,初始化为0 char[] keyArrays = key.toCharArray(); for (int i = 0; i <= keyArrays.length; i++) { if (keyArrays[i] != '\0') { h = (h << 5) + keyArrays[i]; } } return h % tableSize; }
冲突处理方法
常用处理冲突的思路:
- 换个位置:开放地址法
- 同一位置的冲突对象组织在一起:链地址法
开放定址法
一旦产生了冲突(该地址已有其他元素),就按某种规则去寻找另一空地址。假设发生了第 i 次冲突,试探的下一个地址将增加 $d_i$ , 基本公式是:
$d_i(key)=(h(key)+d_i)$ mod tableSize (1 <= i < tableSize)
$d_i$决定了不同的解决冲突方案,包括:线性探测、平方探测、双散列。
线性探测
$d_i=i$
以增量序列1,2,……,(tableSize-1) 循环试探下一个存储地址。做插入操作的时候,要找到一个空位置,或者直到散列表满为止;做查找操作时,探测一个比较依次一次关键字,直到找到特定的数据对象,或者探测到一个空位置表示查找失败为止。
线性探测的缺点就是容易产生聚集的现象,因此引入了平方探测法
平方探测法–二次探测
平方探测的公式:$d_i=\pm i^2$,每次以增量序列$1^2,-1^2,2^2,-2^2……$循环试探下一个存储地址。
平方探测法有可能出现散列表中有空间,但是无法探测到的情况。例如:
散列表的长度为5,插入5,6,7,11这四个元素,散列函数设计为:
h(key)=key mod 5
当插入11时,散列函数找到的位置为2,和6这个元素所在的位置产生冲突,使用平方探测法,探测序列为:
1+1=2 1-1=0 (1+2*2)%5=0 (1-2*2)%5=2 (1+3*3)%5=0 (1-3*3)%5=2 ......
可以发现,探测序列一直在0和2这两个位置之间变动,一直找不到空的位置,但是散列表实际上还有空间。有定理显示:如果散列表长度tableSize是某个$4k+3$(k是正整数) 形式的素数时,平方探测法就可以探查到整个散列表空间。
虽然平方探测法排除了一次聚集,但是散列到同一地址的那些数据对象将探测相同的备选单元,这称为“二次聚集”。
双散列探测法
双散列探测法: $d_i=i*h_2(key)$,$h_2(key)$是另一个散列函数,探测序列为:
$h_2(key), 2h_2(key), 3h_2(key), ……$
探测序列还应该保证所有的散列存储单元都能被探测到。选择以下形式有良好的效果:
$h_2(key)=p$ - (key mod p)
再散列
当散列表元素太多(即装填因子α太大)时,查找效率会下降,实际使用的时候,装填因子一般取 0.5<=α<=0.85。
当装填因子过大时,解决的方法时加倍扩大散列表,这个过程叫做“再散列”。
在开放地址散列表中,删除操作要很小心,通常只能 “懒惰删除”,即需要增加一个删除标记(Deleted),并不是真正删除它。这是因为插入的时候,为了解决冲突问题,这个位置已经被占用了,如果删除掉它,查找的时候就会出现“断链”的现象。
分离链接法
分离链接法时解决冲突的另一种方法,其做法是将所有关键字为同义词的数据对象通过节点链接存储到同一单向链表中。。
如上图所示,分裂链接法实际上是用一个数组来组织散列表的数据结构,这个数组称为哈希桶,数组中的每个元素都指向一个链表,当元素冲突的时候,就在链表的头节点上插入冲突的元素。新元素插入到表头,这不仅仅为了方便,而且还因为新近插入的元素最有可能被最先访问,这样可以加快在单向链表中的顺序查找速度。
散列表的性能分析
散列表的性能使用平均查找长度(ASL)来度量。
线性探测法的查找性能满足下列公式
平方探测法和双散列探测法的查找性能满足下列公式
分离链接法的查找性能满足下列公式
参考:浙江大学陈越老师的数据结构课程