一、散列函数
散列函数的概念
散列函数常见的几种构造方法
二、哈希冲突
哈希冲突的概念
哈希冲突的常见解决办法
三、HashMap
HashMap的简单介绍
HashMap的几个特点
HashMap的两个关键因子
HashMap查找的时间复杂度分析
四、问题小结
HashMap中哈希函数的实现方式是什么?为什么要这样实现?
HashMap中哈希表的容量为什么一定要是2的整数次幂?
HashMap中hashcode()方法和equals()方法有什么用?
一、散列函数
1.散列函数的概念
散列函数,又称哈希函数。它是一种映射关系,根据数据元素的关键字key作为自变量,通过一定的函数关系,计算出该元素存储地址的值
表示为:address = H[key]
就是把任意长度的输入值通过哈希(散列)算法转换,得到固定长度的输出值,即哈希值。
著名的MD5和SHA1算法等等都是目前应用最广泛的hash算法。
2.散列函数常见的几种构造方法
哈希函数的构造方法有很多,总的一个原则就是要尽可能的减少冲突。
除留余数法
取关键字key被某一个p值取余而得到的值作为散列地址(散列值),其中p不大于散列表长度n
即 H(key)=key%p, p<=m
直接寻址法
取关键字key或关键字key的某个线性函数的值作为散列地址(散列值)
即 H(key) = key 或者 H(key) = a * key + b,其中 a 和 b 为常数
数字分析法
当关键字key的位数大于地址的位数,对key的各位分布进行分析,选出分布均匀的任意几位作为散列值
仅适用于所有关键字都已知的情况下,根据实际情况应用确定要选取的部分,尽量减少冲突的发生
平方取中法
先计算出关键字key值的平方,然后取平方值中间任意几位作为散列地址
折叠法(叠加法)
将关键字key分为位数相同的几部分,然后取这几部分的叠加和(舍去进位)作为散列地址
用于关键字位数较多,并且关键字中每一位上的数字分布大致均匀
随机数法
选择一个随机函数,把关键字key的随机函数值作为它的散列值
通常当关键字的长度不相等时使用这种方法
二、哈希冲突
1.哈希冲突的概念
不同的两个关键字key,因为选用哈希函数而计算出相同的哈希值,被映射到表的同一位置上,称为冲突或碰撞(collision)
发生冲突的两个关键字key称为该哈希函数的同义词(synonym)
2.哈希冲突的常见解决办法
拉链法(链接法)
将所有关键字key为同义词的结点链接在同一个单链表中。若选定的散列表长度为 m ,则可将散列表定义为一个由 m 个头指针组成的指针数组 T[0 .. m - 1]。凡是散列地址(散列值)为 i 的结点,均插入到以 T[i] 为头指针的单链表中。指针数组 T 中各分量的初值均应为空指针。
开放定址法
当冲突发生时,使用某种探测技术在散列表中寻找下一个空的散列地址,只要散列表足够大,空的散列地址总能找到。
根据形成探测序列的方法不同,将开放定址法区分为线性探查法,二次探查法,双重散列法等。
这里介绍一下线性探查法:
Hi = ( H(key) + i ) % m, 0 <= i <= m-1
探测时从地址d开始,首先探查T[d],然后依次探查T[d+1]…,直到T[m-1],然后又循环到T[0]、T[1],直到探测到有空余地址或者到T[d-1]为止。
三、HashMap
1.HashMap的简单介绍
HashMap是一个采用Hash表实现的键值对集合,继承自AbstractMap,实现了Map接口。
HashMap特殊的存储结构使得在获取指定元素时先经过哈希运算,得到目标元素在哈希表中的位置,然后再进行少量的比较即可获取到元素,因此,HashMap的查找效率较高。
在HashMap中,发生哈希冲突的时候,采用拉链法进行解决,因此HashMap的底层实现是数组+链表。
jdk1.8之后,HashMap中如果一个桶(bucket)中的元素个数超过TREEIFY_THRESHOLD(默认为8)时,就会使用红黑树来替换链表,提高查找速度,称作桶的树形化。
2.HashMap的几个特点
存储的元素是无序的,顺序也会不定时的改变;
底层实现是链表 + 数组,Java8之后加入了红黑树,大大提高了查找效率;
允许有null键和null值存在,但空键只有一个,且放在第一位,看源码可知;
插入、查找的时间复杂度基本为O(1),(前提是经过良好的哈希函数运算,使得元素分布在均匀的位置);
遍历整个Map需要的时间与桶(数组)的长度成正比,因此在初始化HashMap容量 (capacity) 时不宜太大;
key采用Set存放,因此想要做到key不允许重复,需要在key对应的类中重写 hashcode() 和 equals() 方法;
3.HashMap的两个关键因子
初始容量 ( DEFAULT_INITIAL_CAPACITY : 1<<4 ):默认初始容量为16,且必须是2的整数次方
加载因子 ( DEFAULT_LOAD_FACTOR : 0.75f ):默认加载因子的大小为0.75
加载因子太大,发生冲突的可能性会加大,导致查找的效率降低;太小的话会频繁rehash,又会导致性能降低。
这两个关键因子决定了HashMap应该什么时候扩容,而HashMap扩容又伴随着相当大的开销(重新创建数组,重新哈希计算,重新分配等等),因此在设置初始容量时,应该根据实际情况先考虑Map中有多少对键值对,设置合理的加载因子,尽可能的去避免扩容,提高效率。
4.HashMap查找的时间复杂度分析
在HashMap中,查找的时间复杂度O一般跟链表长度有关,因此哈希算法越好,元素分布的越均匀,get()方法就越快。
当HashMap中有大量的元素都存放到同一个桶中,这时候哈希表中只有一个桶,桶下有一条长长的链表,这个时候HashMap与单链表无异,遍历的时间复杂度又回到了O(n),完全失去了优势。
所以Java8以后,HashMap新增了红黑树 (红黑树的结构可以控制时间复杂度为O(logn)) ,优化了这种极端情况下的性能问题。
四、问题小结
1.HashMap中哈希函数的实现方式是什么?为什么要这样实现?
在HashMap的jdk1.8源码中,hash()方法是这样写的:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
这里同样解释了为什么HashMap允许有null键,因为当键为 null 时,返回了值为0。
通过将传入键的hashcode进行无符号右移16位,然后按位异或,得到该键的哈希值。(无符号右移,高位总补0)由于哈希表中的容量都是2的整数次幂,因此key的hashcode在很多情况下低位总是相同的,这大大加大了冲突的可能性,因此在jdk1.8以后进行了移位操作,将元素的hashcode和自己右移16位后的结果做异或运算,而int类型长度只有32位,因此无符号右移16位就相当于把自己的高位移到低位,然后再与自己的高位做异或运算,得到hash值,最后通过取模运算(n - 1) & hash得到下标值。
这样做可以避免只靠低位数据来计算hash值时导致的冲突,计算结果由高低位共同决定,极大的避免了哈希值分布不均匀,而且,采用位运算的效率非常高。
2.HashMap中哈希表的容量为什么一定要是2的整数次幂?
capacity为2的整数次幂,在计算桶的位置hashcode&(length-1)的时候,相当于hashcode%length,即对length取模,提高了计算效率;
capacity为2的整数次幂,那么capacity-1永远为奇数,则最后一位永远为1,这样就可以保证在进行hashcode&(length-1)计算的时候,得到的hash值的最后一位可能为1也可能为0(取决于hashcode最后一位),即运算完后的hash值既可能为偶数也可能为奇数,保证了散列的均匀性,否则,capacity为奇数的话,capacity-1最后一位永远为0,hashcode&(length-1)最后一位也必为0,即只能为偶数,这样任意的hashcode值只会被散列到数组的偶数下标上,浪费了近一半的空间,也大大增加了碰撞的几率。
3.HashMap中hashcode()方法和equals()方法有什么用?
当插入、查找时需要通过key的hashcode()的值进行hash()计算得到hash值,然后通过 hash&(length-1) 位运算得到下标,从而获得要找的桶的位置。
而当发生冲突时,利用key.equals()方法去链表或树中去找对应的节点。