文章目录
- 前言
- 一、unordered系列关联式容器
- 1、unordered_map
- 2、性能测试
- 二、哈希
- 1、哈希概念
- 2、哈希冲突
- 3、哈希冲突解决
- 3.1 闭散列
- 3.2 开散列
- 3.3 字符串Hash函数
- 3.4 哈希桶实现的哈希表的效率
- 三、哈希表封装unordered_map和unordered_set容器
- 1、unordered_map和unordered_set插入结点的实现
- 2、unordered_map和unordered_set迭代器的实现
- 3、unordered_map容器的[]操作符重载函数的实现
- 4、unordered_map容器中使用自定义类型做key
- 5、面试题map/set容器和unordered_map/unordered_set容器使用的条件
- 6、unordered_map和unordered_set容器的const_iterator迭代器
- 四、哈希的应用
- 1、位图
- 1.1 位图实现
- 1.2 位图应用1
- 1.3 位图应用2
- 1.4 位图应用3
- 1.5 位图优缺点
- 2、布隆过滤器
- 2.1 布隆过滤器概念
- 2.2 布隆过滤器实现
- 2.3 布隆过滤器使用场景
前言
一、unordered系列关联式容器
在学习哈希之前,我们先来学习四个c++11中新添加的两个容器。我们知道在C++98中,STL提供了底层为红黑树结构的一系列关联式容器,在查询时效率可达到 l o g 2 N log_2 N log2N,即最差情况下需要比较红黑树的高度次,当树中的节点非常多时,查询效率也不理想。最好的查询是,进行很少的比较次数就能够将元素找到,因此在C++11中,STL又提供了4个unordered系列的关联式容器,这四个容器与红黑树结构的关联式容器使用方式基本类似,只是其底层结构不同,所以我们只要知道怎么用set和map容器,就应该可以很快学会使用unordered系列的容器。
1、unordered_map
- unordered_map是存储<key, value>键值对的关联式容器,其允许通过key快速的索引到与其对应的value。
- 在unordered_map中,键值通常用于唯一地标识元素,而映射值是一个对象,其内容与此键关联。键和映射值的类型可能不同。
- 在内部,unordered_map没有对<kye, value>按照任何特定的顺序排序, 为了能在常数范围内找到key所对应的value,unordered_map将相同哈希值的键值对放在相同的桶中。
- unordered_map容器通过key访问单个元素要比map快,但它通常在遍历元素子集的范围迭代方面效率较低。
- unordered_maps实现了直接访问操作符(operator[]),它允许使用key作为参数直接访问value。
- 它的迭代器是前向迭代器。
可以看到unordered_map和map的接口函数都类似,需要注意的是因为unordered_map容器只有单向迭代器,所以在迭代器中没有提供rbegin、rend等接口函数。
下面我们来看一下unordered_set的使用
2、性能测试
下面我们根据
二、哈希
1、哈希概念
顺序结构以及平衡树中,元素关键码与其存储位置之间没有对应的关系,因此在查找一个元素时,必须要经过关键码的多次比较。顺序查找时间复杂度为O(N),平衡树中为树的高度,即O( l o g 2 N log_2 N log2N),搜索的效率取决于搜索过程中元素的比较次数。
理想的搜索方法:可以不经过任何比较,一次直接从表中得到要搜索的元素。
如果构造一种存储结构,通过某种函数(hashFunc)使元素的存储位置与它的关键码之间能够建立一一映射的关系,那么在查找时通过该函数可以很快找到该元素。
当向该结构中:
- 插入元素
根据待插入元素的关键码,以此函数计算出该元素的存储位置并按此位置进行存放。 - 搜索元素
对元素的关键码进行同样的计算,把求得的函数值当做元素的存储位置,在结构中按此位置取元素比较,若关键码相等,则搜索成功。
该方式即为哈希(散列)方法,哈希方法中使用的转换函数称为哈希(散列)函数,构造出来的结构称为哈希表(Hash Table)(或者称散列表)。
例如:数据集合{1,7,6,4,5,9};
哈希函数设置为:hash(key) = key % capacity; capacity为存储元素底层空间总的大小。
用上面的方法进行搜索时不必进行多次关键码的比较,因此搜索的速度比较快。
但是当按照上述哈希方式,向集合中插入元素44,会出现什么问题?
此时44%10 = 4,而arr[]中下标为4的位置已经存了数据了,所以这时候就会产生哈希冲突。
2、哈希冲突
哈希冲突/碰撞其实就是不同的值,映射到哈希表的相同的位置。引起哈希冲突的一个原因可能是:哈希函数设计不够合理。
哈希函数设计原则:
- 哈希函数的定义域必须包括需要存储的全部关键码,而如果散列表允许有m个地址时,其值域必须在0到m-1之间。
- 哈希函数计算出来的地址能均匀分布在整个空间中。
- 哈希函数应该比较简单。
常见哈希函数:
- 直接定址法–(常用)
取关键字的某个线性函数为散列地址:Hash(Key)= A*Key + B
优点:简单、均匀。
缺点:需要事先知道关键字的分布情况。
使用场景:适合查找比较小且连续的情况。 - 除留余数法–(常用)
设散列表中允许的地址数为m,取一个不大于m,但最接近或者等于m的质数p作为除数,按照哈希函数:Hash(key) = key% p(p<=m),将关键码转换成哈希地址。 - 平方取中法–(了解)
假设关键字为1234,对它平方就是1522756,抽取中间的3位227作为哈希地址;
再比如关键字为4321,对它平方就是18671041,抽取中间的3位671(或710)作为哈希地址;
平方取中法比较适合:不知道关键字的分布,而位数又不是很大的情况。 - 折叠法–(了解)
折叠法是将关键字从左到右分割成位数相等的几部分(最后一部分位数可以短些),然后将这几部分叠加求和,并按散列表表长,取后几位作为散列地址。
折叠法适合事先不需要知道关键字的分布,适合关键字位数比较多的情况。 - 随机数法–(了解)
选择一个随机函数,取关键字的随机函数值为它的哈希地址,即H(key) = random(key),其中random为随机数函数。 - 数学分析法–(了解)
设有n个d位数,每一位可能有r种不同的符号,这r种不同的符号在各位上出现的频率不一定相同,可能在某些位上分布比较均匀,每种符号出现的机会均等,在某些位上分布不均匀只有某几种符号经常出现。可根据散列表的大小,选择其中各种符号分布均匀的若干位作为散列地址。例如:假设要存储某家公司员工登记表,如果用手机号作为关键字,那么极有可能前7位都是 相同的,那么我们可以选择后面的四位作为散列地址,如果这样的抽取工作还容易出现冲突,还可以对抽取出来的数字进行反转(如1234改成4321)、右环位移(如1234改成4123)、左环移位、前两数与后两数叠加(如1234改成12+34=46)等方法。
数字分析法通常适合处理关键字位数比较大的情况,如果事先知道关键字的分布且关键字的若干位分布较均匀的情况。
注意:哈希函数设计的越精妙,产生哈希冲突的可能性就越低,但是无法避免哈希冲突。
3、哈希冲突解决
解决哈希冲突两种常见的方法是:闭散列和开散列。
3.1 闭散列
闭散列:也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以把key存放到冲突位置中的“下一个” 空位置中去。那如何寻找下一个空位置呢?
(1). 通过线性探测
比如下面的场景,现在需要插入元素44,先通过哈希函数计算哈希地址,hashAddr为4,因此44理论上应该插在下标为4的位置,但是该位置已经放了值为4的元素,即发生哈希冲突。
线性探测:从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止。
下面我们来分析哈希表使用线性探测时进行元素插入和删除的场景。
插入:
- 通过哈希函数获取待插入元素在哈希表中的位置。
- 如果该位置中没有元素则直接插入新元素,如果该位置中有元素发生哈希冲突,使用线性探测找到下一个空位置,插入新元素。
删除:
采用闭散列处理哈希冲突时,不能随便物理删除哈希表中已有的元素,若直接删除元素会影响其他元素的搜索。比如删除元素4,如果直接删除掉,44查找起来可能会受影响,因为如果直接将4删除,那么4的位置就为空,然后查找44时,44%10=4,会去arr[4]的位置查找,然后发现arr[4]为空,就会返回没有44的结果。因此线性探测采用标记的伪删除法来删除一个元素。即将数组中的位置分为3种状态,存在、删除、空。这样当删除元素4时,就将元素4的位置状态变为删除,然后查找44时遇到删除后也继续向后查找,直到遇到空的位置才停止查找。
下面我们就来实现利用线性探测法解决哈希冲突的哈希表。
下面是哈希表中要存的数据类型的定义和哈希表的定义,我们底层使用vector来实现哈希表,然后在vector中存的数据类型为HashData类型,数据类型中还记录了当前位置的状态。
下面我们来实现哈希表中元素的插入,我们使用的哈希函数为除留余数法。上面我们的举例中除留余数法是直接模的数组的容量capacity,但是如果我们底层使用vector容器来充当哈希表的话,再模vector容器的容量capacity的话就会出现错误。因为vector的[]操作符重载函数里面有一个断言来判断传入的下标值,如果下标值小于0或者大于size就会报错。
所以我们应该模size。
下面我们来看哈希表的扩容,当哈希表中的元素很多时,即哈希表快要满时,此时插入一个新元素产生冲突的概率会很大,而且遇到冲突后需要向后面进行多次查找才能找到一个空位置。所以为了降低哈希表插入新元素时产生冲突的概率,不能等哈希表满时再进行扩容,而是当哈希表中的元素达到一定量时就要进行扩容。这就引入了负载因子。
负载因子/载荷因子就是表存储数据量的百分比。
下面我们实现时如果负载因子超过0.7时就对哈希表进行扩容。
下面使用reserve函数来给vector容器扩容是错误的,因为reserve扩容只改变vector的capacity,而size不会改变。那么当访问到size后面的位置后还是越界访问。并且因为我们使用模vector的size来得到每个元素在哈希表中的位置,而vector的size没有变,那么插入元素时产生冲突的概率还是不会降低。
所以我们需要使用resize函数来进行扩容,即将vector的size也改变。并且我们还需要考虑size为0的情况。
但是上面的扩容写法也有问题,因为当我们将size改变后,采用除留余数法计算元素在哈希表中的下标时,模的是新的size的大小,而我们向哈希表中插入元素时,模的是旧的size。这样插入和寻找时使用的不是同一个size,所以计算得到的位置也不一样,这肯定是错误的。例如当我们将哈希表扩容后进行查找时,我们查找元素13,13%20=13,所以我们会去_tables[13]中查找元素13,而tables[13]位置肯定是找不到元素13的。
所以在哈希表扩容后我们需要使用新的size重新计算每一个元素的位置。
下面我们来使用代码实现。下面的这种写法重新计算哈希表中元素的新位置,我们可以看到使用了两次线性检测的代码,即有代码的冗余。
所以我们也可以使用下面的写法。即我们新创建一个HashTable,然后将旧表中的元素都插入到新的哈希表中,因为我们的HashTable不进行扩容时就按照线性检测来插入数据,而且我们新创建的HashTable的大小为扩容之后的大小,所以在新的HashTable中插入元素时不会出现扩容情况。
然后我们进行测试,可以看到不扩容时哈希表插入元素正常。
下面我们再来测试当哈希表扩容时,元素是否都重新计算了位置。我们看到当哈希表扩容后,哈希表中的元素都重新计算了位置。
下面我们再来实现Find函数。
然后我们再实现哈希表中元素的删除,我们删除哈希表中的元素只是改变了要删除元素的状态,并没有真正的将元素的数据删除。
但是像上面这样写删除会出现bug,例如当我们删除13后,还可以查找到13元素,这是因为在删除函数中我们并没有将数据删除,只是将元素的状态改变了。所以使用Find函数查找时还能找到13元素。
所以我们在Find函数中还需要加一个判断,即检测当前元素的状态是否为EXIT,如果为EXIT时才算查找到该元素。然后我们看到这个bug就被解决了。
然后我们再来完善Insert函数,即在Insert函数刚开始的地方调用Find函数查看要插入的元素是否已经存在哈希表中,如果已经存在那么直接返回插入失败,因为哈希表中不允许数据冗余。
但是我们运行时看到程序出现了异常,这是因为当第一次调用Find时,此时哈希表的大小为0,所以出现了模0操作。所以我们需要在Find函数中加一个判断,如果哈希表是空表,那么直接返回nullptr。然后我们就可以看到当重复插入元素时就会插入失败。
上面我们实现的哈希表有很小的概率可能表中全是删除状态,此时如果再调用Find函数进行查找,就会陷入死循环。下面的操作可能导致这种情况出现,即先插入数据后,哈希表没有扩容,然后删除一部分数据,然后再插入数据(因为删除数据时n会减减,所以哈希表不会扩容),并且数据刚好占了哈希表中的空位,此时就可能会导致哈希表里面除了存在就是删除状态的元素。
所以我们可以在Find函数中再判断一下,如果查找了一圈还没有找到元素就直接break。这样我们就简单实现了使用线性探测方法解决冲突的哈希表。
线性探测优点:实现非常简单,
线性探测缺点:一旦发生哈希冲突,所有的冲突连在一起,容易产生数据“堆积”,即:不同关键码占据了可利用的空位置,使得寻找某关键码的位置需要许多次比较,导致搜索效率降低。如何缓解呢?
二次探测
线性探测的缺陷是产生冲突的数据堆积在一块,这与其找下一个空位置有关系,因为找空位置的方式就是挨着往后逐个去找,因此二次探测为了避免该问题,找下一个空位置的方法为:
H
i
H_i
Hi = (
H
0
H_0
H0 +
i
2
i^2
i2 )% m, 或者:
H
i
H_i
Hi = (
H
0
H_0
H0 -
i
2
i^2
i2 )% m。其中:i = 1,2,3…,
H
0
H_0
H0是通过散列函数Hash(x)对元素的关键码 key 进行计算得到的位置,m是表的大小。
可以看到二次探测和线性探测类型,只不过二叉探测向哈希表后面找空位置时不是一个位置一个位置的去找,而是跳着去找空位置。
研究表明:当表的长度为质数且表负载因子a不超过0.5时,新的表项一定能够插入,而且任何一个位置都不会被探查两次。因此只要表中有一半的空位置,就不会存在表满的问题。在搜索时可以不考虑表装满的情况,但在插入时必须确保表的负载因子a不超过0.5,如果超出必须考虑增容。
因此:闭散列最大的缺陷就是空间利用率比较低,这也是哈希的缺陷。
3.2 开散列
因为闭散列的缺点,所以在实际当中是不建议用线性探测或二次探测的。更建议用开散列来解决哈希冲突。
开散列概念
开散列法又叫链地址法(开链法),首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表或双链表链接起来,各链表的头结点存储在哈希表中。
从下图可以看出,开散列中每个桶中放的都是发生哈希冲突的元素。
可以看到使用拉链法来实现哈希表时,插入元素时的冲突概率减少了,而且插入元素的开销低了。
下面我们来实现使用开散列法解决哈希冲突的哈希表。
哈希桶法中的哈希表中存的是这个桶中第一个结点的指针,所以我们将vector容器中存的数据类型为Node *。哈希表中每一个结点中除了存储数据外,还有一个_ next指针用来存储和该结点在一个桶内的下一个结点的指针。
下面我们来实现结点的插入,采用哈希桶的方法时,插入新结点其实就是先算出该结点在哪个桶内,然后向这个桶内插入新结点,即向链表中插入新结点。这里使用头插或尾插法都可以,我们可以看到哈希桶方法的结点插入效率是很高的,可以达到O(1)。注意,下面我们采用的是头插,但是图画错了,图画的为尾插
下面我们再来分析哈希表的扩容问题。采用哈希桶的方式实现哈希表时,当桶的个数和结点的个数相同时,即负载因子为1时,我们进行哈希表的扩容,即增加桶的个数。
我们可以采用前面的扩容办法,即新创建一个哈希表,并且为该哈希表开辟的空间为扩容后的大小。然后再将旧表中的每个结点都重新计算位置并且插入到新表中,最后让旧表和新表交换。
但是上面的这种方法在新表插入结点时,会将旧表中的每个结点重新创建一次,然后还需要将原来的表中链表的结点都释放。所以我们需要自己写析构函数将哈希表中的每个链表的结点都进行释放。因为自动生成的析构函数只会将指针数组释放,不会释放链表中的结点。
这样的话上面的方法进行扩容就会开销很大了,因为需要先重新创建每一个新结点,然后将旧表中的链表中的旧结点都释放。所以我们可以使用下面的方法,即将原表中的结点重新计算位置,挪动到新表中,这样就不用创建新结点和释放旧结点了。
下面我们来测试插入结点是否正确。我们看到哈希表可以正确的插入新结点并且进行扩容,而且扩容后每个结点重新计算了在新表中的位置。
下面我们继续完善哈希表,我们来实现哈希表中查找元素的Find函数。
然后我们再完善Insert函数,在插入新结点时先进行查找,如果当前哈希表中已经有了该元素,那么就不重复插入,而是直接返回插入失败。
然后我们再来实现删除结点,删除结点有两种情况,即删除头结点和删除中间结点。
下面我们来测试删除结点。我们看到12结点和3结点都被成功删除。
3.3 字符串Hash函数
但是我们实现的哈希表还存在一些问题,当我们向哈希表中存储的数据为string类或其它自定义类型时,因为我们使用的是pair对象的first的值来计算元素在哈希表中的位置,所以当pair对象的first存的不是整型类型,而是string类或其它自定义类型时,就不能来做取模运算了,那么也就不能计算元素在哈希表中的位置了。
此时我们就需要给HashTable模板中再添加一个仿函数的模板参数,即如果向哈希表中存入的数据不可以转换为整型时,就需要用户自己提供一个仿函数来将自定义类型转换为整型。这个仿函数也有两种情况,第一种情况为K本身就为整数家族的一个,即char或long等的类型,可以直接转为整数的类型,此时我们就可以直接使用这个模板参数的缺省值HashFunc仿函数,HashFunc仿函数里面用到了隐式类型转换,例如如果K为char或double类型,都会隐式转换为size_t类型返回。
然后我们将Insert、Find、Erase函数中的取模运算中的取pair对象的first的值都改为使用仿函数。
这样当我们将string类或自定义类型存入到哈希表中时,只要自己提供了将自定义类型转换为整型的仿函数后,就不会报错了。下面我们提供一个将字符串的第一个字母的ASCII码值转换为整型的仿函数来求出这个字符串在哈希表中的位置,可以看到此时string类类型也可以直接存入哈希表中了。
但是上面我们提供的方法很容易产生冲突,因为如果一个字符串的首字母相同时就会产生冲突。所以我们也可以将字符串的所有字符都加起来当作整型。如果是自定义类型的话,那么可以取自定义类型中比较不容易重复的数据来当作算这个元素在哈希表中位置的整数。例如身份证号,学号等。但是将字符串相加的仿函数也很容易出现冲突,例如当字符串中字母相同,但是顺序不同时也会冲突。
那么我们应该采用什么样的仿函数,才能使字符串转换为整型后不容易产生冲突呢?
各种字符串的Hash算法也有很多人研究,我们可以看下面的文章来了解一些基本的算法。
文章链接
下面我们就使用一种综合性能比较优的算法,BKDR算法来实现仿函数。此时可以看到字符串的顺序不一样,算出来的整数也不一样,虽然很接近,但是不一样,这样就可以减少字符串存储在哈希表中冲突的概率。
上面的方法我们在实际工作中可能也会用到,例如当用一个字符串和大量一一字符串比较是否相等时,如果一个字符串一个字符串的比较那么效率会很低。我们就可以在存入每一个字符串时多存一个整数项,这个整数项是采用某种算法让一个字符串生成一个整数项,并且相同的字符串生成的整数项一致,这样当查找字符串是否相同时先比较这两个字符串生成的整数项是否相同。如果整数项相同了,再一一比较这些整数项相同的字符串是否相同。这样就减少了不相等字符串的比较,从而提高了比较效率。
我们看到库里面的unordered_map容器也提供了Hash仿函数,即当使用自定义类型做key时,就需要自己提供转换为整数的仿函数。而且库里面的unordered_map容器还提供了一个采用什么样的方法比较key相等的equal_to仿函数。例如你可以自己定义个位数都为5的key值就算相等的仿函数,然后作为模板参数传递过去即可。
但是当我们使用库里面的unorder_map容器,并且存入string类类型作为key时,发现自己没有提供Hash仿函数,但是也可以将string做key。即库里面的unorder_map支持了string作为key。这是因为库里面对string类型进行了特化。如果是string类型就会走特化的函数。这样做的原因就是因为string类型经常被用作key值。
所以我们也这样实现。我们将自己实现的HashTable也支持string类型做key。不需要用户自己提供string的仿函数。这样当编译器检查到HashTable表的key值中存入的是string类类型时,就会调用特化的仿函数了。
3.4 哈希桶实现的哈希表的效率
哈希桶增删查改时间复杂度为O(1),最坏的情况是大部分数据都在一个桶内,但是我们不考虑最坏情况,只算平均情况。 因为有扩容的存在,最坏情况基本不会出现,因为有负载因子在控制着哈希表的扩容。如果真的出现最坏情况后,当扩容后,桶里面的数据会发生改变,然后最坏情况就没有了。所以我们只看平均时间复杂度,平均时间复杂度为O(1)。即如果最坏情况很少出现我们就算平均复杂度。例如快排,取到的参考值为最小值的概率非常低,所以我们只看平均复杂度。
下面我们来查看向哈希表中随机插入数据,哈希表中哈希桶的最大长度。
我们看到哈希桶的最大长度为4,所以很难出现最坏情况。如果真的出现了最坏情况,此时我们也有解决办法,我们可以做一个判断,当桶的长度超过一定值后,就将这个桶改成红黑树,这样查找效率就会提高了。而java中有的容器其实就采用了这种方法。
还有一个需要注意的点是在大多数书上会建议除留余数法时最好模一个素数,这样可能冲突的概率更小。并且SGI版本的stl库中实现的hashtable就采用了这种方法。即源码中提供了一个素数表,每次都会取一个大于哈希表长度并且最接近哈希表长度的素数来进行取模运算。如果我们自己的扩容也想使用这个办法,也可以向下面这样,即每次会返回一个新的素数,这个新的素数是在原素数2倍附近的一个素数。这样每一次哈希表扩容后的大小都为一个素数大小。
三、哈希表封装unordered_map和unordered_set容器
我们查看源码可以看到stl的源码中unordered_map 和 unordered_set容器的底层都是使用hashtable,而且我们还看到hashtable的结点模板中,只有一个模板参数,所以我们使用自己写的哈希表来封装unordered_map和unordered_set容器也需要将我们的哈希表结点模板的模板参数改为只有一个。
1、unordered_map和unordered_set插入结点的实现
我们先改变自己的HashNode结构体的结构,使这个模板只有一个模板参数,当显示传入的为int或pair类型时,该结点存的数据就是int或pair类型的数据。
然后因为默认存储数据的类型不是pair类型了,所以我们在Insert等函数中就不能使用first来获取key值了,因为我们也不知道哈希表中会存入什么样的数据类型,所以此时我们需要给HashTable类模板加一个仿函数模板参数,用来返回标识元素在哈希表中的位置的key值。
然后我们来实现unordered_map和unordered_set容器。这两个容器底层都是创建了一个哈希表,因为我们想让这两个容器都可以复用我们写的哈希表的模板,所以我们需要给这两个容器都写一个仿函数用来返回key的值。
然后我们将HashTable类中的获取key值的步骤都改为使用仿函数来获取。
下面我们来测试unordered_map和unordered_set容器的插入,可以看到unordered_map和unordered_set容器都成功的插入了数据。
2、unordered_map和unordered_set迭代器的实现
下面我们来实现unordered_map和unordered_set容器的迭代器。
我们看到在源码中unordered_map和unordered_set容器的迭代器是复用的hashtable的迭代器,所以我们只需要实现hashtable的迭代器即可。
下面我们来实现HashTable类的迭代器。HashTable的迭代器中因为需要搜索下一个结点,所以迭代器中除了需要结点的指针外,还需要HashTable的指针,因为这样才能通过HashTable的指针找到哈希表,然后搜索下一个结点。并且因为我们需要在迭代器中定义HashTable的指针,所以我们需要提前声明HashTable类模板。
下面我们再来分析哈希表的迭代器++的情况。因为哈希表的迭代器为单向迭代器,所以我们只需要实现迭代器++即可。哈希表的迭代器++有两种情况,第一种情况,当前迭代器指向的结点的_next指针域不为空,即桶中还有下一个结点,此时直接将迭代器指向下一个结点即可。
第二种情况为当前迭代器指向的结点的_next指针域为空,即桶中已经没有结点了,此时需要先算出当前桶在HashTable中的位置,然后通过HashTable指针向后遍历HashTable,找到下一个不为空的桶,然后将迭代器指向这个桶内的第一个结点。
实现了__HashIterator迭代器类的基本函数后,我们再来实现HashTable类中的迭代器。HashTable类中的begin函数返回的是指向哈希表中第一个结点的迭代器,所以我们需要遍历哈希表并且找到哈希表中的第一个结点,end函数就返回一个指向空的迭代器即可。
下面我们复用HashTable的迭代器来实现unordered_map和unordered_set容器的迭代器。
下面我们来测试unordered_map和unordered_set容器的迭代器,然后我们运行时发现报出了无法访问私有成员的错误,这是因为在HashTable类中_tables成员为私有成员,所以在__HashIterator中不能访问_tables成员。此时我们有两种办法来解决这个问题,第一个办法就是在HashTable类中实现gettables和settables等函数。第二种办法就是在HashTable类中将__HashIterator类设置为友元类。
下面我们在HashNode类模板中设置__HashIterator类模板为友元类,因为 __HashIterator为类模板,所以我们在设置友元类时还需要加上 __HashIterator的模板。然后我们看到就可以使用unordered_map和unordered_set容器的迭代器来访问容器内的元素了。
3、unordered_map容器的[]操作符重载函数的实现
下面我们来实现unordered_map容器的[]操作符重载函数,但是在实现这个函数之前我们需要先修改HashTable类之前的Find函数和Insert函数的返回值。
然后我们再来实现unordered_map容器的[]操作符重载函数。并且因为HashTable类里面的Insert函数的返回值改变了,所以我们将unordered_map容器和unordered_set容器的insert函数的返回值也改为返回一个pair类类型对象。
下面我们来测试unordered_map容器的[]操作符重载函数。我们看到unordered_map容器的[]操作符重载函数可以正常使用。
下面我们来完善unordered_map容器和unordered_set容器的find函数和erase函数。
4、unordered_map容器中使用自定义类型做key
下面我们来完善当unordered_map容器中使用自定义类型做key值时,
我们实现一个Date日期类,然后将日期类作为unordered_map容器的key值。
当我们运行时发现报出了下面的错误,这是因为当key值为自定义类型时,不能转换为整型时,就需要自己提供一个将自定义类型转换为整型的仿函数。
但是现在还存在一个问题就是unordered_map模板中只有两个模板参数,如果我们实现了仿函数也无法传递给unordered_map容器,因为这个仿函数参数是在HashTable模板中定义的。所以此时我们需要改为在unordered_map模板和unordered_set模板中传递仿函数。
然后我们采用DKRS算法来实现一个将Date日期类转换为整型的仿函数,但是在实现的过程中因为Date日期类的_year、_month、_day为私有成员,所以我们可以在Date日期类中将仿函数设置为友元类。然后我们创建unordered_map容器时将仿函数也传进去。
但是我们运行时发现出现了下面的错误,这是因为在HashTable中我们使用了key类型的== 的比较,所以我们想要将自定义类型作为unordered_map的key值,就需要给自定义类型提供 == 比较运算符的重载函数。
当我们实现了Date日期类的 == 的比较后,此时就可以在unordered_map容器中将Date日期类作为key值了。
那么如果遇到我们要存到unordered_map容器作为key值的自定义类型中没有提供 == 的比较,并且我们也无法修改这个自定义类型时,此时我们可以再给unordered_map模板多加一个仿函数,这个仿函数用来提供怎样判断key值相等。我们可以看到库里面就提供了一个这样的仿函数。
5、面试题map/set容器和unordered_map/unordered_set容器使用的条件
一个类型要做unordered_map/unordered_set的key时,要满足支持转换成取模的整型,还需要满足 == 比较运算符的重载函数。
一个类型要做map/set的key时,要满足支持小于或者大于比较中的一个。因为 == 可以通过下面的这种if else 逻辑来得到。
if(cur->key < key)
{}
else if(key < cur->key)
{}
else
{}
6、unordered_map和unordered_set容器的const_iterator迭代器
我们上面实现的unordered_set容器还有一个没有解决的问题,那就是我们可以使用unordered_set容器的迭代器来修改对应元素的值。这是肯定不可以的,因为unordered_set容器中的元素都在插入哈希表时已经计算出了对应的位置,而使用迭代器来修改元素的值的话,就会使哈希表中存储的元素和其对应的位置不符。
所以下面我们要解决unordered_set容器中可以使用迭代器修改容器中元素值的问题。我们看到源码中采用的办法是unordered_set容器的普通迭代器和const迭代器都复用hashtable的const_iterator迭代器。这样就不能通过unordered_set容器的普通迭代器修改哈希表中元素的值了。
下面我们也使用源码中的方法,我们先实现HashTable类的const_iterator迭代器。
然后下面我们再实现unordered_set的const_iterator迭代器,此时我们先不将unordered_set的普通迭代器也复用HastTable的const_iterator迭代器。我们先测试unordered_set的const_iterator迭代器。
我们写一个方法来测试unordered_set的const_iterator迭代器,然后发现报出了这样的错误,这是因为发生了权限放大,在print函数中,s是被const修饰的对象,所以在调用begin和end时,传入的也是被const修饰的this指针,而在__HashIterator的构造函数中第二个参数接收的是不被const修饰的HashTable的指针,所以发生了权限放大。
此时我们可以将__HashIterator中的表示HashTable指针的成员使用const修饰,因为我们在迭代器中只是遍历哈希表,而不需要修改哈希表,所以可以将_ht成员变量使用const修饰,这样就解决了上面的权限放大的问题。
下面我们再让unordered_set的普通迭代器也复用HashTable的const_iterator迭代器。但是这样就会出现一个问题,即unordered_set的begin函数内部调用_ht.begin函数,因为_ht没有被const修饰,所以会去调用HashTable中的不被const修饰的begin函数,然后返回的也是HastTable的普通迭代器iterator,但是unordered_set中将普通迭代器也复用了HastTable的const迭代器,所以unordered_set的begin返回的其实是一个HastTable的const迭代器。即我们需要将_ht.begin函数返回的HastTable普通迭代器iterator隐式类型转换为HashTable的const迭代器。但是我们的__HashIterator类中没有将HashTable的普通迭代器变为HashTable的const迭代器的构造函数,所以会出现下面的错误。
此时我们需要在__HashIterator中写一个构造函数支持HashTable的普通迭代器转换为const迭代器。然后我们就可以看到不能通过unordered_set的迭代器来修改哈希表中元素的值了。
下面我们再来实现unordered_map容器的const迭代器,我们看到源码中unordered_map的普通迭代器就是复用的hashtable的普通迭代器,const迭代器就是复用的hashtable的const迭代器,这是因为unordered_map容器在实例化hashtable时就将pair对象的Key使用const修饰了,那么在hashtable中就不能修改pair对象的first的值了。
下面我们也仿照源码中的方法来完善我们实现的unordered_map容器的const迭代器。我们看到使用迭代器不能修改unordered_map容器中的pair对象的first的值,但是可以修改second的值。
四、哈希的应用
1、位图
1.1 位图实现
所谓位图,就是用每一位来存放某种状态,适用于海量数据,数据无重复的场景。通常是用来判断某个数据存不存在的。
下面我们通过一个面试题来体会位图的应用。
给40亿个不重复的无符号整数,没排过序。给一个无符号整数,如何快速判断一个数是否在这40亿个数中。
上面这个问题,我们通常会想到两种办法。一是遍历这40亿个数字,二是先将这40亿个数字进行排序,然后采用二分查找。但是这两种办法都需要很大的内存,显然这两种办法是行不通的。因为我们此时只需要判断数据在不在,所以此时就可以使用位图来解决。
位图其实就是一个采用直接定址法映射的哈希表,我们用每一个比特位来标识一个数字在或是不在,那么就只需要512MB的空间即可。这样当我们想要查看一个数是否存在时,只需要查看位图中对应的比特位为0还是为1即可。
下面我们就来模拟实现位图。下面是位图的基本结构,我们使用一个vector容器来当作位图。
位图中需要提供set函数和reset函数,set函数就是将位图中第x个比特位置为1,而reset就是将位图中第x个比特位置为0。
我们想要找到位图中第x个比特位,我们需要先计算x映射的位在第几个char数组位置,然后再计算x映射的位在这个char的第几个位置。
当我们想要让位图中第x个位置的比特位置为1时,可以通过下面的按位或计算来实现。我们想要让位图中第x个位置的比特位置为0,可以通过下面的按位与计算来实现。
下面我们来写test函数,test函数就是判断位图中是否有指定的数字,如果有的话就返回true,如果没有就返回false。我们可以先计算出x在位图中相对应的位置,然后让1与这个位置相与,如果1还是1,那么就说明位图中这个位置也为1,即x数字存在。如果1相与后变为0,那么说明位图中这个位置本来就为0,即x数字不存在。
下面我们来写bitset的构造函数,即给位图开多大空间。需要注意的是,我们是根据数据的范围来开的位图的空间,并不是根据数据的个数。例如有一组数据为 1, 3, 5, 1000。那么我们也需要开1000个比特位大小的位图。并且因为size_t无符号整型的最大值为42亿多,所以如果有100亿个size_t无符号整型数据时,那么我们也只需要开42亿个比特位大小的位图,因为size_t无符号整型最多能标识42亿多个数字,100亿个数据中肯定有大量的重复数据,故我们只需要申请42亿个比特位大小的位图存储即可。
我们看到当我们申请42亿比特位大小的位图时,此时位图的空间为512MB。即我们只需要512MB的内存就可以存储42亿多个数据在或者不在的信息。
1.2 位图应用1
1. 给定100亿个整数,设计算法找到只出现一次的整数?
这一题我们可以创建两个位图,即一个数字的状态采用两个比特位来表示,那么就可以表示4个状态,我们将00表示该数字一次没有出现,01表示出现一次,10表示出现一次以上。下面我们来实现代码。
我们通过测试可以看到print函数将只出现一次的数据打印了出来。
1.3 位图应用2
2. 给两个文件,分别有100亿个整数,我们只有1G内存,如何找到两个文件交集?
第一种办法
将其中一个文件的值读取到内存的一个位图中,然后再读取另一个文件的值,判断这个值在不在内存中的位图中,如果在就是交集。但是这样的做法会使找到的交集中存在重复的值。例如下面的这个例子,所以我们在找到交集后还需要进行去重。我们可以使用下面的方法来进行改进,即每次找到交集后,都将内存中的位图中对应的值设置为0,这样就可以解决找到的交集中有重复值的问题。
下面我们使用数组来模拟文件中的数据,可以看到两个数组的交集中并没有重复的数据。
第二种办法
我们也可以创建两个位图,如果x在位图1和位图2中都存在,那么x就是交集。或者我们也可以将位图1和位图2进行相与运算,然后检查运算结果中为1的位置就是交集。
1.4 位图应用3
3.位图应用变形:1个文件有100亿个int,1G内存,设计算法找到出现次数不超过2次的所有整数
这个题就是第一个题的变形。我们可以使用00表示出现0次,01表示出现1次,10表示出现2次,11表示出现3次以上。
我们看到出现0次,1次,2次的数据都被打印了出来。
1.5 位图优缺点
位图的优点就是搜索一个整型在不在时的速度快,可以达到O(1)的效率,并且位图存储数字在不在的信息还节省空间。
位图的缺点就是只能映射整型,如果是其它类型,例如浮点数、string等类型不能存储映射。
2、布隆过滤器
我们在使用新闻客户端看新闻时,它会给我们不停地推荐新的内容,它每次推荐时要去重,去掉那些已经看过的内容。问题来了,新闻客户端推荐系统如何实现推送去重的? 用服务器记录了用户看过的所有历史记录,当推荐系统推荐新闻时会从每个用户的历史记录里进行筛选,过滤掉那些已经存在的记录。 那么如何快速查找用户的历史记录中有没有这条新闻呢?
1.用哈希表存储用户记录,缺点:浪费空间。
2.用位图存储用户记录,缺点:位图一般只能处理整形,如果内容编号是字符串,就无法处理了。
3.将哈希与位图结合,即布隆过滤器。
2.1 布隆过滤器概念
布隆过滤器是由布隆(Burton Howard Bloom)在1970年提出的 一种紧凑型的、比较巧妙的概率型数据结构,特点是高效地插入和查询,可以用来告诉你 “某样东西一定不存在或者可能存在”,它是用多个哈希函数,将一个数据映射到位图结构中。此种方式不仅可以提升查询效率,也可以节省大量的内存空间。
当我们将字符串通过hash函数转换为整型后存储到位图中时,因为有可能出现不同的字符串转换为同一个整型的情况,所以这样的方法是一定会存在冲突问题的,而且也无法避免。那么就可能会造成下面的误判情况,例如下面的美团和B站的状态都存储在位图的第3个比特位,那么当美团存在后,虽然我们还没有插入B站,但是此时判断B站也是存在的,这样就造成了误判。
而布隆过滤器的思想就是降低冲突的概率,即一个字符串映射一个位置,容易产生误判,那么让一个字符串采用多种hash函数生成多个整型值,然后映射到多个位置,这样就可以降低误判率。布隆过滤器并不是完全解决冲突,而是降低冲突的概率。
但是这样的情况也会出现误判的概率,例如下面的情况,腾讯字符串转换的两个整型都和其它字符串冲突了,而此时腾讯字符串虽然不在,但是也会被判断为存在,这就出现了误判的情况。所以布隆过滤器是不那么靠谱的一个数据结构。但是布隆过滤器只会在判断字符串在的时候出现误判,而判断字符串不在的时候是准确的。因为要判断字符串不在,必须要有一个位置为0,而为0的位置说明没有冲突,所以不会存在误判。
2.2 布隆过滤器实现
下面我们来简单实现一下布隆过滤器。
2.3 布隆过滤器使用场景
布隆过滤器是一个会产生误判的数据结构,那么我们就需要在允许误判的场景下才能使用布隆过滤器。例如在注册账号时,一般都会让添一个昵称,然后需要快速判断这个昵称是否已经存在,这个场景就可以使用布隆过滤器。
例如下面的场景,数据库中存了10亿个用户的信息,但是数据是存在磁盘中,如果当用户输入昵称后,从数据库中开始查找当前昵称是否存在,这是很慢的,会影响与用户的交互体验。而如果我们提前将用户的昵称存在一个布隆过滤器中,那么当用户输入昵称时,我们直接从布隆过滤器中查找昵称是否存在即可。而且虽然布隆过滤器会存在误判,但是在用户层是不知道的。如果布隆过滤器判断这个昵称不存在,那么这个昵称一定是不存在的,因为布隆过滤器不会误判不存在的情况。而如果布隆过滤器判断这个昵称存在,有可能是这个昵称真的存在,还有可能是布隆过滤器的误判,但是不管是不是误判,用户都需要换一个新的昵称了,而且用户也不知道这个昵称在数据库中是否是真的存在的。
但是判断用户的电话是否存在,我们不能只使用布隆过滤器来判断,因为如果布隆过滤器误判这个电话已存在时,而用户是知道这个电话号码还没有注册账号的。所以当使用布隆过滤器判断用户的电话已经存在时,此时我们需要再次去数据库进行查找,判断当前电话号是否真的存在,然后再将结果返回。而如果布隆过滤器判断电话号不存在,那么这个电话号就一定是不存在的,此时用户可以使用这个电话号。