数据结构 - 散列表(Hash表)

目录

散列表的时间复杂度和由来

Hash算法冲突解决方法

1、开放寻址法

2、拉链法

如何实现工业级的散列表?

1、工业级散列表的要求?

2、如何实现?


散列表的时间复杂度和由来

    在开始散列表数据结构之前先熟悉一下数组和链表的时间复杂度,如下图。那么有没有一种数据结构可以满足新增和修改的时间复杂度都小于O(n)呢?那就是散列表,并且散列表在Java中随处可见;K-V数据库redis中字典也使用了散列表。

         

    散列表也叫Hash表,其思想利用了数组根据下标进行查询时,时间复杂度为 O(1),具体根据下标计算对应内存地址的过程可以参考数据结构 - 数组。其数据结构特点就是K-V(key、value)关系,或者也可以理解成数据库的等值查询【select V from table where id = K】,并且等值查询占了数据库查询很大部分需求。根据key计算的过程叫做Hash函数(Hash算法),需要满足下面的条件:
1、根据key得到一个大于等于0的整数值,对应数组的下标。
    至于采用哪种hash算法由需求本身决定,算法比较复杂那么对应的计算时间复杂度也会更高。也需要根据数据特点进行决定,比如只是对字母数字进行计算那么完全可以使用ASCII叠加 余上数组长度等。
2、key1 = key2,那么hash(key1) = hash(key2), 即相同的字符串经过两次计算得到的值相等。
3、key1 != key2,尽量满足 hash(key1) != hash(key2)

    散列表读、写的时间复杂度近似O(1);当计算完成hash函数找到的下标就是对应要读写的位置(或是链的第一位),所以散列表的最好时间复杂度是O(1); 极端清下所有hash函数计算完成都在同一个下标位置,最坏情况拉链法退化成一个单向链表,开放寻址法退化成一个数组,即退化成读或写整个链表或者数组长度,所以最坏时间复杂度是O(n),  一般情况下平均时间复杂度都接近于最好时间复杂度就是O(1)。

    所以hash函数的设计、以及hash碰撞的解决是性能好坏的关键,hash算法必然发生碰撞,这个论证过程这里不研究。而发生hash碰撞有个很重要的衡量因素就是当前数组中剩余空闲的下标位置,称为装载因子(load factor)。

装载因子 = 数组被占用的位置个数 / 数组的长度。所以装载因子越大则说明整个数组剩余的空位数越少,发送hash碰撞的概率越大。

    散列表是基于数组实现的,而数组本身的特点就是创建时就确定了大小,那么当数据量不断增加出发默认设置的装载因子阈值的时候就需要进行扩容,需要创建更大的数组(一般是1.5倍、或者2倍),并且将原数据遍历取出进行hash计算,添加到新数组中,之前分析过这种情况可以使用扩容类型的均摊时间复杂度,即新增的时间复杂度也是接近最好时间复杂度O(1)。

当发生碰撞时,怎么解决呢?常用的解决方法有:开放寻址法、链表法。

Hash算法冲突解决方法

1、开放寻址法

    开放寻址法就是发现该下标已经存储了数据,则寻找数组的下一个下标位置判断是否为空,一直到可以写入数据(读取的原理一样)。这种挨个探测可用位置的方式叫线性探测,但是在数组装载因子非常大接近阈值的时候,发生Hash碰撞的概率非常大,优化的方法就是二次探测或者双重探测:

二次探测【hash算法只运行一次】:int index = hash(key) ;

    第一次:current_index = index + 0; 第二次:current_index = index + 1²; 第三次: current_index = index + 2²; 第N次:current_index = index + 2ⁿ;

双重探测【hash算法至少运行一次】:定义了一系列的hash函数【hash1、hash2、... 、hashn】

    第一次:hash1(key); 第二次:hash2(key);第N次:hashn(key);

    开放寻址法实现比较简单,但是存储数据不能太多,否则发生碰撞的概率还是非常大(因为所有元素都会占用一个数组下标)。Java中的ThreadLocal底层使用ThreadLocalMap进行存储(具体结构可以参考:并发编程模式 - ThreadLocal源码和图文分析),由于每个线程池中可能存放的最多元素个数就是程序中创建的 ThreadLocal对象个数,所以ThreadLocalMap使用了开放寻址法。

2、拉链法

    拉链法比较常用的解决hash碰撞的方式,一般使用头插法添加数据,Java中的HashMap(LinkedHashMap也使用了散列表,只是比较特殊,后面专门分析其源码)就是使用了拉链的方式解决Hash碰撞。只是当链表的长度比较大时,整个性能下降比较快速,jdk8中在链表长度超过 8 时从链表转换为红黑树,即在下标内的时间复杂度从单链表的O(N) 降低为红黑树的 O(logN),而当元素个数少于 6 个时从红黑树转换为单链表。 由于数据比较少时红黑树旋转,节点存储更多的指针等消耗也比较大,性能并不高,所以数据少时使用了单链表。

 

    HashMap的开发者在开发时并不知道使用它的人会添加多大量级的数据;Redis 作为K-V关系型数据库,存储的数据量非常庞大,处理请求的 tps 可能以万为单位,并且要求的延时非常低,而全局的key(Redis字典)就是 散列表进行存储的。如果我们要实现一个工业级的散列表,需要注意些什么呢?

如何实现工业级的散列表?

1、工业级散列表的要求?

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

2、如何实现?

1)、散列函数的设计

    应该针对数据的特点,并且设计不能太复杂(hash计算的时间复杂度要尽量低),生成的值要尽可能随机并且均匀。

2)、选择合适的hash冲突解决方法

    选择开发寻址法还是链表法,当数据量再增大的时候,再怎么进行优化,上面已经分析的比较清楚了。

3)、高效扩容机制

    针对使用场景的特点,设置合适的默认装载因子值,HashMap中值为 0.75,必要时可以将值设置为 大于1。

    当数据存储越来多装载因子过大时,需要进行动态扩容,此时需要评估性能影响。对于 HashMap使用阻塞扩容的方式,个人理解因为我们使用其开发时,大多数情况下数据规模本身可控,并且我们可以修改默认的初始化长度。而针对Redis,数据量庞大,并且要求低延迟。很多时候不仅仅是平均延迟低,更可能要求 Tp值也在一定范围内, 而且Redis使用了单线程处理核心数据的处理,更不可能使用阻塞扩容的方式。Redis在扩容时,在申请完更大的数组后,同时维护了两个数组,每次只搬运一个(或者也可以固定极少个数),也称为渐进式rehash,将一次要搬运的数据均摊到多次的操作中。

 

 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值