十、散列表(Hash Table)

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

一、概述

散列表(Hash Table),也称“哈希表”或者“Hash 表”

1、相关概念

  • 原始数据叫作键(键值)关键字(key)
  • 将原始数据转化为数组下标的映射方法称为散列函数(或“Hash 函数”“哈希函数”,hash function)
  • 而散列函数计算得到的值就叫作散列值(或“Hash 值”“哈希值”,table)

2、散列表

(1)散列表用的就是数组支持按照下标随机访问的时候,时间复杂度是O(1) 的特性
(2)生成:通过散列函数把元素的键值映射为下标,然后将数据存储在数组中对应下标的位置。
(3)访问元素:当我们按照键值查询元素时,用同样的散列函数,将键值转化数组下标,从对应的数组下标的位置取数据。

二、散列函数

1、定义

  • 定义为 hash(key),其中key 表示元素的键值,hash(key) 的值表示经过散列函数计算得到的散列值。

2、设计

散列函数设计的基本要求:

  • 散列函数计算得到的散列值是一个非负整数; 《== 数组下标
  • 如果 key1 = key2,那 hash(key1) == hash(key2);
  • 如果 key1 ≠ key2,那 hash(key1) ≠ hash(key2)。

第三点的理解:在真实情况下,想找到一个不同的 key 对应的散列值都不一样的散列函数,几乎是不可能的。eg:MD5、SHA、CRC等哈希算法,也无法完全避免这种散列冲突。而且,因为数组的存储空间有限,也会加大散列冲突的概率。

3、散列冲突

两类方法:开放寻址法(open addressing)和链表法(chaining)

(1)开放寻址法

基本思想: 若存在散列冲突,则探测一个空闲位置,将其插入。

A、探测方法

a、线性探测(Linear Probing)
从当前位置开始,依次往后(若不够则从头继续)查找,看是否有空闲位置,直到找到为止。
==》
对应的查找:通过散列函数求出要查找元素的键值对应的散列值,然后比较数组中下标为散列值的元素和要查找的元素。如果相等,则说明就是我们要找的元素;否则就顺序往后依次查找。如果遍历到数组中的空闲位置(遇到标记为deleted的空间时,继续向下探测),还没有找到,就说明要查找的元素并没有在散列表中。
注意:散列表同样支持插入、删除操作。其中,删除操作中:将删除的元素进行特殊标记为deleted。因为如果这个空闲位置是我们后来删除的,就会导致原来的查找算法失效。本来存在的数据,会被认定为不存在。
在这里插入图片描述
b、二次探测(Quadratic Probing)
区别: 步长变成了原来的“二次方“,即:探测的下标序列
hash(key)+0,hash(key)+12,hash(key)+22,hash(key)+32,……
c、双重散列(Double hashing)
使用一组散列函数 hash1(key),hash2(key),hash3(key)……先用第一个散列函数,如果计算得到的存储位置已经被占用,再用第二个散列函数,依次类推,直到找找到空闲的存储位置。

B、存在问题

当数据越来越多时,散列冲突发生的可能性就会越来越大,空闲位置会越来越少,线性探测的时间就会越来越久。极端情况下,最坏情况下的时间复杂度为 O(n)。同理,在删除和查找时,,也有可能会线性探测整张散列表,才能找到要查找或者删除的数据。

C、装载因子(load factor)

为了尽可能保证散列表的操作效率,利用装载因子(load factor)来表示空闲槽位的多少。

散列表的装载因子 = 填入表中的元素个数 / 散列表的长度

装载因子越大,说明空闲位置越少,冲突越多,散列表的性能会下降。

(2)链表法——更常用

所有散列值相同的元素我们都放到相同槽位对应的链表中。
在这里插入图片描述
插入:通过散列函数计算出对应的散列槽位,将其插入到对应链表中即可
==》时间复杂度:O(1)
查找、删除:需要遍历,时间复杂度跟链表的长度 k 成正比,也就是 O(k)。

对于散列比较均匀的散列函数来说,理论上讲,k=n/m,其中 n 表示散列中数据的个数,m 表示散列表中“槽”的个数。

三、工业级散列表

问题:如何设计一个可以应对各种异常情况的工业级散列表,来避免在散列冲突的情况下,散列表性能的急剧下降,并且能抵抗散列碰撞攻击?

1、散列函数设计原则

  • 散列函数的设计不能太复杂。否则,消耗很多计算时间,间接影响散列表性能。
  • 散列函数生成的值尽可能随机且均匀分布==》避免或最小化散列冲突,即便冲突,也比较平均。

