一、HashSet整体介绍
HashSet 是 Java 中的一个集合类,它实现了 Set 接口,用于存储不重复的元素。它是基于哈希表的数据结构实现的。
HashSet 的特点如下:
- 不允许存储重复的元素:HashSet 中的元素是唯一的,如果尝试将重复的元素添加到 HashSet 中,添加操作将被忽略。
- 无序性:HashSet 中的元素没有固定的顺序,元素的存储和检索顺序是不确定的。
- 允许存储 null 元素:HashSet 允许存储 null 元素,但只能存储一个 null 值。
HashSet 的内部实现是基于哈希表(HashMap)的,它使用哈希函数将元素映射到数组的索引位置。HashSet 的底层数据结构是一个数组,每个数组索引处存储一个链表(或者在 JDK 1.8 之后,当链表长度超过阈值时,会转换为红黑树)。
HashSet 的主要操作包括添加元素、删除元素、判断元素是否存在和遍历元素。添加元素使用 add()
方法,删除元素使用 remove()
方法,判断元素是否存在使用 contains()
方法,遍历元素可以使用迭代器或者增强型 for 循环。
当在 HashSet 中执行添加、删除和判断元素是否存在的操作时,会根据元素的哈希值和相等性进行查找和操作。因此,为了正确使用 HashSet,需要确保存储的元素正确实现了 hashCode()
和 equals()
方法。
HashSet 的性能在大多数操作上都是常数时间复杂度 O(1),但在哈希冲突较多时,链表的遍历或者红黑树的操作可能会导致性能下降,最坏情况下的时间复杂度为 O(n)。
二、HashSet的扩容机制是怎么样的?
需要注意的是,HashSet 是非线程安全的,如果在多个线程中同时访问和修改 HashSet,必须采取额外的同步措施或者使用线程安全的集合类。
当 HashSet 中的元素数量超过数组长度的0.75倍时,就会触发扩容操作。HashSet 的扩容机制是为了在保持性能的同时,尽量减少哈希冲突的发生。
HashSet 的扩容过程包括以下步骤:
- 创建一个新的、更大容量的数组。
- 将旧数组中的元素逐个重新计算哈希值,并根据新数组的长度计算新的索引位置。
- 将元素插入到新数组的对应索引位置上。
- 重复步骤2和步骤3,直到将旧数组中的所有元素都插入到新数组中。
- 将 HashSet 的数组引用指向新的数组。
扩容操作的目的是为了增加数组的容量,从而减少哈希冲突的概率。当数组的容量不足时,即使哈希函数分布良好,也会出现多个元素被映射到同一个数组索引的情况,从而导致链表或树结构的形成,影响查找和插入的效率。
通过扩容操作,HashSet 会创建一个更大的数组,并重新计算每个元素在新数组中的索引。这样,元素在新数组中的分布会更加均匀,减少哈希冲突的发生,提高了查找和插入的性能。
为什么选择0.75作为扩容的触发因子呢?这是一个经验值,经过实践得出的一个平衡点。当数组长度达到容量的0.75倍时,既能够保持较低的哈希冲突率,又能够减少频繁的扩容操作,提高性能。
需要注意的是,扩容操作是一个相对耗时的操作,因为需要重新计算元素的哈希值和重新插入到新数组中。因此,在预知元素数量较大的情况下,可以通过构造函数或者 initialCapacity
参数提前指定初始容量,以减少扩容操作的次数,提高性能。
三、什么是哈希冲突?
哈希冲突指的是不同的元素通过哈希函数计算得到相同的哈希值,从而导致它们在哈希表中被映射到相同的数组索引位置。
在哈希表中,通过哈希函数将元素映射到数组的索引位置。理想情况下,每个元素都应该通过哈希函数计算得到唯一的哈希值,并被映射到不同的数组索引上,这样可以达到快速的查找和插入操作。
然而,在实际情况中,由于哈希函数的计算过程无法避免的会产生冲突。哈希函数的输出空间是有限的,而输入空间是无限的,这就意味着不同的元素可能会产生相同的哈希值。
当不同的元素经过哈希函数计算后得到相同的哈希值时,就会发生哈希冲突。这会导致不同的元素被映射到相同的数组索引位置,形成链表或树结构。在哈希表中查找或插入元素时,就需要在这些冲突的元素中进行进一步的查找或插入操作,从而影响了查找和插入的效率。
为了解决哈希冲突,哈希表中通常采用的方法是使用链表或树来处理冲突的元素。当哈希冲突发生时,将新的元素插入到链表或树的末尾,或者在链表长度超过一定阈值时,将链表转换为红黑树。这样可以提高查找和插入的效率。
然而,当哈希冲突过多时,链表或树的长度会过长,导致性能下降。为了尽量减少哈希冲突的发生,可以通过合理设计哈希函数、增加数组的长度(扩容)等方式来优化哈希表的性能。
四、哈希函数是怎么计算哈希值的?计算出哈希值之后又是怎么映射到数组上的?
哈希函数是将输入的数据转换成哈希值的一种算法。它的目的是将数据尽可能均匀地映射到哈希表的索引位置上,以便实现高效的查找和插入操作。
哈希函数的计算过程通常包括以下几个步骤:
- 将输入的数据(例如字符串、数字等)转换成一个整数或固定长度的字节数组。
- 对这个整数或字节数组进行一系列计算,如位运算、数学运算、异或操作等,以获取一个哈希码。
- 将哈希码映射到哈希表的数组索引位置上,通常使用取模运算(对数组长度取模)来实现。
在映射到数组索引位置时,取模运算可以将哈希码的值限定在哈希表数组的有效范围内,确保映射到正确的索引位置。例如,如果哈希表的数组长度是10,哈希码为25,那么取模运算就会将其映射到索引位置为5的数组上。
需要注意的是,好的哈希函数应该具有以下特点:
- 输出的哈希值应该尽可能均匀地分布在哈希表的索引位置上,以减少哈希冲突的发生。
- 输入相同的数据应该始终得到相同的哈希值,保证查找和插入的正确性。
- 哈希函数的计算应该尽量高效,避免耗费过多的时间和计算资源。
哈希函数的选择会根据具体的应用场景和数据特点来确定。常见的哈希函数包括 MD5、SHA-1、SHA-256 等。在实际应用中,也可以根据数据的特点设计自定义的哈希函数。