学一个东西我们首先要知道为什么要学一个东西。
假设我们是一名图书管理员,如果我们现在要对书的借还进行管理。
那么每一次借还都需要登记姓名,时间,书籍的名字等等东西。
可是如果每天来借书和还书的人非常多,日积月累我们将会累计海量的数据,那么如果这个时候有人要来还书,我们要去翻找以前的记录将会非常麻烦。
所以我们通常会将这些信息分类,比如将每一天的记录都分开存放,这样如果别人来还书,只要查找别人借书的那一天的信息就可以了。
可是这样我们也会面临一些问题,比如他如果忘记了是那一天怎么办,如果有重名怎么办,那么我们对这种情况显然需要一些应付的措施。比如我们可以贴一个书签上去记录相关的信息。并且如果这个信息越多,我们的分类越多(比如再对每一天借书人的信息按照字典序进行排序),那我们查找的速度就会越来越快。
那么这个时候其实就可以看的出来,如果我们想要对一个信息进行快速的匹配工作,我们最好将它进行细化处理。
那么同理,如果我们可以将数据通过特殊规则打上标签,并且对标签进行分类,那么我们的查找速度将会非常快。
那么哈希就由此诞生了。
其思想就是将数据通过特殊规则进行转化得到特征值(哈希值),然后我们就可以根据哈希值去直接匹配是否有相应的数据。(比如,如果有人还书,是2023年12月1日借的,名字叫张三,那我们就可以直接去2023年12月1日的,首字母是Z的信息处找,如果找到那就完成还书,如果找不到就存在问题)。
那么接下来我们就要介绍一些产生哈希值的方法!
1:直接定值法(一个值有一个对应的特殊位置)
比如如果我们对整数可以使用取模的操作
比如这样就完成了对应的安放
可是这样我们会发现如果同时有3和13那就发生矛盾。
这个就是哈希碰撞,哈希碰撞越激烈就会导致后续查找效率越低!(如果完全没有哈希碰撞那时间复杂度就是o(1),但是如果发生哈希碰撞,比如插入了3和13,那么查找13的时候就要先找3,然后再往后找一次才能找到13,最坏情况可能到达o(n))
那么首先我们可以提出第一种方法,谁先进入的就放在前,后面进入的就直接放在后面。
比如3放3的位置,13放在4的位置,如果有4,14,24...等等插入,就放在5的位置。以此类推,然后要查找余数为3的数就从3位置开始查齐,直到找到对应数为止,或者找不到(找到空或者到了末尾)。但是我们发现这样会导致较大的麻烦,出现大面积的挤压(比如同时进来3,13,23,33等等)其他数据的位置。我们也可以采用每一次+1,+4,+9个位置,这样就可以让数据不会大面积在一处挤占。
但是如果我们要实现删除,那么我们就要引入新的办法。那么每个位置可以先分好三个状态,第一种是空(从未写入),第二种是已有数据,第三种就是曾经有数据现在删除。
这样我们在查找的时候就只有遇到第一种状态才停,而后两种则需要继续向后寻找。
首先是基本的结构和状态枚举。
然后写插入的逻辑。首先我们看扩容的逻辑,这里要注意,我们一般不会一直把表装满才扩容,一般装70%左右就扩容了。
这样可以减少哈希碰撞。然后如果我们哈希表已经装到70%了,我们就扩容一下子,这里我们用现代写法先扩出一个二倍空间,再把数据旧表数据全部插入到新表,再把新旧表交换一下,然后旧表的数据就会随着insert函数的执行完毕被系统回收。
然后就是是正常插入的逻辑。(这里写的是如果碰撞向后逐个插入,也可以写向后移动n2个的插入方法)。
接下来我们就要写删除的逻辑,其实并没有删除数据只是将其状态改为删除不让访问即可。(伪删除)。
显然通过上面的方法我们暂且实现了哈希,但是我们仍然感觉到这个方法并没有想象的那么好。
存在诸多的问题,尤其是挤占其他数据的位置尤为明显。
那么由此我们就要提出我们的新办法———哈希桶
上面的办法其实真正可以叫做闭散列的实现,即在一段有限的空间(即vector的容量)里面进行安放。
而哈希桶则可以可以打破这个限制,故也被称为开散列。
接下来我们就来研究哈希桶。
哈希桶法也被称为拉链法
其本质就是对每一个位置都用一个链表去装载,而非去挤占其他位置。
比如这个地方3,13,23,就只用在3的位置上依次向外连接就可以了,而不用去挤占4,5的位置。
这样做的优点有如下几个
1:减小哈希碰撞的影响(最起码哪怕有一个值哈希碰撞非常多,但是对其他值都是没有影响的)
2:可以避免频繁扩增,提高空间利用率,提高性能。
比如这个时候我们的负载因子可以从0.7提升到100%甚至200%都是可以的。
接下来我们看实现
首先当然就是节点的构建和哈希表的成员,节点的构建就是简单的链表的构建,这里就不多说了。然后成员类容就是vector(哈希表),和当前装载的个数(记录负载因子)。这里主要是为了后续实现map,set所以使用了pair,根据需求其他数据也是可以的,比如整数,字符串等等都行。
然后我们来继续写我们的哈希表。
这个地方的Hash是我们伪函数,可以针对不同的数据类型写不同的比较方法。增强复用率。(例如排序中的greater()和less())
然后我们就是析构函数,主要工作就是先将与节点连接的数据逐个销毁,
然后就是插入的逻辑,仍然是先根据负载因子判断是否需要扩容。
此处的扩容仍然用的是现代写法,也就是先创建新表,再将旧表数据插入,最后新旧表互换,删除旧表,就完成了更新。
插入的逻辑就是先根据我们的定制的算法(hash)算出的值模上我们开的大小的出对应的哈希值,然后进行简单的链表连接即可。
find的写法也是先得到对应的哈希值,再到对应的位置进行遍历。
删除也即链表的删除,不多赘述。
综上基本就是哈希表的基本结构了,然后我们再来讲解
然后我们就开始讲解unordered_map和unordered_set
首先我们已经学习了map和set的使用和功能,然后我们要来了解unoredered版本的区别。
我们知道map和set的底层实现是红黑树。
而unoredeered版本底层则是哈希表。
我们现在来看看其实现(带封装)
首先我们已经实现了一个哈希表,所以我们的unordered版本的map和set就可以直接复用。
那么我们的主要任务就变成了对哈希表的改成(封装)。
首先我们要加入一个仿函数的接口用来适应不同的比较方式(比如set中直接比key,而map中则要从pair中提取key)(例如sort中的greater()和less())
既然要封装,那么我们势必就要实现迭代器。
那么除了K,TkeyofT,Hash这些,我们还有添加引用和指针的模板参数。
为我们的*和->的返回值做准备。
然后我们实现!=和==
然后我们实现++
就是一个桶一个桶的遍历,一个桶遍历完就去下一个桶。
这样我们的迭代器内部就实现完毕了。
然后在我们的哈希表里面加上begin()和end()即可。
也就是我们的哈希表已经封装的差不多了,接下来我们就可以去研究一下unordered_map和u_set
的封装了。
然后我们对unordered_set封装的时候就要封装我们的普通迭代器和const迭代器。
然后实现begin和end,其实就是复用哈希表的迭代器。(const迭代器,要加const,防止数据被修改,保证常性)
然后我们完成insert和find就基本实现了set的封装。
(insert的返回值就是插入的元素的迭代器,与库中保持一致)
?
map也差不多。
主要的不同点就是加入了[]的重载。实现如下。
主要就是找到对应的key的value,传引用回去方便修改。
说了这么多unordered版本的与红黑树版有什么不同呢?
其优点如下
但是其也有缺点
就是无法有序输出,如果数据除了查找还有排序,那么这个时候就不能使用unordered版本了。
所以针对不同场景要进行不同的取舍。
接下来我们来讲位图
首先我们来看一个题目
给40亿个不重复的无符号整数,没排过序。给一个无符号整数,如何快速判断一个数是否在这40亿个数中。
如果每一次查找都要遍历,那就太慢了,显然不行。
可以考虑搜索树或者哈希表,但是如果要这样操作那么就要先把数据放入内存。
40亿个无符号整数,每个大小4个字节,则一共占用160亿字节。
1GB = 1024 MB, 1MB ?= 1024KB, 1KB = 1024Byte(字节).
所以一共占用16000000000/1024/1024/1024≈14.9G.
所以要用这些方法的话一上来就占用14.9G的系统内存,大多数电脑是吃不消的,更别说搜索树结点还存其他属性信息,这肯定不行的.
当然还可以存在磁盘上,进行外排序+二分查找。但是想一下,这一切是在磁盘上进行,磁盘的速度可是非常慢的,而且要对这么多数据排序,以及在磁盘上进行二分查找,无论代码还是时间都是复杂和比较慢的.
下一个方法,就是我们大名鼎鼎的位图来解决了,具体是怎么样的呢?
首先,我们要给数组开辟一块空间,这片空间我们开多少呢,要根据数据范围开空间,而不是数据个数
我们可以使用比特位进行表示每一个数是否存在的
所以相当于是2^32-1个比特位。由于1byte=8bit.根据上面所说,一共占用(2^32-1)/8/1024/1024/1024=0.5GB=512MB.?
.
主要就是先根据数据范围开足够的大小空间,然后通过位操作将对应的比特设置为1即可。
reset就是用来执行清楚操作的。就是将对应比特位置0即可。
然后再此基础上还可以继续变形。
这个题和1题类似,无非就是多了一种状态:3次及3次以上。最后对应的状态是以下这样:
0次? ? ? ? ? ? ? ? ? ? ?00
1次? ? ? ? ? ? ? ? ? ? ?01
2次? ? ? ? ? ?? ? ? ? ? 10
3次及3次以上? ? ?11
甚至可以更多次数,以此类推。
然后我们就讲最后一个东西
布隆过滤器
很多人想到的是HashMap。
确实可以将值映射到 HashMap 的 Key,然后可以在 O(1) 的时间复杂度内返回结果,效率奇高。但是 HashMap 的实现也有缺点,例如存储容量占比高,考虑到负载因子的存在,通常空间是不能被用满的,而一旦你的值很多例如上亿的时候,那 HashMap 占据的内存大小就变得很可观了。
?
然后我们看一下布隆过滤器的定义
布隆过滤器的原理是,当一个元素被加入集合时,通过K个散列函数将这个元素映射成一个位数组中的K个点,把它们置为1。检索时,我们只要看看这些点是不是都是1就(大约)知道集合中有没有它了:如果这些点有任何一个0,则被检元素一定不在;如果都是1,则被检元素很可能在。这就是布隆过滤器的基本思想。
大概是看不懂的。
我们接下来就要认真的讲解一下这个东西。
首先我们要更详细的解释一下其本质。
本质上布隆过滤器是一种数据结构,比较巧妙的概率型数据结构(probabilistic data structure)高效地插入和查询,可以用来告诉你 “某样东西一定不存在或者可能存在”。相比于传统的 List、Set、Map 等数据结构,它更高效、占用空间更少,但是缺点是其返回的结果是概率性的,而不是确切的。
当你往简单数组或列表中插入新数据时,将不会根据插入项的值来确定该插入项的索引值。这意味着新插入项的索引值与数据值之间没有直接关系。这样的话,当你需要在数组或列表中搜索相应值的时候,你必须遍历已有的集合。若集合中存在大量的数据,就会影响数据查找的效率。
针对这个问题,你可以考虑使用哈希表。利用哈希表你可以通过对 “值” 进行哈希处理来获得该值对应的键或索引值,然后把该值存放到列表中对应的索引位置。这意味着索引值是由插入项的值所确定的,当你需要判断列表中是否存在该值时,只需要对值进行哈希处理并在相应的索引位置进行搜索即可,这时的搜索速度是非常快的。
?
根据定义,布隆过滤器可以检查值是 “可能在集合中” 还是 “绝对不在集合中”。“可能” 表示有一定的概率,也就是说可能存在一定为误判率。那为什么会存在误判呢?下面我们来分析一下具体的原因。
布隆过滤器(Bloom Filter)本质上是由长度为 m 的位向量或位列表(仅包含 0 或 1 位值的列表)组成,最初所有的值均设置为 0,如下图所示。
为了将数据项添加到布隆过滤器中,我们会提供 K 个不同的哈希函数,并将结果位置上对应位的值置为 “1”。在前面所提到的哈希表中,我们使用的是单个哈希函数,因此只能输出单个索引值。而对于布隆过滤器来说,我们将使用多个哈希函数,这将会产生多个索引值。
我们可以通过输入来生成多个哈希值,这样就可以实现多重映射。
然后当我们要进行查询的时候就通过生成的对应哈希值去查看是否都存在,如果都为1则有可能存在,有可能不存在。如果存在值不为1,那么就肯定不存在。
也即当我们搜索一个值的时候,若该值经过 K 个哈希函数运算后的任何一个索引位为 ”0“,那么该值肯定不在集合中。但如果所有哈希索引值均为 ”1“,则只能说该搜索的值可能存在集合中。
很显然,过小的布隆过滤器很快所有的bit位均为1,那么查询任何值都会返回“可能存在”,起不到过滤的目的了。布隆过滤器的长度会直接影响误报率,布隆过滤器越长其误报率越小。
另外,哈希函数的个数也需要权衡,个数越多则布隆过滤器 bit 位置位 1 的速度越快,且布隆过滤器的效率越低;但是如果太少的话,那我们的误报率会变高。
n 是已经添加元素的数量;
k 哈希的次数;
m 布隆过滤器的长度(如比特数组的大小);
具体的实现也不难,就是上面位图的操作,但是多加几个哈希函数,每一次与多个比特位进行绑定即可。
应用也很多
网页爬虫对 URL 去重,避免爬取相同的 URL 地址;
反垃圾邮件,从数十亿个垃圾邮件列表中判断某邮箱是否垃圾邮箱;
Google Chrome 使用布隆过滤器识别恶意 URL;
Medium 使用布隆过滤器避免推荐给用户已经读过的文章;
Google BigTable,Apache HBbase 和 Apache Cassandra 使用布隆过滤器减少对不存在的行和列的查找。
比如利用布隆过滤器我们可以预先把数据查询的主键,比如用户 ID 或文章 ID 缓存到过滤器中。当根据 ID 进行数据查询的时候,我们先判断该 ID 是否存在,若存在的话,则进行下一步处理。若不存在的话,直接返回,这样就不会触发后续的数据库查询。需要注意的是缓存穿透不能完全解决,我们只能将其控制在一个可以容忍的范围内。
然后我们说一下优缺点
优点
相比于其它的数据结构,布隆过滤器在空间和时间方面都有巨大的优势。布隆过滤器存储空间和插入/查询时间都是常数(O(k))。另外,散列函数相互之间没有关系,方便由硬件并行实现。布隆过滤器不需要存储元素本身,在某些对保密要求非常严格的场合有优势。
布隆过滤器可以表示全集,其它任何数据结构都不能;
k和m相同,使用同一组散列函数的两个布隆过滤器的交并运算可以使用位操作进行。
缺点
但是布隆过滤器的缺点和优点一样明显。误算率是其中之一。随着存入的元素数量增加,误算率随之增加。但是如果元素数量太少,则使用散列表足矣。
另外,一般情况下不能从布隆过滤器中删除元素。我们很容易想到把位数组变成整数数组,每插入一个元素相应的计数器加1, 这样删除元素时将计数器减掉就可以了。然而要保证安全地删除元素并非如此简单。首先我们必须保证删除的元素的确在布隆过滤器里面。这一点单凭这个过滤器是无法保证的。另外计数器回绕也会造成问题。
在降低误算率方面,有不少工作,使得出现了很多布隆过滤器的变种。