哈希表的实现
1、TreeMap分析
- 时间复杂度(平均)
- 添加、删除、搜索:O(logn)
- 特点
- Key必须具备可比较性
- 元素的分布是有顺序的
- 在实际应用中,很多时候的需求
- Map中存储的元素不需要讲究顺序
- Map中的Key不需要具备可比较性
- 不考虑顺序、不考虑Key的可比较性,Map有更好的实现方案,平均时间复杂度可以达到O(1)
- 那就是采取哈希表来实现Map
2、哈希表(Hash Table)
- 哈希表也叫作散列表
- 它是如何实现高效处理数据的?
- put("Jack",666)
- put("Rose",777)
- put("Kate",888)
- 添加、搜索、删除的流程都是类似的
- 利用哈希函数生成Key对应的index【O(1)】
- 根据index操作定位数组元素【O(1)】
- 哈希表是【空间换时间】的典型应用
- 哈希函数,也叫做散列函数
- 哈希表内部的数组元素,很多地方也叫Bucket(桶),整个数组叫Buckets或者Bucket Array
3、哈希冲突(Hash Collision)
- 哈希冲突也叫做哈希碰撞
- 2个不同的Key,经过哈希函数计算出相同的结果
- Key1 != key2,hash(Key1) = hash(key2)
- 解决哈希冲突的常见方式
- 开放定址法(Open Addressing)
- 按照一定规则向其他地址探测,知道遇到空桶
- 再哈希法(Re-Hashing)
- 设计多个哈希函数
- 链地址法(Separate Chaining)
- 比如通过链表将同一index的元素串起来
- 开放定址法(Open Addressing)
4、JDK1.8的哈希冲突解决方案
- 默认使用单向链表将元素串起来
- 在添加元素时,可能会由单向链表转为红黑树来存储元素
- 比如当哈希表容量>=64且单向链表的节点数量大于8时
- 当红黑树节点数量少到一定程度时,又会转为单向链表
- JDK1.8中的哈希表是使用链表+红黑树解决哈希冲突
5、哈希函数
-
哈希表中哈希函数的实现步骤大概如下
- 1.先生成Key的哈希值(必须是整数)
- 2.再让Key的哈希值跟数组的大小进行相关运算,生成一个索引值
Public int hash(Object key){ return hash_code(key) % table.length; }
-
为了提高效率,可以使用&位运算取代%运算【前提:将数组的长度设计为2的幂(2^n)】
Public int hash(Object key){ return hash_code(key) & (table.length - 1); }
-
良好的哈希函数
- 让哈希表更加均匀分布-->减少哈希冲突次数-->提升哈希表的性能
6、如何生成Key的哈希值
- key的常见类型种类可能有
- 整数、浮点数、字符串、自定义对象
- 不同种类的Key,哈希值的生成方式不一样,但目标是一致的
- 尽量让每个key的哈希值是唯一的
- 尽量让key的所有信息参与运算
- 在Java中,HashMap的key必须实现hashCode、equals方法,也允许key为null
- 整数
- 整数值当做哈希值
- 比如10的哈希值就是10
- 浮点数
- 将存储的二进制格式转为整数值
public static int hashCode(int value){
return value;
}
public static int hashCode(float value){
return floatToIntBits(value);
}
7、Long和Double的哈希值
public static int hashCode(long value){
return (int)(value ^ (value >>> 32));
}
public static int hashCode(double value){
long bits = doubleToLongBits(value);
return (int)(bits ^ (bits >>> 32));
}
>>>和^的作用是?
- 高32bit和低32bit混合计算出32bit的哈希值
- 充分利用所有信息计算出哈希值
8、字符串的哈希表
- 整数5489是如何计算出来的
- 5 ∗ 103 + 4 ∗ 102 + 8 ∗ 101 + 9 ∗ 100
- 字符串是由若干个字符组成的
- 比如字符串 jack,由 j、a、c、k 四个字符组成(字符的本质就是一个整数)
- 因此,jack 的哈希值可以表示为 j ∗ n 3 + a ∗ n 2 + c ∗ n 1 + k ∗ n 0,等价于 [ ( j ∗ n + a ) ∗ n + c ] ∗ n + k
- 在JDK中,乘数 n 为 31,为什么使用 31?
- 31 是一个奇素数,JVM会将 31 * i 优化成 (i << 5) – i
String string = "jack";
int hashCode = 0;
int len = string.length;
for(int i = 0; i < len; i++){
char c = string.charAt(i);
hashCode = 31 * hashCode + c;
}
String string = "jack";
int hashCode = 0;
int len = string.length;
for(int i = 0; i < len; i++){
char c = string.charAt(i);
hashCode = (hashCode << 5) - hasCode + c;
}
9、关于31的探讨
- 31 * i = (2^5 – 1) * i = i * 2^5 – i = (i << 5) – i
- 31不仅仅是符合2^n – 1,它是个奇素数(既是奇数,又是素数,也就是质数)
- 素数和其他数相乘的结果比其他方式更容易产成唯一性,减少哈希冲突
- 最终选择31是经过观测分布结果后的选择
10、自定义对象的哈希值
public class Person implements Comparable<Person> {
private int age; // 10 20
private float height; // 1.55 1.67
private String name; // "jack" "rose"
public Person(int age, float height, String name) {
this.age = age;
this.height = height;
this.name = name;
}
@Override
/**
* 用来比较2个对象是否相等
*/
public boolean equals(Object obj) {
// 内存地址
if (this == obj) return true;
if (obj == null || obj.getClass() != getClass()) return false;
// if (obj == null || !(obj instanceof Person)) return false;
// 比较成员变量
Person person = (Person) obj;
return person.age == age
&& person.height == height
&& (person.name == null ? name == null : person.name.equals(name));
}
@Override
public int hashCode() {
int hashCode = Integer.hashCode(age);
hashCode = hashCode * 31 + Float.hashCode(height);
hashCode = hashCode * 31 + (name != null ? name.hashCode() : 0);
return hashCode;
}
@Override
public int compareTo(Person o) {
return age - o.age;
}
}
11、自定义对象作为Key
- 自定义对象作为 key,最好同时重写 hashCode 、equals 方法
- equals :用以判断 2 个 key 是否为同一个 key
- 自反性:对于任何非 null 的 x,x.equals(x)必须返回true
- 对称性:对于任何非 null 的 x、y,如果 y.equals(x) 返回 true,x.equals(y) 必须返回 true
- 传递性:对于任何非 null 的 x、y、z,如果 x.equals(y)、y.equals(z) 返回 true,那么x.equals(z) 必须 返回 true
- 一致性:对于任何非 null 的 x、y,只要 equals 的比较操作在对象中所用的信息没有被修改,多次调用 x.equals(y) 就会一致地返回 true,或者一致地返回 false
- 对于任何非 null 的 x,x.equals(null) 必须返回 false
- hashCode :必须保证 equals 为 true 的 2 个 key 的哈希值一样
- 反过来 hashCode 相等的 key,不一定 equals 为 true
- equals :用以判断 2 个 key 是否为同一个 key
- 不重写 hashCode 方法只重写 equals 会有什么后果?
- 可能会导致 2 个 equals 为 true 的 key 同时存在哈希表中
12、哈希值的进一步处理”扰动计算
private int hash(K key){
if(key == null){
return 0;
}
int h = key.hashCode();
return (h ^ (h >>> 16)) & (table.length - 1);
}
13、装填因子
- 装填因子(Load Factor):节点总数量 / 哈希表桶数组长度,也叫做负载因子
- 在JDK1.8的HashMap中,如果装填因子超过0.75,就扩容为原来的2倍
14、TreeMap vs HashMap
- 何时选择TreeMap?
- 元素具备可比较性且要求升序遍历(按照元素从小到大)
- 何时选择HashMap?
- 无序遍历
15、LinkedHashMap
- 在HashMap的基础上维护元素的添加顺序,使得遍历的结果是遵从添加顺序的
- 删除度为2的节点node时
- 需要注意更换 node 与 前驱\后继节点 的连接位置
- 假设添加顺序是
- 37、21、31、41、97、95、52、42、83
LinkedHashMap – 删除注意点
- 删除度为2的节点node时(比如删除31)
- 需要注意更换 node 与 前驱\后继节点 的连接位置
16、LinkedHashMap – 更换节点的连接位置
// 交换prev
LinkedNode<K, V> tmp = node1.prev;
node1.prev = node2.prev;
node2.prev = tmp;
if (node1.prev == null) {
first = node1;
} else {
node1.prev.next = node1;
}
if (node2.prev == null) {
first = node2;
} else {
node2.prev.next = node2;
}
// 交换next
tmp = node1.next;
node1.next = node2.next;
node2.next = tmp;
if (node1.next == null) {
last = node1;
} else {
node1.next.prev = node1;
}
if (node2.next == null) {
last = node2;
} else {
node2.next.prev = node2;
}
17、关于使用%来计算索引
- 如果使用%来计算索引
- 建议把哈希表的长度设计为素数(质数)
- 可以大大减小哈希冲突
- 右边表格列出了不同数据规模对应的最佳素数,特点如下
- 每个素数略小于前一个素数的2倍
- 每个素数尽可能接近2的幂(2 n)