数据结构:散列表

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/Luomingkui1109/article/details/89851853

散列表的英文叫 "Hash Table",我们也叫它 “哈希表” 或者 “Hash 表”。

1. 散列思想?

散列表用的是数组支持按照下标随机访问数据的特性,所以散列表其实就是数组的一种扩展,由数组演化而来。

假如我们有 100 名选手参加运动会,参赛号码从 0~99。为了方便记录查询成绩,我们将参赛号码为 0 的选手的成绩放在数组下标为 0 的位置,参赛号码为 1 的选手的成绩放在数组下标为 1 的位置,以此类推。

这样,当我们想要查找某个选手的成绩时,我们只需要取出数组中该选手参赛号码对应下标的数值即可,时间复杂度为 O(1),效率非常高。

在这个例子中,参赛号码是自然数,并且与数组的下标形成一一映射,这其实就有了散列的思想。

但事实上,有时候我们不能直接将编号作为数组下标,比如参赛选手的编号可能为 051167,05 表示年级,11 表示班级,67 表示序号。

这时候,我们可以通过截取参赛编号的后两位作为下标,当查询选手信息的时候,我们用同样的方法,取出后两位数字,作为数组下标来读取数据。

这就是典型的散列思想。其中,参赛选手的编号我们叫作键(key)或关键字,我们用它来标识一个选手。而把参赛编号转化为数组下标的映射方法就叫作散列函数(或 “Hash 函数”,“哈希函数”),而散列函数计算得到的值就叫作散列值(或 “Hash 值”,“哈希值”)。

散列表其实就是通过散列函数把元素的键值映射为下标,然后将数据存储在数组中对应下标的位置。当我们按照键值查询元素的时候,我们用同样的散列函数,将键值转化为数组下标,从对应下标位置的数组中取数据。

扫描二维码关注公众号,回复: 6143285 查看本文章

 

2. 散列函数?

散列函数在散列表中起着非常关键的作用。

上面两个例子中的散列函数都比较简单,也很容易理解。但如果参赛选手的编号是随机生成的 6 位数字,又或者是字符时,我们该如何构造散列函数呢?

散列函数有以下三个基本要求:

  • 散列函数计算得到的散列值是一个非负整数

  • 如果 key1=key2hash(key1)=hash(key2)key1=key2,那么hash(key1)=hash(key2)

  • 如果 key1key2hash(key1)hash(key2)key1≠key2,那么hash(key1)≠hash(key2)

第一点和第二点都非常好理解,第三点要求看起来合情合理,但在真实情况下,要想找到一个不同 key 值对应的散列值都不一样的散列函数,几乎是不可能的。而且,因为数组的存储空间有限,也会加大散列冲突的概率。因此,我们需要通过其他途径来解决散列冲突问题。

 

3. 散列冲突?

再好的散列函数也无法避免散列冲突,常用的解决蛋类冲突解决方法有两类,开放寻址法(open addressing)链表法(chaining)

3.1. 开放寻址法

开放寻址发的核心思想就是,如果出现了散列冲突,我们就重新探测一个空闲位置,将其插入。

线性探测(Linear Probing) 就是当我们往散列表中插入数据时,如果计算得到的散列值对应的位置已经被占用了,我们就从当前位置开始,依次往后查找,看是否有空闲位置,直到找到为止。

看下面的例子,橙色表示已经有元素,黄色表示空闲。当计算新插入的 x 的散列值为 7 时,我们发现数组中下标为 7 的地方已经有数据了,于是我们就依次向后查找,遍历到尾部都没有找到空闲位置。我们再从头开始查找,直到找到数组第 2 个位置空闲,我们就将 x 插入到这个地方。

在散列表中查找元素的过程与插入类似,我们通过散列函数求出要查找元素的键值对应的散列值,然后比较数组中下标为散列值的元素和要查找的元素。如果相等,那说明就是我们要查找的元素;否则就顺序往后依次查找,若遍历到数组中的空闲位置还没有找到,说明要查找的元素并没有在散列表中。

散列表跟数组一样,不仅支持插入、查找操作,还支持删除操作。对于使用线性探测解决冲突的散列表,删除操作稍微有点特别,我们不能单纯地把要删除的元素设置为空

因为在查找的过程中,一旦我们遍历到数组中的空闲位置,我们就认定数据不在散列表中。但如果这个空闲位置是我们后来删除的,就会导致我们的查找算法失效,本来存在的数据也会被认定为不存在。

我们可以将删除的元素特殊标记为 deleted,然后当我们查找到标记为 deleted 的位置时,我们不是停下来,而是继续往下探测。

