从 0 到 1 读懂:哈希表 待我称王封你为后i 2024-03-22 23:12 16阅读 0赞 #### 哈希表 #### * 一、什么是哈希表? * 二、两种散列函数构造方法 * * 1、直接定址法 * 2、除留余数法(常用) * 三、散列地址冲突 * 四、常用冲突处理 * * 1、负载因子调节(减少冲突概率) * 2、开放定址法(闭散列) * * (1)线性探测 * (2)二次探测 * 3、链地址法(开散列) * 4、冲突严重时的解决办法 * 五、Java 中 HashMap 的实现 * * 1、定义map结点 * 2、put 插入方法的实现 * 3、get 查询方法的实现 ## 一、什么是哈希表? ## 一般来说,搜索的效率取决于搜索过程中元素的比较次数。例如顺序结构中,查找一个元素时需要挨个比对元素,因此时间复杂度为 O ( N ) O(N) O(N)。对于一颗平衡的搜索树,查找的时间复杂度为树的高度,即 O ( l o g 2 ( N ) ) O(log\_2(N)) O(log2(N)). 而上述都并不是理想的搜索方法,理想的搜索方法是,可以不经过任何比较,一次直接从表中得到要搜索的元素。 这时就引出了`哈希表`的数据结构。哈希表就是通过(散列函数)哈希函数,使元素的存储位置与它的关键码 key 之间能够建立一一映射的关系,那样我们在查找关键字时,不需要比较就可获得需要的记录的存储位置。时间复杂度可达到 O ( 1 ) O(1) O(1). 采用`散列技术`将记录存储在一块连续的存储空间中,这块连续存储空间称为`散列表或哈希表`(HashTable). ## 二、两种散列函数构造方法 ## ### 1、直接定址法 ### 如果我们现在要对 0~100 岁的人口数字统计表,那么我们对年龄这个关键字就可以直接用年龄的数字作为地址。此时 f ( k e y ) = k e y f ( key) =key f(key)=key. 如果我们现在要统计的是 80 后出生年份的人口数,那么我们对出生年份这个关键字可以用年份减去 1980 来作为地址。此时 f ( k e y ) = k e y − 1980 f ( key) = key-1980 f(key)=key−1980. 也就是说,我们可以取关键字的某个线性函数值为散列地址,即 f ( k e y ) = a × k e y + b f ( key ) =a \\times key+b f(key)=a×key\+b (a 为常数). 这样的散列函数优点就是简单,均匀,也不会产生`冲突`,但问题是这需要事先知道关键字的分布情况,适合查找表较小且连续的情况。由于这样的限制,在现实应用中,此方法虽然简单,但却并不常用 ### 2、除留余数法(常用) ### 此方法为最常用的构造散列函数方法,对于散列表长为 m 的散列函数公式为: f ( k e y ) = k e y / p f ( key) = key / p f(key)=key/p ,(p <= m). ![d564583429274fec8bf8671eef1ed200.png][] 很显然,本方法的关键就在于选择合适的 p 如果选得不好,就可能会容易产生`冲突`。根据前辈们的经验,若散列表表长为 `m` 通常 `p` 为小于或等于表长(最好接近 m )的最小质数。 ## 三、散列地址冲突 ## 在上文中,提到了`冲突`这个概念,那么到底什么是`冲突`呢? 在理想的情况下,每一个关键字,通过散列函数计算出来的地址都是不一样的,可现实中,这只是个理想。我们时常会碰到两个关键字 k e y 1 ≠ k e y 2 key\_1 \\neq key\_2 key1=key2,但是却有 f ( k e y 1 ) = f ( k e y 2 ) f(key\_1) = f(key\_2) f(key1)=f(key2),这种现象我们称为冲突 (oollision) ,并把 k e y 1 key\_1 key1、 k e y 2 key\_2 key2 称为这个散列函数的同义词 (synonym)。出现了冲突当然非常糟糕,那将造成数据查找错误。尽管我们可以通过精心设计的散列函数让冲突尽可能的少,但是不能完全避免。 ## 四、常用冲突处理 ## ### 1、负载因子调节(减少冲突概率) ### 散列表的负载因子定义为:`loadFactor = 填入表中的元素个数 /散列表的长度` `loadFactor` 是散列表装满程度的标志因子。由于表长是定值,`loadFactor` 与“`填入表中的元素个数`”成正比,所以,`loadFactor` 越大,表明填入表中的元素越多,产生冲突的可能性就越大;反之,Q越小,标明填入表中的元素越少,产生冲突的可能性就越小。 常见的负载因子阈值通常为 0.7 或 0.8,一些采用开放定址法的hash库,如Java的系统库限制了荷载因子为0.75。当负载因子超过一定阈值时,可能会导致哈希冲突的增加,因此我们会对负载因子进行调节,由于表长是一定的,所以一般采用扩容的方式增加哈希表的大小,从而降低负载因子。扩容后,原有的元素需要重新计算哈希值和位置,并存储到新的哈希表中。 ### 2、开放定址法(闭散列) ### 开放定址法就是一旦发生了冲突,就去寻找下一个空的散列地址,只要散列表足够大,空的散列地址总能找到,并将记录存入。根据寻找空散列的方式,可以分为以下两种: #### (1)线性探测 #### 线性探测就是从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止。 **公式**: f i ( k e y ) = ( f ( k e y ) + d i ) / m , ( d = 1 , 2 , . . . . , m − 1 ) f\_i ( key ) = ( f ( key ) +d\_i ) / m,(d =1,2,....,m-1) fi(key)=(f(key)\+di)/m,(d=1,2,....,m−1) ![362dd1da4f9b4d23bbb4e7e89ba5567b.png][] #### (2)二次探测 #### 从上面例子中可以看到,我们在解决冲突的时候,还会碰到如 `25` 这种本来都不是同义词却要争夺一个地址的情况 ,称这种现象为堆积。很显然,堆积的出现,使得我们需要不断处理冲突,无论是存入还是查找效率都会大大降低。为了解决上述种问题,提出了二次探测法,双向寻找到可能的空位置。 **公式**: f i ( k e y ) = ( f ( k e y ) + d i ) / m , ( d i = 1 2 , − 1 2 , 2 2 , − 2 2 , . . . , q 2 , − q 2 , q < = m / 2 ) f\_i ( key ) = ( f (key) +d\_i) / m ,(d\_i=1^2,-1^2,2^2,-2^2,...,q^2,-q^2,q<=m/2) fi(key)=(f(key)\+di)/m,(di=12,−12,22,−22,...,q2,−q2,q<=m/2) 例如使用二次探测,再向上述HashTable中插入 `34`: ![d3dc94ed920a43399d1a4e41187fbb94.png][] ### 3、链地址法(开散列) ### 首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。此时,已经不存在什么冲突换址的问题了,无论有多少个冲突,都只是在当前位置给单链表增加结点的问题。 ![30e9e21ded244a80904f3981f6bae143.png][] ### 4、冲突严重时的解决办法 ### 上面提到了,哈希桶其实可以看作将大集合的搜索问题转化为小集合的搜索问题了,那如果冲突严重,就意味 着小集合的搜索性能其实也时不佳的,这个时候我们就可以将这个所谓的小集合搜索问题继续进行转化,例如: > 1. 每个桶的背后是另一个哈希表 > 2. 每个桶的背后是一棵搜索树 ## 五、Java 中 HashMap 的实现 ## ### 1、定义map结点 ### HashMap底层使用哈希表,下面就使用哈希表简单实现一下,HashMap 中的 `插入` 和 `查询` 方法。首先是定义 map 结点: public class MyHashMap<K,V> { // 定义 map 结点:key-value 模型 static class Node<K,V> { public K key; public V value; public Node<K,V> next; public Node(K key, V value) { this.key = key; this.value = value; } } // 创建哈希表,初始容量为 10 public Node<K,V>[] hashTable = (Node<K, V>[]) new Node[10]; // 哈希表中有效元素个数 public int usedSize; // 负载因子,此处取0.75(负载因子 = 填入表中元素个数/散列表长度) public static final double LOAD_FACTOR = 0.75; } ### 2、put 插入方法的实现 ### 再进行插入操作时,我们需要根据结点 key 值计算散列位置,由于 key 不一定为数值类型,我们不能直接使用它散列函数的计算。此时我们需要用到 hashCode() 方法: > `hashCode()` 方法可以获取对象的哈希码,它是一个整数,用来表示对象的状态,且哈希码具有唯一性。 所以我们可以通过 hashCode() 方法求得 key 的哈希码,然后根据得到的哈希码带入散列函数去计算散列位置。这里需要注意一点是: > 使用 `hashCode()` 获取到的哈希码是一个有符号的整形,这也就意味着得到的哈希码有可能是负数,而哈希表的底层是一个数组,它的下标值不可能为负数,因此需要将得到的哈希码按位与上`Integer.MAX_VALUE` 也就是 `0x7FFFFFFF`,使得到的值是一个正数。 // 1.put(K key,V value):插入操作 public void put(K key,V value) { // 计算 key 值的 hash 值 int hash = key.hashCode() & Integer.MAX_VALUE; // 取留余数法计算散列位置 int index = hash % hashTable.length; // 拿到散列位置的哈希桶 Node<K,V> cur = hashTable[index]; // 判断是否已经存在key值(哈希表中不允许存在相同key值) while (cur != null) { if (cur.key.equals(key)) { // 如果存在 key 相同的元素,更新 value 值 cur.value = value; return; } cur = cur.next; } // 采用头插法 Node<K,V> newNode = new Node<>(key, value); newNode.next = hashTable[index]; hashTable[index] = newNode; usedSize++; // 为减少冲突率,我们要判断负载因子是否超过预设值 if (calculate_LoadFactor() >= LOAD_FACTOR) { // 如果大于等于负载因子,需要扩容 resize(); } } // (1) calculate_LoadFactor():计算此时负载因子 private double calculate_LoadFactor() { return usedSize * 1.0 / hashTable.length; } // (2) resize():扩容并重新计算哈希值和位置,并存储到新的哈希表中。 private void resize() { // 扩容 Node<K,V>[] newhashTable = (Node<K, V>[]) new Node[2* hashTable.length]; // 因为改变了散列表长度,需要重新将每一个结点散列到新的位置 for (int i = 0; i < hashTable.length; i++) { Node<K,V> cur = hashTable[i]; while (cur != null) { // 记录curNext的位置,防止后续调整丢失 Node<K,V> curNext = cur.next; // 重新计算哈希值 int hash = cur.key.hashCode() & Integer.MAX_VALUE; // 根据散列函数计算散列位置 int index = hash % newhashTable.length; // 头插法 cur.next = newhashTable[index]; newhashTable[index] = cur; cur = curNext; } } // 更新哈希表 hashTable = newhashTable; } ### 3、get 查询方法的实现 ### // 2.get(K key):查询 public V get(K key) { // 计算哈希值 int hash = key.hashCode() & Integer.MAX_VALUE; // 根据散列函数计算散列位置 int index = hash % hashTable.length; // 遍历哈希桶,寻找元素 Node<K,V> cur = hashTable[index]; while (cur != null) { if (cur.key.equals(key)) { // 找到返回 值value return cur.value; } cur = cur.next; } // 找不到返回 null return null; } [d564583429274fec8bf8671eef1ed200.png]: https://image.dandelioncloud.cn/pgy_files/images/2024/03/14/bfc397d727404a849feec238da48f6a6.png [362dd1da4f9b4d23bbb4e7e89ba5567b.png]: https://image.dandelioncloud.cn/pgy_files/images/2024/03/14/c85c25705b18440eb98616ca8deaf69b.png [d3dc94ed920a43399d1a4e41187fbb94.png]: https://image.dandelioncloud.cn/pgy_files/images/2024/03/14/fcd3e6132da34e1d94c9b696faf78254.png [30e9e21ded244a80904f3981f6bae143.png]: https://image.dandelioncloud.cn/pgy_files/images/2024/03/14/50660a94fbd541e590fb92e7d3b7c7db.png
相关 从根到叶:深度理解哈希表 哈希函数是一种将输入数据映射为固定大小哈希值的算法,用于在哈希表中实现高效的数据查找、插入和删除。哈希冲突是指不同的输入数据经过哈希函数计算后得到相同的哈希值,这可能导致... 冷不防/ 2024年04月26日 01:39/ 0 赞/ 19 阅读
相关 从 0 到 1 读懂:哈希表 哈希表 一、什么是哈希表? 二、两种散列函数构造方法 1、直接定址法 2、除留余数法(常用) 三、散列地址冲突 四、常用 待我称王封你为后i/ 2024年03月22日 23:12/ 0 赞/ 17 阅读
相关 哈希表 上一篇博客:[静态链表的介绍及实现][Link 1] > 写在前面:大家好!我是`AC-fun`,我的昵称来自两个单词`Accepted`和`fun`。我是一个热爱ACM的 ╰半夏微凉°/ 2022年10月30日 07:24/ 0 赞/ 165 阅读
相关 哈希表 我们知道,通过对数组进行直接寻址(Direct Addressing),可以在 O(1) 时间内访问数组中的任意元素。所以,如果存储空间允许,可以提供一个数组,为每个可能的关键 快来打我*/ 2022年06月05日 02:20/ 0 赞/ 302 阅读
相关 哈希表 1. 什么是哈希表 我们先来做个题(leetCode上387题) ![70][] public class Solution_387 { 朱雀/ 2022年05月16日 10:11/ 0 赞/ 253 阅读
相关 哈希表 哈希表是种数据结构,它可以提供快速的插入操作和查找操作。第一次接触哈希表时,它的优点多得让人难以置信。不论哈希表中有多少数据,插入和删除(有时包括侧除)只需要接近常量的时间即0 今天药忘吃喽~/ 2022年02月01日 14:36/ 0 赞/ 348 阅读
相关 【哈希表】 char FirstNotRepeatingChar(char pString) { // invalid input if(! r囧r小猫/ 2022年01月06日 11:33/ 0 赞/ 274 阅读
相关 哈希表 一、简介 如果所有的键都是小整数,那么我们可以用一个数组来实现无序的符号表,将键作为数组的索引i而数组中i(键)处储存的就是对应的值。 这样就可以快速地访问任意键的值, 旧城等待,/ 2021年12月22日 01:21/ 0 赞/ 347 阅读
相关 0.4、HashMap——从hashCode() 到哈希表 文章目录 前言 本文代码基于 JDK1.8 JDK 中的 hashCode() 墨蓝/ 2021年12月21日 14:51/ 0 赞/ 134 阅读
相关 哈希表 【一】哈希表 > 他通过把关键码值映射到表中的一个位置来访问记录,以加快查找的速度。这个映射函数就是散列函数。 ![watermark_type_ZmFuZ3poZW5na 傷城~/ 2021年08月11日 17:13/ 0 赞/ 490 阅读
还没有评论,来说两句吧...