当前位置: 首页 > news >正文

C++哈希表:冲突解决与高效查找

引入 

通过C++STL库中的unordered_map和unordered_set的学习,我们还需要其底层结构是什么,如何实现的,本节重点讲解哈希

哈希概念

顺序结构以及平衡树中,元素关键码与其存储位置之间没有对应关系,因此在查找一个元素是,必须要经过关键码的多次比较。

顺序查找时间复杂度为O(N),平衡树中为数的高度,即O(logN),搜索的效率取决于搜索过程中元素的比较次数

理想的搜索方式:可以不经过任何比较,一次直接从表中得到搜索的原色。如果构造一种存储结构,通过某种函数(hashFunc)使元素的存储位置与它的关键码之间能够建立一一映射的关系,那么在查找时通过该函数可以很快找到该元素

当向该结构中:

插入元素时,根据带插入元素的关键码,以此函数计算出该元素的存储位置并按此位置进行存放

搜索元素时,对元素的关键码进行同样的计算,把求得的函数值当作元素的存储位置,在结构中按此位置取元素比较,若关键码相等,则搜索成功

该方法即为哈希(散列)方法,哈希方法中使用的转换函数称为哈希(散列)函数,构造出来的结构称为哈希表(Hash Table)(或者称为散列表)

简单来说哈希就是一个映射关系,把关键码和存储地址进行一一映射

例子加深理解

比如你有一个集合{1,7,6,4,5,9}

我们可以使用链表进行存储,这样查找一个元素时时间复杂度就为O(N)

如果使用平衡树存储,就是O(logN)

接下来我们尝试使用哈希表存储,我们得想一个hashFunc出来

比如哈希函数可以设为:hash(key)=key%capacity;capacity为存储元素底层空间的总的大小

比如这个集合最大为9,我们可以开一个10的数组,下标就是0到9

用该方法进行搜索时不必进行多次关键码的比较,因此搜索的速度比较快

问题 :按照上述哈希方式,向集合中插入44,会出现什么问题

显然44%10=4,接着把它存储在4的位置就会出现冲突,那你查找的时候是4还是44

哈希冲突

对于上述插入的44与4产生冲突,我们称为哈希冲突

对于两个数据元素的关键字ki和kj(i!=j),但hash(ki)=hash(kj),即不同的关键字通过相同哈希函数计算出相同的哈希地址,该现象称为哈希冲突或哈希碰撞

把具有不同关键码而相同哈希地址的数据元素称为同义词

问题就转换为如何处理哈希冲突,这是哈希表在设计的时候最需要考虑的问题 

哈希函数设计原则

引起哈希冲突的一个原因可能是:哈希函数设计不够合理。

哈希函数设计原则:

1.哈希函数的定义域必须包括需要存储的全部关键码,而如果散列表允许有m个地址时,其值域必须在0~m-1之间

2.哈希函数计算出来的地址能均匀分布在整个空间中

3.哈希函数应该比较简单

常见的哈希函数

直接定址法:取关键字的某个线性函数为散列地址:Hash(Key)=A*Key+B

优点:简单,均匀

缺点:需要事先知道关键字的分布情况

使用场景:适合查找比较小且连续的情况

例子:比如在一连串字母中查找只出现过一次的字母,a映射数组下标为0,b映射下标为1,最后只用开26个空间,并且数组当中数字为1的就是只出现过一次的,在通过映射关系就可以得原字母

不适合场景:不连续,比如有一个集合{1,2,5,8888,10000}那你开的数组空间是10000会浪费

除留余数法:设散列表中允许的地址数为m,取一个不大于m,但最接近或者等于m的质数p作为除数,按照哈希函数:Hash(Key)=Key%p(p<=m),将关键码转换成哈希地址

一开始例子加深理解那个就是除留余数法

平方取中法:假设关键字为1234,对它平方就是1522756,抽取中间的3位227作为哈希地址;在比如关键字位4321,对它平方18671041,抽取中间的3位671(或710)作为哈希地址

平方取中法比较适合:不知道关键字的分布,而位数又不是很大的情况下

折叠法:折叠法是将关键字从左到右分隔成位数相等的几部分(最后一部分位数可以短些),然后将这几部分叠加求和,并按散列表表长,取后几位作为散列地址。

折叠法适合事先不知道关键字的分布,适合关键字位数比较多的情况

随机数法:选择一个随机函数,取关键字的随机函数值作为它的哈希地址

Hash(Key)=random(Key),其中random为随机数函数

通常应用于关键字长度不等时

注意:以上我们最常用的是直接定址法和除留余数法,其他稍作了解即可,还有很多方法就一一介绍

注意:哈希函数设计的越精妙,产生哈希冲突的可能性就越低,但无法避免哈希冲突

哈希冲突的解决方法 

两种常见的方法:闭散列和开散列

闭散列(开放定址法)

当发生哈希冲突时,如果哈希表未被填满,说明哈希表中比如还有空位置,那么可以把key存放到冲突位置中的下一个”空位置“中去,那如何寻找下一个空位置呢?