线性探测存在很大的问题,当散列表中插入的数据越来越多时,散列冲突的可能性就会越来越大,空闲位置越来越少,线性探测的时间也会越来越久

除了线性探测,还有另外两种比较经典的探测方法,二次探测(Quadratic Probing)双重探测(Double Probing)

所谓二次探测,就是说每次探测的步长变成了原来的二次方,也就是说,它探测的下标序列变为 hash(key)+0,hash(key)+12,hash(key)+22hash(key)+0,hash(key)+12,hash(key)+22……

所谓双重探测,就是说每次不仅仅使用一个散列函数,当第一个散列函数计算得到的存储位置被占用的时候,再使用第二个散列函数,以此类推,直到找到空闲的位置。

不管采用哪种探测方法,当散列表中的空闲位置不多时,散列冲突的概率就会大大提高。我们引入一个装载因子(load factor)来表示散列表中空位的多少 散列表的装载因子 = 填入表中的元素个数 / 散列表的长度。装载因子越大,说明空闲位置越少,冲突越多,散列表的性能会下降。

3.2. 链表法

链表法是一种更加常用的散列表冲突解决方法,相比开放寻址法,它要简单很多。

在散列表中,每个桶(bucket)或者槽(slot)会对应一条链表,所有散列值相同的元素会放到相同槽位对应的链表中。

向散列表中插入数据的时间复杂度为 O(1),而查找或者删除的时间复杂度则与链表的长度 k 成正比。

4. 如何打造一个工业级的散列表?

   散列表的查询效率并不能笼统地说成是 O(1),它和散列函数、装载因子、散列冲突等都有关系。如果散列函数设计得不好,或者装载因子过高,都可能会导致散列冲突发生的概率升高,查询效率下降。

4.1 如何设计散列函数?

散列函数设计的好坏,决定了散列冲突发生的概率,也直接决定了散列表的性能。那什么才是好的散列函数呢?

首先,散列函数的设计不能太复杂。过于复杂的散列函数,势必会消耗很多计算时间,也就间接地影响到散列表的性能。

其次,散列函数生成的值要尽可能随机并且均匀分布。这样才能避免或者最小化散列冲突,而且即便出现冲突,散列在每个槽内的数据也比较平均,不会出现某一个槽内数据特别多的情况。

手机号码前面几位重复的可能性很大,但是后面几位就比较随机,我么可以取手机号的后四位数作为散列值;对运动会参赛成员统计成绩的时候,选手后两位的号码就可以作为散列值。这种散列函数的设计方法,我们一般叫作“数据分析法”。

在 散列表上 实现 Word 中拼写检查功能时,我们可以这样设计:将单词中每个字母的 ASCII 值“进位”相加,然后再和散列表的大小求余、取模,作为散列值。比如,英文单词 nice,转化出来的散列值就是:hash("nice")=(("n" - "a") *26*26*26 + ("i" - "a")*26*26 + ("c" - "a")*26+ ("e"-"a")) / 78978

事实上,散列函数的设计方法还有很多,比如直接寻址法、平方取中法、折叠法、随机数法等。

4.2 装载因子过大了怎么办?

装载因子越大,说明散列表中的元素越多,空闲位置越少,散列冲突的概率就越大。

针对散列表,当装载因子过大时,我们可以进行动态扩容,重新申请一个更大的散列表,将数据搬移到这个新散列表中。

但是,针对散列表的扩容,数据搬移要复杂很多,因为散列表的大小变了,数据的存储位置也变了,所以我们需要散列函数重新计算每个数据的存储位置。

插入一个数据,最好情况下,不需要扩容,最好时间复杂度是 O(1)O(1),最坏情况下,启动扩容,我们需要重新申请内存空间,重新计算哈希位置,并且搬移数据,所以时间复杂度为 O(n)O(n)。用摊还分析法,均摊情况下,时间复杂度接近于最好情况,就是 O(1)O(1)

实际上,对于动态散列表,随着数据的删除,散列表越来越小,我们还可以在装载因子小于某个值之后,启动动态缩容。

装载因子阈值的设定需要权衡时间、空间复杂度。如果内存空间不紧张,对执行效率要求很高,可以降低装载因子的阈值;相反,如果内存空间紧张,对执行效率要求又不高,可以增加装载因子的值,甚至可以大于 1。

4.3 如何避免低效地扩容?

