详细讲解Hash以及如何通过C++实现Hash表


引言
如果是刚开始学习数据结构的读者在不了解Hash的时候,肯定觉得哈希是一种很高级,很复杂,很难实现的数据结构,但是错了,相对来说,哈希对比各种平衡二叉树,图结构来说,反而设计相当简单,并且不仅简单,查询数据的效率还是o(1),杠杠的香数据结构,那么话不多说,线面我们就具体的谈谈Hash这个结构,或者说Hash这种思想。
什么是哈希
哈希又称为散列,是一种组织数据的方式。我们从散列的角度来理解,很多数据分散在数组中的各个位置,他们通过某种约定进行分布,使得我们在查找他们的时候能够迅速定位,这就是哈希的思想。
上面我们谈到了,数据通过某种约定分布在数组中, 下面我们详细塔谈这个约定。
哈希的本质就是通过约定组织数据
细谈约定
约定使我们事先商量好的一种共识,我们所有的数据通过这个约定进行组织。
直接定址法
这是最简单的一种利用约定组织数据的方式
比如说现在我有一个利用ASCII编码的字符串,现在我想要统计这个字符串中各种字符的个数,如果是你,你会怎么做?
如果是我,我会定义一个128大小的数组,每个字符的ASCII值代表一个位置。
// 现在我有一个std::string的buffer
int hash[128] = { 0 };
for(char i : buffer) hash[i]++;
你没看错这里的hash就是一个简单的hash表,我们这里的约定就是
ASCII码映射具体的位置。
哈希函数
哈希函数的就是我们的约定,上面那个问题的哈希函数就是
pos = (int)(ch) 对吧,这就是利用ASCII编码作为哈希函数。
一个好的哈希函数能够将数据均匀分布到数组中,但是显然想要设计一个通用的Hash函数,是很困难的。
-
- 除法散列法 / 除留余数法
当使⽤除法散列法时,要尽量避免M为某些值,如2的幂,10的幂等。如果是M,那么key % M
本质相当于保留key的后X位,那么后x位相同的值,计算出的哈希值都是⼀样的,就冲突了。如:
{63,31}看起来没有关联的值,如果M是16,也就是2^4,那么计算出的哈希值都是15,因为63的⼆
进制后8位是00111111,31的⼆进制后8位是00011111。如果是 ,就更明显了,保留的都是
10进值的后x位,如:{112,12312},如果M是100,也就是10 ^ 2,那么计算出的哈希值都是12。
如果还想了解更多的方法,可以自行查阅。
冲突
我们想象的很美好,不是所有的情况都能够像上面ASCII映射一样一一对应,当然我们希望的场景就是一一对应,那么冲突是什么。冲突就是我们假定哈希函数为f (x),冲突就是当两个变量映射到同一个为止,即a != b, f(a) = f(b), 这就冲突的,哈希冲突很难保证不存在,所以合理降低冲突时程序员应该做的事情。
如何解决冲突
对于如何解决冲突,目前一般有两种主流的方式,一个叫做开放定制法, 一个叫做链地址法。
负载因子
负载因子就是我们当前的有效数据 和 总空间的的比值, 比如我们存入了3个数据,数组长度为5, 负载因子就是0.6 ,我们保证负载因子 < 0.7。
开放定制法
首先简单阐述一下什么是开放定制法, f(a) = pos , 如果pos这个位置已经存储了数据,我们就往后找,因为负载因子 < 1, 我们一定能够找到一个位置没有数据。

// 我们现在有一个数组 array
// 存储新数据的逻辑
size_t pos = hash_function(data); // 通过哈希函数找到位置
if(status[pos] == busy)
while(status[++pos] == busy);
// 找到空闲的位置
array[pos] = data;
status[pos] = busy;
但是聪明的计算机工程师,发现这样的解决方式还有优化的地方
探测的方式
-
- 线性探测
我们上面在移动的时候,默认都是移动一个单位,但是我们发现如果已经映射一个位置,探测的偏移量越来越大,显著影响我们插入的效率。
-
- 二次探测
二次探测的思想就是,我们每次移动 i ^ 2步,比如第一次移动1步,不行,移动4步,不行,移动9步,扩大步长可以减少冲突的效率。
- 二次探测
下面就是更加重要,也是使用更多的解决哈希冲突的方式,链式法

从名称我们可以获取到一些思路,链式法就是我们每个位置存储的不是一个数据,而是一个链表,我们将相同Hash值的数据通过一个链表连接起来。
size_t pos = hash_function(data);
array[pos].push(pos);
缺点
可能因为大量的数据映射到同一个为止,导致一个链表过程,最终退化成一个链表的数据结构,这就需要我们通过优化哈希函数,降低冲突的情况。
细节
无论是,开放定址法还是链式哈希桶,当我们的大于负载因子的时候,都涉及到扩容的问题,扩容就设计到,将所有的数据提取出来,重新根据新的长度来通过哈希函数进行映射。
我自己手写的基于链式法的哈希设计思路
讲解代码逻辑
-
- HashTable类:

- table : 存储hash表
- size : 长度
- harsh : 哈希函数(支持用户直接修改)
- koft : 不用了解,一种模版设计技巧
- HashTable类:
-
- 各种组件的定义,包括迭代器,指针,引用的重命名。

- 各种组件的定义,包括迭代器,指针,引用的重命名。
-
- 可以看到,HashDataType里面存储的就是链表。

- 可以看到,HashDataType里面存储的就是链表。
-
- 插入的逻辑

- 插入的逻辑
-
- 删除的逻辑

- 删除的逻辑
总结
以上就是哈希的原理和设计思路,因为这里我是写了一个访stl ,基于模版编写的容器,后面基于这个容器可以直接实现std::unordered_map, std::unordered_set, 如果还想进一步了解C++ 哈希的接口,可以直接去查看。
说了半天,hash的本质就是通过一种约定(哈希函数),将数据映射到数组的某个位置,但是在实际开发中,想要设计出好用的哈希哈数,还是需要各位计算机工程师继续研究。
4837

被折叠的 条评论
为什么被折叠?