线性探测:比如在一开始那个例子再次插入44

此时你插入44,算出来的地址是4,44理论上应该放在4的位置,但4的位置已经有4了,即发生哈希冲突

线性探测

从发生冲突的位置开始,依次向后寻找,直到寻找到下一个空位置为止

插入: 通过哈希函数获取待插入元素在哈希表中的位置,如果该位置中没有元素则直接插入新元素,如果该位置中有元素发生哈希冲突,使用线性探测找到下一个空位置,插入新元素

 

删除:采用闭散列处理哈希冲突时,不能随便物理删除哈希表中已有的元素,若直接删除元素会影响其他元素的搜索。比如删除4,如果直接删除掉,44查找起来可能会受影响,因此线性探测采用标记的伪删除法来删除一个元素 (比如你删除4,下一次查找44时肯定是先算出4这个位置,4是空的,你就不会往下寻找到44,就会导致没找到)

线性探测的优点:实现非常简单

线性探测的缺点:一旦发生哈希冲突,所有的冲突连在一起,容易产生数据”堆积“,即:不同的关键码占据了可利用的位置,使得寻找某关键码的位置需要多次比较,导致搜索效率降低,如何缓解呢?(二次探测缓解)

二次探测

线性探测的缺陷是产生冲突的数据堆积在一起,这与其找下一个空位置有关系,因为找空位置的方式就是挨着往后逐个去找,因此二次探测为了避免该问题,找下一个空位置的方法为:

Hi=(H0+i^2)%m,或者Hi=(H0-i^2)%m,其中i=1,2,3……,H0是通过散列函数Hash(x)对元素的关键码key进行计算得到的位置,m是表的大小。比如对于上面的插入44,二次探测这样解决

因为4的位置已经被占了,

下一个位置就是(4+1)%10=5

下一个位置就是(4+4)%10=8

8有空位置,直接插入即可

研究表明 :当表的长度为质数且表装载因子α不超过0.5(有效元素/表的大小)时,新的表项一定能够插入,而且任何一个位置都不会被探测两次。因此只要表中有一本的空位置,就不会存在表满的问题。在搜索时可以不考虑表装满的情况,但在插入时必须确保表的装载因子α不超过0.5,如果超出必须扩容

简单了解哈希表的结构

//哈希表每个空间给个标记
enum State
{EMPTY,//表示空EXIST,//表示存在元素DELETE//表示删除,这样它会往下一个位置寻找
}

说明:这里的KeyOfT是一个仿函数,取key的值

哈希表的插入(基于闭散列的实现)

基于线性探测的实现

Hash(key)=Key%表的大小

第一步:如果空间不够需要增容

1.开空间,1.5倍或者2倍

2.把旧表中的数据重新映射到新表(因为你的表大小变了,hash(key)就变了)

3.释放旧表的空间

if (_tables.size() == 0 || _num * 1.0 / _tables.size() >= 0.7)//可能一开始size==0,那就会引发除0错误,所以加一个条件进入if语句
{//增容有三步//1.开空间,1.5或者2倍//2.把旧表中的数据重新映射到新表(因为你的映射关系是key/表的大小,表的大小有变化)// 遍历一遍原来的数组,把数据映射到上面,但是如果映射位置发生哈希冲突就要找下一个位置,// 又要写一遍insert的插入逻辑(可以利用递归,创一个哈希表,然后调用insert,以下方法是重写insert逻辑)//3.释放旧表的空间vector<HashData> _newtables;size_t _newsize = _tables.size() == 0 ? 10 : _tables.size() * 2;//判断要开多大的空间_newtables.resize(_newsize);//开空间for (size_t i = 0; i < _tables.size(); i++){if (_tables[i]._state == EXITS)//遍历一遍如果这个位置存在数据就放到newtables中{int index = koft(_tables[i]._data) % _newtables.size();//计算在新表中的位置while (_newtables[index]._state == EXITS){//如果位置存在且不等于这个数据,就找下一个空位置(线性探测)++index;if (index == _tables.size()){//找到后面没有就要到第一个位置在找index = 0;}}//找到空位置了把数据放进去_newtables[index]._data = _tables[i]._data;_newtables[index]._state = EXITS;}}_tables.swap(_newtables);//交换两个数组,(只是交换里面的私有成员的数据)当_newtables出了作用域自动帮我们析构释放原来的空间
}

说明:if的判断语句后面说,先简单了解即可,对于index=0,因为表中一定有空余的位置,所以这个while一定不是死循环

第二步:线性探测

	//线性探测//第一步计算在表中映射的位置size_t index = koft(data) % _tables.size();while (_tables[index]._state == EXITS){if (koft(_tables[index]._data) == koft(data)){//如果位置存在且是这个数据就不允许插入了return false;}//如果位置存在且不等于这个数据,就找下一个空位置(线性探测)++index;if (index == _tables.size()){//找到后面没有就要到第一个位置在找index = 0;}}//找到空位置了把数据放进去_tables[index]._data = data;_tables[index]._state = EXITS;_num++;return true;

学到这里我们重新回去看if语句的条件