我们刚刚分析到,大部分情况下,动态扩容的散列表插入数据都很快,但是在特殊情况下,当装载因子达到阈值时,需要先进行扩容,再插入数据 ,这时候,插入数据就会很慢,尤其是在数据量已经非常大的情况下。

因此,我们可以考虑不要一次性把数据全部都搬移过去。当装载因子达到阈值时,我们申请新的空间,但并不将老的数据搬移到新散列表中。当有新的数据要插入时,我们不仅将新数据插入到新散列表中,而且同时从老的散列表中拿出一个数据放到新散列表中。这样,经过多次插入操作后,我们就一点一点地完成了数据搬移,插入操作也变得更快了。

至于这期间的查询操作,我们先从新散列表中查找,如果没有找到,再去老的散列表中查找。

通过这样的均摊方法,任何情况下,插入一个数据的时间复杂度都为 O(1)O(1)

4.4 如何选择冲突解决方法?

4.4.1  开放寻址法

  • 优点

  • 数据都存储在数组中,可以有效地利用 CPU 缓存加快查询速度

  • 没有指针,序列化起来比较简单

  • 缺点

  • 删除数据需要特殊标记,比较麻烦

  • 冲突的代价更高,一般装载因子上限不能太大,更浪费内存

    当数据量比较小、装载因子比较小的时候,适合用开放寻址法。

4.4.2  链表法

  • 优点

  • 内存利用率比开放寻址法要高,链表结点可以在需要的时候再创建

  • 对大装载因子容忍度更高,只要散列函数的值随机均匀,即使装载因子变成 10,也就是链表的长度变长了而已

  • 缺点

  • 存储小对象需要额外的指针,比较耗内存,但对于大对象则可以忽略

  • 链表分散存储,无法利用 CPU 缓存

    另外,我们还可以对链表法加以改造,将链表改造成其他更高效的动态数据结构,比如跳表、红黑树。这样,即使出现散列冲突,也可以保证查找的时间复杂度为 O(logn)O(logn)

基于链表的散列冲突方法比较适合存储大对象、大数据量的散列表,而且,比起开放寻址法,它更加灵活,支持更多的优化策略。

4.5 工业级散列表举例分析?

让我们来看一下 Java 中的 HashMap 是怎么实现的。

    • 初始大小:HashMap 的初始默认大小为 16,如果我们事先知道大概的数据量有多大,可以修改默认初始化大小的值。

    • 装载因子和动态扩容:最大装载因子默认是 0.75,当超过这个阈值时,就会启动动态扩容,每次扩容都会扩容为原来的两倍大小。

    • 散列冲突解决方法:HashMap 底层采用链表法来解决冲突,在 JDK 1.8 版本中,当链表长度太长时(默认超过 8),链表就会转化为红黑树。

4.6 .如何设计一个工业级散列表?

一个工业级的散列表应该具有那些特性?

  • 支持快速地查询、插入和删除操作

  • 内存占用合理,不能浪费过多的内存空间

  • 性能稳定,极端情况下,散列表的性能也不会退化到无法接受的程度

如何实现这样一个散列表,可以从以下三方面来考虑设计思路

  • 设计一个合适的散列函数

  • 定义装载因子阈值,并且设计动态扩容策略

  • 选择合适的散列冲突解决方法

5. 为什么散列表和链表经常会一起使用?

散列表和链表经常组合起来使用,但它们是如何组合起来使用的,为什么它们会经常一块使用呢?

5.1  LRU 缓存淘汰算法?

基于链表实现 LRU 缓存淘汰算法的原理是这样的:我们维护一个有序单链表,越靠近链表头部的结点是越早访问的。当有一个新的数据被访问时,我们从链表头开始顺序遍历链表。

5.1.1 如果此数据之前已经被缓存在链表中了,我们将其从原来的位置删除,然后再插入到链表的尾部。

5.1.2 如果此数据没有缓存在链表中,又可以分为两种情况:

  • 如果缓存未满,直接将此结点插入到链表的尾部

  • 如果缓存已满,则将链表尾结点删除,然后再将新的数据结点插入到链表的尾部

因为不管缓存是否已满,我们都需要遍历一遍链表,因此,基于链表实现的缓存访问的时间复杂度为 O(n)

一个缓存(cache)系统主要包含下面这几个操作:

  • 往缓存中添加一个数据

  • 从缓存中删除一个数据

  • 在缓存中查找一个数据

如果我们将散列表和链表两种数据结构结合起来使用,可以将这几个操作的时间复杂度都降低到 O(1)

具体的结构就是下面这个样子:

