引入
通过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;
}
}
//找不到返回false
return 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)
{
//把目标位变成1
size_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)
{
//把目标位变成0
size_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
{
//BKDR
size_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
{
//SDBM
size_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
{
//RS
size_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;
else
return 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这个范围之间
使用模出来的进行映射服务器
如果后续你要增加服务器,那么就不需要所有数据迁移,只需要迁移部分负载重的服务器上的数据