数据结构之散列表

栏目: 数据库 · 发布时间: 5年前

内容简介:此文是数据结构与算法之美学习笔记散列表英文名叫“Hash Table”,也叫哈希表或者Hash表。它利用数组支持按照下标随机访问的特性,所以散列表其实是数组的一种扩展,由数组演化而来。通过散列函数,吧元素的键值映射为下标,然后把数据存储到数组中对应下标的位置。当我们按照键值查询元素的时候,使用同样的散列函数,将键值转化为数组的下标,然后向对应的下标位置去取数据。

此文是数据结构与算法之美学习笔记

散列表英文名叫“Hash Table”,也叫哈希表或者Hash表。它利用数组支持按照下标随机访问的特性,所以散列表其实是数组的一种扩展,由数组演化而来。

通过散列函数,吧元素的键值映射为下标,然后把数据存储到数组中对应下标的位置。当我们按照键值查询元素的时候,使用同样的散列函数,将键值转化为数组的下标,然后向对应的下标位置去取数据。

文中有个例子很好,比如有89名参赛选手,他们的编号是年级加班级加编号(从1到89)的样子比如060760,表示六年级7班60号。那怎么通过编号来快速查找某个选手的信息呢?因为最后两位编号是连续的,我们可以截取后两位来作为数组的下标,那信息放到数组的对应位置

比如上面的060760,我们通过一个散列函数截取出60,然后在数组的下标为60的位置把060760这个数据放进去

散列函数

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

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

(1)因为数组下标是从0开始的,所以散列函数计算得到的散列值是一个非负整数。

(2)同样的key经过散列函数得到的散列值应该是相同的,如果key1 = key2 那么hash(key1)=hash(key2)

(3)如果key1 != key2 那么hash(key1) !=hash(key2)。在真实情况下,想要找到一个不同的key对应的散列值都不一样的散列函数几乎不可能。就算MD5、SHA、CRC等哈希算法也不能避免。这叫做 散列冲突 ,因为数组的存储空间有限,这也会加大散列冲突的概率。

解决散列冲突的办法

常用的办法有两种,开发寻址法和链表法。

1.开放寻址法

如果出现了散列冲突,我们就重新探测一个空闲位置,将其插入。怎么重新探测新的位置呢?

(1)线性探测

当我们往散列表中 添加数据 的时候,如果某个数据经过散列函数处理之后,存储位置已经被占用了,我们就从当前位置开始,依次往后查找,看是否有空闲位置,直到找到为止。

当我们去散列表中 查找数据 的时候,通过散列函数算出要查找元素的键值对应的散列值,然后比较数组中下标为散列值的位置的的元素和要查找的元素,如果相等,则说明就是我们要查找的元素,否则就继续往后一次查找,如果遍历到数组为空的位置还没找到,说明要查找的元素不存在。

删除操作, 删除数据 的时候不能把对应的元素置空,因为上面的查找操作当查找到空闲位置的时候,就认为不存在这个数据,但是如果这个空闲位置是我们后来删除的,就会导致原来的查找算法失效了。我们可以将删除的元素特殊标记为deleted,线性探测的时候,遇到deleted的标记就继续往下。

(2)二次探测

跟线性探测很想,如果线性探测的每次探测的步长是1,二次探测的步长是原来的二次方。

线性探测:hash(key)+0,:hash(key)+1,:hash(key)+2,:hash(key)+3 …

二次探测::hash(key)+0,:hash(key)+1的平方,:hash(key)+2的平方,:hash(key)+3的平方 …

(3)双重散列

不止使用一个散列函数,先使用第一个散列函数,如果计算得到的位置已经被占用,在使用第二个函数计算依次类推直到找到空闲位置。

探测法存在很大的问题,当散列表中的数据越来越多的时候,散列冲突就越来越大。为了保证散列表的操作效率,一般情况下,我们尽可能保证散列表中有一定比例的空闲位置,用 装载因子 来表示空位的多少。装载因子约到表明空闲位置越少,冲突越多,散列表的性能会下降

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

2.链表法

链表法更加常用,相比寻址法更简单,数组中的每个位置都对应一个链表,所有散列值相同的元素,都放到对应的链表中。

插入操作,通过散列函数计算出对应的位置,将其插入到对应的链表中即可

查找和删除操作,通过散列函数找到对应的位置,然后遍历链表查找或者删除