思考:哈希表什么情况下进行扩容?如何扩容?(重点) 

如果一个哈希表中几乎没有空余位置,发生冲突的可能性就比较大,比如10个位置,只剩一个,你插入新元素的时候就很大可能发生冲突,而且可能寻找空位置的时间要找多次

如果一个哈希表中空余位置过多,就会导致空间浪费

所以我们提出一个解决方案,散列表的载荷因子:α=填入表中的元素个数/散列表的长度

α是散列表装满程度的标志因子。由于表长是定值,α与”填入表中的元素个数“成正比,α越大,表明填入表中的元素越多,产生冲突的可能性就越大;反之α越小,表明填入表中的元素越少,产生冲突的可能性就越少。实际上,散列表的平均长度是载荷因子α的函数,只是不同处理冲突的方法有不同的函数。

对于散列表,载荷因子是特别重要的因素,因严格控制在0.7~0.8,超过0.8,查表时的CPU缓存不命中按照指数曲线上升。因此,一些采用开放定址法的hash库,如JAVA的系统库限制了载荷因子为0.75,超过此值将resize散列表

所以上面我们设计载荷因子在0.7,如果超过这个值就会进行扩容,而不是等满的时候进行扩容

基于二次探测的实现

第一步:如果空间不够需要扩容,和线性探测一开始的逻辑一样

第二步:二次探测

//	二次探测
//第一步计算在表中映射的位置size_t start= koft(data) % _tables.size();size_t index = start;size_t i = 1;//i是用来记录二次探测中下一次要加多少while (_tables[index]._state == EXITS){if (koft(_tables[index]._data) == koft(data)){//如果位置存在且是这个数据就不允许插入了return false;}//如果位置存在且不等于这个数据,就找下一个空位置(线性探测)index=start+i*i;index %= _tables.size();if (index >= _tables.size()){//找到后面没有就要到第一个位置在找index = 0;}i++;}//找到空位置了把数据放进去_tables[index]._data = data;_tables[index]._state = EXITS;_num++;return true;

哈希表的查找

HashData* Find(const K& key)
{KeyOfT koft;//拿k的值//第一步计算在表中映射的位置size_t index = key % _tables->size();while (_tables[index]._state != EMPTY){if (koft(_tables[index].data) == key){if (_tables[index].state == DELETE){return nullptr;}else//_tables[index].state == EXITS{return &_tables[index];}}++index;if (index == _tables.size()){//找到后面没有就要到第一个位置在找index = 0;}}return nullptr;
}

注意:这里我们引入了伪删除(DELETE),也就是如果找到的那个位置是删除我们需要接着往后查找 

哈希表的删除

