数据结构之哈希表

数据结构之哈希表

基本概念

  • 散列表

    散列表Hash table,也叫哈希表),是根据(Key)而直接访问在内存存储位置的数据结构。也就是说,它通过计算一个关于键值的函数,将所需查询的数据映射到表中一个位置来访问记录,这加快了查找速度。这个映射函数称做散列函数,存放记录的数组称做散列表

  • 散列函数

    对与关键字k,其值存放在f(k)的位置上,我们成此f散列函数

  • 冲突(碰撞)

    对于不同关键字,计算出来的散列函数值相等,这种现象则称为冲突

  • 哈希算法

    根据设定的哈希函数f(k)和处理冲突方法将一组关键字映象到一个有限的地址区间上的算法

构造散列函数的方法

  1. 直接寻址法

    取keyword或keyword的某个线性函数值为散列地址。即H(key)=key或H(key) = a•key + b,当中a和b为常数

  2. 数字分析法

    分析一组数据,比方一组员工的出生年月日,这时我们发现出生年月日的前几位数字大体同样,这种话,出现冲突的几率就会非常大,可是我们发现年月日的后几位表示月份和详细日期的数字区别非常大,假设用后面的数字来构成散列地址,则冲突的几率会明显减少。因此数字分析法就是找出数字的规律,尽可能利用这些数据来构造冲突几率较低的散列地址。

  3. 平方取中法

    取keyword平方后的中间几位作为散列地址。

  4. 折叠法

    将keyword切割成位数同样的几部分,最后一部分位数能够不同,然后取这几部分的叠加和(去除进位)作为散列地址。

  5. 随机数法

    选择一随机函数,取keyword的随机值作为散列地址,通经常使用于keyword长度不同的场合。

  6. 除留余数法

    取keyword被某个不大于散列表表长m的数p除后所得的余数为散列地址。即 H(key) = key MOD p, p<=m。不仅能够对keyword直接取模,也可在折叠、平方取中等运算之后取模。对p的选择非常重要,一般取素数或m,若p选的不好,容易产生同义词。

冲突解决的方法

开放定址法

从发生冲突的那个单元起,按照一定的次序,从哈希表中找到一个空闲的单元。即H(key) = (H(key)+ di) mod m (i = 1,2,…… ,k (k ≤ m – 1)) , 其中di表示步长。

步长有以下几种选取策略:

  1. 线行探测(步长为1)
  2. 平方探测(步长为n^2)
  3. 双散列函数探测(步长为另一个散列函数算出来的值)
  4. 伪随机探测(步长从伪随机序列获得)

优缺点:

  1. 开放定址法需要的表长度要大于等于所需要存放的元素
  2. 开放定址法的缺点在于删除元素的时候不能真的删除,否则会引起查找错误,只能做一个特殊标记。只到有下个元素插入才能真正删除该元素

链地址法(拉链法)

链接地址法的思路是将哈希值相同的元素构成一个同义词的单链表,并将单链表的头指针存放在哈希表的第i个单元中,查找、插入和删除主要在同义词链表中进行。链表法适用于经常进行插入和删除的情况。

image-20190103051025160

优点

  1. 拉链法处理冲突简单,且无堆积现象,即非同义词决不会发生冲突,因此平均查找长度较短
  2. 由于拉链法中各链表上的结点空间是动态申请的,故它更适合于造表前无法确定表长的情况
  3. 开放定址法为减少冲突,要求装填因子α较小,故当结点规模较大时会浪费很多空间。而拉链法中可取α≥1,且结点较大时,拉链法中增加的指针域可忽略不计,因此节省空间;
  4. 在用拉链法构造的散列表中,删除结点的操作易于实现。只要简单地删去链表上相应的结点即可。而对开放地址法构造的散列表,删除结点不能简单地将被删结 点的空间置为空,否则将截断在它之后填人散列表的同义词结点的查找路径。这是因为各种开放地址法中,空地址单元(即开放地址)都是查找失败的条件。因此在 用开放地址法处理冲突的散列表上执行删除操作,只能在被删结点上做删除标记,而不能真正删除结点。

缺点

  1. 指针需要额外的空间,故当结点规模较小时,开放定址法较为节省空间,而若将节省的指针空间用来扩大散列表的规模,可使装填因子变小,这又减少了开放定址法中的冲突,从而提高平均查找速度

再哈希法

这种方法是同时构造多个不同的哈希函数。当哈希地址Hi=RH1(key)发生冲突时,再计算Hi=RH2(key)……,直到冲突不再产生。这种方法不易产生聚集,但增加了计算时间。

建立公共溢出区

这种方法的基本思想是:将哈希表分为基本表和溢出表两部分,凡是和基本表发生冲突的元素,一律填入溢出表。

装载因子

也叫负载因子,计算公式如下:

= 填入表中的元素个数 / 散列表的长度

是散列表装满程度的标志因子。由于表长是定值, 与“填入表中的元素个数”成正比,所以, 越大,表明填入表中的元素越多,产生冲突的可能性就越大;反之,越小,标明填入表中的元素越少,产生冲突的可能性就越小。实际上,散列表的平均查找长度是载荷因子 的函数,只是不同处理冲突的方法有不同的函数。

扩容和收缩

随着键值对的逐渐增多或减少,为了让哈希表的负载因子维持在一个合理的范围之内,当哈希表保存的键值对太多或者太少时,程序需要对哈希表进行相应的扩展或者收缩。

具体扩容和收缩策略要视具体实现而定,比如Redis采用的渐进式的方式。另外需要特别关注的问题是,触发扩容和收缩的条件如何限定,一般的方式是负载因子达到某个阈值则触发。

代码实现

要实现一个哈希表,需要考虑三个方面

  1. 设计一个合适的散列函数
  1. 选择合适的散列冲突解决策略
  1. 定义装载因子阈值,并且设计动态扩容策略

Redis中哈希表的实现

  1. 散列函数:MurmurHash2 32 bit算法或者Thomas Wang’s 32 Bit / 64 Bit Mix Function算法
  2. 冲突解决:拉链法
  3. 扩容收缩策略:渐进式rehash

想要了解具体详情,请移步至Redis数据类型:字典

Python中哈希表的实现

  1. 散列函数:使用python内置的hash函数
  2. 冲突解决:开放寻址法
  3. 扩容收缩策略:负载因子>=2/3时,触发内存搬迁,并且一次性完成。

想要了解具体详情,请移步至Python字典实现原理

参考

  1. 维基百科:哈希表