使用双向链表来存储数据,链表中的每个结点包括数据(data)、前驱指针(prev)、后继指针(next)还有一个特殊的 hnext 指针。

因为我们使用链表法来解决散列冲突,所以每个结点都会在两条链中存在。一个链是上面的双向链表,另一个链则是散列表中散列值相同的元素组成的拉链。前驱和后继指针是为了将结点串在双向链表中,hnext 指针是为了将结点串在散列表的拉链中。

查找数据的时候,我们通过散列表可以在时间复杂度接近于 O(1)O(1) 内找到一个数据,然后,我们再将其移动到双向链表的尾部。

删除数据的时候,我们在时间复杂度接近于 O(1)O(1) 内找到要删除的结点,然后由于是双向链表,我们可以直接得到前驱指针,删除结点也只需要 O(1)O(1) 的时间复杂度。

添加数据的时候,类似于单链表的情况,我们也可以在 O(1)O(1) 时间复杂度内完成。

而其他操作,比如删除头结点、尾部插入数据等,都可以在 O(1)O(1) 时间复杂度内完成。因此,我们就通过散列表和双向链表的组合使用,实现了一个高效的、支持 LRU 缓存淘太算法的缓存系统原型。

5.2 Redis 有序集合?

    在 跳表 中,我们实现了一个简单的有序集合。但实际上,在有序集合中,每个成员对象有两个重要的属性,key (键值)和 score (分值)。我们不仅会通过 score 来查找数据,还会通过 key 来查找数据。

因此 Redis 有序集合的操作主要有以下几种:

  • 添加一个成员对象

  • 按照键值来删除一个成员对象

  • 按照键值来查找一个成员对象

  • 按照分值区间查找数据

  • 按照分值从小到大排序成员变量

如果我们仅仅按照分值将成员对象组织成跳表的结构,那按照键值来删除、查找成员对象就会很慢,解决方法与 LRU 缓存淘太算法的解决方法类似。我们可以再按照键值构建一个散列表,这样按照键值来删除、查找成员对象的时间复杂度就变成了 O(1)

5.3 Java LinkedHashMap?

HashMap<Integer, Integer> m = new LinkedHashMap<>();
m.put(3, 11);
m.put(1, 12);
m.put(5, 23);
m.put(2, 22);

for (Map.Entry e : m.entrySet()) {
  System.out.println(e.getKey());
}

这段代码的输出是 3, 1, 5, 2,你有没有觉得奇怪?散列表中的数据是经过散列函数打乱之后无规律存储的,这里是如何按照数据的插入顺序来遍历输出的呢?

其实,LinkedHashMap 也是通过散列表和链表结合在一起实现的。实际上,它不仅支持按照插入顺序遍历数据,还支持按照访问顺序来遍历数据。

// 10 是初始大小,0.75 是装载因子,true 是表示按照访问时间排序
HashMap<Integer, Integer> m = new LinkedHashMap<>(10, 0.75f, true);
m.put(3, 11);
m.put(1, 12);
m.put(5, 23);
m.put(2, 22);

m.put(3, 26);
m.get(5);

for (Map.Entry e : m.entrySet()) {
  System.out.println(e.getKey());
}

这段代码的输出是 1, 2, 3, 5,我们来具体看一下。

每次调用 put() 函数,都会将数据添加到链表的尾部,前四个操作后,链表中的数据是下面这样:

在第八行,当我们再次将键值为 3 的数据放入到 LinkedHashMap 中去的时候,就会先查找这个键值是否已经存在。然后,将已经存在的 (3, 11) 删除,并将新的 (3, 26) 放到链表尾部。

在第九行,当我们访问键值为 5 的数据的时候,我们将被访问的数据移动到链表尾部。

可以看到,按照访问时间排序的 LinkedHashMap 本身就是一个支持 LRU 缓存淘汰策略的缓存系统。 LinkedHashMap 中的 Linked 实际上指的是双向链表。

5.4 小结?

    散列表这种结构虽然支持非常高效的数据插入、删除、查找操作,但是散列表中的数据都是通过散列函数打乱之后无规率存储的。也就是说,它无法支持按照某种顺序快速地遍历数据。如果希望按照顺序遍历散列表中的数据,那我们需要将散列表中的数据拷贝到数组中,然后排序遍历。但是,散列表是动态数据结构,需要不停地插入、删除数据,若每次遍历数据都需要先排序,那效率势必很低。为了解决这个问题,我们就将散列表和链表(或者跳表)结合在一起使用。 

猜你喜欢

转载自blog.csdn.net/Luomingkui1109/article/details/89851853