散列函数设计方法:数据分析法(从原始数据中截取部分作为散列函数)、直接寻址法、平方取中法、折叠法、随机数法等

2、装载因子过大时的处理方法——动态扩容

针对散列表的扩容,数据搬移操作要复杂很多。因为散列表的大小变了,数据的存储位置也变了,所以需要通过散列函数重新计算每个数据的存储位置。
例如:
在这里插入图片描述

(1)数据插入

  • 最好时间复杂度(不需要扩容)——O(1)
  • 最坏情况下,散列表装载因子过高,启动扩容,需要重新申请内存空间,重新计算哈希位置,并且搬移数据,所以时间复杂度——O(n)
  • 均摊时间复杂度——O(1)

(2)装载因子阈值的设置

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

(3)扩容

为了解决一次性扩容耗时过多的情况,我们可以将扩容操作穿插在插在插入操作的过程中,分批完成。

具体过程:当装载因子触达阈值之后,我们只申请新空间,但并不将老的数据搬移到新散列表中。当有新数据要插入时,我们将新数据插入新散列表中,并且从老的散列表中拿出一个数据放入到新散列表。每次插入重复上述过程,多次插入之后,老的散列表中的数据就一点一点搬移到新的散列表中。
在这里插入图片描述

查询操作:为了兼容了新、老散列表中的数据,先从新散列表中查找,如果没有找到,再去老的散列表中查找。

小结:避免一次性扩容耗时过多,以动态扩容方法插入数据,在任何情况下,插入一个数据的时间复杂度都是O(1)

四、选择冲突解决方法

1、开放寻址法

(1)优点

  • 易序列化;
  • 可有效利用CPU缓存加速查询速度(数据都存储在数组中)

(2)缺点

  • 删除操作时需要特殊标记已经删除的数据;
  • 冲突代价高于链表法==》装载因子的上限不能太大==》比链表浪费空间

(3)适用场景

数据量比较小、装载因子小的时候

2、链表法

(1)优点

  • 对内存的利用率比开放寻址法要高(链表结点可在需要时再创建);
  • 对大的装载因子的容忍度更高(只要散列函数的值随机均匀,即使装载因子很大,虽查找效率会下降,但比顺序查找要快);

(2)缺点

  • 对于比较小的数据的存储,比较消耗内存(存储指针)
  • 链表的结点不连续,对CPU缓存不友好,执行效率也有一定的影响
  • 注意:若存储大数据(即存储的对象的大小远大于一个指针的大小),则链表中指针的内存消耗可以忽略。

(3)改造的链表法

链表 ==》其他高效的动态数据结构(eg:跳表、红黑树)

(4)适用

比较适合存储大对象、大数据量的散列表,而且,比起开放寻址法,它更加灵活,支持更多的优化策略,比如用红黑树代替链表。

五、工业级散列表举例分析

分析对象:Java 中 HashMap 。

1、初始大小

  • HashMap 默认初值为 16,可修改==》较少动态扩容次数,可提升HashMap的性能。

2、装载因子和动态扩容

  • 最大装载因子默认为 0.75;
  • 当 HashMap 中元素个数超过 0.75 * capacity(capacity表示散列表的容量)时,就会启动扩容,每次扩容都会是原来的两倍大小。

3、散列冲突解决方法

  • 采用链表法解决冲突。
  • 即使负载因子和散列函数设计得再合理,也免不了会出现拉链过长的情况,一旦出现拉链过长,则会严重影响 HashMap 的性能。
  • 优化(JDK1.8):
    • 当链表长度≥8,则链表转换为红黑树==》快速增删改查==》提升HashMap性能
    • 当链表长度≤8,则红黑树转换为链表(数据量小时,红黑树要维护平衡,性能优势不明显)

4、散列函数

keywords:简单、高效、分布均匀

int hash(Object key){
	int h = key.hashCode();
	//capicity 表示散列表的大小}
	return (h^(h >>> 16)) & (capitity - 1);
}
// String 类型的对象的 hasCode() 如下:
public int hasCode(){
	int var1 = this.hash;
	if(var1 == 0 && this.value.length > 0){
		char[] var2 = this.value;
		for(int var3 = 0; var3 < this.value.length; ++var3){
			var1 = 31 * var1 + var2[var3];
		}
		this.hash = var1;
	}
	return var1;
}

六、小结

