数据结构之哈希表(Hash)(一)_概述
基本概念
什么是哈希表
哈希表(Hash table,也称散列表),是根据关键码(key)直接访问内存存储位置的数据结构。即通过关于key的函数,从而映射到一个地址来访问数据。这样可以加快查找速度。
这个关于key的映射函数称作哈希函数(散列函数),存放数据记录的数组称为哈希表(散列表)。哈希表的以数组形式存储。
比如:你打开手机的通讯录,联系人、手机号码都存放在里面。若要找到某个人的号码,你会怎么做呢?你不会一个个滑动的看,你会注意到屏幕右边 有根据联系人姓名的首字母形成的一竖行,如果你要找姓“方”的,点击F,就能很快找到相关的信息了。
这里,人名是关键码key,取人名首字母操作就是哈希函数F(key),存放首字母的表即哈希表。输入人名,通过f(key)得到首字母,得到的首字母直接访问该哈希表。
哈希冲突
上面的例子中,key1是“方xx”,key2是“冯xx”.但它们得到的首字母都是一样的。
这种,对不同的关键字可能得到同一散列地址,即key1≠key2,而f(key1)=f(key2),这种现象称为哈希冲突。
事实上,不可能找到一个哈希函数给每个key都生成一个唯一的哈希值,因此冲突不可避免。即使哈希表很大,冲突仍然存在,生日悖论。
后面介绍下 对哈希冲突的一些处理方法。
key3,key4通过哈希函数,指向了同一个位置,这个就是哈希冲突。
哈希函数
哈希函数性质
为尽量减少冲突,哈希函数应尽量满足:
1.能够非常快速的计算。
2.函数值的分布均匀。
哈希函数应用:
哈希函数经常专门为某一应用设计。
如:用于加密的密码的哈希函数,通过密码计算得到的哈希值是不可逆的,即无法从哈希值得到原始的输入密码,这就有效的保护了密码。SHA-2(其下又可再分为六个不同的算法标准,包括了:SHA-224、SHA-256、SHA-384、SHA-512、SHA-512/224、SHA-512/256)是使用非常广泛的密码哈希函数。
处理哈希冲突
单独链表法(Separate Chaining)
主要思想:使哈希表的每个单元 指向具有相同哈希函数值记录 的单链表中。
比如:哈希函数是:f(key) = key%7; key依次为:50、700、76、85、92、73、101。
最后有它与开放定址法的比较,可以看出单独链表法的特点、以及优势和不足。
开放定址法(Open Addressing)
主要思想:所有元素存储在哈希表本身中。当遇到冲突时,通过某种探测方式,找到合适的位置操作。所以哈希表在开始就创建了所有单元。
几种探测方法:
1.线性探测
哈希函数是f(k),哈希表大小为S。则线性探测就是:
若单元f(x)%S已有数据,则尝试(f(x)+1)%S;
若单元f(f(x)+1)%S已有数据,则尝试(f(x)+2)%S;
若单元f(f(x)+2)%S已有数据,则尝试(f(x)+3)%S; 依次类推。
同样,如上例:哈希函数是:f(key) = key%7; key依次为:50、700、76、85、92、73、101。
2.二次方探测
线性探测间隔为1,二次方探测间隔为i2。
若单元f(x)%S已有数据,则尝试(f(x)+1*1)%S;
若单元f(f(x)+1*1)%S已有数据,则尝试(f(x)+2*2)%S;
若单元f(f(x)+2*2)%S已有数据,则尝试(f(x)+3*3)%S; 依次类推。
3.双重哈希
使用另一种哈希函数f2(k),间隔为i*f2(x)。
若单元f(x)%S已有数据,则尝试(f(x)+1*f2(x)%S;
若单元f(f(x)+1*f2(x))%S已有数据,则尝试(f(x)+2*f2(x))%S;
若单元f(f(x)+2*f2(x))%S已有数据,则尝试(f(x)+3*f2(x))%S; 依次类推。
三种探测比较:
集群现象---就是连续的单元形成一组,这样查找空单元时会花费更多时间,因为要依次查找跳过。
缓存性能---Locality of reference。
线性探测法 | 二次方探测 | 双重哈希 |
---|---|---|
集群现象明显 | 介于二者之间 | 几无集群现象 |
缓存性能最好 | 介于二者之间 | 缓存性能较差 |
计算简单,耗时少 | 介于二者之间,与线性探测差不多 | 耗时较多,因为要进行两次哈希函数计算 |
开放定址法 几个常见方法的伪代码
record pair { key, value }//record
var pair array slot[0..num_slots-1]//hash table
function find_slot(key)
i := hash(key) modulo num_slots
// search until we either find the key, or find an empty slot.
while (slot[i] is occupied) and ( slot[i].key ≠ key )
i = (i + 1) modulo num_slots
return i
function lookup(key)
i := find_slot(key)
if slot[i] is occupied // key is in table
return slot[i].value
else // key is not in table
return not found
function set(key, value)
i := find_slot(key)
if slot[i] is occupied // we found our key
slot[i].value = value
return
if the table is almost full
rebuild the table larger (note 1)
i = find_slot(key)
slot[i].key = key
slot[i].value = value
function remove(key)
i := find_slot(key)
if slot[i] is unoccupied
return // key is not in the table
j := i
loop
mark slot[i] as unoccupied
r2: (note 2)
j := (j+1) modulo num_slots
if slot[j] is unoccupied
exit loop
k := hash(slot[j].key) modulo num_slots
// determine if k lies cyclically in (i,j]
// | i.k.j |
// |....j i.k.| or |.k..j i...|
if ( (i<=j) ? ((i<k)&&(k<=j)) : ((i<k)||(k<=j)) )
goto r2;
slot[i] := slot[j]
i := j
note 1:哈希表满后,需要扩容重建。这个可以参考之前一篇总结(数据结构之栈(Stack)中的数组扩容的部分。
note 2:i即要删除的位置,j即集群(上面有讲)的后续单元,k是j单元key在最初哈希函数计算后的位置(即j是由于k位置冲突而找到的新位置)。这个大概的思想就是用集群后续某个元素填充删除的位置,保证集群的连续,也就是搜索不会再i出停止导致查找失败、插入也可以在i处直接插入。
注: 上述方法说明是以线性探测(间隔为1)为例。
find_slot:查找key,直到找到key或者找到一个空的位置(即没有key,如果有key不会在这里停止)。其他操作都以这个为基础。
remove:这个操作比较特殊。需要保证插入时可以在删除的单元中插入,但搜索不会在删除的单元中停止。
另一种常见的删除方法
即对删除单元进行标记,如标记为AVAILABLE,碰到这个查找到这不停止、插入到这可插入。若此单元再次被使用,则删除标记即可。
单独链表法与开放定址法比较
单独链表法 | 开放定址法 |
---|---|
实现方便 | 需要更多计算 |
内存足够时,不会被填满 | 可能被填满 |
通常在不清楚需要key的数量及使用频率的情况下使用 | 直到key的数量及使用频率的情况下使用 |
缓存性能不佳,因为使用链表存储 | 缓存性能较好,存在同一个表中 |
空间浪费(哈希表中某些部分可能不会被使用,而且需要额外空间存储链接信息) | 无额外信息,而且单元一直被使用 即使没有输入映射到 |