添加元素
public V put(K key, V value) { return putVal(hash(key), key, value, false, true); }
其中 hash(key) 就是得到 插入元素Key 的hash值如下图
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { Node<K,V>[] tab; Node<K,V> p; int n, i; if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; // 如果没有hash碰撞则直接插入元素 if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null); else { ...... } } final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict)
首先我们要知道为什么初始容量是 2次幂?
可以发现,判断桶的索引的实现是 i = ( n - 1 ) & hash,其中 n 是 map 的容量。
任何 2 的整数幂 - 1 得到的二进制都是 1,如:
$$
16 - 1 = 15(1111);32 - 1 = 31(11111)
$$
所以我们的 n-1 永远都是 1,那 ( n - 1 ) & hash 的计算结果就是 低位的hash 值。
过程如下:
00100100 10100101 11000100 00100101 // Hash 值 & 00000000 00000000 00000000 00001111 // 16 - 1 = 15 ---------------------------------- 00000000 00000000 00000000 00000101 // 高位全部归零,只保留末四位。
先了解其中hash值又是怎么来的呢?
h = key.hashCode()) ^ (h >>> 16)
插入元素的key 可以通过这个扰动函数的得到散列值
那为什么不直接用 key.hashCode() 作为散列值呢?
如果没有扰动函数的情况下,我们拿着散列值作为下标找到 hashmap 中对应的桶位存下即可(不发送哈希冲突的情况下),但 int 类型是 32 位,很少有Hashmap的数组有40亿这么大,所以, key 类型自带的哈希函数返回的散列值不能拿来直接用。如果我们取低几位的 hash 值来做数组映射行不行,但是如果低位相同,高位不同的 hash 值就碰撞了,如:
// Hash 碰撞示例: 00000000 00000000 00000000 00000101 & 1111 = 0101 // H1 00000000 11111111 00000000 00000101 & 1111 = 0101 // H2
结果所示 不同hash值 用相同的容量( n - 1 )做与运算 结果相同说明发生了hash碰撞。
为了解决这个问题,HashMap 想了个办法,用扰动函数降低碰撞的概率。将 hash 值右移16位(hash值的高16位)与 原 hash 值做异或运算(^),从而得到一个新的散列值。如:
00000000 00000000 00000000 00000101 // H1 00000000 00000000 00000000 00000000 // H1 >>> 16 00000000 00000000 00000000 00000101 // hash = H1 ^ (H1 >>> 16) = 5 00000000 11111111 00000000 00000101 // H2 00000000 00000000 00000000 11111111 // H2 >>> 16 00000000 00000000 00000000 11111010 // hash = H2 ^ (H2 >>> 16) = 250
H1,H2 两个 hash 值经过扰动后,很明显不会发生碰撞。
由此知道了 hash值的由来
回归话题,为什么初始容量是 2次幂?
!(C:\Users\李俊豪\AppData\Roaming\Typora\typora-user-images\image-20230621190559652.png)
对比来看,哪种发生哈希碰撞的概率更低一目了然,如果 n 为 2次幂,可以保证数据的均匀插入,降低哈希冲突的概率,毕竟冲突越大,代表数组中的链表/红黑树越大,从而降低Hashmap 的性能。
总的来说,无论时 Hashmap 的 n 为 2次幂,还是扰动函数,都是为了一个目标,降低哈希冲突的概率,从而使 HashMap 性能得到优化。而规定 n 为 2次幂,是在新建 Hashmap对象初始化时,规定其容量大小的角度来优化。而扰动函数是插入 key 值时改变 key 的散列值来达到优化效果。
那 HashMap 为什么不选用其他树的数据结构?
先说一下时间复杂度:
-
二叉树:每个节点最多有两个子节点的树结构。时间复杂度与树的深度相关,最坏情况下为O(n),其中n为节点数。
-
B树:多路搜索树,节点可以有多个子节点。时间复杂度为O(log n),其中n为节点数。
-
B+树:基于B树的一种改进,将所有数据都存储在叶子节点上,非叶子节点只包含键值和指向子节点的指针。时间复杂度为O(log n),其中n为节点数。
-
二叉搜索树:一种二叉树,根节点的值大于左子树所有节点的值,小于右子树所有节点的值。平均情况下时间复杂度为O(log n),最坏情况下为O(n),其中n为节点数。
-
AVL树:一种自平衡的二叉搜索树,通过旋转操作保持树的平衡。时间复杂度为O(log n),其中n为节点数。
-
红黑树:一种自平衡的二叉搜索树,通过节点颜色和旋转操作保持树的平衡。时间复杂度为O(log n),其中n为节点数。
再从各自的特点说明:
二叉树是一种树形数据结构,每个节点最多有两个子节点。特点是容易实现,但是在某些情况下可能会导致树的不平衡,进而降低效率。
B树是一种多路搜索树,每个节点可以有多个子节点。特点是适合存储大量的数据,并且保持了较好的平衡性能,因此在数据库索引中广泛应用。
B+树是B树的一种变体,特点是只有叶节点存储了实际的数据,而非叶节点只用于索引。这样可以提高数据查询的效率,尤其适合于范围查询。
二叉搜索树(BST)是一种有序二叉树,特点是左子树的值小于根节点,右子树的值大于根节点。它可以快速地查找、插入和删除元素,但在极端情况下可能会变得不平衡。
AVL树是一种自平衡的二叉搜索树,特点是每个节点的左右子树的高度差不超过1。通过旋转操作来保持整棵树的平衡性,提高查找效率,但是插入和删除操作的性能相对较低。
红黑树是一种自平衡的二叉搜索树,特点是具有良好的平衡性能。通过染色和旋转操作来保持树的平衡,插入和删除操作的性能较好,广泛应用于STL库中。
总结特点:
-
二叉树:容易实现但可能不平衡;
-
B树:适合存储大量数据且保持较好的平衡性能;
-
B+树:适合范围查询,只有叶节点存储实际数据;
-
二叉搜索树:快速查找、插入和删除,但可能不平衡;
-
AVL树:自平衡二叉搜索树,插入和删除操作较慢;
-
红黑树:自平衡二叉搜索树,插入和删除操作较好。
由此可见 AVL树 和红黑树比较适合 那为什么不用AVL树呢?
AVL树 一般用 平衡因子判断是否平衡并通过旋转来实现平衡,左右子树树高不超过1,和红黑树相比,AVL树是
高度平衡的二叉树,平衡条件必须满足(所有节点的左右子树高度差不超过1)。不管我们是执行插入还
是删除操作,只要不满足上面的条件,就要通过旋转来保持平衡,而的由于旋转比较耗时,由此我们可
以知道AVL树适合用于插入与删除次数比较少,但查找多的情况。
红黑树 也是一种平衡二叉树,但每个节点有一个存储位表示节点的颜色,可以是红或黑。通过对任何一条从根
到叶子的路径上各个节点着色的方式的限制,红黑树确保没有一条路径会比其它路径长出两倍,因此,
红黑树是一种弱平衡二叉树红黑树从根到叶子的最长路径不会超过最短路径的**2倍**(由于是弱平衡,可以
看到,在相同的节点情况下,AVL树的高度<=红黑树),相对于要求严格的AVL树来说,它的旋转次数
少,所以对于搜索,插入,删除操作较多的情况下,用红黑树。