数据结构-散列表

什么是散列表?

散列表其实就是我们平常说的哈希表或者hash表,散列表采用的是数组支持下标随机访问数据的特性,所以散列表也是数组的一种拓展,由数组演化而来。散列表其核心思想就是通过散列函数计算出散列值,然后通过散列值获取到位置对元素实现增删等操作。

散列函数

散列函数是散列表中的核心,顾名思义,散列函数是一个函数,我们可以把它定义为hash(key),其中key表示元素的键值,hash(key)的值表示经过散列函数计算得到的散列值。散列表的基本设计要求:

1. 散列函数计算得到的散列值是一个非负整数;
2. 如果key1 = key2,那hash(key1) == hash(key2);
3. 如果key1 ≠ key2,那hash(key1) ≠ hash(key2)。

散列冲突

再好的散列函数也无法避免散列冲突,常用的散列冲突解决方案有两种,开发寻址法和链表法。

1.开发寻址法

开发寻址的核心思想是,如果出现了散列冲突,则重新探测一个空闲的位置插入元素。那么如何重新探测新的位置呢?一个比较简单的方法是线性探测。

当我们向散列表中插入数据时,如果某个元素经过散列函数之后,存储的位置已经被占用了,我们就会从当前位置依次往后开始查找,直到找到空闲位置将元素插入。如下图,黄色的色块表示空闲位置,橙色的色块表示已经存储了数据:

从图中可看出,x经过散列函数之后得出存储位置在7,但是7已经有元素了,所以产生了冲突。于是我们就一个一个往后找空闲位置,遍历到尾部也没找到空闲位置,于是我们从表头开始找,直到找到空闲位置2,于是插入到2的位置。

线性探测存在的问题:

当散列表中的数据越来越多时,发生散列冲突的概率就越大,空闲位置就会越来越少,线性探测的时间就会越久。极端情况下,我们需要探测整个散列表,时间复杂度O(n)。

除了线性探测之外,还有两种比较经典的探测方法,二次探测和二重散列。

二次探测,跟线性探测很像,线性探测每次探测的步长是1,那它探测的下标序列就是hash(key)+0,hash(key)+1,hash(key)+2......而二次探测探测的步长就变 成了原来的“二次方”,也就是说,它探测的下标序列就是hash(key)+0,hash(key)+1^2,hash(key)+2^2......

双重散列,意思就是不仅要使用一个散列函数。我们使用一组散列函数hash1(key),hash2(key),hash3(key)......我们先用第一个散列函数,如果计算得到的存储位置已经被占用,再用第二个散列函数,依次类推,直到找到空闲的存储位置。

无论采用哪种探测方法,当散列表中空闲位置不多的时候,散列冲突发生的可能性就越大。为了提高散列表的性能,一般情况下我们会尽量保证散列表中有一定的空闲位置。用装载因子来表示空位有多少。

装载因子计算公式:

装载因子=散列中元素个数/散列总长度

装载因子越大,空闲位置越多,发生散列冲突的概率越大,散列表的性能就会下降。

2.链表法

链表法是一种比较常见的解决散列冲突的方法,在散列表中,每个“桶(bucket)”或者“槽(slot)”会对应一条 链表,所有散列值相同的元素我们都放到相同槽位对应的链表中。

当插入元素时,我们只需要通过散列函数计算出对应的槽位,然后插入槽位对应的链表,所以插入的时间复杂度为O(1)。

当查找或者删除时,我们也需要通过散列函数计算出对应的槽位,然后遍历链表查找或者删除,这两个操作的时间复杂度跟链表的长度k成正比,也就是O(k)。对于散列比较均匀的散列函数来说,理论上讲,k=n/m,其中n表示散列中数据的个 数,m表示散列表中“槽”的个数。

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

通过上面的分析,我们直到散列表的查询效率并不能说为O(1),它跟装载因子、散列函数、散列冲突都有关。如果散列函数设计的不好或者装载因子过大,都会加大散列冲突的概率从而影响查询效率。

在有些情况下,有些恶意攻击者,还有可能通过自己构造的数据,使得所有数据经过散列函数之后,都散列到同一个槽中,如果我们是基于链表法去解决散列冲突的话,此时散列表就会退化成链表,查询时间复杂度就会从O(1)退化到O(n)。

那么何为一个工业级的散列表?

1,支持快速的查询、插入、删除操作;

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

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

那么如何实现一个这样的散列表呢?

1,设计一个合适的散列函数;

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

3,选择合适的散列冲突解决方法。

如何设计散列函数?

散列函数设计的好坏,决定来散列冲突概率的大小,也直接影响了散列表的性能。

1,散列函数不能设计的太复杂,过于复杂的散列函数,必定会耗费一定的计算时间,也间接影响到散列表的性能。

2,散列函数生成的值要尽可能随机和均匀分布,这样能避免最小化散列冲突,如果出现散列冲突,散列到各个槽里的数据也会比较平均。

散列函数的设计方法有很多,比如直接寻址法、平方取中法、折叠法、随机数法等,本文就不一一介绍里。

 

装载因子过大怎么办?

上面说过,装载因子越大,散列表中的空闲位置越少,发生散列冲突的几率就越高。

1,动态扩容

针对散列表,当装载因子过大时,我们也可以进行动态扩容,重新申请一个更大的散列表,将旧的数据重新搬移到新的散列表。假设每次扩容都重新申请一个两倍大小的散列表,如果原来的装载因子为0.8,扩容之后则是0.4,大大的降低里装载因子。