1、工业级散列表特性

  • 支持快速查找、插入、删除操作;
  • 内存占用合理,不浪费过多的内存空间;
  • 性能稳定,在极端情况下,散列表的性能也不会退化到无法接受的情况。

2、设计思路

  • 设计一个合适的散列函数;
    • 散列后的值随机且均匀分布 ==》减少散列冲突
    • 不能太复杂,太复杂会太耗时间,影响散列表的性能。
  • 定义装载因子阈值,并设计动态扩容策略;
  • 选择合适的散列冲突方法。
    • 链表法 及其 改造的链表法(链表换成其他动态查找数据结构,eg:红黑树)
    • 开放寻址法:适用于小规模数据、装载因子不高的散列表。

七、散列表 + 链表

1、LRU缓存淘汰算法

(1)链表实现 LRU 缓存淘汰算法过程

目标需要维护一个按照访问时间从大到小有序排列的链表结构。
==》因为缓存大小有限,当缓存空间不够,需要淘汰一个数据的时候,直接将链表头部的结点删除。

当要缓存某个数据的时候,先在链表中查找该数据。若没找到,则直接将该数据放在链表的尾部;若找到,则将其移动到链表的尾部。
==》时间复杂度:O(n)

(2)缓存(cache)系统主要包含的操作:

  • 往缓存中添加一个数据;
  • 从缓存中删除一个数据;
  • 从缓存中查找一个数据;

==》三个操作均涉及“查找操作”
==》单纯使用链表:时间复杂度——O(n)
==》散列表 + 链表:时间复杂度——O(1)

(3)散列表+双向链表 实现 LRU 缓存淘汰算法

在这里插入图片描述

双向链表存储数据:链表中的每个结点处理存储数据(data)、前驱指针(prev)、后继指针(next)之外,还新增了一个特殊的字段 hnext,hnext 指针是为了将结点串在散列表的拉链中。==》每个结点会在两条链中:双向链表和散列表的拉链中。

数据查找:散列表中查找数据的时间复杂度接近 O(1),然后将其移动到双向链表的尾部
数据删除:O(1)时间复杂度找到要删除的结点,之后利用双向链表的前驱指针O(1)时间复杂度获得前驱结点,删除结点也只需O(1)的时间复杂度。
添加数据:首先查找该数据是否已经在缓存中。若在,则将其移动到双向链表的尾部;若不在,则查看缓存中是否已满。若满,则将双向链表头部的结点删除,然后再将数据放到链表的尾部;若未满,则直接在链表尾部直接插入。

2、Redis 有序集合

有序集合中,每个成员对象有两个重要的属性:key ( 键值 ) 和 score ( 分值 )。

(1)Redis 有序集合的操作

  • 添加一个成员变量;
  • 按照键值来删除一个成员变量;
  • 按照键值来查找一个成员变量;
  • 按照分值区间查找数据,eg:查找积分在[100,555]之间的成员对象;
  • 按照分值从小到大排序成员变量;

(2)散列表+链表

Redis 有序集合可以按照 键值 或 分值 进行操作。

按照分值(或键值)将成员对象组织成跳表的结构,然后按照键值(或分值)构建一个散列表
==》可按键值进行删除、查找一个成员对象
==》时间复杂度:O(1)

3、Java中LinkedHashMap容器

==》通过 “ 散列表 + 双向链表 ” 结构实现

虽然散列表中数据是经过散列函数打乱之后无规律存储的,但是 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。


// 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() 函数,往LinkedHashMap 中添加新数据时,都会先检查新数据的键值是否已经存在,若存在则删除,然后将新数据添加到链表的尾部;
  • 当调用 get() 函数,访问数据时,将访问的数据移动到链表的尾部。

数据的过程
在前四个操作完成之后,链表中的数据是下面这样:
在这里插入图片描述
执行 m.put(3, 26); 之后
在这里插入图片描述
执行 m.get(5); 之后
在这里插入图片描述
==》本质:LRU缓存淘汰策略的缓存系统

八、碎碎念

主要就是两种数据结构:链表和数组。

数组占据随机访问的优势,却有需要连续内存的缺点。

链表具有可不连续存储的优势,但访问查找是线性的。

散列表和链表、跳表的混合使用,是为了结合数组和链表的优势,规避它们的不足。

我们可以得出数据结构和算法的重要性排行榜:连续空间 > 时间 > 碎片空间。

猜你喜欢

转载自blog.csdn.net/u012736685/article/details/83795585
今日推荐