前言
数组是我们平时常见的并且经常使用的一种数据结构,那么它具有什么优点呢?我们都知道,在我们知道数组中某元素的下标值时,我们可以通过下标值的方式获取到数组中对应的元素,这种获取元素的速度是非常快的
但是呢,数组也是有一定的缺点的,如果我们不知道某个元素的下标值,而只是知道该元素在数组中,这时我们想要获取该元素就只能对数组进行线性查找,即从头开始遍历,这样的效率是非常低的,时间复杂度为 O(N)
链表中查找对应元素也是如此,需要从头开始遍历整个链表,时间复杂度为 O(N)
但是有一种数据结构,它的查找效率达到了惊人的 O(1) ,它就是 -- 哈希表
哈希表的物理结构:
哈希表像是数组与链表的结合体:用一串数组存储元素的映射位置,然后再该位置下再用链表将相同映射值的元素链接在一起
这样我们在找指定元素时,例如要找 11 ,首先找到数组的映射位置 1,在从 1 这个位置遍历链表找到我们的 11
也许你会问,如果要存储的元素很多,链表的长度就会很长,那么哈希表是否还能进行高效查找呢?
答:哈希表内部设置了一个负载因子,当达到一定条件,我们的数组就会扩容,到时候映射位置就会更新,这样能容纳的元素就更多了,相反链表的长度也就更短了
如果你还想了解哈希表详细内容,请看以下内容:
✨上期回顾:Java 深浅拷贝
✨本篇文章所涉及的代码已上传至码云:哈希表的实现
目录
哈希表的介绍
哈希表的 key 与 vaule
哈希表又称散列表,它通过建立键 key 与值 value 之间的映射,实现高效的元素查询。例如:我们向哈希表中输入一个键 key ,则可以在 O(1) 时间内获取对应的值 value
具体而言:就是把 key 通过哈希函数转换成一个整型数字,然后就将该数字对数组长度进行取余,取余结果就当作数组的下标,将 value 存储在以该数字为下标的数组空间里
当使用哈希表进行查询的时候,就是再次使用哈希函数将 key 转换为对应的数组下标,并定位到该空间获取 value ,如此一来,就可以充分利用到数组的定位性能进行数据定位
哈希表的优劣
哈希表是一种拿空间换时间的数据结构:它把数据的存储和查找消耗的时间大大降低,几乎可以看成是常数时间;而代价仅仅是消耗比较多的内存。然而在当前可利用内存越来越多的情况下,用空间换时间的做法是值得的。另外,编码比较容易也是它的特点之一。 哈希表又叫做散列表,分为 “开散列” 和 “闭散列”
哈希冲突
哈希冲突的概念
在计算哈希地址的过程中会出现对于不同的关键字出现相同的哈希地址的情况,即 key1 ≠ key2 ,但是 f(key1) = f(key2) ,这种情况就是哈希冲突。具有相同关键字的 key1 和 key2 称之为同义词
这里举个例子 ~
例子一:
我们 key1、key2 通过哈希函数获取到的是同一个哈希地址,该怎么 存储 或者 区别它们的位置就叫做 “ 哈希冲突 ”
例子二:
我们的汉字博大精深:如果我们通过拼音去查字拼音,而如上图:我们的雪与学拼音都是一样的。也就是说通过关键字学和关键字雪拼音可以映射到一样的字典页码的位置,这就是哈希冲突
如何解决哈希冲突
哈希冲突是无可避免的,因为如果要完全避开这种情况,只能每个哈希地址都扩容一次数组,然后才能保证每个 key 在索引里面都有对应的映射,这就可以避免冲突。但是会导致空间增大,这样是非常不划算的 ~
既然无法避免,就只能尽量减少冲突带来的损失:
冲突处理分为以下四种方式:
- 开放地址
- 再哈希
- 链地址
- 建立公共溢出区
本章将会详细介绍 链地址 的处理方法。链地址法:首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。我们将这种方式称之为:哈希桶。形象点说就像是在出现冲突的地方直接把后续的值以 尾插 或者 头插 的方式新增
我们可以理解为:是把一个在大集合中的搜索问题转化为在小集合中做搜索
冲突严重时的解决办法
哈希桶其实可以看作将大集合的搜索问题转化为小集合的搜索问题,那如果冲突严重,就意味小集合的搜索性能其实也时不佳的,这个时候我们就可以将这个所谓的小集合搜索问题继续进行转化,例如:
每个桶的背后是另一个哈希表 每个桶的背后是一棵搜索树
负载因子
负载因子的概念
哈希表的负载因子(load factor),也叫做扩容因子和装载因子,它是哈希表在进行扩容时的一个阈值,当哈希表中的元素个数超过了容量乘以负载因子时,就会进行扩容。默认的负载因子是 0.75,也就是说当哈希表中的元素个数超过了容量的 75% 时,就会进行扩容
负载因子的计算
a = 填入表中的元素个数 / 散列表的长度
a 是散列表装满程度的标志因子。由于表长是定值,a 与 “填入表中的元素个数” 成正比,所以,a越大,表明填入表中的元素越多,产生冲突的可能性就越大;反之,越小,标明填入表中的元素越少,产生冲突的可能性就越小
对于链地址法,荷载因子是特别重要因素,应严格限制在0.7 - 0.8以下。超过 0.8,查表时的CPU缓存不命中按照指数曲线上升。因此,一些采用开放定址法的 hash 库,如 Java 的系统库限制了荷载因子为0.75,超过此值将resize散列表
所以当冲突率达到一个无法忍受的程度时,我们需要通过降低负载因子来变相的降低冲突率,如何降低呢? -- 这就需要我们进行扩容了
负载因子的面试三问
什么是负载因子? -- 用来衡量哈希表已使用空间与总空间的比例,它直接影响哈希表的性能
为什么要进行扩容? -- 扩容的目的是为了减少哈希冲突,提高哈希表性能的
为什么默认负载因子是 0.75? -- 负载因子太低会导致大量的空桶浪费空间,负载因子太高会导致大量的碰撞,降低性能。0.75 的负载因子在这两个因素之间取得了良好的平衡
哈希表的模拟实现
哈希表的定义
public class HashBucket<K,V> {
// 设置内部类存储哈希属性
private class HashNode<K,V>{
// 设置 key 下标表示插入数据的映射位置
private K key;
// 设置 val 表示插入元素
private V value;
private HashNode<K,V> next;
public HashNode(K key, V value){
this.key = key;
this.value = value;
}
}
private int size;
private static final int DEFAULT_SIZE = 10;
private static final double LOAD_FACTOR = 0.75;
// 定义一个数组存储哈希链表的地址
private HashNode<K,V>[] elem = (HashNode<K,V>[])new HashNode[DEFAULT_SIZE];
}
哈希表的插入
public V put(K key, V value) {
int hash = key.hashCode();
// 获取位置映射
int keyHash = hash % elem.length;
// 设置映射位置的头引用
HashNode<K,V> cur = elem[keyHash];
while(cur != null){
// 遍历链表,查看该位置是否存在哈希表
if(cur.equals(key)){
// 如果存在则更新 value 值
V oldValue = cur.value;
cur.value = value;
return oldValue;
}else{
cur = cur.next;
}
}
// 该位置不存在于哈希表,则头插
HashNode<K,V> newNode = new HashNode<>(key, value);
newNode.next = elem[keyHash];
elem[keyHash] = newNode;
size++;
// 当我们哈希表的长度超过了我们的负载因子
// 进行扩容
if(loadFactor()>=LOAD_FACTOR){
resize(key);
}
return null;
}
HashCode 的特性
HashCode 的存在主要是用于查找的快捷性,如Hashtable,HashMap等,HashCode 经常用于确定对象的存储地址,由于我们 Object 父类底层已经实现了,我们直接使用即可
HashCode 的特性
如果两个对象相同, equals 方法一定返回 true,并且这两个对象的 HashCode 一定相同 |
两个对象的 HashCode 相同,并不一定表示两个对象就相同,即 equals() 不一定为 true,只能说明这两个对象在一个散列存储结构中 |
如果对象的 equals 方法被重写,那么对象的 HashCode 也尽量重写 |
判定负载因子
private double loadFactor() {
return size * 1.0 / elem.length;
}
判定哈希扩容
public void resize(K key) {
// 扩容
HashNode<K,V>[] newElem = (HashNode<K,V>[])new HashNode[elem.length*2];
// 重新哈希
for(int i = 0; i < elem.length; i++){
HashNode<K,V> cur = elem[i];
int hash = key.hashCode();
while(cur != null){
// 重新哈希的新映射
int indexHash = hash%newElem.length;
HashNode curNext = cur.next;
// 头插
cur.next = newElem[indexHash];
newElem[indexHash] = cur;
cur = curNext;
}
elem = newElem;
}
}
查找哈希元素
public V get(K key) {
int hash = key.hashCode();
int index = hash % elem.length;
HashNode<K,V> head = elem[index];
for (HashNode<K,V> cur = head; cur != null; cur = cur.next) {
if (key.equals(cur.key)) {
return cur.value;
}
}
return null;
}
删除哈希元素
public void remove(K key) {
int hash = key.hashCode();
int index = hash % elem.length;
if (elem[index] == null) {
return;
}
HashNode<K,V> cur = elem[index];
if (cur.key == key) {
elem[index] = cur.next;
size--;
return;
}
while (cur.next != null) {
if (cur.next.key == key) {
cur.next = cur.next.next;
}
cur = cur.next;
}
size--;
}
代码测试
public class Student {
private String StudentId;
public Student(String studentId) {
StudentId = studentId;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Student student = (Student) o;
return Objects.equals(StudentId, student.StudentId);
}
@Override
public int hashCode() {
return Objects.hashCode(StudentId);
}
}
class Test {
public static void main(String[] args) {
Student student1 = new Student("2024103030116");
Student student2 = new Student("2024103030115");
HashBucket<Student,Double> hashBucket = new HashBucket();
hashBucket.put(student1,99.9);
hashBucket.put(student2,100.0);
System.out.println(hashBucket.get(student1));
System.out.println(hashBucket.get(student2));
hashBucket.remove(student1);
System.out.println(hashBucket.get(student1));
}
}
哈希性能分析
虽然哈希表一直在和冲突做斗争,但在实际使用过程中,我们认为哈希表的冲突率是不高的,冲突个数是可控的, 也就是每个桶中的链表的长度是一个常数,所以,通常意义下,我们认为哈希表的插入、删除、查找时间复杂度是 O(1)
总结
输入 key ,哈希表能够在 O(1) 时间内查询到 value ,效率非常高 |
哈希函数将 key 映射为数组索引,从而访问对应桶并获取 value |
两个不同的 key 可能在经过哈希函数后得到相同的数组索引,导致查询结果出错,这种现象被称为哈希冲突 |
哈希表容量越大,哈希冲突的概率就越低。因此可以通过扩容哈希表来缓解哈希冲突 |
链式地址通过将单个元素转化为链表,将所有冲突元素存储在同一个链表中。然而,链表过长会降低查询效率,可以通过进一步将链表转换为红黑树来提高效率 |