java基础 之 Hash家族_哈希冲突
文章目录
- 前言
- 入门小场景
- 哈希冲突
- 定义
- 出现冲突的原因
- 解决哈希冲突的办法
- 拉链法
- 开放地址法
- 再哈希法
- 哈希冲突的实现代码
- 拉链法
- 开放地址法
- 线性探测法
- 二次探测法
- 双重哈希法
- 再哈希法
前言
前文主要介绍了HashCode、HashMap、HashTable、ConcurrentHashMap和HashSet这五大行者,这期就往细里扒拉一下~
戳这里 → java基础 之 Hash家族_五行者
入门小场景
提到哈希表,那肯定得说哈希函数;说到哈希函数,不可避免的就是哈希冲突,那么他们是什么呢?接下来我们借用图书馆来想象一下 ~
- 哈希函数
图书管理员希望快速找到每本书,于是他设计了一个规则(哈希函数):根据书名的字母来决定把书放到哪个书架的哪个格子里。比如书名有5个字母的书就放在第五个书架的第五个格子,书名有6个字母的书就放在第六个书架的第六个格子(这里就是举例,拒绝较真哈)。这样管理员在找书时就很快了。
- 哈希冲突
但是问题来了。比如有两本书,一本叫《猫》,一本叫《狗》,书名都是3个字母。按照规则它们都会被放在第三个书架的第三个格子。但是每个格子只能放一本,放了一本后另一本怎么办?这就发生了冲突(哈希冲突)
- 解决哈希冲突
- 1、拉链法
管理员发现冲突后,想了个办法:在每个书架格子里挂一个小挂钩,如果有多本书被分配到同一个格子,就把它们都挂在同一个挂钩上。比如《猫》和《狗》都被分配到第3个书架的第3个格子,管理员就把它们都挂在同一个挂钩上。这样,虽然它们共享一个格子,但通过挂钩可以区分它们。在哈希表中,这种方法叫“拉链法”,每个格子后面挂一个链表来存储冲突的元素。
- 2、开放地址法
管理员还有另一种办法:如果一个格子被占用了,就找下一个空的格子。比如《猫》放在了第3个书架的第3个格子,而《狗》也被分配到这里,管理员就看看第3个书架的第4个格子是不是空的,如果空就放进去。如果第4个格子也被占了,就继续往后找,直到找到一个空的格子。这种方法叫“开放定址法”,意思是“如果这个地方满了,就找下一个空的地方”。
- 3、建立更大的书架(扩容)
如果书架格子太少,冲突太多,管理员也可以选择增加书架的格子数量。比如原来只有10个书架,每个书架有10个格子,现在增加到20个书架,每个书架有20个格子。这样,冲突的概率就会降低,因为格子更多了。在哈希表中,这叫“扩容”,也就是增加哈希表的大小。
- 1、拉链法
这么讲,是不是比较清晰呢?那我们言归正传,来吧
哈希冲突
定义
哈希冲突是指在哈希表中,两个或多个元素被映射到了同一个位置的情况。
映射到同一个位置的原因就是经过哈希函数的处理,得到了相同的哈希值。
出现冲突的原因
-
哈希函数设计不合理
如果哈希函数设计的不合理,那么生成的哈希值就会分布不均匀。例如取模运算,就会出现规律性的输入值就会有大概率映射到相近的哈希值
-
哈希表容量不足
当数据量远大于哈希表的容量时,冲突的概率会显著增加。例如,将10000个数据映射到长度为10的表中
-
负载因子过高
负载因子 = 已存储元素数 ➗ 哈希表长度,用来衡量哈希表的空间使用率
当负载因子过高时,哈希表总的空槽位减少,扩容不及时会增加冲突概率。
java中的hashMap默认表长为16,负载因子是0.75,也就是使用了75%容量时会触发扩容
解决哈希冲突的办法
拉链法
将冲突的元素存储在同一个哈希桶中。哈希桶通常使用链表、红黑树等数据结果
- 操作过程
- 查询
计算哈希值并找到对应的哈希桶,然后遍历桶内元素。如果找到相等的key就返回值,否则返回null;
- 插入
计算哈希值并找到对应的哈希桶,如果桶内存在相同的key,就更新值;没有就将元素插入桶中
- 删除
计算哈希值并找到对应的哈希桶,如果桶内存在相同的key,就删除该节点,并调整链表或树结构
- 查询
- 优缺点
- 优点
1、可以使用多种数据结构来优化查询效率,例如jdk1.8之后,当hashMap在哈希桶链表长度超过8且哈希表长度超过64时,链表会转换为红黑树
2、插入、删除操作简单,不需要移动其他元素 - 缺点
1、如果某个桶内链表过长,查询效率会降低
2、每个节点需要额外存储指针,增加内存开销
- 优点
开放地址法
将所有元素直接存储在哈希表的数组中。如果发生冲突,系统会根据探测策略查找下一个空槽位
- 常见的探测策略
-
线性探测
每次冲突后按照固定步长(通常为1)向后探测;可能导致冲突元素集中在相邻槽位中,称为一次聚集
index = (Hash(key)+i)%table_size,其中i为探测次数,i=0,1,2,3,4,5…
-
平方探测
每次冲突后按照平方递增的步长向后探测,减少一次聚集现象,但冲突元素会总会探测到相同槽位,称为二次聚集
index = (Hash(key) + i²) % table_size,其中 i 为探测次数, i² = 0,1,4,9 …
-
双重哈希
使用两个不同的哈希函数处理冲突,第一个哈希函数确定初始槽位,第二个哈希函数确定步长,减少聚集现象,但对哈希函数要求较高
index = (Hash1(key) + i * Hash2(key)) % table_size,其中 i 为探测次数
-
- 如何判断哈希表探测完成?
- 空槽位:探测到空槽位,说明该位置未被占用,可以停止探测。
- 已删除槽位:探测到已删除槽位,说明该位置曾经被使用过,后续可能还存在冲突元素,应继续探测。
- 回到起始槽位:表明所有位置已探测完毕。
- 操作过程
- 查询
计算哈希值确定初始槽位,按探测策略依次检查槽位,若找到目标 key,则返回值;否则返回 null。
- 插入
空槽位,插入,停止探测;非空槽位,若 key 相等更新值,否则继续探测
- 删除
找到 key 相等的目标后,把槽位标记为已删除,并停止探测
- 查询
- 优缺点
- 优点
1、实现简单,不需要额外的数据结构,所有数据都存储在哈希表中。
2、查询效率较高,直接访问数组。 - 缺点
1、冲突频繁时,探测效率会急剧下降。
2、扩容成本高,需要重新分配和移动所有元素
- 优点
再哈希法
使用多个独立的哈希函数处理冲突。冲突时,依次用不同的哈希函数来探测槽位
- 操作过程
- 查询
计算哈希值 hash = H1(key)定位槽位,若冲突,则尝试 H2(key),H3(key)… 依次类推
- 插入
计算哈希值 hash = H1(key)定位槽位,若冲突,则尝试 H2(key),H3(key)… 依次类推
- 删除
计算哈希值 hash = H1(key)定位槽位,若槽位中的 key 匹配,则标记为已删除;否则尝试下一个哈希函数
- 查询
- 优缺点
- 优点
哈希值分布更均匀,冲突概率低
- 缺点
1、实现复杂,需要设计多个独立的哈希函数。
2、性能依赖哈希函数的计算效率
- 优点
哈希冲突的实现代码
拉链法
public class Chaining {// 链地址法的基本思想是,每个槽包含一个链表,当冲突发生时,新的键值对被插入到链表中。private int size;private LinkedList<Integer>[] table;public Chaining(int size) {this.size = size;table = new LinkedList[size];for (int i = 0; i < size; i++) {table[i] = new LinkedList<Integer>();}}// 插入public void insert(int key) {int index = key % size;if (!table[index].contains(key)) {table[index].add(key);}else {System.out.println("Key " + key + " already exists.");}}// 查找public boolean search(int key) {int index = key % size;return table[index].contains(key);}
}
开放地址法
线性探测法
public class Linear {private int[] table;private int size;public Linear(int size) {this.size = size;table = new int[size];Arrays.fill(table,-1);}/*** 插入 */public void insert(int key){int hash = key%size;while (table[hash]!=-1){// 线性探测,容易出现堆积效应hash = (hash+1)%size;}table[hash] = key;}/*** 搜索 */public int search(int key){int hash = key%size;while (table[hash]!=-1){if (table[hash]==key) return hash;hash = (hash+1)%size;}return -1;}
}
二次探测法
public class LinearSquare {private int[] table;private int size;public LinearSquare(int size) {this.size = size;table = new int[size];Arrays.fill(table,-1);}/*** 插入 */public void insert(int key){int hash = key%size;int num = 0;while (table[hash]!=-1){// 平方探测法:按照二次方级别递增探测位置,避免堆积效应hash = (hash+num*num)%size;}table[hash] = key;}/*** 搜索 */public int search(int key){int hash = key%size;int num = 0;while (table[hash]!=-1){if (table[hash]==key) return hash;hash = (hash+num*num)%size;}return -1;}
}
双重哈希法
public class DoubleHashing {private int[] hashTable;private int size;public DoubleHashing(int size){this.size = size;hashTable = new int[size];Arrays.fill(hashTable,-1);}// 第一个哈希函数private int hash1(int key){return key % size;}// 第二个哈希函数private int hash2(int key){//①对关键字取模7,确保结果再0~6之间;//②7-(key % 7)保证最终结果为1~7,避免步长为0// 选择质数7是为了提高探测序列的分布均匀性,减少聚集现象;一般这个质数的选择取决于哈希表的sizereturn 7 - (key % 7);}public void insert(int key){int hash = hash1(key);int step = hash2(key);int num = 0;while (hashTable[hash]!=-1){hash = (hash+num*step)%size;}hashTable[hash] = key;}public int search(int key){int hash = hash1(key);int step = hash2(key);int num = 0;while (hashTable[hash]!=-1){if (hashTable[hash]==key){return hash;}hash = (hash+num*step)%size;}return -1;}
}
再哈希法
public class ReHashing {// 再哈希法的基本思想是,当哈希表达到一定装填因子时,重新计算一个新的哈希函数,将所有数据重新分配到更大的哈希表中private int[] table;private int size;private int length;private final double loadFactor = 0.75;public ReHashing(int size) {this.size = size;table = new int[size];Arrays.fill(table, -1);}// 插入public void insert(int key) {if (length >= size * loadFactor) {// 扩容int newSize = size * 2;int[] newTable = new int[newSize];Arrays.fill(newTable, -1);for (int i = 0; i < size; i++) {if (table[i] != -1) {int index = table[i] % newSize;while (newTable[index] != -1) {index = (index + 1) % newSize;// 线性探测法}newTable[index] = table[i];}}table = newTable;size = newSize;}}// 查找public boolean search(int key) {int index = key % size;while (table[index] != -1) {if (table[index] == key) return true;index = (index + 1) % size;}return false;}
}