散列表英文名叫“hash table”,我们平时也叫做哈希表或者hash表
散列表用的是数组支持下标随机访问数据的特性,所以散列表就是数组的一种扩展,由数组演化而来,可以说没有数组就没有散列表
用一个例子解释一下,假如有89位同学参加学校运动会,每位同学都有自己的编号,用6位数字来表示,例如051167,05表示年级,11表示班级,67表示排名。为了方便查找,该如何储存呢?
我们可以截取编号的后俩位,作为数组的下标,把同学的信息存入数组
这里就是典型的散列思想,其中参赛选手的编号我们叫做键key,用它来标识一位选手,我们吧参赛编号转换为数组下标的方法叫做散列函数(或者hash函数),我们散列函数计算得到的值叫做散列值(或者hash值)
我们可以得出一个规律,散列表就是用数组支持下标随机访问,时间复杂度为O(1)的特性,我们通过散列函数把数组的键计算为下标,然后把数据储存在对应的位置,取出时也是一样,通过散列函把值计算为下标,然后取出值
散列函数
从上面我们可以看出,散列函数起着重要的作用,那到底什么是散列函数呢
散列函数,顾名思义,他是一个函数,我们可以把它定义为hash(key),其中key表示他的键值,hash(key)表示经过散列函数计算后的散列值
那么上方的例子的散列函数该怎么写呢
int hash(String key) {
// 获取后两位字符
string lastTwoChars = key.substr(length-2, length);
// 将后两位字符转换为整数
int hashValue = convert lastTwoChas to int-type;
return hashValue;
}
上方的散列函数比较简单,如果学生的编号是随机生成的6位数,或者是a-z之间的字母,我们该如何构造散列函数呢?
散列函数有以下三点要求
1 散列函数计算后得到的值,为非负整数
2 如果key1==key2 那么 hash(key1)==hash(key2)
3 如果key1≠key2 那么hash(key1)≠hash(key2)
第一点比较好理解,因为作为数组下标只能用非负整数,第二点也好理解,key相同经过hash函数计算后的值也应该相同
第三点 这个要求看起来合情合理,但是想要找到一个不同key,对应散列值都不一样的散列函数,几乎不可能,即使业界非常有名的MD5,SHA等哈希算法,也不能完全避免散列冲突,而且数组空间有限,所以也会加大散列冲突的概率
散列冲突
既然散列冲突不可避免,那么该如何解决散列冲突呢?
链表法
链表法是一种常用的解决散列冲突的方法,在链表法中,每个槽,都会有对应的一条链表,所有散列值的相同的元素都会放到相同槽位的链表里。
当插入的时候,我们通过散列函数计算得出对应的槽位,直接插入链表中,时间复杂度为O(1),查找和删除同样通过散列函数,计算出槽位,在链表上进行查找和删除,那么查找和删除的时间复杂度为多少呢
实际上这俩个的时间复杂度和链表的长度K成正比,也就是O(K),理论上K=n/m,n表示数据的个数,m表示槽的个数
装载因子
当散列表内的空闲位置不多了,那么散列冲突的概率就会增加,为了尽可能保证散列表的效率,一般情况下,我们会保证散列表中有一定比例的空闲槽位,我么能用装载因子来表示空闲槽位的多少。
转载因子的计算公式是
散列表的装载因子 = 填入表中的元素个数 / 散列表的长度
装载因子越大,说明散列表内空余槽位越少,冲突越多,散列表的性能会下降,一般我们保证散列表的装载因子在0.7,大于0.7 就扩容
一个合格散列表应该满足下面条件
1 支持快速查询,插入,删除操作
2 内存占用合理,不能浪费过多内存
3 性能稳定,在极端情况下,也不能退化到不能接受的地步
如何设计一个散列函数
散列函数的好坏,决定了散列表冲突的概率大小,也决定了散列表的性能,那么什么才是好的散列函数呢?
首先,散列函数不能设计的太复杂,太复杂势必要耗费一些时间,也就间接影响到散列表的性能,散列函数生成的值尽量随机并均匀分布,这样才能避免或者最小化散列冲突,即使出现散列冲突也不会一个槽内数据特别多的情况
装载因子过大怎么办
装载因子过大,说明散列表中的元素越多,空闲位置越少,散列冲突就越大,这个时候我们需要动态扩容
针对数组的扩容比较简单,但是针对散列表的扩容就比较麻烦了,因为散列表的大小变了,数据的存储的位置也变了,所以我们要重新计算,每个元素的位置。
如何避免低效的扩容
假如我们有一个1GB的数据需要扩容,那么搬运1GB的数据,非常的耗费在时间,为了解决一次性扩容的耗费时间太多的情况,我们可以把扩容,分批完成,当装载因子过大,我们只申请新的空间,不去搬运数据,每当有新数据插入的时候,我们拿出一个老数据插入到新的空间,重复上面的过程,就一点点完成了搬运
我们分析一下java中的hashmap这样一个工业级的散列表是如何设计的
1 初始大小
hashmap的初始大小是16,这个值可以设置,如果提前知道数据大小,就可以,设置初始值,减少动态扩容,提高效率
装载因子和动态扩容
最大装载因子为0.75,每次扩容就会是之前的两倍大小
散列表解决冲突的办法
hashmap用了链表法解决hash冲突,因为即使负载因子和散列函数设计的在好,也会有链表过长的情况,当链表过长超过8时,就把链表换为红黑树,当红黑树结点小于8时换为链表,因为在数据量比较小的情况下,红黑树要维持平衡,比起链表性能并不是很明显
散列函数
hashmap的散列函数
int hash(Object key) {
int h = key.hashCode();
return (h ^ (h >>> 16)) & (capitity -1); //capicity 表示散列表的大小
}
其中hashcode就是java对象的hash code,比如string类型的hash code就是下面这样的
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;
}