bool erase(const K& key)
{HashData* ret = Find(key);if (ret){//ret不等于nullptr说明找到了ret->_state = DELETE;--_num;return true;}else{return false;}
}

总结 

我们使用最常用的除留余数法来设计哈希表,Hash(Key)=Key%size(表的大小),但这样设计会有哈希冲突,如何减少哈希冲突,我们尝试使用闭散列,闭散列中具体使用线性探测和二次探测

对于find和erase,注意伪删除即可

哈希冲突的解决方法(开散列)

概念:开散列又叫链地址法(开链法),首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子集合,每一个子集合称为一个桶,每个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中

从上图可以看出就类似于一个桶,桶里面装的都是冲突元素 

为什么有开散列 ???

弥补闭散列的不足:通过学习闭散列,无论是线性探测(挨着一个一个往后找空位置)还是二次探测(跳着找位置),都是一种占用别人位置的方法,也就是我的位置被占了,我就占别人的位置,可能会导致一片接着一片的冲突,效率很低,所以提出了开散列-哈希桶

开散列的实现

了解哈希桶的基本结构

这里我们就不需要DELETE/EMPTY/EXITS表示一个位置的状态了

结点: 

 

对于template中的参数说明,K就是key,T就是key或者key-value,KeyofT就是仿函数用于取key的值,Hash是一个仿函数:把key转换成整形

对于其他的说明我们边讲解边说明 

哈希表的插入

第一步:增容

增容有三步
1.开空间,1.5或者2倍
2.把旧表中的数据重新映射到新表(因为你的映射关系是key/表的大小,表的大小有变化)
3.释放旧表的空间

负载因子的控制 

通过闭散列的学习,我们知道闭散列需要控制负载因子在0.7~0.8之间,那开散列需不需要控制呢???答案是必须的

为什么需要控制?

当大量的数据冲突时,这些哈希冲突的数据就会挂在同一个链式桶,查找时效率就会降低,所以开散列-哈希桶也要控制哈希冲突

如何控制?

通过负载因子,一般把开散列的负载因子控制到1会比较好一些

假设总是有一些桶挂的数据很多,冲突很厉害如何解决 ?

1.一个桶链的长度超过一定值,就将挂链表改成挂红黑树,例如JavaHashMap就当桶长度超过8时就改成挂红黑树

2.控制负载因子

pair<Iterator,bool> Insert(const T&data)
{KeyOfT koft;//创一个对象去取key//控制负载因子if (_tables.size() == _num)//如果一开始负载因子为0,也会进入if语句,当负载因子为1时也会进入增容,避免大量的哈希冲突{//增容有三步//1.开空间,1.5或者2倍//2.把旧表中的数据重新映射到新表(因为你的映射关系是key/表的大小,表的大小有变化)//3.释放旧表的空间vector<Node*>_newtables;size_t newsize = _tables.size() == 0 ? 10 : _tables.size() * 2;//优化:对于表的大小,当表的大小为素数时冲突可能会少一点//可以给一个素数表,每次按照里面的素数来开空间,素数表中基本也是按照二倍的速度增加_newtables.resize(newsize);for (size_t i = 0; i < _tables.size(); i++){Node* cur = _tables[i];while (cur){Node* next = cur->_next;//如果cur不为空则把cur的数据重新映射到新表中size_t index = HashFunc(koft(cur->_data)) % _newtables.size();//头插进新表cur->_next = _newtables[index];_newtables[index] = cur;cur = next;}_tables[i] = nullptr;}_tables.swap(_newtables);}

注意这里的插入是头插,所以里面冲突元素是无序的

HashFunc函数的讲解 
size_t HashFunc(const K&key)
{//这个函数的作用在于把key转成整形,因为你插入等操作时是需要取模的操作,// 当你插入的key是一个string或者结构体时就需要将这些转换成整形才能取模Hash hash;return hash(key);
}

Hash这个是我们在讲解哈希桶的基本结构中提到的Hash函数:把key转换成整形,现实中当我们使用unordered_map和unordered_set时,是不是可以使用char/string/int/自定义类型等等,不是每一个存进去的都可以进行取模运算,此时我们就需要一个函数对存进来的key进行处理,后续我们才能进行取模运算,你可以自己在我们进行处理,也可以使用默认的。

这里我们自行设计了一个简陋的转换整形,仅仅支持普通和string(可以自行添加完善功能)

	template<class K>struct _Hash{const K& operator()(const K& key){return key;}};//就是只要你的key不支持取模,就需要你自己动手写一个仿函数//那对于结构体怎么写仿函数呢?可以找结构体中唯一代表结构体的项,比如电话号码之类的struct _HashString//如果你传的是字符串,就需要这个仿函数去把字符串转成整形{size_t operator()(const string& key){size_t hash = 0;for (size_t i = 0; i < key.size(); i++){//这里不能简单的把所有的字符的ascll相加,因为有些不一样的可能相加一样(了解字符串哈希算法)//BKDR算法:Java就是用的这个算法(算法最优打分最高)  SDBM算法   RS算法hash *= 131;//BKDR算法hash += key[i];}return hash;}};

但这里有没有发现无法使用模板,class Hash=_Hash/_HashString这样,比如你设计map的时候是默认_Hash还是_HashString,对于STL实现的我们都不用传,内置类型它会使用默认的,那我们如何设计使得我们后续设计map的时候直接class Hash=_hash呢?(这样只要自定义类型自己传不使用默认的就可以)(问题就是每次使用我们都要传仿函数

	template<class K>struct _Hash{const K& operator()(const K& key){return key;}};//_Hash特化版本<string>,这样外部传string时可以不用传仿函数,//当K为string时就会自动调这个特化版本的,//先调默认仿函数,在调默认仿函数中的特化版本template<>struct _Hash<string>//由于日常使用很容易用到string,可以写一个特化版本,传参时就不要传仿函数{size_t operator()(const string& key){size_t hash = 0;for (size_t i = 0; i < key.size(); i++){//这里不能简单的把所有的字符的ascll相加,因为有些不一样的可能相加一样(了解字符串哈希算法)//BKDR算法:Java就是用的这个算法(算法最优打分最高)  SDBM算法   RS算法hash *= 131;//BKDR算法hash += key[i];}return hash;}};

对于特化版本可以去我的C++专栏了解

总结:我们先设计Hash函数:作用就是转换成整形,后面才能进行取模运算,我们在表中设计HashFunc函数:作用就是使用Hash传进来的仿函数进行转换成整形然后返回

到这里我们已经完成了增容,现在就是需要想如何插入新元素

第一步:计算位置

第二步:进行头插

	//第一步计算映射位置size_t index = HashFunc(koft(data)) % _tables.size();Node* cur = _tables[index];while (cur){//找挂起来的数据有没有与插入的数据冗余if (koft(cur->_data) == koft(data)){return make_pair(Iterator(cur,this),false);}else{cur = cur->_next;}}//走到这里表示数据没有冗余//第二步选择头插或者尾插Node* newnode = new Node(data);newnode->_next = _tables[index];_tables[index] = newnode;_num++;return make_pair(Iterator(newnode,this), true);
}

对于make_pair不熟悉的可以去看我的map和set的讲解,返回就是第一个为迭代器类型,第二个为布尔值,对于迭代器一会讲解

优化: 

研究表明:当模上一个素数表时,可以减少冲突,所以我们可以给一个每次增长2倍的素数表,每次模这个数字

const int PRIMECOUNT=28;
const size_t primeList[PRIMECOUNT] =
{53ul,97ul,193ul,389ul,769ul,1543ul,3079ul,6151ul,12289ul,24593ul,49157ul,98317ul,196613ul,393241ul,786433ul,1572869ul,3145739ul,6291469ul,12582917ul,25165843ul,50331653ul,100663319ul,
201326611ul, 402653189ul, 805306457ul,
1610612741ul, 3221225473ul, 4294967291ul
};
size_t GetNextPrime(size_t prime)
{size_t i=0;
for(; i<PRIMECOUNT; ++i)
{if(primeList[i] >primeList[i])return primeList[i];
}return primeList[i];
}

哈希表的查找 

Node* Find(const K& key)
{KeyOfT koft;size_t index =HashFunc( key) % _tables.size();Node* cur = _tables[index];while (cur){if (koft(cur->_data) == key){return cur;}else{cur = cur->_next;}}return nullptr;
}

哈希表的删除 

bool Erase(const K&key)
{KeyOfT koft;size_t index = HashFunc(key) % _tables.size();Node* cur = _tables[index];Node* prev = nullptr;//记录cur的前一个位置while (cur){if (koft(cur->_data) == key)//如果相等就删除{if (prev)//如果prev不等于nullptr,说明cur不是第一个结点{prev->_next = cur->_next;delete cur;return true;}else{//说明cur是第一个结点,把那个位置置空即可_tables[index] = cur->_next;delete cur;return true;}}else{//接着往下找prev = cur;cur = cur->_next;}}//找不到返回falsereturn false;
}

迭代器的讲解 (重点难点)

插入什么顺序,遍历出来就是什么顺序,如何实现这种结构呢???

可以把插入顺序也用链表连接起来,这样遍历时就走这个链表即可,但我们这里为了设计方便按照桶的顺序进行遍历即可(可以自行设计)

设计这个迭代器,我们需要想一想私有成员有什么?一个肯定是结点,还有吗?如果我们++--等操作时如何找到下一个桶,这就需要哈希表,也就是迭代器类型还要一个私有成员哈希表

//迭代器
template <class K, class T, class KeyOfT, class Hash>
class HashTable;                                                                       
//加这一句是因为迭代器里面用到了HashTable,编译器会往前面找,
//但前面没有,所以加了一个前置声明template <class K,class T,class KeyOfT,class Hash>
//最后一个参数传hashtables用于++等操作,否则找不到下一个桶
struct _HashTableIterator
{typedef _HashTableIterator<K, T, KeyOfT,Hash> Self;//迭代器typedef HashNode<T> Node;//结点typedef HashTable <K, T, KeyOfT, Hash> HT;//哈希表
public://构造函数_HashTableIterator( Node* node,HT*pht):_node(node),_pht(pht){}//operator*T& operator*(){return _node->_data;}//operator->T* operator->(){return &_node->_data;}Self operator++(){if (_node->_next){//如果结点的下一个不为空,也就证明在同一个桶中,那就返回下一个结点_node = _node->_next;}else{//说明已经到桶的最后一个位置了,要找下一个桶//先找原来的桶是映射到了哪个位置KeyOfT koft;size_t i = _pht->HashFunc(koft(_node->_data)) % _pht->_tables.size();i++;//往下一个桶找for (; i < _pht->_tables.size(); i++){Node* cur = _pht->_tables[i];if (cur){ _node = cur;return *this;}}//找到这里还没有,就证明没有桶了_node = nullptr;}return *this;}bool operator!=(const Self&s){return _node != s._node;}private:Node* _node;HT* _pht;
};

可以看到迭代器里面的私有成员为一个_node(结点)和一个_pht(哈希表) 

通过自行打代码我们可以深刻理解哈希底层结构的设计,再次回顾上面的insert中的返回值

	return make_pair(Iterator(newnode,this), true);

返回中使用newnode创建一个结点成员,this就是这个哈希表,使用这两个创建一个迭代器类型

开散列和闭散列的比较

应用链地址法处理溢出,需要增设链接指针(也就是一个结点要存下一个结点的地址),似乎增加了存储开销。事实上,由于开放地址法必须保持大量的空闲以确保搜索效率,如二次探测法要求负载因子<=0.7,而表项所占空间又比指针大的多,所以使用链地址法反而比开地址法节省存储空间,而且搜索时效率也很高

unordered_map和unordered_set的实现

对于哈希表底层结构我们已经实现完毕,unordered_map和unordered_set只要调用接口即可,然后设计参数传参即可

unordered_map设计

#pragma once
#include "HashTable.h"	
using namespace  open_hash;
namespace June
{ template<class K,class V,class Hash= _Hash<K>>//增加这个仿函数用于key的对比方式class Unordered_map{struct MapKOfT{const K& operator()(const pair<K, V>& kv){return kv.first;}};public:typedef typename HashTable<K, pair<K, V>, MapKOfT, Hash>::Iterator Iterator;pair<Iterator, bool> insert(const pair<K,V>& kv){return _ht.Insert(kv);}Iterator begin(){return _ht.begin();}Iterator end(){return _ht.end();}V& operator[](const K& key){pair<Iterator, bool> ret = _ht.Insert(make_pair(key, V()));return ret.first->second;}private:HashTable<K, pair<K,V>, MapKOfT,Hash> _ht;};

 unordered_set设计

#pragma once
#include "HashTable.h"
using namespace  open_hash;
namespace June
{template<class K, class Hash= _Hash<K>>//增加这个仿函数用于key的对比方式class Unordered_set{struct SetKOfT{const K& operator()(const K& key){return key;}};public:typedef typename HashTable<K,K, SetKOfT, Hash>::Iterator Iterator;pair<Iterator,bool> insert(const K& key){return _ht.Insert(key);}Iterator begin(){return _ht.begin();}Iterator end(){return _ht.end();}private:HashTable<K,K, SetKOfT,Hash> _ht;};

哈希的应用 

面试题

给40亿个不重复的无符号整数,没排过序,给一个无符号整数,如何快速判断一个数是否在这40亿个数中。

大概估算40亿个整型,一个整型4bit,40亿就是160亿bit,1g=1024mb=1024*1024kb=1024*1024*1024bit=2^30次方多一点(大概10亿左右)

那160亿就是16个g

1.遍历,时间复杂度O(N)

2.排序O(logN)-->二分查找O(logN)

3.哈希表存在来再找(如果使用直接定址法,有可能最大的数是整型的最大,那就可能开42亿多的空间)

以上的方案的问题:数据量太大,放不到内存中

此时就可以使用位图

位图

概念:所谓位图,就是用每一位来存放某种状态,适用于海量数据,数据无重复的场景。通常是用来判断某个数据存不存在的

解决场景:

数据是否在给定的整形数据中,结果是在或不在,刚好是两种状态,那么可以使用一个二进制比特位来代表数据是否存在的信息,如果二进制比特位为1,代表存在,为0代表不存在

这种就叫位图:即节省了空间,效率也高

位图的简单用法

这个类模拟了一个 bool 元素数组,但针对空间分配进行了优化:通常情况下,每个元素仅占用一个位(在大多数系统中,这比最小的基本类型 char 少占用七分之八的空间)。

每个位的位置都可以单独访问:例如,对于一个名为 foo 的 bitset,表达式 foo [3] 访问的是其第四个位,就像普通数组访问元素一样。但由于在大多数 C++ 环境中没有单个位的基本类型,因此各个元素通过一种特殊的引用类型(参见 bitset::reference)进行访问。

bitset 具有以下特性:可以从整数值和二进制字符串构造,也可以转换为整数值和二进制字符串(参见其构造函数和 to_ulong、to_string 成员函数)。它们还可以直接以二进制格式从流中插入或提取(参见相关运算符)。

bitset 的大小在编译时固定(由其模板参数决定)。如果需要一个同样优化空间分配且允许动态调整大小的类,请参见 vector 的 bool 特化(vector<bool>)。

set():就是把某个数设置成1,表示存在

reset():就是把某个设置成0,表示不存在

test():就是测试某个位置存不存在 

位图的实现 

#pragma once
#include <iostream>
#include <vector>
using namespace std;
namespace June
{class bitset {public:bitset(size_t N)//开多少个位{_bits.resize(N / 32 + 1, 0);//开空间,因为数组是int,// N/32算有多少个int,+1是保证如果有余数类似60也要保证有空间_num = 0;}void set(size_t x){//把目标位变成1size_t index = x / 32;//算出在哪个整形,例如60/32=1,//第一个整形是0,第二个是1,所以不用加1刚刚好size_t pos = x % 32;//算出在目标整形的哪个位置,例如60%32=28;模出来刚好是那个位置_bits[index] |= (1 << pos);//把第index个位置的整形 或上 1<<pos_num++;}void reset(size_t x){//把目标位变成0size_t index = x / 32;//算出在哪个整形,例如60/32=1,//第一个整形是0,第二个是1,所以不用加1刚刚好size_t pos = x % 32;//算出在目标整形的哪个位置,例如60%32=28;模出来刚好是那个位置_bits[index] &= ~(1 << pos);//先左移把1移到目标位,然后在按位取反,目标位为0,//其他位为1,然后在与,那目标位就会变成0,其他有1的还是1;_num--;}bool test(size_t x){//判断x在不在,(映射的位置是否为1)size_t index = x / 32;//算出在哪个整形,例如60/32=1,// 第一个整形是0,第二个是1,所以不用加1刚刚好size_t pos = x % 32;//算出在目标整形的哪个位置,例如60%32=28;模出来刚好是那个位置return _bits[index] &= (1 << pos);}private:vector<int> _bits;size_t _num;//表示映射存储有多少个数据};

位图的应用

1.快速查找某一个数据是否在一个集合中

2.排序

3.求两个集合的交集、并集等

4.操作系统中磁盘块标记 

布隆过滤器(哈希与位图的结合)

布隆过滤器的提出

我们在使用新闻客户端看新闻时,它会给我们不断地推出新的内容,它每次推荐时要去重,去掉那些已经看过的内容。问题来了,新闻客户端推荐系统如何实现推送去重的?用服务器记录了用户看过的所有历史记录,当推荐系统推荐新闻时会从每个用户的历史记录里进行筛选,过滤掉那些已经存在的记录,如何快速查找呢?

1.用哈希表存储用户记录,缺点:浪费空间

2.用位图存储用户记录,缺点:不能处理哈希冲突

3.将哈希与位图结合,即布隆过滤器

布隆过滤器概念

布隆过滤器是由布隆(Burton Howard Bloom)在1970年提出的一种紧凑的。比较巧妙地概率型数据结构,特点是高效地插入和查询,可以用来告诉你”某样东西一定不存在或者可能存在“,它是用多个哈希函数,将一个数据映射到位图结构中,此种方式不仅可以提高查询效率,也可以节省大量地内存空间

也就是一个数据通过多次地哈希函数,映射到不同地位置,如果查询时这些位置都为1就可能存在,如果其中某个不为1就一定不存在 

布隆过滤器的插入 

简单来说就是利用多个哈希函数,算出不同的值,然后用位图,把这些位都设成1

布隆过滤器的查找

简单来说就是判断所有的位是否都为1,如果都是1,就可能存在,如果有一个不为1,就一定不存在

布隆过滤器的删除

布隆过滤器不支持删除工作,因为在删除一个元素时,可能会影响其他元素

因为布隆过滤器是算多个值一起映射,也就是你把重叠的给删除了,导致原本应该存在的变成了不存在

一种支持删除的方法:将布隆过滤器中的每个比特位扩展成一个计数器,插入元素时给k个计数器(k个哈希函数计算出的哈希地址)+1,删除元素时,给k个计算器-1,通过多占用几倍存储空间的代价来增加删除操作

缺陷:

1.无法缺点元素是否真的存在

2.存在计数回绕(溢出风险)

3.浪费空间

#pragma once
#include "bitset.h"
namespace June
{struct HashStr1{//BKDRsize_t operator()(const string& str){size_t hash = 0;for (size_t i = 0; i < str.size(); i++){hash *= 131;hash += str[i];}return hash;}};struct HashStr2{//SDBMsize_t operator()(const string& str){size_t hash = 0;for (size_t i = 0; i < str.size(); i++){hash *= 65599;hash += str[i];}return hash;}};struct HashStr3{//RSsize_t operator()(const string& str){size_t hash = 0;size_t magic = 63689;//魔数for (size_t i = 0; i < str.size(); i++){hash *= 131;hash += str[i];magic *= 378551;}return hash;}};template<class K=string,//由于很多时候都是string class  Hash1=HashStr1,class Hash2 = HashStr2,class Hash3 = HashStr3>class BloomFilter{public:BloomFilter(size_t num):_bitset(5*num)//对于开多大的空间,大神总结过公式,//与你的HashStr的个数和映射多少数据有关,这里做了近似(且hashstr为3),_N(5*num){}void set(const K&key){//用三个哈希算法算出三个位置size_t index1 = Hash1()(key)%_N;size_t index2 = Hash2()(key)%_N;size_t index3 = Hash3()(key) % _N;//标记三个位_bitset.set(index1);_bitset.set(index2);_bitset.set(index3);}bool test(const K&key){//用三个哈希算法算出三个位置size_t index1 = Hash1()(key) % _N;if (_bitset.test(index1) == false)return false;size_t index2 = Hash2()(key) % _N;if (_bitset.test(index2) == false)return false;size_t index3 = Hash3()(key) % _N;if (_bitset.test(index1) == false)return false;elsereturn true;//布隆过滤器还是会误判,只是误判的概率降低//无法保证它是真的存在,但如果返回false,那就能保证真的不存在}//void resert(),不支持这个,因为可能存在误删,
//如果不小心将别人的映射位置删掉了,可能会导致本来存在的变成不存在private:bitset _bitset;size_t _N;//用于HashStr算法算出整形后模上你的位图的长度};
}

注意:使用布隆过滤器是可能存在误判的,可能那个视频没有推荐过,但其他很多视频算出来后把那个视频的给标记了,就导致了误判 

如何解决误判?

解决不了,只能降低误判的概率,一个值映射一个位置,容易误判

所以一个值映射多个位置(哈希算法)

布隆过滤器的优缺点 

优:

1.增加和查询元素的时间复杂度为O(K)(K为哈希函数的个数,一般比较小),与数据量无关

2.哈希函数相互之间没有联系,方便硬件进行运算

3.布隆过滤器不需要存储元素本身,在某些对保密要求比较严格的场合有很大的优势

4.在能够承受一定的误判时,布隆过滤器比其他数据结构有很大的空间优势

5.数据量很大时,布隆过滤器可以表示全集,其他数据结构不能

6.使用同一组散列函数的布隆过滤器可以进行交、并、差运算

缺:

1.有误判率,即存在假阳性,即不能准确判断元素是否在集合中(补救方法:在建立一个白名单,存储可能会误判的数据)

2.不能获取元素本身

3.一般情况下不能从布隆过滤器中删除元素

4.如果采用计数方式删除,有缺陷

布隆过滤器的应用

比如一个app一开始需要去昵称,就可以使用布隆过滤器

比如视频推荐

海量数据处理

位图应用:

1.给定一个100亿个整数,设计算法找到只出现一次的整数

(用两个位表示)(改位图/创两个位图)

2.给定两个文件,分别有100亿个整数,只有1g的内存,如何找到两个文件交集

映射一个文件到位图,读取另一个文件判断是否在位图中

映射两个文件到两个位图,然后进行按位与

3.1个文件有100亿个int,1g内存,设计算法找到出现次数不超过2次的所有整数

跟第一个解法类似

布隆过滤器:

1.给两个文件,分别有100亿个query,只有1g内存,如何找到两个文件交集?分别给出精确算法和近似算法

假设平均每个query30~60byte,100亿个query占用大约300~600g

近似算法:将文件1中的query映射到一个布隆过滤器,读取文件2中的query,判断在不在布隆过滤器中,在就是交集。缺点:交集中有些数不准

精确算法:每个文件切1000份,那一个小文件就是300~600m

可以加载到内存,用set,A0与B0,A0与B1……但是这样需要不断地比较

优化:使用哈希切分Hash(query),优化前平均切i=hashStr(query)%1000

i是0,query去A0/B0小文件,i是1,query去A1/B1小文件

这样只是A0与B0比较,A1与B1比较,因为你算出来的如果相等肯定都在同一个下标的文件

2.如何扩展布隆过滤器使得它支持删除元素的操作

计数器,可以自行查询一下

一致性哈希

一致性哈希(Consistent Hashing) 是一种分布式系统中常用的哈希算法,用于解决传统哈希在节点动态增减时导致的大量数据迁移问题。传统哈希(如取模法)在节点数量变化时,几乎所有数据的存储节点都会被重新映射,而一致性哈希通过环结构虚拟节点机制,将数据迁移范围限制在局部,大幅提升了系统的可扩展性和稳定性。

结合我们之前所学的可以给出一个场景:比如我们现在要存储每个人的微信号和他的朋友圈信息

那就是kv模型,<微信号,朋友圈信息>

我们现在需要考虑的就是服务器存储数据问题,微信假设有10亿用户,假设平均一个用户的信息是100M,(简单估算,实际很大)

那就是一亿个g,10wT,假设一个服务器1T,需要10w台服务器

这就涉及多机存储-->需要满足增删查改数据的需求

简化版:用户发朋友圈,插入到哪台服务器,浏览和删除朋友圈去哪台查找

那么用户的朋友圈信息存储和机器建立一个映射关系

i=hashStr(用户名)%10w

i=几,就存到几号机器

方案缺陷:数据增加/用户增长-->服务器就不够了,那就要增加到15w台

i=hashStr(用户名)%15w

对于模不一样,所有的映射关系就变了,导致需要迁移数据,这么庞大的数据量迁移,不好迁移

所以推出一致性哈希,直接一开始模一个很大的数 

比如i=hashStr()%2^32

这里模也可以是其他数,但必须足够大,模这么大的数是保证以后+服务器不用全部迁移数据

模完之后的值肯定在0~2^32-1这个范围之间

使用模出来的进行映射服务器

如果后续你要增加服务器,那么就不需要所有数据迁移,只需要迁移部分负载重的服务器上的数据 

 

相关文章:

  • 总结:线程安全问题的原因和解决方案
  • 结构化控制语言(SCL) 与梯形图(LAD)相互转换的步骤指南
  • 16QAM在瑞利信道下的性能仿真:从理论到实践的完整解析(附完整代码)
  • PH热榜 | 2025-06-01
  • SpringBoot-Thymeleaf
  • Arch安装botw-save-state
  • Google 发布的全新导航库:Jetpack Navigation 3
  • MySQL中的事务
  • Figma 中构建 Master Control Panel (MCP) 的完整设计方案
  • 【python深度学习】Day43 复习日
  • Go开发简历优化指南
  • ESP-IDF 离线安装——同时存在多个版本以及进行版本切换的方法
  • 头指针 VS 头节点 VS 首元节点
  • Day43打卡(补41+42) @浙大疏锦行
  • 【dshow】VIDEOINFOHEADER2 头文件
  • Java内存模型与互斥锁
  • Nuxt3部署
  • 机器视觉图像形态学中的腐蚀、膨胀、开运算、闭运算
  • 人工智能工程技术专业 和 其他信息技术专业 有哪些关联性?
  • 借助 Python 实现 AIOps 高级日志分析:实践者行动指南
  • 提升网站性能/seo外链发布
  • 网站建设 费用/软件推广平台
  • 有哪些专做旅游定制的网站/网站推广郑州
  • 河北省城乡住房和城乡建设厅网站/营销型企业网站建设步骤
  • 数码电子产品网站名称/b2b电子商务网站都有哪些
  • php怎么建立网站/网站排名查询平台