散列表
# 散列表基础
散列表用的是数组支持按照下标随机访问数据的特性,所以散列表其实就是数组的一种扩展,由数组演化而来。可以说,如果没有数组,就没有散列表。
散列函数(具体 参照散列表基础 文档)
- 设计原则
- 计算简单
- 散列地址分布均匀
- 直接定址法
- 数字分析法
- 平方取中法
- 折叠法
- 随机数法
- 除留余数法(最常用) ——————
HashMap、LinkedHashMap在计算哈希的时候就是用的这个方法
- 设计原则
散列冲突(具体 参照散列表基础 文档)
- 开放定址法(常用)
- 二次探测法
- 再散列函数法
- 公共溢出区法
- 链地址法——(常用)
- 将所有为同义词(发生冲突的元素)的记录存储在一个单链表中。
- ==这里也可以用 双链表、红黑树这种结构==
装载因子
- 散列表的装载因子=填入表中的元素个数/散列表的长度
- 装载因子越大,说明空闲位置越少,冲突越多,散列表的性能会下降。
问题思考
Word 文档中单词拼写检查功能是如何实现的?
常用的英文单词有 20 万个左右,假设单词的平均长度是 10 个字母,平均一个单词占用 10 个字节的内存空间,那 20 万英文单词大约占 2MB 的存储空间,就算放大 10 倍也就是 20MB。
对于现在的计算机来说,这个大小完全可以放在内存里面。所以我们可以用散列表来存储整个英文单词词典。
当用户输入某个英文单词时,我们拿用户输入的单词去散列表中查找。如果查到,则说明拼写正确;如果没有查到,则说明拼写可能有误,给予提示。
借助散列表这种数据结构,我们就可以轻松实现快速判断是否存在拼写错误。
# 设计工业级散列表
如何设计散列函数
- 散列函数的设计不能太复杂
- 散列函数生成的值要尽可能随机并且均匀分布
装载因子过大怎么办
动态扩容
假设原散列函数是用的除数留余法;
当扩容后,原散列表中的元素的位置会发生改变,需要将小散列表中的元素,按计算放到大散列表中。
==法1==——申请了大数组后,将原来的元素全部按散列结果搬移到大数组中。
均摊时间复杂度——O(1)
插入一个数据,最好情况下,不需要扩容,最好时间复杂度是 O(1)。
最坏情况下,散列表装载因子过高,启动扩容,我们需要重新申请内存空间,重新计算哈希位置,并且搬移数据,所以时间复杂度是 O(n)。
用摊还分析法,均摊情况下,时间复杂度接近最好情况,就是 O(1)。
如何避免低效扩容
法1的做法,太低效了,当数据过多时,十分的耗时。
如果我们的业务代码直接服务于用户,尽管大部分情况下,插入一个数据的操作都很快,但是,极个别非常慢的插入操作,也会让用户崩溃。这个时候,“一次性”扩容的机制就不合适了。
==法2==——我们可以将扩容操作穿插在插入操作的过程中,分批完成
当装载因子触达阈值之后,我们只申请新空间,但并不将老的数据搬移到新散列表中。
当有新数据要插入时,我们将新数据插入新散列表中,并且从老的散列表中拿出一个数据放入到新散列表。每次插入一个数据到散列表,我们都重复上面的过程。
经过多次插入操作之后,老的散列表中的数据就一点一点全部搬移到新散列表中了。这样没有了集中的一次性数据搬移,插入操作就都变得很快了。

