时间复杂度(平均)
特点
在实际应用中,很多时候的需求
不考虑顺序、不考虑 Key 的可比较性,Map 有更好的实现方案,平均时间复杂度可以达到 O(1)
空间换时间:一开始数组里面的索引肯定是比 key 的数量多的。浪费了一定的空间。
1、哈希表中哈希函数的实现步骤大概如下
&
)效率更高,一个【2 的 n 次方】的数【减一】,它的二进制一定全是 1。table.length-1
的。(以下图的与运算为例)❗❗❗ hash_code & (table.length-1) 实际上就是保留
hash_code
的二进制表示中的低位,忽略掉高位(因为低位们就是余数)。❗❗❗ 这样就能保证取模运算
hash_code % length
的效果和位运算hash_code & (table.length-1)
是一样的,但是位运算的速度更快。
2^1 == 010
2^1 - 1 == 001 == 1
2^2 == 100
2^2 - 1 == 011 == 11
10101 10111
&00111 &00111
------- -------
00101 00111
2、良好的哈希函数
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);
}
比如浮点数 10.6f
在内存中实际上存储的就是二进制数据
// 哈希值
int code = Float.floatToIntBits(10.6f);
System.out.printIn(code);
System.out.println(Integer.toBinaryString(code));
// 控制台输出
1093245338
10000010010101100110110011010
long 和 double 都是 8 个字节,即 64 位。
而哈希值必须是 int,即 32 位。所以需要计算。
需注意以下几点:
^
)和无符号右移(>>>
)计算 long 类型:
public static int hashCode(long value) {
return (int)(value ^ (value >>> 32));
}
计算 double 类型:
public static int hashCode(double value) {
long bits = doubleToLongBits(value);
return (int)(bits ^ (bits >>> 32));
}
具体如下图所示:
用【原数值的低位】和【无符号右移得到的高位】进行【异或运算】。
字符串的哈希值计算如下:
在 JDK 中,(乘法因子)乘数 n 为 31,为什么会使用 31?
原因分析:
素数是指大于 1 的自然数中,除了 1 和它本身以外不再有其他因数的数。
主要有以下两个优势:
11111
,这个特性使得在进行乘法运算时,可以通过移位和减法的组合来实现,提高了效率。31 * i
优化成 (i << 5) - i
31 * i
== i * 2^5 - i
== (i << 5) - i
总之,选择 31 是为了在字符串哈希计算中充分利用计算机的位运算和乘法优化,以提高效率和性能。
源码分析:
public int hashCode() {
int h = hash; // 步骤 1:检查是否已计算哈希值
if (h == 0 && value.length > 0) {
char val[] = value;
for (int i = 0; i < value.length; i++) {
// 步骤 2:使用乘法因子 31 计算哈希
// 通过将当前哈希乘以 31 并加上当前字符的 Unicode 编码来更新哈希
h = 31 * h + val[i];
}
// 步骤 3:缓存计算的哈希值以便将来使用
hash = h;
}
// 步骤 4:返回计算的哈希值
return h;
}
这是 String
类中的 hashCode
方法的源码。这方法的目的是计算字符串的哈希值,以便在哈希表等数据结构中使用。
具体说明:
hash
变量是否已经计算过哈希值,如果是,则直接返回这个哈希值。hash
为 0(表示没有计算过哈希值),并且字符串长度大于 0,那么就进入计算哈希值的过程。hash
变量,并返回这个哈希值。这种哈希计算方法具有一定的效率和均匀分布的特点,适用于一般的字符串。通过遍历每个字符,以字符的 Unicode 编码值来影响最终的哈希值,从而保证字符串中每个字符都有影响。这样设计的目的是尽可能减小哈希冲突的概率。
如何计算自定义对象的哈希值呢?
代码如下:
@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;
}
思考:
1、哈希值太大,整型溢出怎么办?
不用做任何处理,只要最后返回的是一个 int 类型的值就行。
2、如果不重写 hashCode()
会怎么样?⭐️
@Data
注解生成的hashCode()
和 MybatisX 插件生成的hashCode()
不一样
int
),也就是 32 字节。首先得明确以下几点:
哈希值一样,索引绝对一样
哈希值不一样,索引也可能一样
11000 10000 --- 哈希值
&111 &111
------- -------
000 000 --- 索引值
所以在判断两个对象是否相等时,需要用到 equals
方法,equals
和 hashCode
是两个不同的方法。
先利用
hashCode
计算哈希值,然后再利用哈希函数hash
获取索引值,最后利用equals
判断两者是否真的相等。
@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));
}
默认的 equals 是比较内存地址是否相等
假设有两个 Person
对象:p1 和 p2。
只实现 equals,不实现 hashCode,会导致判断时不稳定。
equals
。 public static void main(String[] args) {
Person p1 = new Person(10, 1.67f, "jack");
Person p2 = new Person(10, 1.67f, "jack");
HashMap<Object, Object> map = new HashMap<>();
map.put(p1, "abc");
map.put("test", "ccc");
map.put(p2, "bcd");
System.out.println("map.size() = " + map.size());
}
代码分析:
自定义对象作为 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.eguals(z)
返回 true
,那么 x.equals(z)
必须反回 true
null
的 y,只要 equals 的比较操作在对象中所用的信息没有被修改,多次调用 x.equals(y)
就会一致地返回 true
,或者一致地返回 false
null
的 x,x.equals(null)
必须返回 false
hashCode
:必须保证 equals 为 true 的 2 个 key 的哈希值一样hashCode
相等的 key,不一定 equals
为 true此时,建议哈希表的长度(size)为素数。(质数)
下方表格列出了不同数据规模对应的最佳素数,特点如下
注意树化的阈值是:
// 默认的初始容量,即哈希表在创建时的默认大小。它被设置为 16,使用了位运算 1 << 4 来表示 2 的 4 次方,即 16。
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
// 哈希表允许的最大容量。它被设置为 2 的 30 次方,即 1073741824。
static final int MAXIMUM_CAPACITY = 1 << 30;
// 默认的负载因子。负载因子是表示哈希表在什么时候会进行扩容的一个因子。当哈希表中的元素数量超过容量乘以负载因子时,哈希表将会进行扩容。默认负载因子是 0.75。
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 当哈希桶中的链表长度超过这个阈值(>=9)时,链表将会被转化为红黑树。这是为了提高在链表中查找元素的效率。
static final int TREEIFY_THRESHOLD = 8;
// 当哈希桶中的红黑树节点数量小于这个阈值时,红黑树将会被转化为链表。
static final int UNTREEIFY_THRESHOLD = 6;
// 当哈希表的容量小于这个值时,不会进行红黑树化,即不会将链表转化为红黑树。
static final int MIN_TREEIFY_CAPACITY = 64;
其中,hash ^ (hash >>> 16
是为了保险起见,因为无法确定传进来的这个key,实现的 hashCode() 均不均匀
static final int hash(Object key) {
int h;
// hash ^ (hash >>> 16 是为了保险起见,因为无法确定传进来的这个key,实现的 hashCode() 均不均匀
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
1、put(K key, V value)
public V put(K key, V value) {
// 表示 不仅仅在键不存在时才插入,在插入后可能触发一些处理
return putVal(hash(key), key, value, false, true);
}
2、putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict)
binCount
为 0 时,意味着链表的大小为 2 == 0 + 2。参数分析:
onlyIfAbsent
为 true
,表示仅在当前哈希表中不存在指定键(key)时才执行插入操作。onlyIfAbsent
为 false
,则不考虑哈希表中是否已经存在指定键,直接执行插入操作。evict
为 true
,表示在执行插入操作后,可能触发一些处理,例如在插入新键值对后,会检查是否需要进行扩容或将链表转为红黑树,从而保持哈希表的性能和结构。evict
为 false
,表示插入新键值对后,不进行上述的检查和调整,仅简单地插入键值对。方法分析:
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; // 声明哈希表数组
Node<K,V> p; // 声明节点变量
int n, i;
// 如果哈希表为空或长度为 0,则进行扩容
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 计算在哈希表中的位置
if ((p = tab[i = (n - 1) & hash]) == null)
// 如果位置为空,直接插入新节点
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
// 如果位置不为空,判断当前节点是否是目标节点
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 如果当前节点是红黑树节点,调用红黑树的插入方法
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
// 如果是链表节点,遍历链表查找节点
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
// 找到链表末尾,插入新节点
p.next = newNode(hash, key, value, null);
// 如果链表大小达到阈值,将链表转为红黑树
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
// 如果找到相同的键,退出循环
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
// 如果找到相同的键,更新值
if (e != null) {
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
// 哈希表中新增节点,更新 modCount 和 size
++modCount;
if (++size > threshold)
resize();
// 新增键值对后的操作
afterNodeInsertion(evict);
return null;
}
这个方法主要作用是:对 HashMap 进行扩容,以适应更多的键值对,并在扩容时迁移旧数据到新的数组中。。
/**
* 对HashMap进行扩容,调整数组大小,并迁移数据。
*
* @return 扩容后的新数组
*/
final Node<K,V>[] resize() {
// 保存旧的哈希表数组
Node<K,V>[] oldTab = table;
// 旧的哈希表容量
int oldCap = (oldTab == null) ? 0 : oldTab.length;
// 旧的阈值
int oldThr = threshold;
int newCap, newThr = 0;
// 如果旧的哈希表容量大于 0
if (oldCap > 0) {
// 如果旧的容量已经达到最大容量,直接将阈值设为 Integer.MAX_VALUE,不进行扩容
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 计算新的容量为旧容量的两倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
// 新的阈值为旧的两倍
newThr = oldThr << 1; // double threshold
}
// 如果旧的哈希表容量为 0 且有初始阈值
else if (oldThr > 0)
// 新的容量为旧的阈值
newCap = oldThr;
// 如果旧的容量和阈值都为 0,使用默认初始容量和阈值
else {
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 如果新的阈值仍然为 0
if (newThr == 0) {
// 计算新的阈值,取新的容量和负载因子的乘积,确保不超过最大容量
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
// 更新哈希表的阈值
threshold = newThr;
// 创建新的哈希表数组
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
// 更新哈希表的数组引用
table = newTab;
// 如果旧的哈希表数组不为空
if (oldTab != null) {
// 遍历旧的哈希表数组
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
// 获取旧数组中的节点
if ((e = oldTab[j]) != null) {
// 将旧数组中的槽置为 null
oldTab[j] = null;
// 如果当前节点没有后续节点
if (e.next == null)
// 将节点放入新数组中的对应槽
newTab[e.hash & (newCap - 1)] = e;
// 如果当前节点为树节点
else if (e instanceof TreeNode)
// 执行树化操作
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // 保持相对顺序的链表节点
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
// 将链表节点按照哈希值的最高位是否为 0 分为两组
do {
next = e.next;
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
// 将低位链表节点放入新数组中的对应槽
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
// 将高位链表节点放入新数组中的对应槽
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
// 返回新的哈希表数组
return newTab;
}
主要描述了 LinkedHashMap 中的双向链表结构和迭代顺序的属性。
head
是双向链表的头部,指向最老的元素(最早插入的元素)。tail
是双向链表的尾部,指向最新插入的元素。accessOrder
为 true
,则表示迭代顺序是基于访问顺序,即最近访问的元素排在最前面。accessOrder
为 false
,则表示迭代顺序是基于插入顺序,即元素按照插入的顺序排列。这种灵活性使得 LinkedHashMap
既可以按照插入顺序来迭代,也可以按照访问顺序来迭代。这对于实现 LRU(Least Recently Used)缓存非常有用,因为可以基于访问顺序轻松实现【缓存淘汰策略】。
此外,
transient
关键字表示:这两个字段不会被默认的序列化机制保存,因为 LinkedHashMap 类提供了自己的序列化机制。
/**
* The head (eldest) of the doubly linked list. -- 双链表的头(最早插入的元素)。
*/
transient LinkedHashMap.Entry<K,V> head;
/**
* The tail (youngest) of the doubly linked list. -- 双链表的尾部(最新插入的元素)。
*/
transient LinkedHashMap.Entry<K,V> tail;
/**
* The iteration ordering method for this linked hash map: true
* for access-order, false for insertion-order.
*
* 表示LinkedHashMap的迭代顺序
*
* @serial
*/
final boolean accessOrder;
*非常有用,因为可以基于访问顺序轻松实现【缓存淘汰策略】。
此外,
transient
关键字表示:这两个字段不会被默认的序列化机制保存,因为 LinkedHashMap 类提供了自己的序列化机制。
/**
* The head (eldest) of the doubly linked list. -- 双链表的头(最早插入的元素)。
*/
transient LinkedHashMap.Entry<K,V> head;
/**
* The tail (youngest) of the doubly linked list. -- 双链表的尾部(最新插入的元素)。
*/
transient LinkedHashMap.Entry<K,V> tail;
/**
* The iteration ordering method for this linked hash map: true
* for access-order, false for insertion-order.
*
* 表示LinkedHashMap的迭代顺序
*
* @serial
*/
final boolean accessOrder;