目录
♫哈希表
♪什么是哈希表
在顺序结构以及平衡树 中,元素关键码与其存储位置之间没有对应的关系,因此在 查找一个元素时,必须要经过关键 码的多次比较 。其中 顺序查找时间复杂度为 O(N) ,平衡树查找的时间复杂度为 O(logn ),那还有没有一种更高效的查找结构呢?哈希表 就是这样的一种数据结构,它通过 哈希函数 (Hash Function)将键值映射到表中的位置以进行快速检索,因此 可以不经过任何比较,一次直接从表中得到要搜索的元素。哈希表查找的平均 时间复杂度为O(1) 。♪插入元素
将一个键值对进行插入操作之前,需要先根据哈希函数计算出该键值对的哈希值,然后根据哈希值找到相应的位置, 按此位置进行存放。♪搜索元素
对元素的关键码进行同样的计算,把求得的函数值当做元素的存储位置,在结构中按此位置取元素比较,若关键码相等,则搜索成功。♪删除元素
从哈希表中删除一个元素,同样需要先计算出该元素的哈希值,然后找到对应的位置,再删除对应位置的元素。♪常见的哈希函数
1. 直接定制法--(常用)取关键字的某个线性函数为散列地址:Hash( Key )= A*Key + B 优点:简单、均匀 缺点:需要事先知道关键字的分布情况。使用场景:适合查找比较小且连续的情况 。2. 除留余数法--(常用)设散列表中允许的地址数为m ,取一个不大于 m ,但最接近或者等于 m 的质数p作为除数,按照哈希函数:Hash(key) = key% p(p<=m),将关键码转换成哈希地址。3. 平方取中法--(了解)假设关键字为1234 ,对它平方就是 1522756 ,抽取中间的 3 位 227 作为哈希地址;再比如关键字为 4321,对它平方就是18671041,抽取中间的 3 位 671( 或 710) 作为哈希地址平方取中法比较适合:不知道关键字的分布,而位数又不是很大的情况。4. 折叠法 --( 了解)折叠法是将关键字从左到右分割成位数相等的几部分( 最后一部分位数可以短些 ),然后将这几部分叠加求和,并按散列表表长,取后几位作为散列地址。 折叠法适合事先不需要知道关键字的分布,适合关键字位数比较多的情况。5. 随机数法 --( 了解)选择一个随机函数,取关键字的随机函数值为它的哈希地址,即H(key) = random(key), 其中 random为随机数函数。 通常应用于关键字长度不等时采用此法。6. 数学分析法 --( 了解)设有n 个 d 位数,每一位可能有 r 种不同的符号,这 r种不同的符号在各位上出现的频率不一定相同,可能在某些位上分布比较均匀,每种符号出现的机会均等,在某些位上分布不均匀只有某几种符号经常出现。可根据散列表的大小,选择其中各种符号分布均匀的若干位作为散列地址。♪哈希冲突
当两个或多个输入值在经过同一个哈希函数计算后可能会得到相同的哈希值,从而造成数据冲突,这种情况就叫作哈希冲突。哈希冲突是不可避免的,但可以通过合理的哈希算法设计和调优来尽量减少冲突的发生。处理哈希冲突的方法包括开放地址法、链表法、再哈希法等。♪负载调节因子
哈希表的负载调节因子是指哈希表中存储元素的比例,通常表示为 α = n/m,其中 n 是哈希表中存储的元素数量,m 是哈希表的大小。负载调节因子可以用来衡量哈希表的空间利用率和性能,通常情况下,一个合理的负载调节因子应该在 0.5 到 0.8 之间。当哈希表负载因子过高时,会导致哈希冲突的概率增加,进而影响查找、插入和删除的效率,因此需要进行扩容操作。扩容操作会重新调整哈希表的大小,从而使负载因子降低到合理的范围。而当哈希表负载因子过低时,会导致哈希表中存在大量的空闲位置,从而浪费空间。因此,在实际应用中,需要根据具体情况来确定合适的负载因子。♪闭散列法解决哈希冲突
闭散列法的基本思想是让冲突的元素继续寻找散列表中的下一个空槽,直到找到一个空槽为止。常用的闭散列方法包括线性探测、二次探测和双重哈希。
♩线性探测:当发生哈希冲突时,线性探测从冲突的槽的下一个槽开始,依次查找每个槽,直到找到一个空槽为止。
♩二次探测:在发生哈希冲突时,二次探测从冲突槽的下一个槽开始,依次查找每个槽,但是探测的步长是由一个二次函数计算出来的,例如:步长为1^2,2^2,3^2,4^2,…,直到找到一个空槽。
♩双重哈希:当发生哈希冲突时,双重哈希使用一个额外的哈希函数计算出一个新的哈希值,然后使用该哈希值来查找散列表中的下一个槽,直到找到一个空槽为止。
闭散列法的优点是它可以避免在散列表中存储指针所需要的额外空间,然而当负载因子较大时,闭散列法的性能可能会变得很差,这时候就有了开链法。
♪开链法解决哈希冲突
开链法的基本思想是将哈希表中每个位置上的元素不再是单个的值,而是一个链表,当发生哈希冲突时,将新的元素添加到链表的末尾。具体实现步骤如下:
①. 初始化一个数组,数组中每个元素是一个链表。
②. 对于每个要插入哈希表的元素,计算它的哈希值。
③. 查找该哈希值对应的链表,遍历该链表查找是否已经存在相同的元素,如果存在则直接返回,如果不存在则将该元素添加到链表末尾。
④. 在进行查找时,同样需要计算该元素的哈希值,找到对应的链表后再遍历链表查找相应元素。
开链法的优点是简单易实现,同时可以避免哈希冲突,但是在哈希表中存在大量元素时,链表的遍历可能会降低查找效率,因此需要根据实际情况进行权衡和优化。
♪性能分析
虽然哈希表一直在和冲突做斗争,但在实际使用过程中,我们认为哈希表的冲突率是不高的,冲突个数是可控的,也就是每个桶中的链表的长度是一个常数,所以,通常意义下,我们认为哈希表的插入/ 删除 / 查找时间复杂度是 O(1) 。♪Java集合中哈希表的应用
① . Map 和 Set 的实现类中 HashMap 和 HashSet 就是 利用哈希表实现。② . java 中使用的是开链法解决哈希冲突的③ . java 会在冲突链表长度大于一定阈值后,将链表转变为搜索树(红黑树)④ . java 中计算哈希值实际上是调用的类的 hashCode 方法,进行 key 的相等性比较是调用 key 的 equals 方法。所以如果要用自定义类作为 HashMap 的 key 或者 HashSet 的值, 必须覆写 hashCode 和 equals 方 法 ,而且要做到 equals 相等的对象, hashCode 一定是一致的。
♫模拟实现HashMap
接下来我们就来模拟实现一个简单的HashMap,采用数组和链表结合的方式来存储键值对(哈希函数采用除留余数法,哈希冲突采用开链法解决):
♪定义节点和成员属性
public class HashBuck<K,V> { //静态内部类定义节点 static class Entry<K,V> { public K key; public V val; public Entry<K,V> next; //通过构造函数初始化key和val public Entry(K key, V val) { this.key = key; this.val = val; } } //数组的初始大小 private int DEFAULT_SIZE = 16; //存放节点的数组 public Entry<K,V>[] array; //节点的有效个数 public int usedSize; //初始化数组 public HashBuck() { array = new Entry[DEFAULT_SIZE]; } }
♪resize()
这里采用二倍扩容的方式,扩容后还需要将扩容后的数组大小代入哈希函数重新计算并分配地址:
//扩容 private void resize() { Entry<K,V>[] newArray = (Entry<K, V>[]) new Entry[array.length*2]; for (int i = 0; i < array.length; i++) { Entry<K,V> cur = array[i]; while (cur != null) { Entry<K,V> curNext = cur.next; int newIndex = cur.key.hashCode() % newArray.length; cur.next = newArray[newIndex]; newArray[newIndex] = cur; cur = curNext; } } array = newArray; }
♪put()
计算键的hash值和对数组长度取模,以此确定该键值对应的数组下标,在对应下标处采用头插法插入,插入后若负载因子过大还需要扩容:
//插入 public void put(K key,V val) { int hash = key.hashCode(); int index = hash % array.length; Entry<K,V> cur = array[index]; while (cur != null) { if(cur.key.equals(key)) { cur.val = val; return; } cur = cur.next; } //头插 Entry<K,V> entry = new Entry<>(key, val); entry.next = array[index]; array[index] = entry; usedSize++; //负载因子过大,扩容 if (loadFactor() > 0.75f) { resize(); } } //获取当前负载因子的大小 private float loadFactor() { return usedSize*1.0f / array.length; }
♪get()
get方法先计算键的hash值和对数组长度取模确定对应的数组下标。如果该位置为null,则返回null;遍历链表找到对应的键值,找到了返回对应节点,没找到返回null:
public V get(K key) { int hash = key.hashCode(); int index = hash % array.length; Entry<K,V> cur = array[index]; while (cur != null) { if(cur.key.equals(key)) { return cur.val; } cur = cur.next; } return null; }
♫HashMap的一些注意事项
Java集合中HashSet的底层是HashMap,HashMap的底层是哈希桶,它们需要注意:
①.在Java底层中,第一次put时才会为HashMap开辟内存空间。
②.new HashMap<>(1000)时,实际开辟的内存空间为1024(大于等于1000且最接近1000的二次幂的值)。
③.hashCode用于支持基于哈希的集合类(方法返回的值应该尽可能地分布均匀),equals用于比较两个对象是否相等(重写后需要保证相等的对象具有相同的hashCode)。hashCode相同,equals不一定相同;equals相同,hashCode一定相同。
④.当HashMap满了后,扩容时需要注意对HashMap的所有数据进行重新哈希操作。
⑤.当HashMap的元素数量达到一定阈值(默认为8)并且桶的数量超过了当前数组的长度(默认为16),此时HashMap就会触发扩容操作。在扩容操作中,如果桶中元素的个数大于等于8,且当前数组的长度大于等于64,那么这个桶就会被转化为红黑树结构,以提高查找、删除等操作的效率。