数据结构-散列表

散列表(Hash table)是实现字典操作的一种有效的数据结构。

简介

定义

散列表也叫哈希表,是根据键和值(key 和 value)直接进行访问的数据结构。也就是说,它通过把 keyvalue 映射到表中的一个位置来访问记录,以加快查找的速度。这个映射函数叫做散列函数,存放记录的表叫做散列表

给定表 M,存在函数 f(key),对任意给定的关键字值 key,代入函数后若能得到包含该关键字的记录在表中的地址,则称表 M 为哈希表,函数 f(key)哈希函数

散列表的数据结构如下图所示:
散列表的数据结构

术语

  • 散列函数和散列表:若关键字为 key,则其值存放在 f(key) 的存储位置上。由此,不需比较便可直接取得所查记录。称这个对应关系 f 为 散列函数,按这个思想建立的表为散列表。
  • 哈希冲突:对不同的关键字可能得到同一散列地址,即 key1≠key2,而f(key1)=f(key2),这种现象称为哈希冲突。具有相同函数值的关键字对该散列函数来说称做同义词。
  • 散列和散列地址:根据散列函数 f(k) 和处理冲突的方法将一组关键字映射到一个有限的连续的地址集上,并以关键字在地址集中的“像”作为记录在表中的存储位置,这种表便称为散列表,这一映射过程称为散列造表或散列,所得的存储位置称散列地址。
  • 均匀散列函数:若对于关键字集合中的任一个关键字,经散列函数映象到地址集合中任何一个地址的概率是相等的,则称此类散列函数为均匀散列函数(Uniform Hash function),这就是使关键字经过散列函数得到一个“随机的地址”,从而减少冲突。

散列表的特点

散列表有两种用法:一种是 key 的值与 value 的值一样,一般称这种情况的结构为 Set(集合);而如果 key 和 value 所对应的内容不一样时,那么我们称这种情况为 Map(图)。

根据散列表的存储结构,可以得出散列表的以下特点。

  • 访问速度很快:由于散列表有散列函数,可以将指定的 key 都映射到一个地址上,所以在访问一个 key 对应的 value 时,根本不需要一个一个地进行查找,可以直接跳到那个地址。所以在对散列表进行添加、删除、修改、查找等任何操作时,速度都很快。
  • 需要额外的空间:首先,散列表实际上是存不满的,如果一个散列表刚好能够存满,那么肯定是个巧合。而且当散列表中元素的使用率越来越高时,性能会下降,所以一般会选择扩容来解决这个问题。另外,如果有冲突的话,则也是需要额外的空间去存储的,比如链地址法,不但需要额外的空间,甚至需要使用其他数据结构。这个特点有个很常用的词可以表达,叫作“空间换时间”,在大多数时候,对于算法的实现,为了能够有更好的性能,往往会考虑牺牲些空间,让算法能够更快些。
  • 无序:散列表还有一个非常明显的特点,那就是无序。为了能够更快地访问元素,散列表是根据散列函数直接找到存储地址的,这样我们的访问速度就能够更快,但是对于有序访问却没有办法应对。
  • 可能会产生碰撞:没有完美的散列函数,无论如何总会产生冲突,这时就需要采用冲突解决方案,这也使散列表更加复杂。通常在不同的高级语言的实现中,对于冲突的解决方案不一定一样。

常用的哈希函数

哈希函数能使对一个数据序列的访问过程更加迅速有效,通过哈希函数,数据元素将被更快地定位。

直接寻址表

直接寻址法是指取关键字或关键字的某个线性函数值为散列地址,即 H(key)=key 或 H(key) = a*key + b,其中 a 和 b 为常数,这种散列函数叫做自身函数。

  • 优点:简单、均匀,也不会产生冲突。
  • 缺点:需要事先知道关键字的分布情况,适合查找表较小且连续的情况。

由于这样的限制,在现实应用中,此方法虽然简单,但却并不常用。

数字分析法

数字分析法是指分析一组数据,比如一组员工的出生年月日,这时我们发现出生年月日的前几位数字大体相同,这样的话,出现冲突的几率就会很大,但是我们发现年月日的后几位表示月份和具体日期的数字差别很大,如果用后面的数字来构成散列地址,则冲突的几率会明显降低。因此数字分析法就是找出数字的规律,尽可能利用这些数据来构造冲突几率较低的散列地址。

