HashMap存储原理
1.什么是hash冲突
常见解决方法:
哈希冲突(Hash Collision)发生在使用哈希函数将不同的输入(通常是键或数据项)映射到同一输出(哈希值)时。哈希函数设计的目的是将数据尽可能均匀地分布到一个有限的输出范围(如哈希表的槽位),但由于输出范围有限,而可能的输入可能无限多,因此不可避免地会发生冲突。
哈希冲突的例子
假设有一个哈希函数,它简单地计算一个字符串中所有字符的ASCII值之和,并取模10得到哈希表的索引。考虑以下两个字符串:
- 字符串 “abc” — ASCII值分别为 97, 98, 99,总和为 294。
- 字符串 “cba” — ASCII值分别为 99, 98, 97,总和为 294。
两个不同的字符串得到相同的总和294,哈希函数处理后(例如取模10),两者都映射到同一个索引上(294 % 10 = 4)。这就是一个哈希冲突的例子。
哈希冲突的处理方法
哈希冲突的处理是哈希表设计中的一个关键问题,有几种常见的解决策略:
1.链地址法(Separate Chaining):
- 在这种方法中,每个哈希表的槽位不直接存储数据项,而是链接到一个链表。
- 如果发生冲突,即两个数据项哈希到同一个槽位,那么它们都可以存储在同一个链表中。
- 查找、插入和删除的时间复杂度取决于链表的长度。
2.开放地址法(Open Addressing):
- 当一个数据项的哈希槽位已被占用时,开放地址法探索哈希表中的其他槽位,直到找到一个空槽位。
- 常见的探索方法有线性探测、二次探测和双重哈希。
- 这种方法的优点是不需要额外的存储空间,但是填充因子(表中已填充的部分)过高时,性能会下降。
3.再哈希法(Rehashing):
- 当冲突发生时,使用第二个、第三个等备用的哈希函数重新计算位置。
- 通过多个哈希函数减少冲突的概率。
2.Java中实现使用链表解决哈希冲突的哈希表
节点类(HashNode)
这个类用来表示哈希表中的一个节点,每个节点包含键、值和一个指向下一个节点的引用。
class HashNode<K, V> {
K key;
V value;
HashNode<K, V> next;
public HashNode(K key, V value) {
this.key = key;
this.value = value;
this.next = null;
}
}
哈希表类(MyHashMap)
这个类实现了基本的哈希表功能,包括插入、获取和计算哈希码。
class MyHashMap<K, V> {
private HashNode<K, V>[] buckets; // 哈希表数组
private int numBuckets; // 哈希表中桶的数量
private int size; // 哈希表中的键值对数量
public MyHashMap() {
buckets = new HashNode[10]; // 初始大小为10
numBuckets = 10;
size = 0;
}
private int getBucketIndex(K key) {
int hashCode = key.hashCode(); // 获取键的原生哈希码
int index = hashCode % numBuckets; // 使用模运算找到这个键应该在的桶的索引
return index < 0 ? index + numBuckets : index; // 确保索引为正数
}
public void put(K key, V value) {
int bucketIndex = getBucketIndex(key);
HashNode<K, V> head = buckets[bucketIndex];
while (head != null) {
if (head.key.equals(key)) {
head.value = value; // 如果键已存在,更新它的值
return;
}
head = head.next;
}
size++;
head = buckets[bucketIndex];
HashNode<K, V> newNode = new HashNode<K, V>(key, value); // 创建一个新的节点
newNode.next = head;
buckets[bucketIndex] = newNode; // 将新节点插入到链表的头部
}
public V get(K key) {
int bucketIndex = getBucketIndex(key); // 定位桶索引
HashNode<K, V> head = buckets[bucketIndex];
while (head != null) {
if (head.key.equals(key)) {
return head.value; // 返回找到的值
}
head = head.next;
}
return null; // 如果没有找到,返回null
}
public int size() {
return size;
}
}
示例使用
public class TestHashMap {
public static void main(String[] args) {
MyHashMap<String, Integer> map = new MyHashMap<>();
map.put("this", 1);
map.put("coder", 2);
map.put("this", 4); // 更新已有的键
map.put("hi", 5);
System.out.println(map.get("this")); // 输出 4
System.out.println(map.get("coder")); // 输出 2
System.out.println(map.get("hi")); // 输出 5
}
}
3.桶是什么
每个桶实际上可以是一个链表的头节点,所有具有相同哈希值的元素都链接在这个链表中。
哈希表通常是由一个数组实现的。每个数组元素称为一个“桶”,可以通过哈希函数和模运算来决定一个特定键(key)应该存储在哪个桶中。具体来说:
哈希函数:首先使用哈希函数将键转换成一个整数,这个整数称为哈希码。
模运算:然后,使用模运算(hashCode % numBuckets
)将这个哈希码转换成一个数组索引。这个索引指向数组中的一个位置,即一个桶。
4.扩容机制
扩容机制:HashMap中默认初始容量为16,默认负载因子为0.75。这里的容量是指Entry数组的长度,不是HashMap中总的元素数。当HashMap中Entry[]数组已使用容量达到负载因子*容量后,会调用resize()方法自动进行扩容,将容量扩大为原来的2倍,并重新计算元素在数组中的位置,然后将元素复制到新数组中。当数组长度到达64且链表长度大于8时,链表转为红黑树。
1)为什么负载因子设计为0.75?
设计负载因子主要是为了降低插入时哈希冲突的概率。如果负载因子设计大了,则容易发生哈希冲突降低使用效率,如果设计小了,则不能充分利用空间,0.75这个值是通过数学中的泊松分布计算出来的,在“冲突的概率”与“空间利用率”之间可以达到一个最好的平衡折中。
(2)为何HashMap的数组长度一定是2的次幂?
数组长度设计为2的次幂主要是为了优化取模运算,在最后计算存储下标时如果通过h % length取模来计算存储下标的话效率不高,如果数组长度为2的次幂,那么length-1的二进制的数值位就全为1,那么就可以像源码中那样通过与运算h & (length-1)来计算存储下标,效率更高。