混合散列表如何查询
对于查询操作,为了兼容了新、老散列表中的数据,我们先从新散列表中查找,如果没有找到,再去老的散列表中查找。
如何选择冲突解决方法
开放定址法——
ThreadLocalMap优势
散列表中的数据都存储在数组中,可以有效地利用 CPU 缓存加快查询速度。而且,这种方法实现的散列表,==序列化==起来比较简单。
劣势
用开放寻址法解决冲突的散列表,删除数据的时候比较麻烦,需要特殊标记已经删除掉的数据。
而且,在开放寻址法中,所有的数据都存储在一个数组中,比起链表法来说,冲突的代价更高。
所以,使用开放寻址法解决冲突的散列表,装载因子的上限不能太大。这也导致这种方法比链表法更浪费内存空间。
适用场景
当数据量比较小、装载因子小的时候,适合采用开放寻址法。
链地址法——
LinkedHashMap优势
- 链表法对内存的利用率比开放寻址法要高。因为链表结点可以在需要的时候再创建,并不需要像开放寻址法那样事先申请好。
- **链表法比起开放寻址法,对大装载因子的容忍度更高。**即便装载因子变成 10,也就是链表的长度变长了而已,虽然查找效率有所下降,但是比起顺序查找还是快很多。
劣势
1. 链表因为要存储指针,所以对于比较小的对象的存储,是比较消耗内存的,还有可能会让内存的消耗翻倍。(当然,如果我们存储的是大对象,也就是说要存储的对象的大小远远大于一个指针的大小(4 个字节或者 8 个字节),那链表中指针的内存消耗在大对象面前就可以忽略了。)
2. 因为链表中的结点是零散分布在内存中的,不是连续的,所以**对 CPU 缓存是不友好**的,这方面对于执行效率也有一定的影响。改进
我们将链表法中的链表改造为其他高效的动态数据结构,比如双链表、跳表、红黑树。这样,即便出现散列冲突,极端情况下,所有的数据都散列到同一个桶内,那最终退化成的散列表的查找时间也只不过是 O(logn)。这样也就有效避免了前面讲到的散列碰撞攻击。
适用场景
基于链表的散列冲突处理方法比较适合存储大对象、大数据量的散列表,而且,比起开放寻址法,它更加灵活,支持更多的优化策略,比如用红黑树代替链表。
如果设计一个工业级散列函数
- 特性
- 支持快速地查询、插入、删除操作;
- 内存占用合理,不能浪费过多的内存空间;
- 性能稳定,极端情况下,散列表的性能也不会退化到无法接受的情况。
- 如何实现——具体业务,具体数据,具体分析
- 设计一个合适的散列函数;
- 定义装载因子阈值,并且设计动态扩容策略;
- 选择合适的散列冲突解决方法
- ==没有最好的方法,只有最合适的方法==
- 特性
# Java HashMap 分析
初始大小
HashMap 默认的初始大小是 16
这个默认值是可以设置的,如果事先知道大概的数据量有多大,可以通过修改默认初始大小,减少动态扩容的次数,这样会大大提高 HashMap 的性能。
==大小设置为 2^n^==,如果你传入的数不是2^n^,在构造函数中,会调整到大于等于它的2^n^
//假设传入5=00000101 static final int tableSizeFor(int cap) { //这里减一是为了防止传入的就是2^n,处理结果会变成2^(n+1) int n = cap - 1; // >>> 是无符号右移的意思 //一直右移,直到n右移过后为0,保证n的后面几位全为1,即达成 (2^n)-1的目的 //这里是n和n右移一位取或,并赋值给n n |= n >>> 1;// 00000101 | 00000010 = 00000111==7 n |= n >>> 2;// 00000111 | 00000001 = 00000111==7 n |= n >>> 4;// 00000111 | 00000000 = 00000111==7 n |= n >>> 8;// 00000111 | 00000000 = 00000111==7 n |= n >>> 16;// 00000111 | 00000000 = 00000111==7 //n<0时返回1, //n>0时 当n不大于最大值时,返回n+1—————— (2^n)-1 + 1 =2^n //n>0时 当n大于最大值时,返回最大值 return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1; }
装载因子和动态扩容
最大装载因子默认是 0.75
当 HashMap 中元素个数超过 0.75*capacity(capacity 表示散列表的容量)的时候,就会启动扩容,每次扩容都会扩容为原来的两倍大小。
散列冲突解决方法
HashMap 底层采用链表法来解决冲突
而当链表长度太长(默认超过 8)时,链表就转换为红黑树。
- 当链表长度为>=8时,启用红黑树
- 当链表长度为<=6时,启用单链表
散列函数
散列函数的设计并不复杂,追求的是简单高效、分布均匀。
使用的是除数留余法
A % B = A & (B - 1),当B为2^n^时,等式生效。 设余数为C,商为D,即A%B=C,A=B*D+C 用9和11对4,8求余来说明
9=2*4+1===========1001 = 0010 * 0100 + 0001- 其中1001 的前两位(4-4/2=2)
10当作商。- 第一个4 是9的2进制有效长度
- 第二个4是除数4
- 后两位(4/2=2)
01是余数 - 而当B-1,4-1后,后面两位全是1,A&(B-1) 相当于取A的后两位的值 即
01=1
- 其中1001 的前两位(4-4/2=2)
11=1*8+3===========1011 = 0001 * 1000 + 0011- 其中1011 的前1位(4-8/2=1)
1当作商。 - 后两位(8/2=3)
011是余数 - 而当B-1,8-1后,后三位全是1,A&(B-1) 相当于取A的后三位的值 即
011=3
- 其中1011 的前1位(4-8/2=1)
异或补充
- 相同为0,相异为1
- 与0异或,都保存不变
- 与1异或,都取反
h ^ (h>>>16)int 是32位的
设
g=h>>>16,即g的高16位为0,低16位为原h的高16位x=g^h- ==x的高16位 是原h的高16位==,因为g的高16位全为0,与0异或,都保存不变
- ==x的低16位,即原h的高16位和低16位的异或值==
作用
- 为后续计算index截取低位,保证低位的随机性。
- 保证32位值,每一位都起作用。
// 散列函数 ==扰动函数+除数留余法--------- 将 key 与 散列表 位置 联系起来,即存储,或查找 // 将哈希值 对散列表(数组)大小 求余, 得到一个下标位置。然后将key值放入该下标位置 static final int getindex(Object key) { int h = key.hashCode();//就是获取对象的哈希码 return (h ^ (h >>> 16)) & (capacity -1); //capicity表示散列表的大小 } //JDK源码实现 分两步走 扰动函数+除数留余 // 扰动函数--哈希函数 //h = key.hashCode() ————就是获取对象的哈希码 //哈希码代表了对象的一种特征,用来区分不同的对象 // 返回 键 key 的哈希值 ——————这里是单纯的求哈希值 ,并没有涉及到存储 static final int hash(Object key) { int h; //这是是将 h和h右移16位后的值 做异或 //作用1--加强随机性 //作用2--且保证每一位值的作用 return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); } //在插入或查找的时候,计算Key被映射到桶的位置: //相当于 hash(key) % (capacity) //即除数留余法 int index = hash(key) & (capacity - 1)
# 散列表和链表的结合
LRU缓存淘汰算法(最近最少使用)
链表实现
实现方法
我们需要维护一个按照访问时间从大到小有序排列的链表结构。
从缓存中删除一个数据
因为缓存大小有限,当缓存空间不够,需要淘汰一个数据的时候,我们就直接将链表头部的结点删除。
往缓存中添加一个数据(查找)
当要缓存某个数据的时候,先在链表中查找这个数据。**如果没有找到,则直接将数据放到链表的尾部;**如果找到了,我们就把它移动到链表的尾部。
在缓存中查找一个数据
遍历链表,查找数据。
复杂度
因为查找数据需要遍历链表,所以单纯用链表实现的 LRU 缓存淘汰算法的时间复杂很高,是 O(n)。
总之,就是单链表的查找比较耗费时间。
链表+散列表实现
实现方法
- 利用散列表实现链表中元素的查找。
我们使用双向链表存储数据,链表中的每个结点处理存储数据(data)、前驱指针(prev)、后继指针(next)之外,还新增了一个特殊的字段 hnext。这个 hnext 有什么作用呢?
因为我们的散列表是通过链表法解决散列冲突的,所以每个结点会在两条链中。一个链是刚刚我们提到的双向链表,另一个链是散列表中的拉链。前驱和后继指针是为了将结点串在双向链表中,hnext 指针是为了将结点串在散列表的拉链中。
==说白了,hnext就是将有冲突的链表中的元素,串到一块的==
复杂度
散列表的查找时间复杂度为O(1),
散列表+双向链表可以使 查找,删除,添加 都为O(1)
Redis有序集合
- 细化一下Redis 有序集合的操作
- 1.添加一个成员对象;
- 2.按照键值来删除一个成员对象;
- 3.按照键值来查找一个成员对象;
- 4.按照分值区间查找数据,比如查找积分在[100, 356]之间的成员对象;
- 5.按照分值从小到大排序成员变量;
- 数据结构实现
- 将分值与成员对象组织成跳表,更好的实现操作4
- 按键值构建一个散列表,更好实现操作2,3,
- 细化一下Redis 有序集合的操作
Java LinkedHashMap
特性
一般的HashMap,通过散列函数后,原键值在数组(散列表)中会均匀的分布。即散列表中数据是经过散列函数打乱之后无规律存储的。
但是
LinkedHashMap借助链表+散列表,支持按插入插入顺序遍历数据,支持按插入时间遍历数据。按插入顺序访问
//LinkedHashMap //输出结果 3 1 5 2 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()); } //HashMap 的 访问 //1,17,2,3,5 按照散列值排序的,除数求余吧,除的是16---默认容量 HashMap<Integer, Integer> m1 = new HashMap<>(); m1.put(3, 11); m1.put(1, 12); m1.put(5, 23); m1.put(2, 22); m1.put(17, 22); //这里是map集合的遍历方式 // Set<Map.Entry<Integer, Integer>> entries = m.entrySet(); for (Map.Entry<Integer,Integer> e : m1.entrySet()) { System.out.println(e.getKey());//1,17,2,3,5 }
按插入时间访问
- 其实本身就是一个支持 LRU 缓存淘汰策略的缓存系统
// 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前4个插入

第5个插入——
m.put(3, 26);- 会先查找这个键值是否已经有了,然后,再将已经存在的 (3,11) 删除,并且将新的 (3,26) 放到链表的尾部。

第一个取值——
m.get(5);- 访问到 key 为 5 的数据的时候,我们将被访问到的数据移动到链表的尾部

为什么散列表和链表经常一块使用?
散列表这种动态数据数据结构虽然支持非常高效的数据插入、删除、查找操作,但是散列表中的数据都是通过散列函数打乱之后无规律存储的。也就说,它无法支持按照某种顺序快速地遍历数据。
因为散列表是动态数据结构,不停地有数据的插入、删除,所以每当我们希望按顺序遍历散列表中的数据的时候,都需要先排序,那效率势必会很低。
而链表则可以解决这个顺序遍历的问题。所以,我们将散列表和链表(或者跳表)结合在一起使用。
