HashMap 是 Java 中最常用的数据结构之一,它提供了基于哈希表的键值对存储。HashMap 的设计和实现使其在大多数情况下能够提供快速的插入、删除和查找操作。以下是对 HashMap 原理的详细介绍。
1. 基本结构
HashMap 由一个数组和链表(或红黑树)组成。数组中的每个元素称为一个桶(bucket),每个桶存储一个链表或红黑树,用于处理哈希冲突。
2. 哈希函数
HashMap 使用哈希函数将键映射到数组中的索引位置。哈希函数的目的是将键的哈希码转换为数组的索引,以便快速定位存储位置。
int hash = key.hashCode();
int index = (hash & 0x7FFFFFFF) % array.length;
3. 哈希冲突
由于不同的键可能具有相同的哈希值(哈希冲突),HashMap 使用链表或红黑树来处理冲突。在同一个桶中,所有具有相同哈希值的键值对将被存储在链表或红黑树中。
4. 链表和红黑树
- 链表:在 Java 8 之前,
HashMap使用链表来处理哈希冲突。当多个键映射到同一个桶时,这些键值对将被存储在链表中。 - 红黑树:在 Java 8 之后,当链表长度超过一定阈值(默认是 8)时,
HashMap会将链表转换为红黑树,以提高查找和插入性能。
5. 扩容机制
HashMap 具有动态扩容机制。当 HashMap 中的元素数量超过一定阈值(负载因子 * 数组长度)时,HashMap 会进行扩容,将数组大小加倍,并重新分配所有键值对到新的数组中。
6. 主要操作
插入操作
- 计算键的哈希值和数组索引。
- 如果桶为空,直接插入键值对。
- 如果桶不为空,遍历链表或红黑树,检查是否存在相同的键。
- 如果存在相同的键,更新值;否则,将键值对插入链表或红黑树。
查找操作
- 计算键的哈希值和数组索引。
- 如果桶为空,返回
null。 - 如果桶不为空,遍历链表或红黑树,查找具有相同键的节点。
- 如果找到相同的键,返回对应的值;否则,返回
null。
删除操作
- 计算键的哈希值和数组索引。
- 如果桶为空,返回
null。 - 如果桶不为空,遍历链表或红黑树,查找具有相同键的节点。
- 如果找到相同的键,从链表或红黑树中删除节点,并返回对应的值;否则,返回
null。
示例代码
以下是一个简单的 HashMap 示例代码:
import java.util.HashMap;
public class Example {
public static void main(String[] args) {
HashMap<String, Integer> map = new HashMap<>();
// 插入键值对
map.put("one", 1);
map.put("two", 2);
map.put("three", 3);
// 查找键值对
System.out.println("Value for key 'one': " + map.get("one")); // 输出: Value for key 'one': 1
// 删除键值对
map.remove("two");
// 遍历键值对
for (Map.Entry<String, Integer> entry : map.entrySet()) {
System.out.println("Key: " + entry.getKey() + ", Value: " + entry.getValue());
}
}
}
总结
- 基本结构:
HashMap由一个数组和链表(或红黑树)组成,用于存储键值对。 - 哈希函数:将键的哈希码转换为数组的索引,以便快速定位存储位置。
- 哈希冲突:使用链表或红黑树处理哈希冲突。
- 扩容机制:动态扩容以保持性能。
- 主要操作:插入、查找和删除操作通过计算哈希值和遍历链表或红黑树来实现。
通过这些机制,HashMap 提供了高效的键值对存储和查找功能,适用于大多数应用场景。
为什么要引入红黑树
在 Java 8 之前,HashMap 处理哈希冲突的方式是使用链表。然而,当链表中的元素数量较多时,查找、插入和删除操作的性能会显著下降,因为这些操作的时间复杂度是 O(n)。为了提高性能,Java 8 引入了红黑树来替代链表,当链表长度超过一定阈值(默认是 8)时,将链表转换为红黑树。
红黑树的优缺点
优点
-
平衡二叉树:
- 红黑树是一种自平衡二叉搜索树,确保树的高度始终保持在 O(log n)。
- 这意味着查找、插入和删除操作的时间复杂度都是 O(log n),相比链表的 O(n) 有显著的性能提升。
-
高效的查找:
- 在红黑树中,查找操作的时间复杂度是 O(log n),而在链表中是 O(n)。
- 当哈希冲突较多时,红黑树可以显著提高查找性能。
-
高效的插入和删除:
- 红黑树的插入和删除操作也具有 O(log n) 的时间复杂度。
- 这使得在高冲突情况下,红黑树的插入和删除操作比链表更高效。
缺点
-
复杂性:
- 红黑树的实现比链表复杂得多,需要维护树的平衡性。
- 这增加了代码的复杂性和维护成本。
-
内存开销:
- 红黑树的节点比链表节点占用更多的内存,因为每个节点需要存储额外的颜色信息(红或黑)和指向父节点的引用。
- 在低冲突情况下,链表的内存开销更低。
链表的优缺点
优点
-
简单性:
- 链表的实现相对简单,插入和删除操作只需要调整指针。
- 这使得链表的代码更容易理解和维护。
-
低内存开销:
- 链表节点只需要存储数据和指向下一个节点的引用,内存开销较低。
- 在低冲突情况下,链表的内存利用率更高。
缺点
-
低效的查找:
- 在链表中,查找操作的时间复杂度是 O(n),当链表长度较长时,查找性能会显著下降。
- 这在高冲突情况下会导致性能瓶颈。
-
低效的插入和删除:
- 在链表中,插入和删除操作的时间复杂度也是 O(n),因为需要遍历链表找到插入或删除的位置。
- 这在高冲突情况下也会导致性能问题。
为什么引入红黑树
引入红黑树的主要原因是为了提高 HashMap 在高冲突情况下的性能。具体来说:
-
提高查找性能:
- 当链表长度较长时,查找操作的时间复杂度是 O(n),而红黑树的查找操作时间复杂度是 O(log n)。
- 通过将链表转换为红黑树,可以显著提高查找性能。
-
提高插入和删除性能:
- 红黑树的插入和删除操作时间复杂度是 O(log n),而链表的插入和删除操作时间复杂度是 O(n)。
- 通过使用红黑树,可以提高插入和删除操作的性能。
总结
- 红黑树的优点:平衡二叉树结构,查找、插入和删除操作的时间复杂度是 O(log n),在高冲突情况下性能更高。
- 红黑树的缺点:实现复杂,内存开销较高。
- 链表的优点:实现简单,内存开销低。
- 链表的缺点:查找、插入和删除操作的时间复杂度是 O(n),在高冲突情况下性能较低。
通过在 HashMap 中引入红黑树,Java 8 提高了 HashMap 在高冲突情况下的性能,使其在大多数情况下能够提供更高效的键值对存储和查找功能。
为什么当链表长度超过一定阈值(默认是 8)时,将链表转换为红黑树。这个阈值(默认是 8)是怎么确定的?
在 Java 8 中,当 HashMap 中的链表长度超过一定阈值(默认是 8)时,将链表转换为红黑树。这个阈值的确定是基于性能权衡和经验的结果。以下是一些关键因素,解释了为什么选择 8 作为默认阈值:
1. 性能权衡
- 链表的性能:链表的查找、插入和删除操作的时间复杂度是 O(n)。当链表长度较短时(例如,小于 8),链表的性能是可以接受的,因为操作的时间复杂度相对较低。
- 红黑树的性能:红黑树的查找、插入和删除操作的时间复杂度是 O(log n)。当链表长度较长时(例如,大于 8),红黑树的性能显著优于链表。
2. 内存开销
- 链表的内存开销:链表节点只需要存储数据和指向下一个节点的引用,内存开销较低。
- 红黑树的内存开销:红黑树节点需要存储额外的颜色信息(红或黑)和指向父节点的引用,内存开销较高。
3. 实现复杂性
- 链表的实现:链表的实现相对简单,插入和删除操作只需要调整指针。
- 红黑树的实现:红黑树的实现相对复杂,需要维护树的平衡性,插入和删除操作涉及旋转和重新着色。
4. 经验和实践
- 经验和实践:选择 8 作为阈值是基于经验和实践的结果。Java 开发团队通过大量的性能测试和分析,发现当链表长度超过 8 时,转换为红黑树可以显著提高性能,而不会带来过多的内存开销和实现复杂性。
具体原因
-
链表长度较短时,链表的性能是可以接受的:
- 当链表长度较短时(例如,小于 8),链表的查找、插入和删除操作的时间复杂度相对较低,性能是可以接受的。
- 在这种情况下,使用链表可以避免红黑树的额外内存开销和实现复杂性。
-
链表长度较长时,红黑树的性能显著优于链表:
- 当链表长度较长时(例如,大于 8),链表的查找、插入和删除操作的时间复杂度显著增加,性能下降。
- 在这种情况下,转换为红黑树可以显著提高性能,因为红黑树的查找、插入和删除操作的时间复杂度是 O(log n)。
-
8 是一个合理的折中值:
- 选择 8 作为阈值是一个合理的折中值,既能在链表长度较短时保持较低的内存开销和实现复杂性,又能在链表长度较长时显著提高性能。
示例代码
以下是一个简单的示例代码,展示了 HashMap 在链表长度超过阈值时转换为红黑树的过程:
import java.util.HashMap;
import java.util.Map;
public class Example {
public static void main(String[] args) {
Map<String, Integer> map = new HashMap<>();
// 插入键值对,模拟哈希冲突
for (int i = 0; i < 10; i++) {
map.put("key" + i, i);
}
// 打印 HashMap 的内容
map.forEach((key, value) -> System.out.println("Key: " + key + ", Value: " + value));
}
}
总结
- 性能权衡:链表在长度较短时性能可以接受,红黑树在长度较长时性能显著优于链表。
- 内存开销:链表的内存开销较低,红黑树的内存开销较高。
- 实现复杂性:链表的实现相对简单,红黑树的实现相对复杂。
- 经验和实践:选择 8 作为阈值是基于经验和实践的结果,通过大量的性能测试和分析得出。
通过选择 8 作为阈值,Java 8 的 HashMap 实现能够在大多数情况下提供高效的键值对存储和查找功能,同时保持较低的内存开销和实现复杂性。
所以在长度小于8时,只是链表的性能是可以接受的,并不是说在小于8时链表的性能优于红黑树?
是的,准确地说,在链表长度小于 8 时,链表的性能是可以接受的,并不是说链表的性能优于红黑树。选择 8 作为阈值主要是基于以下几个方面的考虑:
1. 性能权衡
- 链表的性能:在链表长度较短时(例如,小于 8),链表的查找、插入和删除操作的时间复杂度虽然是 O(n),但由于 n 较小,实际性能是可以接受的。
- 红黑树的性能:红黑树的查找、插入和删除操作的时间复杂度是 O(log n),在链表长度较短时,红黑树的性能虽然更好,但由于操作的常数开销较大,性能优势并不明显。
2. 内存开销
- 链表的内存开销:链表节点只需要存储数据和指向下一个节点的引用,内存开销较低。
- 红黑树的内存开销:红黑树节点需要存储额外的颜色信息(红或黑)和指向父节点的引用,内存开销较高。
3. 实现复杂性
- 链表的实现:链表的实现相对简单,插入和删除操作只需要调整指针。
- 红黑树的实现:红黑树的实现相对复杂,需要维护树的平衡性,插入和删除操作涉及旋转和重新着色。
4. 经验和实践
- 经验和实践:选择 8 作为阈值是基于经验和实践的结果。Java 开发团队通过大量的性能测试和分析,发现当链表长度超过 8 时,转换为红黑树可以显著提高性能,而不会带来过多的内存开销和实现复杂性。
具体原因
-
链表长度较短时,性能差异不明显:
- 当链表长度较短时(例如,小于 8),链表和红黑树的性能差异不明显,因为操作的常数开销在总时间中占比较大。
- 在这种情况下,链表的实现简单且内存开销较低,因此选择链表是合理的。
-
链表长度较长时,红黑树的性能优势显著:
- 当链表长度较长时(例如,大于 8),链表的查找、插入和删除操作的时间复杂度显著增加,性能下降。
- 在这种情况下,转换为红黑树可以显著提高性能,因为红黑树的查找、插入和删除操作的时间复杂度是 O(log n)。
-
8 是一个合理的折中值:
- 选择 8 作为阈值是一个合理的折中值,既能在链表长度较短时保持较低的内存开销和实现复杂性,又能在链表长度较长时显著提高性能。
总结
- 链表的性能在长度较短时是可以接受的:在链表长度较短时(例如,小于 8),链表的性能是可以接受的,虽然红黑树的性能更好,但由于操作的常数开销较大,性能优势并不明显。
- 红黑树在长度较长时性能更好:当链表长度较长时(例如,大于 8),红黑树的性能显著优于链表,因此转换为红黑树可以显著提高性能。
- 合理的折中值:选择 8 作为阈值是基于性能权衡、内存开销、实现复杂性和经验实践的结果,是一个合理的折中值。
通过选择 8 作为阈值,Java 8 的 HashMap 实现能够在大多数情况下提供高效的键值对存储和查找功能,同时保持较低的内存开销和实现复杂性
为什么红黑树的时间复杂是 O(log n) ?
红黑树是一种自平衡二叉搜索树(BST),它通过一组规则来确保树的高度始终保持在 O(log n) 的范围内,从而使得查找、插入和删除操作的时间复杂度都是 O(log n)。以下是详细解释:
红黑树的性质
红黑树具有以下性质,这些性质确保了树的高度始终保持在 O(log n):
- 每个节点是红色或黑色。
- 根节点是黑色。
- 所有叶子节点(NIL 节点)是黑色。
- 如果一个节点是红色的,则它的两个子节点都是黑色的(即没有两个连续的红色节点)。
- 从任一节点到其每个叶子的所有路径都包含相同数目的黑色节点。
自平衡机制
红黑树通过插入和删除操作中的旋转和重新着色来保持平衡。这些操作确保了树的高度始终保持在 O(log n) 的范围内。
高度分析
红黑树的高度与节点数 n 之间的关系可以通过以下分析得出:
-
黑色节点的最小数量:
- 由于红黑树的性质 5,从根节点到任一叶子的所有路径都包含相同数目的黑色节点。
- 设树的高度为 h,黑色节点的最小数量为 h/2(因为红黑树中没有两个连续的红色节点)。
-
节点的最小数量:
- 在最坏情况下,红黑树的每个红色节点都必须有两个黑色子节点。
- 因此,红黑树的最小节点数可以表示为 2^(h/2) - 1。
-
高度与节点数的关系:
- 设红黑树的节点数为 n,则有 n >= 2^(h/2) - 1。
- 通过对数运算,可以得到 h <= 2 * log2(n + 1)。
时间复杂度
由于红黑树的高度始终保持在 O(log n) 的范围内,查找、插入和删除操作的时间复杂度都是 O(log n)。具体原因如下:
-
查找操作:
- 查找操作需要从根节点遍历到目标节点,遍历的路径长度最多为树的高度 h。
- 由于 h = O(log n),查找操作的时间复杂度是 O(log n)。
-
插入操作:
- 插入操作需要首先找到插入位置,然后进行插入,并通过旋转和重新着色来保持树的平衡。
- 查找插入位置的时间复杂度是 O(log n),旋转和重新着色的操作次数与树的高度成正比,因此插入操作的时间复杂度是 O(log n)。
-
删除操作:
- 删除操作需要首先找到删除节点,然后进行删除,并通过旋转和重新着色来保持树的平衡。
- 查找删除节点的时间复杂度是 O(log n),旋转和重新着色的操作次数与树的高度成正比,因此删除操作的时间复杂度是 O(log n)。
总结
红黑树通过一组规则和自平衡机制,确保树的高度始终保持在 O(log n) 的范围内。由于树的高度是 O(log n),查找、插入和删除操作的时间复杂度都是 O(log n)。这使得红黑树在需要高效查找、插入和删除操作的场景中非常有用。
红黑树为什么叫红黑树?红色节点代表什么?黑色节点代表什么?
红黑树(Red-Black Tree)是一种自平衡二叉搜索树,它通过节点的颜色(红色或黑色)和一组规则来保持树的平衡。红黑树之所以叫红黑树,是因为每个节点都被标记为红色或黑色,这些颜色用于确保树的平衡性。
红黑树的命名
红黑树之所以被称为红黑树,是因为它的每个节点都有一个颜色属性,可以是红色或黑色。这些颜色用于帮助维护树的平衡性,并确保树的高度始终保持在 O(log n) 的范围内。
红色节点和黑色节点的意义
在红黑树中,红色节点和黑色节点的颜色本身并没有具体的意义,但它们的存在和相互关系通过一组规则来确保树的平衡性。以下是红黑树的五条性质,这些性质利用了节点的颜色来保持树的平衡:
- 每个节点是红色或黑色。
- 根节点是黑色。
- 所有叶子节点(NIL 节点)是黑色。
- 如果一个节点是红色的,则它的两个子节点都是黑色的(即没有两个连续的红色节点)。
- 从任一节点到其每个叶子的所有路径都包含相同数目的黑色节点。
红黑树的性质解释
-
每个节点是红色或黑色:
- 这是红黑树的基本性质,每个节点都被标记为红色或黑色。
-
根节点是黑色:
- 确保树的根节点始终是黑色的,有助于保持树的平衡。
-
所有叶子节点(NIL 节点)是黑色:
- 叶子节点(通常是空节点或 NIL 节点)被视为黑色,这有助于简化树的平衡规则。
-
如果一个节点是红色的,则它的两个子节点都是黑色的:
- 这条规则确保了没有两个连续的红色节点,从而限制了树的高度增长。
-
从任一节点到其每个叶子的所有路径都包含相同数目的黑色节点:
- 这条规则确保了树的平衡性,因为它限制了从根节点到叶子节点的路径长度差异。
红黑树的插入和删除操作
红黑树的插入和删除操作通过旋转和重新着色来保持树的平衡。以下是这些操作的基本步骤:
-
插入操作:
- 插入新节点时,初始颜色为红色。
- 通过旋转和重新着色来修复任何违反红黑树性质的情况。
-
删除操作:
- 删除节点时,可能需要调整树的结构以保持平衡。
- 通过旋转和重新着色来修复任何违反红黑树性质的情况。
示例代码
以下是一个简单的红黑树节点类的示例代码:
class RedBlackTreeNode {
int value;
RedBlackTreeNode left;
RedBlackTreeNode right;
RedBlackTreeNode parent;
boolean isRed;
public RedBlackTreeNode(int value) {
this.value = value;
this.isRed = true; // 新插入的节点初始为红色
}
}
总结
- 红黑树的命名:红黑树之所以被称为红黑树,是因为它的每个节点都有一个颜色属性,可以是红色或黑色。
- 红色节点和黑色节点的意义:节点的颜色用于帮助维护树的平衡性,通过一组规则确保树的高度始终保持在 O(log n) 的范围内。
- 红黑树的性质:红黑树的五条性质利用了节点的颜色来保持树的平衡,确保查找、插入和删除操作的时间复杂度都是 O(log n)。
通过这些性质和操作,红黑树能够在需要高效查找、插入和删除操作的场景中提供良好的性能。
什么时候将链表转换为红黑树
Java 8 中的 HashMap 在处理哈希冲突时确实有两个条件来决定是否将链表转换为红黑树:
- 链表中的元素个数大于 8:这是第一个条件,当链表中的元素个数超过 8 时,
HashMap会考虑将链表转换为红黑树。 - 数组的长度大于等于 64:这是第二个条件,当数组的长度(即
HashMap的容量)大于等于 64 时,才会将链表转换为红黑树。如果数组的长度小于 64,HashMap会选择扩容而不是转换为红黑树。
详细解释
1. 链表中的元素个数大于 8
当 HashMap 中某个桶(bucket)中的链表长度超过 8 时,HashMap 会考虑将链表转换为红黑树。这是为了提高查找和插入的性能,因为红黑树的查找和插入操作的时间复杂度是 O(log n),而链表的时间复杂度是 O(n)。
2. 数组的长度大于等于 64
在决定将链表转换为红黑树之前,HashMap 会检查当前数组的长度。如果数组的长度小于 64,HashMap 会选择扩容而不是转换为红黑树。扩容可以减少链表的长度,因为扩容后元素会重新分布到新的、更大的数组中。
为什么有这个限制?
- 性能优化:在数组长度较小时,通过扩容可以有效减少链表的长度,从而避免不必要的红黑树转换。红黑树的插入和删除操作比链表复杂,涉及旋转和重新着色,因此在数组长度较小时,优先选择扩容可以提高性能。
- 空间效率:红黑树节点比链表节点占用更多的内存,因为红黑树节点需要存储额外的颜色信息和指向父节点的引用。在数组长度较小时,优先选择扩容可以节省内存。
示例代码
以下是 HashMap 中相关代码的简化示例,展示了如何决定是否将链表转换为红黑树:
if (binCount >= TREEIFY_THRESHOLD - 1) { // TREEIFY_THRESHOLD is 8
if (tab.length < MIN_TREEIFY_CAPACITY) // MIN_TREEIFY_CAPACITY is 64
resize();
else
treeifyBin(tab, hash);
}
总结
- 链表中的元素个数大于 8:这是第一个条件,当链表中的元素个数超过 8 时,
HashMap会考虑将链表转换为红黑树。 - 数组的长度大于等于 64:这是第二个条件,当数组的长度大于等于 64 时,才会将链表转换为红黑树。如果数组的长度小于 64,
HashMap会选择扩容而不是转换为红黑树。
为什么在数组长度较小时,优先选择扩容可以提高性能?
在数组长度较小时,优先选择扩容而不是将链表转换为红黑树可以提高性能,主要有以下几个原因:
1. 减少链表长度
- 扩容的效果:扩容会增加数组的容量,并将现有的元素重新分布到新的、更大的数组中。这通常会减少每个桶中的元素数量,从而缩短链表的长度。
- 减少冲突:扩容后,哈希冲突的概率降低,因为更多的桶可以容纳元素。这样可以减少链表的长度,避免链表过长导致的性能问题。
2. 简化操作
- 链表操作简单:链表的插入和删除操作相对简单,只需要调整指针即可。虽然链表的查找时间复杂度是 O(n),但在链表长度较短时,这种操作的性能是可以接受的。
- 红黑树操作复杂:红黑树的插入和删除操作相对复杂,需要维护树的平衡性,涉及旋转和重新着色。这些操作的时间复杂度是 O(log n),但在元素数量较少时,操作的常数开销较大,可能不如链表高效。
3. 内存开销
- 链表内存开销低:链表节点只需要存储数据和指向下一个节点的引用,内存开销较低。
- 红黑树内存开销高:红黑树节点需要存储额外的颜色信息(红或黑)和指向父节点的引用,内存开销较高。在数组长度较小时,优先选择扩容可以节省内存。
4. 扩容的成本
- 扩容的成本:扩容操作虽然需要重新分配数组并重新哈希所有元素,但这是一次性的成本。扩容后,哈希冲突减少,链表长度缩短,后续操作的性能会显著提高。
- 避免频繁转换:如果在数组长度较小时频繁将链表转换为红黑树,可能会导致不必要的复杂性和性能开销。优先选择扩容可以避免这种情况。
示例
以下是一个简化的示例代码,展示了 HashMap 在链表长度超过阈值时如何决定是扩容还是转换为红黑树:
static final int TREEIFY_THRESHOLD = 8;
static final int MIN_TREEIFY_CAPACITY = 64;
void put(K key, V value) {
int hash = hash(key);
int index = (n - 1) & hash;
Node<K, V> node = table[index];
if (node == null) {
table[index] = new Node<>(hash, key, value, null);
} else {
// Handle collision
Node<K, V> current = node;
int binCount = 0;
while (current != null) {
binCount++;
current = current.next;
}
if (binCount >= TREEIFY_THRESHOLD - 1) {
if (table.length < MIN_TREEIFY_CAPACITY) {
resize();
} else {
treeifyBin(table, index);
}
} else {
// Add to the end of the linked list
current.next = new Node<>(hash, key, value, null);
}
}
}
void resize() {
// Resize the array and rehash all elements
}
void treeifyBin(Node<K, V>[] table, int index) {
// Convert the linked list at table[index] to a red-black tree
}
总结
- 减少链表长度:扩容可以减少每个桶中的元素数量,降低链表长度,从而提高查找和插入性能。
- 简化操作:链表的操作相对简单,而红黑树的操作复杂且有较高的常数开销。在元素数量较少时,链表操作的性能是可以接受的。
- 内存开销:链表的内存开销较低,而红黑树的内存开销较高。优先选择扩容可以节省内存。
- 扩容的成本:扩容是一次性的成本,扩容后哈希冲突减少,后续操作的性能会显著提高。
红黑树会占用更多内存,那在元素很多时,选择红黑树是不是比选择链表会占用更多的内存?但是时间效率上会快一些,这个是不是就是用空间换时间?
是的,你的理解是正确的。在 HashMap 中,当链表中的元素很多时,将链表转换为红黑树确实会占用更多的内存,但在时间效率上会更快。这种策略可以被视为用空间换时间的典型例子。
用空间换时间
内存占用
-
链表:
- 每个节点只需要存储数据和指向下一个节点的引用。
- 内存占用较低,但在元素很多时,查找、插入和删除操作的时间复杂度是 O(n)。
-
红黑树:
- 每个节点除了存储数据和指向子节点的引用外,还需要存储颜色信息(红或黑)和指向父节点的引用。
- 内存占用较高,但查找、插入和删除操作的时间复杂度是 O(log n)。
时间效率
-
链表:
- 在元素较少时,链表的操作性能是可以接受的,因为操作的常数开销较低。
- 在元素很多时,链表的操作性能会显著下降,因为查找、插入和删除操作的时间复杂度是 O(n)。
-
红黑树:
- 在元素较多时,红黑树的操作性能显著优于链表,因为查找、插入和删除操作的时间复杂度是 O(log n)。
- 红黑树通过自平衡机制(旋转和重新着色)保持树的高度在 O(log n) 范围内,从而提高操作性能。
示例
以下是一个简化的示例代码,展示了 HashMap 在链表长度超过阈值时如何决定是扩容还是转换为红黑树:
static final int TREEIFY_THRESHOLD = 8;
static final int MIN_TREEIFY_CAPACITY = 64;
void put(K key, V value) {
int hash = hash(key);
int index = (n - 1) & hash;
Node<K, V> node = table[index];
if (node == null) {
table[index] = new Node<>(hash, key, value, null);
} else {
// Handle collision
Node<K, V> current = node;
int binCount = 0;
while (current != null) {
binCount++;
current = current.next;
}
if (binCount >= TREEIFY_THRESHOLD - 1) {
if (table.length < MIN_TREEIFY_CAPACITY) {
resize();
} else {
treeifyBin(table, index);
}
} else {
// Add to the end of the linked list
current.next = new Node<>(hash, key, value, null);
}
}
}
void resize() {
// Resize the array and rehash all elements
}
void treeifyBin(Node<K, V>[] table, int index) {
// Convert the linked list at table[index] to a red-black tree
}
总结
-
内存占用:
- 链表的内存占用较低,但在元素很多时,操作性能较差。
- 红黑树的内存占用较高,但在元素很多时,操作性能较好。
-
时间效率:
- 链表在元素较少时操作性能可以接受,但在元素很多时性能显著下降。
- 红黑树在元素较多时操作性能显著优于链表,因为查找、插入和删除操作的时间复杂度是 O(log n)。
-
用空间换时间:
- 将链表转换为红黑树是用空间换时间的典型例子,通过增加内存占用来提高操作性能。
何时扩容
扩容在 HashMap 中是一个常见的操作,但它不仅仅发生在链表长度大于 8 并且数组长度小于 64 的情况下。扩容主要是为了保持 HashMap 的性能,避免过多的哈希冲突和链表过长。
扩容的触发条件
-
负载因子:
HashMap有一个负载因子(load factor),默认值是 0.75。当HashMap中的元素数量超过容量 * 负载因子时,HashMap会进行扩容。- 例如,如果当前
HashMap的容量是 16,负载因子是 0.75,那么当元素数量超过 12 时(16 * 0.75),HashMap会进行扩容。
-
链表长度大于 8 并且数组长度小于 64:
- 当某个桶中的链表长度大于 8 并且数组长度小于 64 时,
HashMap会选择扩容而不是将链表转换为红黑树。 - 这种情况确实只会发生在哈希碰撞的时候,但它并不是唯一触发扩容的条件。
- 当某个桶中的链表长度大于 8 并且数组长度小于 64 时,
扩容的过程
扩容的过程包括以下步骤:
-
增加容量:
HashMap的容量通常会加倍。例如,从 16 增加到 32。
-
重新哈希:
- 所有现有的元素会被重新哈希并分布到新的数组中。这是为了确保新的数组能够更均匀地分布元素,减少哈希冲突。
扩容的频率
扩容的频率取决于 HashMap 的使用情况和元素的分布情况:
-
正常使用情况下:
- 在正常使用情况下,
HashMap会根据负载因子进行扩容,以保持良好的性能。 - 扩容是一个相对频繁的操作,特别是在
HashMap中不断添加新元素的情况下。
- 在正常使用情况下,
-
哈希碰撞情况下:
- 当某个桶中的链表长度超过 8 并且数组长度小于 64 时,
HashMap会选择扩容。这种情况确实只会发生在哈希碰撞的时候。 - 由于哈希函数的设计和元素的分布情况,极端的哈希碰撞情况应该不会很多,但在某些特定情况下(如哈希函数设计不佳或元素分布不均匀),可能会发生。
- 当某个桶中的链表长度超过 8 并且数组长度小于 64 时,
总结
-
扩容的触发条件:
- 主要由负载因子决定,当元素数量超过
容量 * 负载因子时,HashMap会进行扩容。 - 当某个桶中的链表长度大于 8 并且数组长度小于 64 时,
HashMap会选择扩容而不是将链表转换为红黑树。
- 主要由负载因子决定,当元素数量超过
-
扩容的过程:
- 增加容量,通常是加倍。
- 重新哈希所有现有的元素,并分布到新的数组中。
-
扩容的频率:
- 在正常使用情况下,扩容是一个相对频繁的操作。
- 在极端的哈希碰撞情况下,扩容会减少链表长度,避免性能下降。
Hash 算法
HashMap 的哈希算法在 Java 中是一个关键部分,它决定了如何将键映射到数组中的索引位置。扩容时,哈希值本身并没有改变,但由于数组的大小发生了变化,元素在数组中的位置可能会改变。以下是对 HashMap 哈希算法和扩容过程中哈希值重新计算的详细解释。
HashMap 的哈希算法
1. 哈希函数
HashMap 使用键的 hashCode 方法生成哈希值,然后通过进一步处理来计算数组中的索引位置。以下是一个简化的哈希函数示例:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
key.hashCode():调用键的hashCode方法生成哈希值。h ^ (h >>> 16):将哈希值的高位和低位进行异或运算,以增加哈希值的随机性和分布性。
2. 计算索引
计算数组中的索引位置时,使用哈希值和数组长度进行按位与运算:
int index = (n - 1) & hash;
n:数组的长度。(n - 1) & hash:通过按位与运算将哈希值映射到数组的索引范围内。
扩容时的哈希值重新计算
1. 扩容的原因
当 HashMap 中的元素数量超过一定阈值(容量 * 负载因子)时,HashMap 会进行扩容。扩容通常会将数组的大小加倍。
2. 重新计算索引
扩容时,所有现有的元素需要重新计算索引并分布到新的数组中。虽然哈希值本身没有改变,但由于数组的大小发生了变化,元素在数组中的位置可能会改变。
假设原数组长度为 n,扩容后数组长度为 2n。对于一个元素,其哈希值 hash 的索引计算如下:
- 原索引:
index = (n - 1) & hash - 新索引:
newIndex = (2n - 1) & hash
由于 2n 是 n 的两倍,2n - 1 的二进制表示比 n - 1 多一位低位的 1。这意味着新索引 newIndex 可能会与原索引 index 不同。
3. 示例
假设原数组长度为 16(n = 16),扩容后数组长度为 32(2n = 32)。对于一个哈希值 hash,其原索引和新索引的计算如下:
- 原索引:
index = (16 - 1) & hash = 15 & hash - 新索引:
newIndex = (32 - 1) & hash = 31 & hash
由于 31 的二进制表示比 15 多一位低位的 1,新索引 newIndex 可能会与原索引 index 不同。
示例代码
以下是一个简化的示例代码,展示了 HashMap 在扩容时如何重新计算索引:
public class HashMapExample {
public static void main(String[] args) {
int n = 16; // 原数组长度
int newN = 32; // 扩容后数组长度
int hash = 123456; // 示例哈希值
int index = (n - 1) & hash;
int newIndex = (newN - 1) & hash;
System.out.println("Original index: " + index);
System.out.println("New index after resizing: " + newIndex);
}
}
输出
Original index: 8
New index after resizing: 24
总结
- 哈希函数:
HashMap使用键的hashCode方法生成哈希值,并通过进一步处理计算数组中的索引位置。 - 扩容的原因:当
HashMap中的元素数量超过一定阈值时,HashMap会进行扩容,通常将数组的大小加倍。 - 重新计算索引:扩容时,所有现有的元素需要重新计算索引并分布到新的数组中。哈希值本身没有改变,但由于数组的大小发生了变化,元素在数组中的位置可能会改变。