C语言数据结构之Hash散列表
作者:Abstracted
一、散列表
1.1 散列表
散列表(哈希表),其思想主要是基于数组支持按照下标随机访问数据,时间复杂度为O(1)的特性。
可以说是数组的一种拓展。
假设,我们为了方便记录某高校数学专业的某个学生的信息,要求可以按照学号(入学时间+年级+专业+专业内自增序号,如2019 1001 0002)能够快速找到某个学生的信息。
这个时候我们可以取学号的自增序号部分,即后四位作为数组的索引下表,把学生相应的信息存储到对应的内存空间内即可。
如上图所示,我们把学号作为Key,通过截取学号后四位的函数计算后得到索引下标,将数据存储到数组中。
当我们按照键值(学号)查找时,只需要再次计算出索引下标,然后取出相应数据即可。
以上便是散列思想。
计算下标的函数我们叫它为 散列函数 或 哈希函数
1.2 散列函数
上面的例子中,截取学号后四位的函数就是一个简单的散列函数。
//散列函数 伪代码 int Hash(string key) { // 获取后四位字符 string hashValue =int.parse(key.Substring(key.Length-4, 4)); // 将后两位字符转换为整数 return hashValue; }
在这里散列函数的作用就是将Key值映射成数组的索引下标。
关于散列函数的设计方法有很多,如:直接寻址法、数字分析法、随机数法、取余法等等。
但即使再优秀的设计方法也不能够避免散列冲突。
在散列表中散列函数不应设计的太复杂,不然太复杂的散列函数会造成更多的性能消耗,结果就适得其反了。
1.3 散列冲突
就像图上所表达的一样,不同的key通过散列函数计算出来的hash值相同,造成了这两个数据都需要存储在这个同一个下标(内存空间)上。
散列函数具有确定性和不确定性:
- 确定性:哈希的散列值不同,那么哈希的原始输入绝对不相同。即hash(key1) ≠ hash(key2),key1≠key2
- 不确定性:同一个散列值很有可能对应多个不同的原始输入。即hash(key1) =hash(key2),key1≠key2
散列冲突:即key1≠key2,hash(key1)=hash(key2)的情况,散列冲突是不可避免的,如果我们key的个数为100,而数组的索引数量只有50,那么再优秀的算法也无法避免散列冲突。
关于散列冲突也有很多解决办法,这里简单说明两种: 开放寻址法 和 链表法 。
1.3.1 开放寻址法
开放寻址法的核心思想是,如果出现了散列冲突,我们就重新探测一个空闲位置,将其插入。
比如我们可以使用线性探测法。当我们散列表中插入数据时,如果某个数据的key经过散列函数散列之后,散列值对应的存储位置(下标)已经被占用,那么就从这个被占用的位置开始,依次往后进行寻找空闲的位置,如果遍历到尾部都没有找到空闲的位置,那么我们就在从表头开始找,知道找到为止。
散列表中查找元素的时候,我们通过散列函数求出要查找元素的键值对应的散列值,然后比较数组中下标为散列值的元素中的key和要查找的元素的key,如果相等则说明我就是我们要找的元素,否则就按顺序往后依次查找。如果遍历到数组中的空闲位置还是没有找到想要的元素,就说明我们的找的元素不在该散列表中。
好,虽然使用开放寻址法完成了键值对的存储和查找,但是对删除操作稍微有些特别,不能单纯的把要删除的元素设置为空。因为在查找的时候,我们通过线性探测法就会按顺序向后面的内存空间(下标)一个一个的找,直到找到对应的value或空为止,但是如果这时候碰到一个内存空间(下标)中的值是空,而这个空位置是我们删除了其他元素后置为空的,就导致这个查找算法失效。 我们可以将删除的元素,特殊的标记为deleted。当线性探测查找的时候,遇到标记为deleted的空间并不是停下来,而是继续向下探测。
线性探测法存在很大的问题。当散列表中插入的数据越来越多时,其散列冲突的可能性就会越来越大,在极端情况下甚至要探测整个散列表,因此最坏的时间复杂度为O(N)。在开放寻址法中,除了线性探测法,我们可以使用二次探测和双重散列等方式。
1.3.2 链表法
链表法是一种比较常见的散列解决办法,Java的HashMap和Redis的Hash结构就是使用的链表法来解决散列冲突。 链表法的原理:如果遇到冲突,就会在原地址上新建一个空间,让已经存在的元素中的next属性指向当前空间的地址。以链表节点的形式插入到该空间。 当插入的时候我们只需要通过散列函数计算出对应的散列槽位,将其插入到对应链表中即可。
1.3.3 负载因子loadFactory与rehash
我们可以使用负载因子来衡量散列表的“健康状况”。
散列表的负载因子 = 填入表中的元素个数 ÷ 散列表的长度(数组的长度)
散列表的负载因子越大,代表空闲位置越少,冲突也就越多,散列表的性能会下降。对于散列表来说,负载因子过大或过小都不好,因为负载因子是决定散列表的数据密度。
负载因子过大,散列表的数据密度就会高,链表的长度就会长,导致散列表的性能下降。负载因子过小,散列表很容易就会触发扩容,则会造成内存不能合理使用,从而形成内存浪费。
因此我们为了保证负载因子维持在一个合理的范围内,要对散列表的大小进行收缩或拓展,即rehash。
散列表的rehash过程类似于数组的扩容,但是相比要多出来数据的从新排列,更换空间位置等复杂操作。
1.3.4 开放寻址法与链表比较
对于开放寻址解决冲突的散列表,由于数据都存储在数组中,因此可以有效的利用CPU缓存加快查询速度(数组占用内存中连续的空间)。但是删除数据时比较麻烦,需要标记已删除的元素为deleted。而且开放寻址法中,所有数据都存储在一个数组中,比起链表来说,冲突的代价更高,*(每次都要遍历数组,遍历到数组尾部时再转向数组头部开始遍历,仍没有找到位置便需要扩容。)*所以使用开放寻址法解决冲突的散列表,负载因子的上限不能太大,也就是数据的密度不能太高。将导致比链表法更浪费内存空间。
对于链表法解决冲突的散列表,堆内存的利用率要比开放寻址法要高。因为链表结点可以在需要的时候再创建,并不需要像开放寻址法那样提前准备好内存空间。链表法比起开放寻址法对装载因子的容忍度更高。开放寻址法只能适用装载因子小于1的情况。接近1时,就可能会有大量的散列冲突,性能会下降很多。但是对于链表法,只要散列函数的值随机均匀,即便装载因子变成1,也就是链表变长了而已,虽然查找效率要有所下降,但是比起开放寻址的线性探测顺序查找还是要快的很多。但是链表要存储指针,所以对于比较小的存储对象,是比较消耗内存的,性价比并不高。而且链表中的节点是零散分布在内存中,不是连续的,所以对CPU缓存不是很友好,这对于执行效率有一定的影响,但是链表中的每个节点有相互连接,所以影响不大。
到此这篇关于C语言数据结构之Hash散列表的文章就介绍到这了,更多相关Hash散列表内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!