数据结构之散列表

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

散列表英文名叫“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是通过双向链表和散列表两种数据结构实现的。

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

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

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

你可能感兴趣的:(数据结构与算法)