如何设计散列函数

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

1.散列表的设计不能太复杂

过于复杂的散列函数,肯定会消耗更多的计算时间。也就影响到散列表的性能。

2.散列表的值尽可能随机并且均匀分布,这样才能尽可能的减少散列冲突,即便出现冲突,散列到每个槽里的数据也会比较平均,不会出现某个槽内的数据特别多的情况。

装载因子太大怎么办

装载因子越大说明散列表中的元素越多,空闲的位置越少,出现散列冲突的概率就越大,插入数据需要经过很多次的寻址或者拉很长的链,查找过程也会很慢。

这个时候可以进行动态扩容,重新申请一个更大的散列表,将数据版移到新的散列表中。如果我们申请一个原来散列表两倍大小的空间,新的散列表的装载因子就是原来的一半了。

装载因子超过某个阈值的时候就开始扩容,所以装载因子的阈值的设定需要选择得当,需要权衡时间和空间的复杂度,如果内存紧张,对执行效率要求很高,可以降低装载因子的阈值,反之可以增大装载因子的阈值。

如何避免低效的扩容

散列表插入一个数据是很快的,但是假如某个插入操作正好达到了装载因子的阈值,需要对散列表进行扩容,这时候需要先扩容在重新计算哈希值并搬运到新的表中,如果数据特别大的情况下,这是非常耗时的。比如我们用一个软件正常都是挺好的,突然又一次卡主的现象,这种体验很不好。

为了解决这种一次性扩容耗时过多的情况,我们可以将扩容操作穿插在插入操作的过程中分批完成。当装载因子达到某个阈值的时候,我们只申请新的空间并不把老的数据搬移到新的散列表中。

当在有新的数据插入的时候,把新的数据插入到新的散列表中,并从旧的表中拿出一个来放到新的表中,重复上面的操作,经过多次插入操作之后老的散列表中的数据就慢慢搬移到新表中了。这样就把统一扩容的时间代价均摊了。

在这期间,如果有查询操作,先从新表中查找,如果没有,在去老的表中查找。

怎么选择冲突的解决方法

上面说了实际开发中经常用到的两种解决冲突的方法,开放寻址法和链表法,比如 Java 中的LinkedHashMap使用了链表法,ThreadLocalMap使用了线性探测来解决冲突。他们各有优势,那怎么选择呢?

1.开放寻址法

优点:

散列表中的数据都存在数组中,可以有效的利用CPU的缓存加快查询速度,序列化起来也比较简单。列表法有指针序列化起来麻烦

缺点:

删除数据的时麻烦,需要标记为deleted,所有的数据都存在数组中,相对于链表冲突的代价更高,所以使用装载因子的上限不能太大。而且比链表法更浪费内存空间。

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

2.链表法

优点:

链表法对内存的利用率比寻址法高。因为链表节点可以在需要的时候在创建,不像寻址法那样实现申请好。

链表法对装载因子的容忍度更高,寻址法只能使用与装载因子小于1的情况,接近1的时候就可能有大量的散列冲突、从而导致大量的探测和在散列性能会下降。而链表法只要散列函数的值随机均匀就好,即使装载因子变成10,也就是链表的长度变的长了,查询虽然会有所下降但是相比于寻址法好很多

缺点:

因为链表需要存指针,所以对于比较小的对象的存储,是比较耗内存的。

因为链表中的节点是零散分部在内存中的,不是连续的,多以对CPU不友好,会影响一定的执行效率。

如果把链表换成一些高效的数据结构,如跳表,红黑树等,效率也会更高。

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

Java HashMap

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

2.最大装载因子默认是0.75,当HashMap中的元素个数超过0.75*capacity(容量)的时候就会启动扩容,每次扩大为原来的两倍

3.HashMap底层采用链表来解决冲突,即使负载因子和散列函数设计的再好也无法避免链表过长的情况,一旦链表过长会严重影响HashMap的性能。

所以在JDK1.8版本中,对HashMap做了优化,假如了红黑树,当链表长度太长默认超过8,链表就转为红黑树,可以利用红黑树快可快速增删查的特点提高HashMap的性能。当红黑树结点小于8的时候红黑树在转化为链表,因为在数据量较小的情况下,红黑树要维护平衡比起链表,性能提升并不明显。

4.散列函数的设计并不复杂,主球简单高效、均匀

