哈希表
前面博客已经介绍过HashMap和HashSet,不太了解的朋友可以看看: HashMap和TreeMap
在聊哈希表之前我们先聊聊搜索
一、关于搜索相关的算法和数据结构
1、为什么搜索很重要
计算机中针对数据结构主要抽象出的是四个操作:增/删/改/查
其中查找的使用频率最高
2、搜索的通用模型是什么
在一组关键字集合中,找到指定关键字的过程
-
只需要找关键字------判断是否存在------纯Key模型
纯Key模型 HashSet TreeSet
-
同时找关键字相关联的数据-----Key-Value模型
Key-Value模型 HashMap TreeMap
3、场景分类
-
关键字集合基本不变。
最适合算法:二分查找(前提:数据有序)
-
关键字变化比较频繁
两类数据结构实现 哈希表 vs 平衡搜索树
二、哈希表
1、哈希表查找
前提:利用数组 下标访问元素时间复杂度为O(1)
哈希表的整体思路:把一个很大的keySet 的查找过程,转换为很多个小 keyset 的查找过程
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传
那么问题来了怎么通过key来的得到需要放的下标呢?
这里面我们需要先注意一个问题,key可以为任意的类型,但是我们想要得到的是下标是 int 类型,这个时候就需要用到哈希函数来帮助我们,相同的key通过哈希函数的计算会得到相同的 int 类型的数字(哈希值)。
这个时候又有可能出现问题了,因为keyset 中的 key 数量 是远远大于数组的长度,这样的话,不同的key,经过哈希函数的计算就会得到相同的hash值 (名为冲突)(就是上图中的小集合),这个时候应该怎么办?
-
你肯定想到了数组扩容,这个是不现实的,因为太浪费空间了
-
那应该怎么办?答案是冲突是不可避免的,因为 把 M 个 数 ,放到 N 个下标中(M 远大于 N),所以肯定会冲突
-
那肿么办?我们能做的只是减少冲突的发生,使冲突呈现一个比较好的形态,那么问题又来了怎么减少冲突呢?
-
冲突就是我们上面所说的小集合,我们想要减少冲突,那么我们就需要设计一个比较好的哈希函数,使得下标尽可能的均匀
-
这个时候我们需要引进来几个概念
-
冲突率 = 插入一个新的 key ,会遇到冲突的概率
-
负载因子 = 所有 key 的数量 / 数组的长度
-
-
知道了前面这几个概念,我们就可以来想想怎么减少冲突?我们可以通过把负载因子控制在一个阈值范围内来达到目的。即通过减少负载因子来减少冲突率。具体的做法是,当负载因子超过某个阈值的时候,我们就增加数组的长度(在Java中,默认的负载因子为 0.75 ,又名扩容因子)
-
扩容后数组的长度必须为 2!(数组的长度一直都应该2!)(原因后面讲)
-
但是这个只是解决“怎么减少冲突”,但是根据阈值来看,冲突还是会发生的,那么发生了冲突应该怎么办?这里提供两个方案,其实方案很多。
- 数组内部解决(闭散列)-----有个印象即可
- 另起炉灶,把所有的冲突 key 放到另外的集合
- 可以使用链表 ---- 我们认为冲突的 key 不会太多(HashMap实现JDK1.7:数组+链表)
- 红黑树----冲突多了(HashMap实现JDK1.8:数组+链表+红黑树)
2、聊聊put的过程
-
通过 key 得到一个下标(index)
-
如果key为自定义类型,需要通过key得到hash值,必须重写hashCode()。当两个对象相同时,得到的哈希值也应该相同,则需要重写equals()方法
-
哈希值比一定是在[0,array.length)里面,即不一定是合法的下标
-
这时候我们就会想到可以采用 hash % array.length ,这样就可以得到合法的下标,但是Java没有这样子,因为这样操作相对比较慢
-
Java采取另一中方式,这种方式需要满足一个前提 array.length 一定是 2 的 n 次方
int index = (array.length - 1) & hash
上面 这种方式,导致 hash 中真正被用到的只有后面 4 bit,没有把全部的bit 全部用上,所以会导致下标不均匀。
Java这个时候就多做了一件事
-
hash = (hash >>> 16) ^ hash
index = (array.length - 1) & hash
利用这种方式使得小标均匀
-
-
-
因为Java内部是用拉链法解决冲突的,用下标,只能找到对应的小集合即可(链表)
利用数组下标访问是O(1)的特性
-
在小集合中查找对应的 key 所在的节点
3、聊聊HashMap的树化过程
为什么需要树化?
这个本质上,是一个安全问题。因为在元素放置过程中,如果一个对象哈希冲突,都被放置到同一个桶中,则会形成一个链表。而链表查询时线性的,会严重影响存取的性能。
在现实生活中,可能在某个index位置处,key过多的可能性
原因:key 的分布不符合理想的分布。(理想情况下, key 的数量巨大时,都是符合高斯分布(正态分布))
比如,黑客知道了哈希函数,就会构造一组 key ,必然发生冲突。这就导致了,在某一下标处,链表的长 度特别的长,这就违背了哈希表的最大思想----把大数据集的查找转换为小数据集的查找.
怎么解决呢?再次使用查找用的数据结构(哈希表,搜索树)上去
说明:
- 树的情况时非常少的
- 树化是需要阈值的,选择了8 ,因为泊松分布。
4、自己实现HashMap
Map接口:
package map;
public interface Map<K,V> {
V get(K key);
V put(K key ,V value);
}
HashMap类:
package map;
public class HashMap<K,V> implements Map<K,V> {
private static class Entry<K ,V> {
K key;
V value;
Entry<K,V> next;
public Entry(K key, V value) {
this.key = key;
this.value = value;
}
}
// 16 就是2 的 n 次方
private Entry<K,V>[] table = new Entry[16];
private int size;
private static final double LOAD_FACTOR_THRESHOLD = 0.75;
@Override
public V get(K key) {
int hash = key.hashCode();
hash = (hash >>> 16) ^ hash;
int index = (table.length - 1) & hash;
// 得到的是头结点
Entry<K,V> head = table[index];
// 在链表中查找
Entry<K,V> node = head;
while (node != null) {
if(key.equals(node.key)) {
return node.value;
}
node = node.next;
}
return null;
}
@Override
public V put(K key, V value) {
int hash = key.hashCode();
hash = (hash >>> 16) ^ hash;
int index = (table.length - 1) & hash;
Entry<K,V> head = table[index];
Entry<K,V> node = head;
// 在链表中查
while (node != null) {
if(key.equals(node.key)) {
V oldValue = node.value;
node.value = value;
return oldValue;
}
node = node.next;
}
// 没有找到,所以插入节点
Entry<K,V> newNode = new Entry<>(key,value);
// 头插和尾插都可以
// 尾插
if(head == null) {
table[index] = newNode;
}else {
Entry<K,V> last = head;
while (last.next != null) {
last = last.next;
}
last.next = newNode;
}
size ++;
// 通过调整负载因子,来控制冲突
if((double) size / table.length >= LOAD_FACTOR_THRESHOLD) {
// 扩容
resize();
}
return null;
}
/*
* 需要把所有的 key 重新计算 hash,重新插入
*/
private void resize() {
// 保证新的长度也是 2 的 n 次方
Entry<K,V>[] newTable = new Entry[table.length * 2];
// 遍历所有的 key
// 首先遍历所有的下标位置,找到一条条的链表
// 在次遍历每个链表,找到一个个的 key
for (int i = 0; i < table.length ; i++) {
Entry<K,V> node = table[i];
while (node != null) {
// 为了简便,重新创建节点
Entry<K,V> newNode = new Entry<>(node.key,node.value);
int hash = node.hashCode();
hash = (hash >>> 16) ^ hash;
int index = hash & (newTable.length - 1); // 这里是新数组
// 使用头插,简单一点
newNode.next = newTable[index];
newTable[index] = newNode;
node = node.next;
}
}
}
}
总结:
- put的过程
- 计算出 key 的 hash值
- 找出 key 所在数组的下标index
- 在链表中查找,如果有这个 key 已经存在了,那么就替换原来的 value 值
- 如果不存在,那么就插入节点
- 判断 负载因子 和 0.75 的大小关系,如果负载因子 >= 0.75,需要扩容
- 创建新的数组newTable,长度为原来数组的2倍
- 遍历数组的所有下标位置,再遍历所有下标位置的链表,重新计算 hash值,重新做插入
- get的过程
- 计算出 key 的 hash值
- 找出 key 所在数组的下标index
- 找的数组下标index的链表,如果没有返回null,表示没找到
- 如果有,遍历这条链表,返回 key 所对应 的 value