如果现在要存储某家公司的登记表,若用手机号作为关键字,极有可能前7位都是相同的,选择后四位成为散列地址就是不错的选择。若容易出现冲突,对抽取出来 的数字再进行反转、右环位移等。总的目的就是为了提供一个散列函数,能够合理地将关键字分配到散列表的各个位置。

数字分析法通过适合处理关键字位数比较大的情况,如果事先知道关键字的分布且关键字的若干位分布比较均匀,就可以考虑用这个方法。

平方取中法

平方取中法是指当无法确定关键字中哪几位分布较均匀时,可以先求出关键字的平方值,然后按需要取平方值的中间几位作为哈希地址。这是因为:平方后中间几位和关键字中每一位都相关,故不同关键字会以较高的概率产生不同的哈希地址。

这个方法计算很简单,假设关键字是1234,那么它的平方就是1522756,再抽取中间的3位就是227,用做散列地址。

平方取中法比较适合不知道关键字的分布,而位数又不是很大的情况。

折叠法

折叠法是指将关键字从左到右分割成位数相同的几部分,最后一部分位数可以不同,然后将这几部分叠加求和,并按散列表表长,取后几位作为散列地址。数位叠加可以有移位叠加和间界叠加两种方法。移位叠加是将分割后的每一部分的最低位对齐,然后相加;间界叠加是从一端向另一端沿分割界来回折叠,然后对齐相加。

比如关键字是9876543210,散列表表长为三位,将它分为四组,987|654|321|0,然后将它们叠加求和987 + 654 + 321 + 0 = 1962,再求后3位得到散列地址962。

折叠法事先不需要知道关键字的分布,适合关键字位数较多的情况。

随机数法

随机数法是指选择一随机函数,取关键字的随机值作为散列地址,即 f(key)=random(key)。这里 random() 是随机函数,通常用于关键字长度不同的场合。

除留余数法

除留余数法是指取关键字被某个不大于散列表表长 m 的数 p 除后所得的余数为散列地址,即 H(key)=key mod p,p<=m,mod 是取模(求余数)。不仅可以对关键字直接取模,也可在折叠、平方取中等运算之后取模。对 p 的选择很重要,一般取素数或 m,若 p 选的不好,容易产生同义词。

总结

总之,实际工作中需视不同的情况采用不同的哈希函数,通常考虑的因素有:

  • 计算哈希函数所需时间
  • 关键字的长度
  • 哈希表的大小
  • 关键字的分布情况
  • 记录的查找频率

综合以上等因素,才能决策选择哪种散列函数更合适。

哈希冲突的解决方法

有时不同的 key 通过哈希函数可能会得到相同的地址,这在操作时可能会对数据造成覆盖、丢失。之所以产生冲突是由于哈希函数有时对不同的 key 计算之后获得了相同的地址。

冲突的处理方式也有很多,下面介绍几种。

开放寻址法

开放寻址法是指一旦发生了冲突,就去寻找下一个空的散列地址,只要散列表足够大,空的散列地址总能找到,并将记录存入。

它的公式为:fi(key)=(f(key)+di) MOD m (i=1,2,…,m-1),其中 f(key) 为散列函数,m 为散列表长,di 为增量序列,可有下列三种取法:

  • di=1,2,3,…,m-1,称线性探测再散列;
  • di=12,-12,22,-22,…,±k2,(k<=m/2)称二次探测再散列;
  • di=伪随机数序列,称伪随机探测再散列。

再哈希法

再哈希法是指在产生冲突之后,使用关键字的其他部分继续计算地址,如果还是有冲突,则继续使用其他部分再计算地址。

它的公式为:fi(key)=RHi(key) (i=1,2,…,k),其中 RHi(key) 就是不同的散列函数,可以把前面说的除留余数、折叠、平方取中全部用上。每当发生散列地址冲突时,就换一个散列函数计算。

这种方法能够使得关键字不产生聚集,但相应地也增加了计算的时间。

链地址法

链地址法也叫拉链法,其实就是对 key 通过哈希之后落在同一个地址上的值,做一个链表。在很多高级语言的实现当中,也是使用这种方式处理冲突的。