int hash(Object key) {
    int h = key.hashCode();
    return (h ^ (h >>> 16)) & (capitity -1); //capicity 表示散列表的大小
}
//hashCode()返回的是java对象的hashcode,比如String的hashcode是下面
public int hashCode() {
  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;
}

5.get()方法:当我们调用 get() 方法,HashMap 会使用键对象的 hashcode 找到数组的对应位置,找到 位置之后,会调用 keys.equals() 方法去找到链表中正确的节点,最终找到要找的值对象。

LRU缓存淘汰算法

(1)通过链表实现LRU淘汰算法

维护一个按照访问时间从大到小的有序排列的链表结构,因为缓存的大小有限,当缓存空间不够的时候,直接将链表头部的节点删除。

当要缓存一个数据的时候,先去链表中查找是否有这个数据,如果没有就直接把数据放到链表的尾部,如果有就把它移动到链表的尾部。

从缓存中查找、添加、删除一个数据都会涉及到查找这个操作,由于查找数据需要遍历链表,所以单纯的使用链表实现的LRU缓存淘汰算法不是太高效。

(2)使用散列表和链表结合实现LRU淘汰算法

使用散列表和双向链表来实现。使用双向链表来存储数据,这里会有两种链

第一种是一个双向链表通过双向链表的前驱(prev)和后继(next)指针把所有的节点都串起来

第二种是散列表中为了解决散列冲突而形成的一个数据链,这时候给双向链表在加上一个后继指针(hnext),把散列表中的拉链串联起来。

Redis有序集合

在有序集合中,每个成员对象有两个重要的属性,key(键值),score(分值)。我们不仅会通过score来查找数据,也会通过key来查找数据

比如用户的积分排行榜,我们可以通过用户的id来查找积分信息,也可以通过积分区间来查找用户id和用户信息。用户id就是key,积分就是score

有序集合的操作一般有:

  • 添加一个成员对象
  • 按照键值删除一个成员对象
  • 按照键值查找一个成员对象
  • 按照分值区间查找数据
  • 按照分值从小到大 排序 成员变量

如果只是使用分值把成员组织成跳表结构,那么使用键值来删除、查询成员对象就会很慢。

我们可以按照键值构建一个散列表,这样按照key来删除、查找一个对象的效率就高了

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()函数,都会把数据添加到链表的尾部。

在执行第8行的时候,先查找这个键值是否已经有了,然后把(3,11)删除,并且把新的(3,26)放到链表的尾部。

在执行第9行的时候,把被访问的数据移动到链表的尾部。

其实其原理跟LRU缓存实现原理一样

LinkedHashMap是通过双向链表和散列表两种数据结构实现的。

为什么散列表和链表经常一块使用?

散列表虽然支持高效的插入、删除、查找操作,但是散列表中的数据都是通过散列函数打乱之后无规律存储的,无法支持按照某种顺序快速的遍历数据。如果想要按照顺序遍历散列表中的数据,需要把散列表中的数据拷贝到数组中,然后排序在遍历

散列表是动态的数据结构,会不停地插入、删除按顺序遍历散列表总的数据的时候,都需要先排序,那效率就低了,为了解决这个问题,我们把散列表和链表(或者跳表)结合起来使用。


以上就是本文的全部内容,希望本文的内容对大家的学习或者工作能带来一定的帮助,也希望大家多多支持 码农网

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

深入解析Spring MVC与Web Flow

深入解析Spring MVC与Web Flow

Seth Ladd、Darren Davison、Steven Devijver、Colin Yates / 徐哲、沈艳 / 人民邮电出版社 / 2008-11 / 49.00元

《深入解析Spring MVCgn Web Flow》是Spring MVC 和Web Flow 两个框架的权威指南,书中包括的技巧和提示可以让你从这个灵活的框架中汲取尽可能多的信息。书中包含了一些开发良好设计和解耦的Web 应用程序的最佳实践,介绍了Spring 框架中的Spring MVC 和Spring Web Flow,以及着重介绍利用Spring 框架和Spring MVC 编写Web ......一起来看看 《深入解析Spring MVC与Web Flow》 这本书的介绍吧!

HTML 编码/解码
HTML 编码/解码

HTML 编码/解码

MD5 加密
MD5 加密

MD5 加密工具

html转js在线工具
html转js在线工具

html转js在线工具