数据结构之哈希表(Hash)(一)_概述

基本概念

什么是哈希表

哈希表(Hash table,也称散列表),是根据关键码(key)直接访问内存存储位置的数据结构。即通过关于key的函数,从而映射到一个地址来访问数据。这样可以加快查找速度。
这个关于key的映射函数称作哈希函数(散列函数),存放数据记录的数组称为哈希表(散列表)。哈希表的以数组形式存储。

比如:你打开手机的通讯录,联系人、手机号码都存放在里面。若要找到某个人的号码,你会怎么做呢?你不会一个个滑动的看,你会注意到屏幕右边 有根据联系人姓名的首字母形成的一竖行,如果你要找姓“方”的,点击F,就能很快找到相关的信息了。
这里,人名是关键码key,取人名首字母操作就是哈希函数F(key),存放首字母的表即哈希表。输入人名,通过f(key)得到首字母,得到的首字母直接访问该哈希表。

哈希冲突

上面的例子中,key1是“方xx”,key2是“冯xx”.但它们得到的首字母都是一样的。
这种,对不同的关键字可能得到同一散列地址,即key1≠key2,而f(key1)=f(key2),这种现象称为哈希冲突。
事实上,不可能找到一个哈希函数给每个key都生成一个唯一的哈希值,因此冲突不可避免。即使哈希表很大,冲突仍然存在,生日悖论。
后面介绍下 对哈希冲突的一些处理方法。

hash_overview
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。
hash_sapate_chaining

最后有它与开放定址法的比较,可以看出单独链表法的特点、以及优势和不足。

开放定址法(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。
hash_open_linear

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的数量及使用频率的情况下使用
缓存性能不佳,因为使用链表存储 缓存性能较好,存在同一个表中
空间浪费(哈希表中某些部分可能不会被使用,而且需要额外空间存储链接信息) 无额外信息,而且单元一直被使用 即使没有输入映射到
posted @ 2020-06-20 22:20  流浪_归家  阅读(728)  评论(0编辑  收藏  举报