将所有关键字为同义词的记录存储在一个单链表中,称这种表为同义词子表,在散列表中只存储所有同义词子表前面的指针。此时,已经不存在什么冲突换地址的问题,无论有多少个冲突,都只是在当前位置给单链表增加结点的问题。

链地址法对于可能会造成很多冲突的散列函数来说,提供了绝不会出现找不到地址的保证。当然,这也就带来了查找时需要遍历单链表的性能损耗。

公共溢出区法

公共溢出区法是指是建立一个公共溢出区,当地址存在冲突时,把新的地址放在公共溢出区里。

在查找时,对给定值通过散列函数计算出散列地址后,先与基本表的相应位置进行比对,如果相等,则查找成功;如果不相等,则到溢出表中进行顺序查找。如果相对于基本表而言,有冲突的数据很少的情况下,公共溢出区的结构对查找性能来说还是非常高的。

哈希表的性能分析

在没有哈希冲突的情况下,哈希表是在查找中效率最高的,因为它的时间复杂度为 O(1)。然而在实际应用中,冲突是不可避免的,那么散列表的平均查找长度取决于那些因素呢?

  • 处理冲突的方法:相同的关键字,相同的散列函数,处理冲突的方法不同,会使得平均查找长度不同。
  • 哈希表的装填因子 α: α 为填入表中的记录个数/哈希表长度。α 标志着哈希表的装满程度。当填入表中的记录越多,α 越大,产生冲突的可能性就越大。也就是说哈希表的平均查找长度取决于装填因子,而不是取决于集合中的记录个数。可以通过将哈希表的空间设置的比查找集合大,通过牺牲空间,在换取查找效率。这样哈希查找的时间复杂度就是真的是 O(1) 了。

散列表的适用场景

根据散列表的特点可以想到,散列表比较适合查找性能要求高,数据元素之间无逻辑关系要求的情况。

缓存

通常开发程序时,对一些常用的信息会做缓存,用的就是散列表,比如要缓存用户的信息,一般用户的信息都会有唯一标识的字段,比如 ID。这时做缓存,可以把 ID 作为 key,而 value 用来存储用户的详细信息,这里的 value 通常是一个对象,包含用户的一些关键字段,比如名字、年龄等。

在每次需要获取一个用户的信息时,就不用与数据库这类的本地磁盘存储交互了(其实在大多数时候,数据库可能与服务不在一台机器上,还会有相应的网络性能损耗),可以直接从内存中得到结果。这样不仅能够快速获取数据,也能够减轻数据库的压力。

有时要查询一些数据,这些数据与其他数据是有关联的,如果进行数据库的关联查询,那么效率会非常低,这时可以分为两部分进行查询:将被关联的部分放入散列表中,只需要遍历一遍;对于另一部分数据,则通过程序手动关联,速度会很快,并且由于是通过散列表的 key 和 value 的对应关系对应数据的,所以性能也会比较好。

快速查找

这里说的查找,不是排序,而是在集合中找出是否存在指定的元素。

这样的场景很多,比如要在指定的用户列表中查找是否存在指定的用户,这时就可以使用散列表了。在这个场景下使用的散列表其实是在上面提到的 Set 类型,实际上不需要 value 这个值。

还有一个场景,一般对网站的操作会有个 IP 地址黑名单,如果某些 IP 有大量的非法操作,于是就封锁了这些 IP 对我们网站的访问。这个 IP 是如何存储的呢?就是用的散列表。当一个访问行为发送过来时,通常会获取其 IP,判断其是否存在于黑名单中,如果存在,则禁止其访问。这种情况也是使用的 Set。


评论
 上一篇
SQL 之基础操作 SQL 之基础操作
SQL 是用于访问和处理数据库的标准的计算机语言。 SQL 简介SQL 是什么? SQL 是指结构化查询语言,全称是 Structured Query Language。 SQL 是一种 ANSI(American National Sta
2019-03-28
下一篇 
数据结构-树 数据结构-树
树(Tree)是算法中常用的一种数据结构,为了存储和查找的方便,用各种树结构来存储文件。 简介定义树是由 n(n>=0) 个有限节点组成一个具有层次关系的集合,它是一种非线性的数据结构。把它叫做树是因为它看起来像一棵倒挂的树,也就是说
2019-03-23
  目录