散列表上
1.抛出一个思考题开课
word中单词拼写检查功能如何实现的?
学完今天的内容你就微软office工程师一样,轻松实现这个功能
2.散列表
散列表用的就是数组支持按照下标[随机访问]数据的特性,实现时间复杂度o(1),所以散列表其实就是数组的一种扩展。
3.散列冲突
我们常用的散列冲突解决方法有两类,开放寻址法(open addressing)和链表法(chaining)。
i.开放寻址法
线性探测(+1)
二次探测(+1^2)
双重散列(再次散列)
用装载因子来观测空位多少,装载因子越大,说明空闲位置越少,冲突越多,散列表的性能会下降。
ii.链表法
在散列表中,每个“桶(bucket)”或者“槽(slot)”会对应一条链表,所有散列值相同的元素都放到相同槽位对应的链表中。
3.开篇解答
散列表存储单词字典,然后散列查询,查不到可能拼写错误
常用的英文单词有 20 万个左右,假设单词的平均长度是 10 个字母,平均一个单词占用 10 个字节的内存空间,那 20 万英文单词大约占 2MB 的存储空间,就算放大 10 倍也就是 20MB。对于现在的计算机来说,这个大小完全可以放在内存里面。所以我们可以用散列表来存储整个英文单词词典。
4.小结
散列表两个核心问题是散列函数设计和散列冲突解决。散列冲突有两种常用的解决方法,开放寻址法和链表法。
5.课后思考题
假设我们有 10 万条 URL 访问日志,如何按照访问次数给 URL 排序?
解:url为key,访问次数为value,10万*100B=20MB
有两个字符串数组,每个数组大约有 10 万条字符串,如何快速找出两个数组中相同的字符串?
解:一个字符串做参考串做key,出现次数为value,第二个字符串做检验串,遍历字符串查key,若value不为0则存在相同
散列表中
1.散列表碰撞攻击
在极端情况下,恶意攻击者,通过精心构造的数据,使得所有的数都散列到同一个槽。如使用的是基于链表的冲突解决方法,散列表就会退化为链表,查询的时间复杂度就从 O(1) 急剧退化为 O(n)。这样就有可能因为查询操作消耗大量 CPU 或者线程资源,导致系统无法响应其他请求,从而达到拒绝服务攻击(DoS)的目的。这就是散列表碰撞攻击的基本原理(调虎离山)
2.如何设计工业级散列函数
工业级散列函数标准:
i.支持快速的查询、插入、删除操作;
ii.内存占用合理,不能浪费过多的内存空间;
iii.性能稳定,极端情况下,性能不会退到无法接受
如何实现这样一个散列表:
i.设计一个合适的散列函数;
ii.定义装载因子阈值,并且设计动态扩容策略;
iii.选择合适的散列冲突解决方法。
3.简单的随机函数
i.数据分析法
选择越随机的做key,可能重复的做value如下:
运动员编号做key其他信息做value
手机后四位做key前面各位做value
ii.进位相加
比如单词拼写检查,每位26个,
hash("nice")=(("n" - "a") * 26*26*26 + ("i" - "a")*26*26 + ("c" - "a")*26+ ("e"-"a")) / 78978
4.装载因子过大
静态数据集,容易设计散列函数,因为数据都是已知的
动态数据集,数据集合频繁变动,无法预估,有扩大和缩小两种
假设变大,数据集慢慢变大,装载因子也会变大,直到崩溃,借助前面所学的动态扩容,可以解决
i.数组动态扩容
假设原来装载因子0.8,现在扩容为原来2倍,装载因子变为0.4,然后数据搬移
ii.避免低效扩容
将新数据插入新散列表,并且从老的散列表中拿出一个数据放入到新散列表,一点点分批完成插入,查询就改为先查老表再查新表
假设变小,数据集慢慢变小,装载因子变小,如果内存吃紧,可以设置阀值动态缩容
5.冲突解决办法
数据量小、装载因子小,适合采用开放寻址法。这也是 Java 中的ThreadLocalMap使用开放寻址法解决散列冲突的原因。
大对象,大数据量,适合基于链表的散列冲突处理方法,比起开放寻址法,它更加灵活,支持更多的优化策略,比如用红黑树代替链表,比如,Java 中 LinkedHashMap 就采用了链表法解决冲突。
6.工业级散列表:java中HashMap
i.初试大小
HashMap 默认的初始大小是 16,如果事先知道大概数据量多大,可修改默认初始大小,减少动态扩容的次数,这会提高HashMap 的性能。
ii.装载因子和动态扩容
最大装载因子默认是 0.75,当 HashMap 中元素个数超过 0.75*capacity(capacity 表示散列表的容量)的时候,就会启动扩容,每次扩容都会扩容为原来的两倍大小。
iii.散列冲突解决办法
HashMap 底层采用链表法来解决冲突,免不了会出现拉链过长的情况,会严重影响 HashMap 的性能。在 JDK1.8 版本,引入了红黑树。而当链表长度太长(默认超过 8)时,链表就转换为红黑树。可以利用红黑树快速增删改查的特点,提高 HashMap 的性能。当红黑树结点个数少于 8 个的时候,又会将红黑树转化为链表。因为在数据量较小的情况下,红黑树要维护平衡,比起链表来,性能上的优势并不明显。
iiii.散列函数设计
int hash(Object key) {
int h = key.hashCode();
return (h ^ (h >>> 16)) & (capitity -1);
//capicity 表示散列表的大小
}
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;
}
散列表下
1.为何散列表和链表经常一起使用
链表实现插入删除o(1),散列表实现查询o(1)
比如LRU,跳表,LinkedHashMap
2.LRU缓存淘汰算法
在链表那一节中,借助散列表,可以把 LRU 缓存淘汰算法的时间复杂度降低为 O(1)。
思想:first in last out,一个链表,查询找到就放到末尾,查询不到插入也放到末尾(表示是最新加入的元素放最后删除),如果满存了,就删除头部元素
分析:三个操作:插入,删除,查询,最关键是查询o(n)如何让查询变为o(1),建立新的散列表(索引)来专门实现查询,原链表实现插入删除
实现:用双向链表存储数据,链表中7的每个结点处理存储数据(data)、前驱指针(prev)、后继指针(next)之外,还新增了一个特殊的字段 hnext,用来将结点串入到索引表链表中
注:这时每次结点都在两条链表中,一是双向链表中,二是索引表链表中
3.Redis有序集合
在有序集合中,每个成员对象有两个重要的属性,key(键值)和score(分值)。我们不仅会通过 score 来查找数据,还会通过 key 来查找数据。
i.添加一个成员对象;
ii.按照键值来删除一个成员对象;
iii.按照键值来查找一个成员对象;
iiii.按照分值区间查找数据,比如查找积分在 [100, 356] 之间的成员对象;
iiiii.按照分值从小到大排序成员变量;
解:i.ii.iii可以借助双向链表和索引表之链表法两个链表实现,iiii.iiiii可以用跳表实现,若要一起实现五个功能,肯定是两者合一,组成一个新的数据结构实现
4.Java中LinkedHashMap
LinkedHashMap 也是通过散列表和链表组合在一起实现的。实际上,它不仅支持按照插入顺序遍历数据,还支持按照访问顺序来遍历数据。
如:put(3,2)(1,10)(5,7)(2,9)
解:遍历结果是:3-1-5-7
说明是按照插入顺序遍历的的
如:put(3,2)(1,10)(5,7)(2,9)(3,25)get(5)
解:遍历结果是1-2-3-5
说明LinkedHashMap效果跟LRU一模一样,说明两者原理同,双向链表和散列表两表一起实现
注:LinkedHashMap 本身就是一个支持 LRU 缓存淘汰策略的缓存系统,Linked表示双向链表,HashMap表示链表法解决冲突,两者合并使用
5.课后思考
题一,为何使用双向链表不是单向链表?
因为单链表插入删除需分别定位到后一个位置,前一个位置,在操作,需要o(n),故使用双向链表
题二,10个对象,内容包括id和积分,需要实现
i.根据id快速查找,删除,插入积分信息
ii.积分在某个区间的所有id
iii.积分从小到大排序,查找任意区间[x,y]所有id