针对数组的扩容,数据搬移比较简单。但是散列的扩容,进行数据搬移时就要复杂得多。由于散列表的大小改变里,数据存储的位置也变里,所有的数据都需要重新经过散列函数计算出新的存储位置。

如下图所示,在原来的散列表中,21这个元素原来存储在下标为0的位置,搬移到新的散列表中,存储在下标为7的位置。

 

2,如何避免低效扩容?

上面分析得出,当装载因子过大时,会进行动态扩容,这个时候插入数据就会变得很慢。

如果散列表当前大小为1GB,要想扩容为原来的两倍大小,那就需要对1GB的数据重新计算哈希值,并且从原来的散列表搬移到新的散列表,这样就会消耗大量的时间。

为了解决一次性扩容过多的情况,我们可以将扩容操作穿插在插入过程中,分批完成。当装载因子达到阈值之后,我们只申请新的空间,并不立刻进行数据搬移。当有新数据插入时,我们将新数据插入到新的散列表,同时也从老的散列表中拿出一个数据放到新的数列表中。经过多次插入操作之后,老散列表的数据一点点地搬移到老新的散列表,没有了一次性数据搬移,提高了数据插入的性能。

在数据搬移期间,对于查询操作,为了兼容新老散列表中的数据,我们先从新的散列表中查询,没找到再从老的散列表中查询。

如何选择冲突解决方法

上面说到,散列冲突解决方案主要有两个,开放寻址法和链表法,这两种方法在实际开发中都比较常用。如,Java中LinkedHashMap就采用了链表法解决冲突,ThreadLocalMap是通过线性探测的开放寻址法来解决冲突。下面对比这两种方法的优缺点:

在对比两种散列冲突解决方案之前,先了解下cpu的缓存机制:

cpu的缓存机制:cpu从内存中读取数据的时候,会先把读取的数据加载到cpu的缓存中。而cpu每次从内存中读取数据并不是只读取那个特定要访问的地址,而是读取一个数据块(视情况而定一段连续的内存地址)并保存到cpu的缓存中,然后下次访问内存数据的时候就会先从cpu缓存开始查找,如果找到就不需要再从内存中取。这样就实现了比内存访问速度更快的机制,这同时也是cpu缓存存在的意义:为了弥补内存访问速度过慢与cpu执行速度快之间的差异而引入的。

数组与链表:对于数组来说,存储空间是连续的,所以在加载某个下标的时候可以把以后的几个下标元素也加载到cpu缓存中,这样执行速度会快于存储空间不连续的链表存储。
 

开放寻址法:

优点:散列表中的数据都存储在数组中,可以有效地利用CPU缓存加快查询速度。而且,这种方法实现的散列表,序列化起来比较简单。

缺点:用开放寻址法解决冲突的散列表,删除数据的时候比较麻烦,需要特殊标记已经删除掉的数据。而且,在开放寻址法中,所有的数据都存储在一个数组中,比起链表法来说,冲突的代价更高。所以,使用开放寻址法解决冲突的散列表,装载因子的上限不能太大。这也导致这种方法比链表法更浪费内存空间。

所以,我总结一下,当数据量比较小、装载因子小的时候,适合采用开放寻址法。这也是Java中的ThreadLocalMap使用开放寻址法解决散列冲突的原因。

链表法:

优点:1,链表法对内存的利用率比开放寻址法要高。因为链表结点可以在需要的时候再创建,并不需要像开放寻址法那样事先申请好。实际上,这也是链表优于数组的地方。

2,链表法比起开放寻址法,对大装载因子的容忍度更高。开放寻址法只能适用装载因子小于1的情况。接近1时,就可能会有大量的散列冲突,导致大量的探测、再散列等,性能会下降很多。但是对于链表法来说,只要散列函数的值随机均匀,即便装载因子变成10,也就是链表的长度变长了而已,虽然查找效率有所下降, 但是比起顺序查找还是快很多。

缺点:由于链表是需要存储指针的,所以存储较小的对象,是比较消耗内存的,甚至有可能会使内存翻倍。而且,因为链表中节点存储在内存中是零散的,对CPU缓存不友好,这方面对执行效率也是有一定影响的。

所以,我总结一下,基于链表的散列冲突处理方法比较适合存储大对象、大数据量的散列表,而且,比起开放寻址法,它更加灵活,支持更多的优化策略,比如用红黑树代替链表。

java HashMap实现简单分析

1,初始化大小

HashMap默认初始化大小为16,这个默认值是可以设置的,如果事先知道数据大概有多少,可以通过修改这个值,减少动态扩容次数,这样会大大提升HashMap的性能。

2,装载因子和动态扩容

最大装载因子默认为0.75,当HashMap中元素的个数超过0.75*散列表的容量时,就会进行动态扩容,每次扩容都是原来的两倍。

3,散列冲突解决方案

jdk1.7之前,HashMap底层采用链表来解决散列冲突。即使负载因子和散列函数设计得再合理,也免不了会出现拉链过长的情况,一旦出现拉链过长,则会严重影响HashMap的性能。

针对这情况,在jdk1.8版本中,对HashMap进行了优化,引入了红黑树。当链表长度太长(默认为8)时,链表就会转化为红黑树,利用红黑树快速增删改查的特点,提升HashMap的性能。当链表长度少于8个的时候,红黑树又会转换成链表,因为在数据量较少的情况下,红黑树需要维护树的平衡,比起链表来,性能上的优势不明显。

4,散列函数的设计

散列函数设计的并不复杂,如下:

static final int hash(Object var0) {
    int var1;
    return var0 == null ? 0 : (var1 = var0.hashCode()) ^ var1 >>> 16;
}

其中hashCode返回的是Java对象的hash code。

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值