文章目录
- unordered系列关联式容器
- unordered_map
- unordered_map的接口说明
- unordered_set
- set 与 unordered_set的效率比较
- 底层结构
- 哈希概念
- 哈希冲突
- 哈希函数
- 常见哈希函数
- 哈希冲突解决
- 闭散列 —— 开放定址法
- 哈希表的插入
- 线性探测
- 二次探测
- 哈希表的闭散列实现
- 哈希表的结构
- 插入代码实现
- 哈希表的查找
- 哈希表的删除
- 开散列 —— 链地址法(拉链法、哈希桶)
- 开散列的概念
- 开散列增容
- 开散列与闭散列比较
- 哈希表的开散列实现(哈希桶)
- 哈希表的结构
- 哈希表的插入
- 哈希表的查找
- 哈希表的删除
- 哈希表和哈希桶全部源码
- 模拟实现封装unordered_map 和 unordered_set
- 模板参数控制
- 哈希表正向迭代器的实现
- 接引用和->
- 判断节点是否同一个
- ++运算符重载函数
- HashTable.h
- MyUnorderedSet.h
- MyUnorderedMap.h
- 扩容机制是素数?
- 位图
- 位图概念
- 代码实现
- 将x比特位置1
- 将x比特位置0
- 检测位图中x是否为1
- 全部代码实现
- 位图的应用
- 布隆过滤器
- 布隆过滤器提出
- 布隆过滤器概念
- 布隆过滤器的特点
- 控制误判率
- 布隆过滤器的实现
- 布隆过滤器的插入
- 布隆过滤器的查找
- 布隆过滤器的删除
- 布隆过滤器优点
- 布隆过滤器缺陷
- 布隆过滤器使用场景
- 位图的应用
- 布隆过滤器相关
- 哈希切割相关
unordered系列关联式容器
- 在C++98中,STL提供了底层为红黑树结构的一系列关联式容器,在查询时效率可达到 l o g 2 N log_2N log2N,即最差情况下需要比较红黑树的高度次,当树中的节点非常多时,查询效率也不理想。最好的查询是,进行很少的比较次数就能够将元素找到,因此在C++11中,STL又提供了4个unordered系列的关联式容器,这四个容器与红黑树结构的关联式容器使用方式基本类似,只是其底层结构不同
unordered_map
unordered_map在线文档说明
- unordered_map是存储<key, value>键值对的关联式容器,其允许通过keys快速的索引到与其对应的value。
- 在unordered_map中,键值通常用于惟一地标识元素,而映射值是一个对象,其内容与此键关联。键和映射值的类型可能不同。
- 在内部,unordered_map没有对<kye, value>按照任何特定的顺序排序, 为了能在常数范围内找到key所对应的value,unordered_map将相同哈希值的键值对放在相同的桶中。
- unordered_map容器通过key访问单个元素要比map快,但它通常在遍历元素子集的范围迭代方面效率较低。
- unordered_maps实现了直接访问操作符(operator[]),它允许使用key作为参数直接访问value。
- 它的迭代器至少是前向迭代器。
unordered_map的接口说明
- unordered_map的构造
- unordered_map的容量
- unordered_map的迭代器
4. unordered_map的元素访问
- 注意:该函数中实际调用哈希桶的插入操作,用参数key与V()构造一个默认值往底层哈希桶中插入,如果key不在哈希桶中,插入成功,返回V(),插入失败,说明key已经在哈希桶中,将key对应的value返回。
- unordered_map的查询
注意:unordered_map中key是不能重复的,因此count函数的返回值最大为1
- unordered_map的修改操作
- unordered_map的桶操作
unordered_set
参见 unordered_set在线文档说明
set 与 unordered_set的效率比较
#include<unordered_map>
#include<unordered_set>
int main()
{
const size_t N = 100000;
unordered_set<int> us;
set<int> s;
vector<int> v;
v.reserve(N);
srand(time(0));
for (size_t i = 0; i < N; ++i)
{
//v.push_back(rand()); // N比较大时,重复值比较多
//v.push_back(rand()+i); // 重复值相对少
v.push_back(i); // 没有重复,有序
}
size_t begin1 = clock();
for (auto e : v)
{
s.insert(e);
}
size_t end1 = clock();
cout << "set insert:" << end1 - begin1 << endl;
size_t begin2 = clock();
for (auto e : v)
{
us.insert(e);
}
size_t end2 = clock();
cout << "unordered_set insert:" << end2 - begin2 << endl;
size_t begin3 = clock();
for (auto e : v)
{
s.find(e);
}
size_t end3 = clock();
cout << "set find:" << end3 - begin3 << endl;
size_t begin4 = clock();
for (auto e : v)
{
us.find(e);
}
size_t end4 = clock();
cout << "unordered_set find:" << end4 - begin4 << endl << endl;
cout << "插入数据个数:" << s.size() << endl;
cout << "插入数据个数:" << us.size() << endl << endl;
size_t begin5 = clock();
for (auto e : v)
{
s.erase(e);
}
size_t end5 = clock();
cout << "set erase:" << end5 - begin5 << endl;
size_t begin6 = clock();
for (auto e : v)
{
us.erase(e);
}
size_t end6 = clock();
cout << "unordered_set erase:" << end6 - begin6 << endl << endl;
return 0;
}
根据测试结果可以得出以下结论:
- 当处理数据量小时,map/set容器与unordered_map/unordered_set容器增删查改的效率差异不大。
- 当处理数据量大时,map/set容器与unordered_map/unordered_set容器增删查改的效率相比,unordered系列容器的效率更高。
底层结构
- unordered系列的关联式容器之所以效率比较高,是因为其底层使用了哈希结构。
哈希概念
-
顺序结构以及平衡树中,元素关键码与其存储位置之间没有对应的关系,因此在查找一个元素时,必须要经过关键码的多次比较。顺序查找时间复杂度为O( N N 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,会出现什么问题?–> 哈希冲突
哈希冲突
-
对于两个数据元素的关键字 k i k_i ki和 k j k_j kj(i != j),有 k i k_i ki != k j k_j kj,但有:Hash( k i k_i ki) ==Hash( k j k_j kj),即:不同关键字通过相同哈希哈数计算出相同的哈希地址,该种现象称为哈希冲突或哈希碰撞。
-
把具有不同关键码而具有相同哈希地址的数据元素称为“同义词”。
发生哈希冲突该如何处理呢?
哈希函数
引起哈希冲突的一个原因可能是:哈希函数设计不够合理。
- 哈希函数设计原则:
- 哈希函数的定义域必须包括需要存储的全部关键码,而如果散列表允许有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)等方法。
- 数字分析法通常适合处理关键字位数比较大的情况,如果事先知道关键字的分布且关键字的若干位分布较均匀的情况
注意:哈希函数设计的越精妙,产生哈希冲突的可能性就越低,但是无法避免哈希冲突
哈希冲突解决
解决哈希冲突两种常见的方法是:闭散列和开散列
闭散列 —— 开放定址法
- 闭散列,也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表种必然还有空位置,那么可以把产生冲突的元素存放到冲突位置的“下一个”空位置中去。
哈希表的插入
线性探测
- 当发生哈希冲突时,从发生冲突的位置开始,依次向后探测,直到找到下一个空位置为止。
线性探测:从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止。
-
通过哈希函数获取待插入元素在哈希表中的位置
-
通过哈希函数获取待插入元素在哈希表中的位置 如果该位置中没有元素则直接插入新元素,如果该位置中有元素发生哈希冲突, 使用线性探测找到下一个空位置,插入新元素
我们将数据插入到有限的空间,那么空间中的元素越多,插入元素时产生冲突的概率也就越大,冲突多次后插入哈希表的元素,在查找时的效率必然也会降低。介于此,哈希表当中引入了负载因子(载荷因子):
-
负载因子 = 表中有效数据个数 / 空间的大小
- 负载因子越大,产出冲突的概率越高,增删查改的效率越低。
- 负载因子越小,产出冲突的概率越低,增删查改的效率越高。
-
但负载因子越小,也就意味着空间的利用率越低,此时大量的空间实际上都被浪费了。对于闭散列(开放定址法)来说,负载因子是特别重要的因素,一般控制在0.7~0.8以下,超过0.8会导致在查表时CPU缓存不命中(cache missing)按照指数曲线上升。
-
因此,一些采用开放定址法的hash库,如JAVA的系统库限制了负载因子为0.75,当超过该值时,会对哈希表进行增容。
二次探测
- 线性探测的缺陷是产生冲突的数据堆积在一块,这与其找下一个空位置有关系,因为找空位 置的方式就是挨着往后逐个去找,因此二次探测为了避免该问题,找下一个空位置的方法 为: 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是表 的大小。
对于2.1中如果要插入44,产生冲突,使用解决后的情况为:
研究表明:当表的长度为质数且表装载因子a不超过0.5时,新的表项一定能够插入,而且任 何一个位置都不会被探查两次。因此只要表中有一半的空位置,就不会存在表满的问题。在 搜索时可以不考虑表装满的情况,但在插入时必须确保表的装载因子a不超过0.5,如果超出 必须考虑增容。
因此:比散列最大的缺陷就是空间利用率比较低,这也是哈希的缺陷。
线性探测的优点:实现非常简单。
线性探测的缺点:一旦发生冲突,所有的冲突连在一起,容易产生数据“堆积”,即不同关键码占据了可利用的空位置,使得寻找某关键码的位置需要多次比较(踩踏效应),导致搜索效率降低。
- 向哈希表中插入数据的步骤如下:
- 查看哈希表中是否存在该键值的键值对,若已存在则插入失败。
- 判断是否需要调整哈希表的大小,若哈希表的大小为0,或负载因子过大都需要对哈希表的大小进行调整。
- 将键值对插入哈希表。
- 哈希表中的有效元素个数加一。
- 其中,哈希表的调整方式如下:
- 若哈希表的大小为0,则将哈希表的初始大小设置为10。
- 若哈希表的负载因子大于0.7,则先创建一个新的哈希表,该哈希表的大小为原哈希表的两倍,之后遍历原哈希表,将原哈希表中的数据插入到新哈希表,最后将原哈希表与新哈希表交换。
注意: 在将原哈希表的数据插入到新哈希表的过程中,不能只是简单的将原哈希表中的数据对应的挪到新哈希表中,而是需要根据新哈希表的大小重新计算每个数据在新哈希表中的位置,然后再进行插入。
将键值对插入哈希表的具体步骤如下:
- 通过哈希函数计算出对应的哈希地址。
- 若产生哈希冲突,则从哈希地址处开始,采用线性探测向后寻找一个状态为EMPTY或DELETE的位置。
- 将键值对插入到该位置,并将该位置的状态设置为EXIST。
哈希表的闭散列实现
哈希表的结构
- EMPTY(无数据的空位置)。
- EXIST(已存储数据)。
- DELETE(原本有数据,但现在被删除了)。
我们可以用枚举定义这三个状态
// 状态
enum Status
{
EMPTY, // 空
EXIST, // 存在
DELETE // 删除
};
闭散列的哈希表中的每个位置存储的结构,应该包括所给数据和该位置的当前状态。
//哈希表每个位置存储的结构
template<class K, class V>
struct HashData
{
pair<K, V> _kv; // 键值对
Status _status = EMPTY; // 状态
};
而为了在插入元素时好计算当前哈希表的负载因子,我们还应该时刻存储整个哈希表中的有效元素个数,当负载因子过大时就应该进行哈希表的增容。
//哈希表
template<class K, class V>
class HashTable
{
public:
//...
private:
vector<HashData<K, V>> _tables; //哈希表
size_t _n = 0; //哈希表中的有效元素个数
};
插入代码实现
template<class K, class V>
struct HashData
{
pair<K, V> _kv; // 键值对
Status _status; // 状态
};
template<class K>
struct HashFunc
{
size_t operator()(const K& key)
{
return (size_t)key;
}
};
// 特化
template<>
struct HashFunc<string>
{
size_t operator()(const string& key)
{
size_t hash = 0;
for (auto e : key)
{
hash *= 31; // BKDR
hash += e;
}
cout << key << ":" << hash << endl;
return hash;
}
};
// 插入方法
bool Insert(const pair<K, V>& kv)
{
// 1、查看哈希表中是否存在该键值的键值对
if (Find(kv.first))// 哈希表中已经存在该键值的键值对(不允许数据冗余)
return false;
//2、判断是否需要调整哈希表的大小
if (_tables.size() == 0)
_tables.resize(10);
else if (_n * 10 / _tables.size() == 7)// 负载因子大于0.7需要增容
{
// 2倍扩容
size_t newSize = _tables.size() * 2;
//a、创建一个新的哈希表,新哈希表的大小设置为原哈希表的2倍
HashTable<K, V, HashFun> newHT;
newHT._tables.resize(newSize);
//b、遍历旧表,将原哈希表当中的数据插入到新哈希表
for (size_t i = 0; i < _tables.size(); i++)
{
// 如果_tables[i]的位置有数据就进行再次映射
if (_tables[i]._status == EXIST)
{
newHT.Insert(_tables[i]._kv);
}
// c、与旧表进行交换
_tables.swap(newHT._tables);
}
}// 扩容 end...
HashFun hf; // 对于int来说是直接用值来比较,对于string类型使用BKDR方法来比较
// 3、将键值对插入哈希表
// a、通过哈希函数计算哈希地址,线性探测
size_t hashi = hf(kv.first) % _tables.size(); // 除数不能是capacity
size_t index = hashi, i = 1;
//b、找到一个状态为EMPTY或DELETE的位置
while (_tables[hashi]._status == EXIST)
{
index = hashi + i; // 线性探测
index = hashi + i * i; // 二次探测
hashi %= _tables.size(); // 防止下标超出哈希表范围
i++;
}
//c、将数据插入该位置,并将该位置的状态设置为EXIST
_tables[hashi]._kv = kv;
_tables[hashi]._status = EXIST;
//4、哈希表中的有效元素个数++
++_n;
return true;
}
哈希表的查找
在哈希表中查找数据的步骤如下:
- 先判断哈希表的大小是否为0,若为0则查找失败。
- 通过哈希函数计算出对应的哈希地址。
- 从哈希地址处开始,采用线性探测向后向后进行数据的查找,直到找到待查找的元素判定为查找成功,或找到一个状态为EMPTY的位置判定为查找失败。
- 注意: 在查找过程中,必须找到位置状态为EXIST,并且key值匹配的元素,才算查找成功。若仅仅是key值匹配,但该位置当前状态为DELETE,则还需继续进行查找,因为该位置的元素已经被删除了。
// 查找方法
HashData<K, V>* Find(const K& key)
{
if (_tables.size() == 0)
return nullptr;
HashFun hf;
// 计算位置
size_t hashi = hf(key) % _tables.size();
size_t index = hashi, i = 1;
// 不为空就一直找
while (_tables[hashi]._status != EMPTY)
{
//若该位置的状态为EXIST,并且key值匹配,则查找成功
if (_tables[hashi]._status == EXIST
&& _tables[hashi]._kv.first == key)
{
return &_tables[hashi];
}
index = hashi + i; // 线性探测
// index = hashi + i * i; // 二次探测
hashi %= _tables.size(); // //防止下标超出哈希表范围
++i;
}
// 找不到的情况
return nullptr;
}
哈希表的删除
- 采用闭散列处理哈希冲突时,不能随便物理删除哈希表中已有的元素,若直接删除元素 会影响其他元素的搜索。因此线性探测采用标记的伪删除法来删除一个元素。
在哈希表中删除数据的步骤如下:
- 查看哈希表中是否存在该键值的键值对,若不存在则删除失败。
- 若存在,则将该键值对所在位置的状态改为DELETE即可。
- 哈希表中的有效元素个数减一。
// 伪删除法
bool Erase(const K& key)
{
//1、查看哈希表中是否存在该键值的键值对
HashData<K, V>* res = Find(key);
if (res)
{
//2、若存在,则将该键值对所在位置的状态改为DELETE即可
res->_status = DELETE;
--_n; //3、哈希表中的有效元素个数减一
return true; // 删除成功
}
return false; // 删除失败
}
开散列 —— 链地址法(拉链法、哈希桶)
开散列的概念
- 开散列法又叫链地址法(开链法),首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链 接起来,各链表的头结点存储在哈希表中。
从上图可以看出,开散列中每个桶中放的都是发生哈希冲突的元素。
开散列增容
-
桶的个数是一定的,随着元素的不断插入,每个桶中元素的个数不断增多,极端情况下,可能会导致一个桶中链表节点非常多,会影响的哈希表的性能,因此在一定条件下需要对哈希表进行增容,那该条件怎么确认呢?开散列最好的情况是:每个哈希桶中刚好挂一个节点, 再继续插入元素时,每一次都会发生哈希冲突,因此,在元素个数刚好等于桶的个数时,可以给哈希表增容。
-
闭散列的开放定址法,负载因子不能超过1,一般建议控制在[0.0, 0.7]之间。
-
开散列的哈希桶,负载因子可以超过1,一般建议控制在[0.0, 1.0]之间。
哈希桶的极端情况就是,所有元素全部产生冲突,最终都放到了同一个哈希桶中,此时该哈希表增删查改的效率就退化成了 O ( N ) O ( N ) O(N)
- 这时,我们可以考虑将这个桶中的元素,由单链表结构改为红黑树结构,并将红黑树的根结点存储在哈希表中。
-
为了避免出现这种极端情况,当桶当中的元素个数超过一定长度,有些地方就会选择将该桶中的单链表结构换成红黑树结构,比如在JAVA中比较新一点的版本中,当桶当中的数据个数超过8时,就会将该桶当中的单链表结构换成红黑树结构,而当该桶当中的数据个数减少到8或8以下时,又会将该桶当中的红黑树结构换回单链表结构。
-
但有些地方也会选择不做此处理,因为随着哈希表中数据的增多,该哈希表的负载因子也会逐渐增大,最终会触发哈希表的增容条件,此时该哈希表当中的数据会全部重新插入到另一个空间更大的哈希表,此时同一个桶当中冲突的数据个数也会减少,因此不做处理问题也不大。
开散列与闭散列比较
- 开散列与闭散列比较 应用链地址法处理溢出,需要增设链接指针,似乎增加了存储开销。事实上: 由于开地址法必须保持大量的空闲空间以确保搜索效率,如二次探查法要求装载因子a <= 0.7,而表项所占空间又比指针大的多,所以使用链地址法反而比开地址法节省存储空间。
字符串哈希算法
哈希表的开散列实现(哈希桶)
哈希表的结构
template<class K,class V>
struct HashData
{
HashData<K, V>* _next;
pair<K, V> _kv;
// 构造
HashData(const pair<K,V> &kv)
:_kv(kv)
,_next(nullptr)
{}
};
-
与闭散列的哈希表不同的是,在实现开散列的哈希表时,我们不用为哈希表中的每个位置设置一个状态字段,因为在开散列的哈希表中,我们将哈希地址相同的元素都放到了同一个哈希桶中,并不需要经过探测寻找所谓的“下一个位置”。
-
哈希表的开散列实现方式,在插入数据时也需要根据负载因子判断是否需要增容,所以我们也应该时刻存储整个哈希表中的有效元素个数,当负载因子过大时就应该进行哈希表的增容。
//哈希表
template<class K, class V>
class HashTable
{
public:
//...
private:
vector<Node*> _table; //哈希表
size_t _n = 0; //哈希表中的有效元素个数
};
哈希表的插入
向哈希表中插入数据的步骤如下:
- 查看哈希表中是否存在该键值的键值对,若已存在则插入失败。
- 判断是否需要调整哈希表的大小,若哈希表的大小为0,或负载因子过大都需要对哈希表的大小进行调整。
- 将键值对插入哈希表。
- 哈希表中的有效元素个数加一。
- 若哈希表的负载因子已经等于1了,则先创建一个新的哈希表,该哈希表的大小为原哈希表的两倍,之后遍历原哈希表,将原哈希表中的数据插入到新哈希表,最后将原哈希表与新哈希表交换即可。
bool Insert(const pair<K, V>& kv)
{
// 1、查看哈希表中是否存在该键值的键值对
if (Find(kv))
return false;
Hash hf;
// 2、判断是否需要调整哈希表的大小
if (_n == _tables.size())// 哈希表的大小为0,或负载因子超过1
{
//增容
//a、创建一个新的哈希表,新哈希表的大小设置为原哈希表的2倍(若哈希表大小为0,则将哈希表的初始大小设置为10)
vector<Node*> newTables;
size_t newsize = _tables.size() == 0 ? 10 : _tables.size() * 2;
newTables.resize(newsize, nullptr);
// b、将原哈希表当中的结点插入到新哈希表
for (size_t i = 0; i < _tables.size(); i++)
{
if (_tables[i])// 桶不为空
{
Node* cur = _tables[i]; //将该桶的结点取完为止
while (cur)
{
Node* next = cur->_next; //记录cur的下一个结点
size_t index = hf(cur->_kv.first) % newTables.size(); // 通过哈希函数计算出对应的哈希桶编号index cur->_next = newTables[index];
newTables[index] = cur; // 将该结点头插到新哈希表中编号为index的哈希桶中
cur = next; // 取原哈希表中该桶的下一个结点
}
_tables[i] = nullptr; // 该桶取完后将该桶置空
}
}
}
size_t hashi = hf(kv.first) % _tables.size();
Node* newnode = new Node(kv);
// 头插
newnode->_next = _tables[hashi];
_tables[hashi] = newnode;
++_n;
return true;
}
哈希表的查找
在哈希表中查找数据的步骤如下:
- 先判断哈希表的大小是否为0,若为0则查找失败。
- 通过哈希函数计算出对应的哈希地址。
- 通过哈希地址找到对应的哈希桶中的单链表,遍历单链表进行查找即可。
Node* Find(const K& key)
{
Hash hf; // 通过仿函数
if (_tables.size() == 0) // 哈希表大小为0,查找失败
return nullptr;
size_t hashi = hf(key) % _tables.size(); // 通过哈希函数计算出对应的哈希桶编号
// 遍历哈希桶
Node* cur = _tables[hashi];
while (cur)
{
if (cur->_kv.first == key)
return cur;
cur = cur->_next;
}
return nullptr;
}
哈希表的删除
在哈希表中删除数据的步骤如下:
- 通过哈希函数计算出对应的哈希桶编号。
- 遍历对应的哈希桶,寻找待删除结点。
- 若找到了待删除结点,则将该结点从单链表中移除并释放。
- 删除结点后,将哈希表中的有效元素个数减一。
- 注意: 不要先调用查找函数判断待删除结点是否存在,这样做如果待删除不在哈希表中那还好,但如果待删除结点在哈希表,那我们还需要重新在哈希表中找到该结点并删除,还不如一开始就直接在哈希表中找,找到了就删除。
bool Erase(const K& key)
{
Hash hf;
//1、通过哈希函数计算出对应的哈希桶编号index(除数不能是capacity)
size_t hashi = hf(key) % _tables.size();
//2、在编号为index的哈希桶中寻找待删除结点
Node* prev = nullptr;
Node* cur = _tables[hashi];
while (cur)
{
//3、若找到了待删除结点,则删除该结点
if (cur->_kv.first == key)
{
if (prev == nullptr) //待删除结点是哈希桶中的第一个结点
{
_tables[hashi] = cur->_next; // 将第一个结点从该哈希桶中移除
}
else // 待删除结点不是哈希桶的第一个结点
{
prev->_next = cur->_next; // 将该结点从哈希桶中移除
}
delete cur;
--_n; // 4、删除结点后,将哈希表中的有效元素个数减一
return true;
}
// 继续往后找
prev = cur;
cur = cur->_next;
}
return false;
}
哈希表和哈希桶全部源码
#pragma once
#include<iostream>
#include<string>
#include<vector>
#include<map>
#include<set>
using namespace std;
template<class K>
struct HashFunc
{
size_t operator()(const K& key)
{
return (size_t)key;
}
};
// 特化
template<>
struct HashFunc<string>
{
size_t operator()(const string& key)
{
size_t hash = 0;
for (auto e : key)
{
hash *= 31; // BKDR
hash += e;
}
cout << key << ":" << hash << endl;
return hash;
}
};
namespace lsl_open_address
{
// 状态
enum Status
{
EMPTY, // 空
EXIST, // 存在
DELETE // 删除
};
//哈希表每个位置存储的结构
template<class K, class V>
struct HashData
{
pair<K, V> _kv; // 键值对
Status _status = EMPTY; // 状态
};
template<class K, class V, class HashFun = HashFunc<K>>
class HashTable
{
public:
HashTable()
{
_tables.resize(10);
}
// 插入方法
bool Insert(const pair<K, V>& kv)
{
// 1、查看哈希表中是否存在该键值的键值对
if (Find(kv.first))// 哈希表中已经存在该键值的键值对(不允许数据冗余)
return false;
//2、判断是否需要调整哈希表的大小
if (_tables.size() == 0)
_tables.resize(10);
else if (_n * 10 / _tables.size() == 7)// 负载因子大于0.7需要增容
{
// 2倍扩容
size_t newSize = _tables.size() * 2;
//a、创建一个新的哈希表,新哈希表的大小设置为原哈希表的2倍
HashTable<K, V, HashFun> newHT;
newHT._tables.resize(newSize);
//b、遍历旧表,将原哈希表当中的数据插入到新哈希表
for (size_t i = 0; i < _tables.size(); i++)
{
// 如果_tables[i]的位置有数据就进行再次映射
if (_tables[i]._status == EXIST)
{
newHT.Insert(_tables[i]._kv);
}
// c、与旧表进行交换
_tables.swap(newHT._tables);
}
}// 扩容 end...
HashFun hf; // 对于int来说是直接用值来比较,对于string类型使用BKDR方法来比较
// 3、将键值对插入哈希表
// a、通过哈希函数计算哈希地址,线性探测
size_t hashi = hf(kv.first) % _tables.size(); // 除数不能是capacity
size_t index = hashi, i = 1;
//b、找到一个状态为EMPTY或DELETE的位置
while (_tables[hashi]._status == EXIST)
{
index = hashi + i; // 线性探测
index = hashi + i * i; // 二次探测
hashi %= _tables.size(); // 防止下标超出哈希表范围
i++;
}
//c、将数据插入该位置,并将该位置的状态设置为EXIST
_tables[hashi]._kv = kv;
_tables[hashi]._status = EXIST;
//4、哈希表中的有效元素个数++
++_n;
return true;
}
// 查找方法
HashData<K, V>* Find(const K& key)
{
if (_tables.size() == 0)
return nullptr;
HashFun hf;
// 计算位置
size_t hashi = hf(key) % _tables.size();
size_t index = hashi, i = 1;
// 不为空就一直找
while (_tables[hashi]._status != EMPTY)
{
//若该位置的状态为EXIST,并且key值匹配,则查找成功
if (_tables[hashi]._status == EXIST
&& _tables[hashi]._kv.first == key)
{
return &_tables[hashi];
}
index = hashi + i; // 线性探测
// index = hashi + i * i; // 二次探测
hashi %= _tables.size(); // //防止下标超出哈希表范围
++i;
}
// 找不到的情况
return nullptr;
}
// 伪删除法
bool Erase(const K& key)
{
//1、查看哈希表中是否存在该键值的键值对
HashData<K, V>* res = Find(key);
if (res)
{
//2、若存在,则将该键值对所在位置的状态改为DELETE即可
res->_status = DELETE;
--_n; //3、哈希表中的有效元素个数减一
return true; // 删除成功
}
return false; // 删除失败
}
void Print()
{
for (size_t i = 0; i < _tables.size(); i++)
{
if (_tables[i]._status == EXIST)
{
// printf("[%d]->%d\n", i, _tables[i].first);
cout << "[" << i << "]->" << _tables[i]._kv.first << ":" << _tables[i]._kv.second << endl;
}
else if (_tables[i]._status == EMPTY)
{
printf("[%d]->\n", i);
}
else
{
printf("[%d]->E\n", i);
}
}
cout << endl;
}
private:
vector<HashData<K, V>> _tables;
size_t _n = 0;// 存储的关键字的个数
};
}
namespace lsl_hash_bucket
{
template<class K,class V>
struct HashData
{
HashData<K, V>* _next;
pair<K, V> _kv;
// 构造
HashData(const pair<K,V> &kv)
:_kv(kv)
,_next(nullptr)
{}
};
template<class K, class V, class Hash = HashFunc<K>>
class HashTable
{
typedef HashData<K, V> Node;
public:
HashTable()
{
_tables.resize(10);
}
~HashTable()
{
for (int i = 0; i < _tables.size(); i++)
{
Node* cur = _tables[i];
while (cur)
{
Node* next = cur->_next;
delete cur;
cur = next;
}
_tables[i] = nullptr;
}
}
Node* Find(const K& key)
{
Hash hf; // 通过仿函数
if (_tables.size() == 0) // 哈希表大小为0,查找失败
return nullptr;
size_t hashi = hf(key) % _tables.size(); // 通过哈希函数计算出对应的哈希桶编号
// 遍历哈希桶
Node* cur = _tables[hashi];
while (cur)
{
if (cur->_kv.first == key)
return cur;
cur = cur->_next;
}
return nullptr;
}
bool Insert(const pair<K, V>& kv)
{
// 1、查看哈希表中是否存在该键值的键值对
if (Find(kv))
return false;
Hash hf;
// 2、判断是否需要调整哈希表的大小
if (_n == _tables.size())// 哈希表的大小为0,或负载因子超过1
{
//增容
//a、创建一个新的哈希表,新哈希表的大小设置为原哈希表的2倍(若哈希表大小为0,则将哈希表的初始大小设置为10)
vector<Node*> newTables;
size_t newsize = _tables.size() == 0 ? 10 : _tables.size() * 2;
newTables.resize(newsize, nullptr);
// b、将原哈希表当中的结点插入到新哈希表
for (size_t i = 0; i < _tables.size(); i++)
{
if (_tables[i])// 桶不为空
{
Node* cur = _tables[i]; //将该桶的结点取完为止
while (cur)
{
Node* next = cur->_next; //记录cur的下一个结点
size_t index = hf(cur->_kv.first) % newTables.size(); // 通过哈希函数计算出对应的哈希桶编号index cur->_next = newTables[index];
newTables[index] = cur; // 将该结点头插到新哈希表中编号为index的哈希桶中
cur = next; // 取原哈希表中该桶的下一个结点
}
_tables[i] = nullptr; // 该桶取完后将该桶置空
}
}
}
size_t hashi = hf(kv.first) % _tables.size();
Node* newnode = new Node(kv);
// 头插
newnode->_next = _tables[hashi];
_tables[hashi] = newnode;
++_n;
return true;
}
bool Erase(const K& key)
{
Hash hf;
//1、通过哈希函数计算出对应的哈希桶编号index(除数不能是capacity)
size_t hashi = hf(key) % _tables.size();
//2、在编号为index的哈希桶中寻找待删除结点
Node* prev = nullptr;
Node* cur = _tables[hashi];
while (cur)
{
//3、若找到了待删除结点,则删除该结点
if (hf(cur->_kv.first) == key)
{
if (prev == nullptr) //待删除结点是哈希桶中的第一个结点
{
_tables[hashi] = cur->_next; // 将第一个结点从该哈希桶中移除
}
else // 待删除结点不是哈希桶的第一个结点
{
prev->_next = cur->_next; // 将该结点从哈希桶中移除
}
delete cur;
--_n; // 4、删除结点后,将哈希表中的有效元素个数减一
return true;
}
// 继续往后找
prev = cur;
cur = cur->_next;
}
return false;
}
private:
vector<Node*> _tables;
size_t _n = 0;
};
}
模拟实现封装unordered_map 和 unordered_set
模板参数控制
unordered_set是K模型的容器,而unordered_map是KV模型的容器
- 为了与原哈希表的模板参数进行区分,这里将哈希表的第二个模板参数的名字改为T。
template<class K, class T>
class HashTable
- 如果使用的是
unordered_set
容器,那么传入哈希表的模板参数就是key和key。
template<class K>
class unordered_set
{
public:
//...
private:
HashTable<K, K> _ht; //传入底层哈希表的是K和K
};
- 但如果使用的是
unordered_map
容器,那么传入哈希表的模板参数就是key以及key和value构成的键值对。
template<class K, class V>
class unordered_map
{
public:
//...
private:
HashTable<K, pair<K, V>> _ht; //传入底层哈希表的是K以及K和V构成的键值对
};
而哈希结点的模板参数也应该由原来的K、V变为T:
- 上层容器是unordered_set时,传入的T是键值,哈希结点中存储的就是键值。
- 上层容器是unordered_map时,传入的T是键值对,哈希结点中存储的就是键值对。
更改模板参数后,哈希结点的定义如下:
//哈希结点的定义
template<class K, class T>
struct HashData
{
HashData<T>* _next;
T _data;
// 构造
HashData(const T& data)
:_data(data)
, _next(nullptr)
{}
};
哈希表正向迭代器的实现
- 哈希表的正向迭代器实际上就是对哈希结点指针进行了封装,但是由于在实现++运算符重载时,可能需要在哈希表中去寻找下一个非空哈希桶,因此每一个正向迭代器中都应该存储哈希表的地址。
template<class K, class T, class KeyOfT, class HashFunc = Hash<K>>
struct __HTIterator
{
typedef HashNode<T> Node; //哈希结点的类型
typedef HashTable<K, T, KeyOfT, HashFunc> HT; //哈希表的类型
typedef __HTIterator<K, T, KeyOfT, HashFunc> Self; //正向迭代器的类型
Node* _node; //结点指针
HT* _pht; //哈希表的地址
// 构造
__HTIterator(Node* node,HT* ht)
:_node(node)
,_pht(ht)
{}
};
接引用和->
T& operator*()
{
//返回哈希结点中数据的引用
return _node->_data;
}
T* operator->()
{
//返回哈希结点中数据的地址
return &_node->_data;
}
判断节点是否同一个
bool operator!=(const Self& s) const
{
return _node != s._node;
}
bool operator==(const Self& s) const
{
return _node == s._node;
}
++运算符重载函数
- 若当前结点不是当前哈希桶中的最后一个结点,则++后走到当前哈希桶的下一个结点。
- 若当前结点是当前哈希桶的最后一个结点,则++后走到下一个非空哈希桶的第一个结点。
//前置++
Self& operator++()
{
if (_node->_next) //该结点不是当前哈希桶中的最后一个结点
{
_node = _node->_next; //++后变为当前哈希桶中的下一个结点
}
else //该结点是当前哈希桶中的最后一个结点
{
KeyOfT kot;
HashFunc hf;
size_t index = hf(kot(_node->_data)) % _pht->_table.size(); //通过哈希函数计算出当前所处哈希桶编号index(除数不能是capacity)
index++; //从下一个位置开始找一个非空的哈希桶
while (index < _pht->_table.size()) //直到将整个哈希表找完
{
if (_pht->_table[index]) //当前哈希桶非空
{
_node = _pht->_table[index]; //++后变为当前哈希桶中的第一个结点
return *this;
}
index++; //当前哈希桶为空桶,找下一个哈希桶
}
_node = nullptr; //哈希表中已经没有空桶了,++后变为nullptr
}
return *this;
}
HashTable.h
#pragma once
#include<iostream>
#include<string>
#include<vector>
using namespace std;
// 使用素数
size_t GetNextPrime(size_t prime)
{
const int PRIMECOUNT = 28;
static 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 i = 0;
for (; i < PRIMECOUNT; ++i)
{
if (primeList[i] > prime)
return primeList[i];
}
return primeList[i];
}
template<class K>
struct Hash
{
size_t operator()(const K& key)
{
return (size_t)key;
}
};
// 特化
template<>
struct Hash<string>
{
size_t operator()(const string& key)
{
size_t hash = 0;
for (auto e : key)
{
hash *= 31; // BKDR
hash += e;
}
cout << key << ":" << hash << endl;
return hash;
}
};
namespace lsl_open_address
{
// 状态
enum Status
{
EMPTY, // 空
EXIST, // 存在
DELETE // 删除
};
//哈希表每个位置存储的结构
template<class K, class V>
struct HashData
{
pair<K, V> _kv; // 键值对
Status _status = EMPTY; // 状态
};
template<class K, class V, class HashFun = Hash<K>>
class HashTable
{
public:
HashTable()
{
_tables.resize(10);
}
// 插入方法
bool Insert(const pair<K, V>& kv)
{
// 1、查看哈希表中是否存在该键值的键值对
if (Find(kv.first))// 哈希表中已经存在该键值的键值对(不允许数据冗余)
return false;
//2、判断是否需要调整哈希表的大小
if (_tables.size() == 0)
_tables.resize(10);
else if (_n * 10 / _tables.size() == 7)// 负载因子大于0.7需要增容
{
// 2倍扩容
size_t newSize = _tables.size() * 2;
//a、创建一个新的哈希表,新哈希表的大小设置为原哈希表的2倍
HashTable<K, V, HashFun> newHT;
newHT._tables.resize(newSize);
//b、遍历旧表,将原哈希表当中的数据插入到新哈希表
for (size_t i = 0; i < _tables.size(); i++)
{
// 如果_tables[i]的位置有数据就进行再次映射
if (_tables[i]._status == EXIST)
{
newHT.Insert(_tables[i]._kv);
}
// c、与旧表进行交换
_tables.swap(newHT._tables);
}
}// 扩容 end...
HashFun hf; // 对于int来说是直接用值来比较,对于string类型使用BKDR方法来比较
// 3、将键值对插入哈希表
// a、通过哈希函数计算哈希地址,线性探测
size_t hashi = hf(kv.first) % _tables.size(); // 除数不能是capacity
size_t index = hashi, i = 1;
//b、找到一个状态为EMPTY或DELETE的位置
while (_tables[hashi]._status == EXIST)
{
index = hashi + i; // 线性探测
index = hashi + i * i; // 二次探测
hashi %= _tables.size(); // 防止下标超出哈希表范围
i++;
}
//c、将数据插入该位置,并将该位置的状态设置为EXIST
_tables[hashi]._kv = kv;
_tables[hashi]._status = EXIST;
//4、哈希表中的有效元素个数++
++_n;
return true;
}
// 查找方法
HashData<K, V>* Find(const K& key)
{
if (_tables.size() == 0)
return nullptr;
HashFun hf;
// 计算位置
size_t hashi = hf(key) % _tables.size();
size_t index = hashi, i = 1;
// 不为空就一直找
while (_tables[hashi]._status != EMPTY)
{
//若该位置的状态为EXIST,并且key值匹配,则查找成功
if (_tables[hashi]._status == EXIST
&& _tables[hashi]._kv.first == key)
{
return &_tables[hashi];
}
index = hashi + i; // 线性探测
// index = hashi + i * i; // 二次探测
hashi %= _tables.size(); // //防止下标超出哈希表范围
++i;
}
// 找不到的情况
return nullptr;
}
// 伪删除法
bool Erase(const K& key)
{
//1、查看哈希表中是否存在该键值的键值对
HashData<K, V>* res = Find(key);
if (res)
{
//2、若存在,则将该键值对所在位置的状态改为DELETE即可
res->_status = DELETE;
--_n; //3、哈希表中的有效元素个数减一
return true; // 删除成功
}
return false; // 删除失败
}
void Print()
{
for (size_t i = 0; i < _tables.size(); i++)
{
if (_tables[i]._status == EXIST)
{
// printf("[%d]->%d\n", i, _tables[i].first);
cout << "[" << i << "]->" << _tables[i]._kv.first << ":" << _tables[i]._kv.second << endl;
}
else if (_tables[i]._status == EMPTY)
{
printf("[%d]->\n", i);
}
else
{
printf("[%d]->E\n", i);
}
}
cout << endl;
}
private:
vector<HashData<K, V>> _tables;
size_t _n = 0;// 存储的关键字的个数
};
}
namespace lsl_hash_bucket
{
template<class T>
struct HashNode
{
HashNode<T>* _next;
T _data;
// 构造
HashNode(const T& data)
:_data(data)
, _next(nullptr)
{}
};
// 前置声明
template<class K, class T, class KeyOfT, class Hash>
class HashTable;
template<class K, class T, class Ref, class Ptr, class KeyOfT, class Hash>
struct __HTIterator
{
typedef HashNode<T> Node; // 哈希节点的类型
typedef __HTIterator<K, T, Ref, Ptr, KeyOfT, Hash> Self; // 迭代器的类型
const HashTable<K, T, KeyOfT, Hash>* _pht; // 哈希表的地址
Node* _node; // 节点指针
size_t _hashi;
// 构造
__HTIterator(Node* node, HashTable<K, T, KeyOfT, Hash>* pht, size_t hashi)
:_node(node)
, _pht(pht)
, _hashi(hashi)
{}
__HTIterator(Node* node, const HashTable<K, T, KeyOfT, Hash>* pht, size_t hashi)
:_node(node)
, _pht(pht)
, _hashi(hashi)
{}
Ref operator*()
{
//返回哈希结点中数据的引用
return _node->_data;
}
Ptr operator->()
{
//返回哈希结点中数据的地址
return &_node->_data;
}
bool operator!=(const Self& s) const
{
return _node != s._node;
}
bool operator==(const Self& s) const
{
return _node == s._node;
}
//前置++
Self& operator++()
{
if (_node->_next)
{
// 当前桶还有节点,走到下一个节点
_node = _node->_next;
}
else
{
// 当前桶已经走完了,找下一个桶开始
++_hashi;
while (_hashi < _pht->_tables.size())
{
if (_pht->_tables[_hashi])
{
_node = _pht->_tables[_hashi];
break;
}
++_hashi;
}
if (_hashi == _pht->_tables.size())
{
_node = nullptr;
}
}
return *this;
}
};
template<class K, class T, class KeyOfT, class Hash>
class HashTable
{
typedef HashNode<T> Node;
template<class K, class T, class Ref, class Ptr, class KeyOfT, class Hash>
friend struct __HTIterator;
public:
typedef __HTIterator<K, T, T&, T*, KeyOfT, Hash> iterator;
typedef __HTIterator<K, T, const T&, const T*, KeyOfT, Hash> const_iterator;
iterator begin()
{
for (size_t i = 0; i < _tables.size(); i++)
{
if (_tables[i])
{
return iterator(_tables[i], this, i);
}
}
return end();
}
iterator end()
{
return iterator(nullptr, this, -1);
}
const_iterator begin() const
{
for (size_t i = 0; i < _tables.size(); i++)
{
if (_tables[i])
{
return const_iterator(_tables[i], this, i);
}
}
return end();
}
const_iterator end() const
{
return const_iterator(nullptr, this, -1);
}
HashTable()
{
_tables.resize(GetNextPrime(0));
}
~HashTable()
{
//将哈希表当中的结点一个个释放
for (size_t i = 0; i < _tables.size(); i++)
{
if (_tables[i])
{
Node* cur = _tables[i];
while (cur) //将该桶的结点取完为止
{
Node* next = cur->_next; //记录下一个结点
delete cur;
cur = next;
}
_tables[i] = nullptr;
}
}
}
//赋值运算符重载函数
HashTable& operator=(HashTable ht)
{
//交换哈希表中两个成员变量的数据
_tables.swap(ht._table);
swap(_n, ht._n);
return *this;
}
iterator Find(const K& key)
{
Hash hf;
KeyOfT kot;
size_t hashi = hf(key) % _tables.size();
Node* cur = _tables[hashi];
while (cur)
{
if (kot(cur->_data) == key)
{
return iterator(cur, this, hashi);
}
cur = cur->_next;
}
return end();
}
pair<iterator, bool> Insert(const T& data)
{
Hash hf;
KeyOfT kot;
iterator it = Find(kot(data));
if (it != end())
return make_pair(it, false);
// 负载因子最大到1
if (_n == _tables.size())
{
vector<Node*> newTables;
newTables.resize(_tables.size() * 2, nullptr);
// 遍历旧表
for (size_t i = 0; i < _tables.size(); i++)
{
Node* cur = _tables[i];
while (cur)
{
Node* next = cur->_next;
// 挪动到映射的新表
size_t hashi = hf(kot(cur->_data)) % newTables.size();
cur->_next = newTables[hashi];
newTables[hashi] = cur;
cur = next;
}
_tables[i] = nullptr;
}
_tables.swap(newTables);
}
size_t hashi = hf(kot(data)) % _tables.size();
Node* newnode = new Node(data);
// 头插
newnode->_next = _tables[hashi];
_tables[hashi] = newnode;
++_n;
return make_pair(iterator(newnode, this, hashi), true);
}
bool Erase(const K& key)
{
Hash hf; // hf是要算的hash值,这里采用BKDR
KeyOfT kot; // kot是要怎么取值,比如set是直接就是key,map是要取pair的first
//1、通过哈希函数计算出对应的哈希桶编号index(除数不能是capacity)
size_t hashi = hf(kot(key)) % _tables.size();
//2、在编号为index的哈希桶中寻找待删除结点
Node* prev = nullptr;
Node* cur = _tables[hashi];
while (cur)
{
//3、若找到了待删除结点,则删除该结点
if (kot(cur->_data) == key)
{
if (prev == nullptr) //待删除结点是哈希桶中的第一个结点
{
_tables[hashi] = cur->_next; // 将第一个结点从该哈希桶中移除
}
else // 待删除结点不是哈希桶的第一个结点
{
prev->_next = cur->_next; // 将该结点从哈希桶中移除
}
delete cur;
--_n; // 4、删除结点后,将哈希表中的有效元素个数减一
return true;
}
// 继续往后找
prev = cur;
cur = cur->_next;
}
return false;
}
private:
vector<Node*> _tables;
size_t _n = 0;
};
}
MyUnorderedSet.h
#include"HashTable.h"
namespace lsl
{
template<class K, class Hash = Hash<K>>
class unordered_set
{
struct SetKeyOfT
{
const K& operator()(const K& key)
{
return key;
}
};
public:
//现在没有实例化,没办法到HashTable里面找iterator,所以typename就是告诉编译器这里是一个类型,实例化以后再去取
typedef typename lsl_hash_bucket::HashTable<K, K, SetKeyOfT, Hash>::const_iterator iterator;
typedef typename lsl_hash_bucket::HashTable<K, K, SetKeyOfT, Hash>::const_iterator const_iterator;
const_iterator begin() const
{
return _ht.begin();
}
const_iterator end() const
{
return _ht.end();
}
iterator find(const K& key)
{
return _ht.Find(key);
}
bool erase(const K& key)
{
return _ht.Erase(key);
}
pair<const_iterator, bool> insert(const K& key)
{
auto ret = _ht.Insert(key);
return pair<const_iterator, bool>(const_iterator(ret.first._node, ret.first._pht, ret.first._hashi), ret.second);
}
private:
lsl_hash_bucket::HashTable<K, K, SetKeyOfT, Hash> _ht;
};
}
MyUnorderedMap.h
#include"HashTable.h"
namespace lsl
{
template<class K, class V, class Hash = Hash<K>>
class unordered_map
{
struct MapKeyOfT
{
const K& operator()(const pair<K, V>& kv)
{
return kv.first;
}
};
public:
typedef typename lsl_hash_bucket::HashTable<K, pair<const K, V>, MapKeyOfT, Hash>::iterator iterator;
iterator begin()
{
return _ht.begin();
}
iterator end()
{
return _ht.end();
}
pair<iterator, bool> insert(const pair<K, V>& kv)
{
return _ht.Insert(kv);
}
V& operator[](const K& key)
{
pair<iterator, bool> ret = _ht.Insert(make_pair(key, V()));
return ret.first->second;
}
const V& operator[](const K& key) const
{
pair<iterator, bool> ret = _ht.Insert(make_pair(key, V()));
return ret.first->second;
}
iterator find(const K& key)
{
return _ht.Find(key);
}
bool erase(const K& key)
{
return _ht.Erase(key);
}
private:
lsl_hash_bucket::HashTable<K, pair<const K, V>, MapKeyOfT, Hash> _ht;
};
}
扩容机制是素数?
- 除留余数法,最好模一个素数,如何每次快速取一个类似两倍关系的素数?
// 使用素数
size_t GetNextPrime(size_t prime)
{
const int PRIMECOUNT = 28;
static 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 i = 0;
for (; i < PRIMECOUNT; ++i)
{
if (primeList[i] > prime)
return primeList[i];
}
return primeList[i];
}
位图
位图概念
- 给40亿个不重复的无符号整数,没排过序。给一个无符号整数,如何快速判断一个数是否在
这40亿个数中。
- 遍历,时间复杂度O( N N N)
- 排序(O( N l o g N NlogN NlogN)),利用二分查找: l o g N logN logN
- 位图解决
- 数据是否在给定的整形数据中,结果是在或者不在,刚好是两种状态,那么可以使用一个二进制比特位来代表数据是否存在的信息,如果二进制比特位为1,代表存在,为0代表不存在。比如:
- 只需要0.5GB的内存空间~~
所谓位图,就是用每一位来存放某种状态,适用于海量数据,数据无重复的场景。通常是用来判断某个数据存不存在的。
映射x的时候:
- 那么
x
在数组的第几个整形呢?i = x / 32
- 那么
x
在数组的第几个位呢?i = x % 32
代码实现
将x比特位置1
在左移的时候不是方向,不管是大端机还是小端机,左移是向高位移动
// 将x比特位置1
void set(size_t x)
{
// 计算第几个整形
size_t i = x / 32;
// 计算第几个位
size_t j = x % 32;
// 将第j位处理成1其他位不变
_bits[i] |= (1 << j);
}
将x比特位置0
// 将x比特位置0
void reset(size_t x)
{
// 计算第几个整形
size_t i = x / 32;
// 计算第几个位
size_t j = x % 32;
// 将第j位处理成0其他位不变
_bits[i] &= ~(1 << j);
}
检测位图中x是否为1
// 检测位图中x是否为1
bool test(size_t x)
{
// 计算第几个整形
size_t i = x / 32;
// 计算第几个位
size_t j = x % 32;
// 检测第j位是否为1
return _bits[i] & (1 << j);
}
全部代码实现
#pragma once
#include <iostream>
#include <vector>
using namespace std;
namespace lsl
{
template<size_t N>
class bitset
{
public:
// 构造
bitset()
{
// _bits.resize((N >> 5) + 1, 0); // 可以这样写,但是要注意优先级
_bits.resize(N / 32 + 1, 0);
}
// 将x比特位置1
void set(size_t x)
{
// 计算第几个整形
// size_t i = x >> 5; // 也可以这样写
size_t i = x / 32;
// 计算第几个位
size_t j = x % 32;
// 将第j位处理成1其他位不变
_bits[i] |= (1 << j);
}
// 将x比特位置0
void reset(size_t x)
{
// 计算第几个整形
size_t i = x / 32;
// 计算第几个位
size_t j = x % 32;
// 将第j位处理成0其他位不变
_bits[i] &= ~(1 << j);
}
// 检测位图中x是否为1
bool test(size_t x)
{
// 计算第几个整形
size_t i = x / 32;
// 计算第几个位
size_t j = x % 32;
// 检测第j位是否为1
return _bits[i] & (1 << j);
}
private:
vector<int> _bits;
};
}
位图的应用
- 快速查找某个数据是否在一个集合中
- 排序 + 去重
- 求两个集合的交集、并集等
- 操作系统中磁盘块标记
布隆过滤器
布隆过滤器提出
- 我们在使用新闻客户端看新闻时,它会给我们不停地推荐新的内容,它每次推荐时要去重,去掉 那些已经看过的内容。问题来了,新闻客户端推荐系统如何实现推送去重的? 用服务器记录了用户看过的所有历史记录,当推荐系统推荐新闻时会从每个用户的历史记录里进行筛选,过滤掉那 些已经存在的记录。 如何快速查找呢?
- 用哈希表存储用户记录,缺点:浪费空间
- 用位图存储用户记录,缺点:位图一般只能处理整形,如果内容编号是字符串,就无法处理 了。
- 将哈希与位图结合,即布隆过滤器
布隆过滤器概念
- 布隆过滤器是由布隆(Burton Howard Bloom)在1970年提出的 一种紧凑型的、比较巧妙的概率型数据结构,特点是高效地插入和查询,可以用来告诉你 “某样东西一定不存在或者可能存在”,它是用多个哈希函数,将一个数据映射到位图结构中。此种方式不仅可以提升查询效率,也 可以节省大量的内存空间。
布隆过滤器的特点
- 当布隆过滤器判断一个数据存在可能是不准确的,因为这个数据对应的比特位可能被其他一个数据或多个数据占用了。
- 当布隆过滤器判断一个数据不存在是准确的,因为如果该数据存在那么该数据对应的比特位都应该已经被设置为1了。
控制误判率
- 其中k为哈希函数个数,m为布隆过滤器长度,n为插入的元素个数,p为误判率。
我们这里可以大概估算一下,如果使用3个哈希函数,即k的值为3,值我们取0.7,那么 m m m 和 n n n 的关系大概是 m = 4 × n m = 4 × n m=4×n,也就是布隆过滤器的长度应该是插入元素个数的4倍。
布隆过滤器的实现
-
首先,布隆过滤器可以实现为一个模板类,因为插入布隆过滤器的元素不仅仅是字符串,也可以是其他类型的数据,只有调用者能够提供对应的哈希函数将该类型的数据转换成整型即可,但一般情况下布隆过滤器都是用来处理字符串的,所以这里可以将模板参数K的缺省类型设置为string。
-
布隆过滤器中的成员一般也就是一个位图,我们可以在布隆过滤器这里设置一个非类型模板参数N,用于让调用者指定位图的长度。
//布隆过滤器
template<size_t N, class K = string, class HashFunc1 = BKDRHash, class HashFunc2 = APHash, class HashFunc3 = DJBHash>
class BloomFilter
{
public:
//...
private:
lsl::bitset<N> _bs;
};
-
实例化布隆过滤器时需要调用者提供三个哈希函数,由于布隆过滤器一般处理的是字符串类型的数据,因此这里我们可以默认提供几个将字符串转换成整型的哈希函数。
-
这里选取将字符串转换成整型的哈希函数,是经过测试后综合评分最高的BKDRHash、APHash和DJBHash,这三种哈希算法在多种场景下产生哈希冲突的概率是最小的。
-
此时本来这三种哈希函数单独使用时产生冲突的概率就比较小,现在要让它们同时产生冲突概率就更小了。
代码:
struct BKDRHash
{
size_t operator()(const string& s)
{
size_t value = 0;
for (auto ch : s)
{
value = value * 131 + ch;
}
return value;
}
};
struct APHash
{
size_t operator()(const string& s)
{
size_t value = 0;
for (size_t i = 0; i < s.size(); i++)
{
if ((i & 1) == 0)
{
value ^= ((value << 7) ^ s[i] ^ (value >> 3));
}
else
{
value ^= (~((value << 11) ^ s[i] ^ (value >> 5)));
}
}
return value;
}
};
struct DJBHash
{
size_t operator()(const string& s)
{
if (s.empty())
return 0;
size_t value = 5381;
for (auto ch : s)
{
value += (value << 5) + ch;
}
return value;
}
};
布隆过滤器的插入
- 布隆过滤器当中需要提供一个Set接口,用于插入元素到布隆过滤器当中。插入元素时,需要通过三个哈希函数分别计算出该元素对应的三个比特位,然后将位图中的这三个比特位设置为1即可。
void Set(const K& key)
{
//计算出key对应的三个位
size_t hash1 = HashFunc1()(key) % N;
size_t hash2 = HashFunc2()(key) % N;
size_t hash3 = HashFunc3()(key) % N;
// 将位图中的这三个位设置成1
_bs.set(hash1);
_bs.set(hash2);
_bs.set(hash3);
}
布隆过滤器的查找
-
检测时,需要通过三个哈希函数分别计算出该元素对应的三个比特位,然后判断位图中的这三个比特位是否被设置为1。
- 只要这三个比特位当中有一个比特位未被设置则说明该元素一定不存在。
- 如果这三个比特位全部被设置,则返回true表示该元素存在(可能存在误判)。
bool Test(const T& key)
{
//依次判断key对应的三个位是否被设置
size_t hash1 = HashFunc1()(key) % N;
if (_bs.test(hash1) == false)
return false;
size_t hash2 = HashFunc2()(key) % N;
if (_bs.test(hash1) == false)
return false;
size_t hash3 = HashFunc3()(key) % N;
if (_bs.test(hash1) == false)
return false;
return true; // 可能存在,但是可能存在误判
}
布隆过滤器的删除
布隆过滤器一般不支持删除操作:
- 因为布隆过滤器判断一个元素存在时可能存在误判,因此无法保证要删除的元素确实在布隆过滤器当中,此时将位图中对应的比特位清0会影响其他元素。
- 此外,就算要删除的元素确实在布隆过滤器当中,也可能该元素映射的多个比特位当中有些比特位是与其他元素共用的,此时将这些比特位清0也会影响其他元素。
如何让布隆过滤器支持删除?
要让布隆过滤器支持删除,必须要做到以下两点:
- 保证要删除的元素在布隆过滤器当中。比如刚才的呢称例子当中,如果通过调用Test函数得知要删除的昵称可能存在布隆过滤器当中后,可以进一步遍历存储昵称的文件,确认该昵称是否真正存在。
- 保证删除后不会影响到其他元素。可以为位图中的每一个比特位设置一个对应的计数值,当插入元素映射到该比特位时将该比特位的计数值++,当删除元素时将该元素对应比特位的计数值–- 即可。
可是布隆过滤器最终还是没有提供删除的接口,因为使用布隆过滤器本来就是要节省空间和提高效率的。在删除时需要遍历文件或磁盘中确认待删除元素确实存在,而文件IO和磁盘IO的速度相对内存来说是很慢的,并且为位图中的每个比特位额外设置一个计数器,就需要多用原位图几倍的存储空间,这个代价也是不小的。
- 缺陷:
- 无法确认元素是否真正在布隆过滤器中
- 存在计数回绕
布隆过滤器优点
- 增加和查询元素的时间复杂度为:O( K K K), (K为哈希函数的个数,一般比较小),与数据量大小无关
- 哈希函数相互之间没有关系,方便硬件并行运算
- 布隆过滤器不需要存储元素本身,在某些对保密要求比较严格的场合有很大优势
- 在能够承受一定的误判时,布隆过滤器比其他数据结构有这很大的空间优势
- 数据量很大时,布隆过滤器可以表示全集,其他数据结构不能
- 使用同一组散列函数的布隆过滤器可以进行交、并、差运算
布隆过滤器缺陷
- 有误判率,即存在假阳性(False Position),即不能准确判断元素是否在集合中(补救方法:再建立一个白名单,存储可能会误判的数据)
- 不能获取元素本身
- 一般情况下不能从布隆过滤器中删除元素
- 如果采用计数方式删除,可能会存在计数回绕问题
布隆过滤器使用场景
-
比如当我们首次访问某个网站时需要用手机号注册账号,而用户的各种数据实际都是存储在数据库当中的,也就是磁盘上面。
-
当我们用手机号注册账号时,系统就需要判断你填入的手机号是否已经注册过,如果注册过则会提示用户注册失败。
-
但这种情况下系统不可能直接去遍历磁盘当中的用户数据,判断该手机号是否被注册过,因为磁盘IO是很慢的,这会降低用户的体验。
-
这种情况下就可以使用布隆过滤器,将所有注册过的手机号全部添加到布隆过滤器当中,当我们需要用手机号注册账号时,就可以直接去布隆过滤器当中进行查找。
- 如果在布隆过滤器中查找后发现该手机号不存在,则说明该手机号没有被注册过,此时就可以让用户进行注册,并且避免了磁盘IO。
- 如果在布隆过滤器中查找后发现该手机号存在,此时还需要进一步访问磁盘进行复核,确认该手机号是否真的被注册过,因为布隆过滤器在判断元素存在时可能会误判。
- 由于大部分情况下用户用一个手机号注册账号时,都是知道自己没有用该手机号注册过账号的,因此在布隆过滤器中查找后都是找不到的,此时就避免了进行磁盘IO。而只有布隆过滤器误判或用户忘记自己用该手机号注册过账号的情况下,才需要访问磁盘进行复核。
位图的应用
- 给定一个100亿个整数,设计算法找到只出现一次的整数?
- 出现0次–>00来代表
- 出现1次–>01来代表
- 出现2次以上–>10来代表
- 存储100亿个整数大概需要40G的内存空间,因此题目中的100亿个整数肯定是存储在文件当中的,代码中直接从vector中读取数据是为了方便演示。
- 为了能映射所有整数,位图的大小必须开辟为2^32位,也就是代码中的4294967295,因此开辟一个位图大概需要512M的内存空间,两个位图就要占用1G的内存空间,所以代码中选择在堆区开辟空间,若是在栈区开辟则会导致栈溢出。
代码实现:
template<size_t N>
class twobitset
{
public:
void set(size_t x)
{
if (_bs1.test(x) == false && _bs2.test(x) == false) // 00
{
_bs2.set(x); // _bs1不需要动 _ba2设置成1
}
else // if (_bs1.test(x) == false && _bs2.test(x) == true) // 01
{
_bs1.set(x); // 1
_bs2.reset(x); // 0
}
}
void Print()
{
for (size_t i = 0; i < N; i++)
{
if (_bs1.test(i) == false && _bs2.test(i) == true) // 01 -->出现一次
{
cout << "1->" << i << endl;
}
else if (_bs1.test(i) == true && _bs2.test(i) == false) //10 -->出现两次以上
{
cout << "2->" << i << endl;
}
}
cout << endl;
}
private:
bitset<N> _bs1;
bitset<N> _bs2;
};
- 给两个文件,分别有100亿个整数,我们只有1G内存,如何找到两个文件的交集
-
各自映射到一个位图,一个值在两个位图都存在,则是交集
-
方案一:(一个位图需要512M内存)
- 依次读取第一个文件中的所有整数,将其映射到一个位图。
- 再读取另一个文件中的所有整数,判断在不在位图中,在就是交集,不在就不是交集。
- 方案二:(两个位图刚好需要1G内存,满足要求)
- 依次读取第一个文件中的所有整数,将其映射到位图1。
- 依次读取另一个文件中的所有整数,将其映射到位图2。
- 将位图1和位图2进行与操作,结果存储在位图1中,此时位图1当中映射的整数就是两个文件的交集。
- 对于32位的整型,无论待处理的整数个数是多少,开辟的位图都必须有2^32
个比特位,也就是512M,因为我们要保证每一个整数都能够映射到位图当中,因此这里位图的空间消耗是固定的。
- 位图应用变形:1个文件有100亿个int,1G内存,设计算法找到出现次数不超过2次的整数
- 出现0次–>00来代表
- 出现1次–>01来代表
- 出现2次–>10来代表
- 出现3次及以上–>11来代表
一个整数要表示四种状态也是只需要两个位就够了,此时当我们读取到重复的整数时,就可以让其对应的两个位按照00→01→10→11的顺序进行变化,最后状态是01或10的整数就是出现次数不超过2次的整数。
代码实现:
template<size_t N>
class twobitset
{
public:
void set(size_t x)
{
if (_bs1.test(x) == false && _bs2.test(x) == false) // 00
{
_bs2.set(x); // _bs1不需要动 _ba2设置成1
}
else if (_bs1.test(x) == false && _bs2.test(x) == true) // 01
{
_bs1.set(x); // 1
_bs2.reset(x); // 0
}
else if (_bs1.test(x) == true && _bs2.test(x) == false) // 10
{
_bs1.set(x); // 1
_bs2.set(x); // 1
}
}
void Print()
{
for (size_t i = 0; i < N; i++)
{
if (_bs1.test(i) == false && _bs2.test(i) == true) // 01 -->出现一次
{
cout << "1->" << i << endl;
}
else if (_bs1.test(i) == true && _bs2.test(i) == false) //10 -->出现两次
{
cout << "2->" << i << endl;
}
}
cout << endl;
}
private:
bitset<N> _bs1;
bitset<N> _bs2;
};
布隆过滤器相关
- 给两个文件,分别有100亿个query,我们只有1G内存,如何找到两个文件的交集?给出近似算法。
-
允许存在一些误判,那么我们就可以用布隆过滤器
-
先读取其中一个文件当中的query,将其全部映射到一个布隆过滤器当中。
-
然后读取另一个文件当中的query,依次判断每个query是否在布隆过滤器当中,如果在则是交集,不在则不是交集。
哈希切割相关
- 给两个文件,分别有100亿个query,我们只有1G内存,如何找到两个文件的交集?给出精确算法。
-
现在要求给出精确算法,那么就不能使用布隆过滤器了,此时需要用到哈希切分。
- 假设平均每个query为20字节,那么100亿个query就是200G,由于我们只有1G内存,这里可以考虑将一个文件切分成400个小文件。
- 这里我们将这两个文件分别叫做A文件和B文件,此时我们将A文件切分成了A0~A399共400个小文件,将B文件切分成了B0~B399共400个小文件。
在切分时需要选择一个哈希函数进行哈希切分,以切分A文件为例,切分时依次遍历A文件当中的每个query,通过哈希函数将每个query转换成一个整型 i ( 0 < = i < = 399 ) i (0 <= i <= 399) i(0<=i<=399),然后将这个query写入到小文件Ai当中。对于B文件也是同样的道理,但切分A文件和B文件时必须采用的是同一个哈希函数。
由于切分A文件和B文件时采用的是同一个哈希函数,因此A文件与B文件中相同的query计算出的 i ii 值都是相同的,最终就会分别进入到Ai和Bi文件中,这也是哈希切分的意义。
我们就只需要分别找出A0与B0的交集、A1与B1的交集、…、A399与B399的交集,最终将这些交集和起来就是A文件和B文件的交集。
那各个小文件之间又应该如何找交集呢?
- 经过切分后理论上每个小文件的平均大小是512M,因此我们可以将其中一个小文件加载到内存,并放到一个set容器中,再遍历另一个小文件当中的query,依次判断每个query是否在set容器中,如果在则是交集,不在则不是交集。
- 当哈希切分并不是平均切分,有可能切出来的小文件中有一些小文件的大小仍然大于1G,此时如果与之对应的另一个小文件可以加载到内存,则可以选择将另一个小文件中的query加载到内存,因为我们只需要将两个小文件中的一个加载到内存中就行了。
- 但如果两个小文件的大小都大于1G,那我们可以考虑将这两个小文件再进行一次切分,将其切成更小的文件,方法与之前切分A文件和B文件的方法类似。
将这些小文件看作一个个的哈希桶,将大文件中的query通过哈希函数映射到这些哈希桶中,如果是相同的query,则会产生哈希冲突进入到同一个小文件中。
- 给一个超过100G大小的log file,log中存着IP地址,设计算法找到出现次数最多的IP地址?如何找到top K的IP?如何直接用Linux系统命令实现?
- 我们将这个log file叫做A文件,由于A文件的大小超过100G,这里可以考虑将A文件切分成200个小文件。
- 在切分时选择一个哈希函数进行哈希切分,通过哈希函数将A文件中的每个IP地址转换成一个整型
i
(
0
≤
i
≤
199
)
i(0 ≤ i ≤ 199)
i(0≤i≤199),然后将这个IP地址写入到小文件Ai当中。
由于哈希切分时使用的是同一个哈希函数,因此相同的IP地址计算出的 i ii 值是相同的,最终这些相同的IP地址就会进入到同一个Ai小文件当中。
经过哈希切分后得到的这些小文件,理论上就能够加载到内存当中了,如果个别小文件仍然太大那可以对其再进行一次哈希切分,总之让最后切分出来的小文件能够加载到内存。
- 现在要找到出现次数最多的IP地址,就可以分别将各个小文件加载到内存中, 然后用一个map<string, int> 容器统计出每个小文件中各个IP地址出现的次数,然后比对各个小文件中出现次数最多的IP地址,最终就能够得到log file中出现次数最多的IP地址。
- 如果要找到出现次数top K的IP地址,可以先将一个小文件加载到内存中,选出小文件中出现次数最多的K个IP地址建成一个小堆,然后再依次比对其他小文件中各个IP地址出现的次数,如果某个IP地址出现的次数大于堆顶IP地址出现的次数,则将该IP地址与堆顶的IP地址进行交换,然后再进行一次向下调整,使其仍为小堆,最终比对完所有小文件中的IP地址后,这个小堆当中的K个IP地址就是出现次数top K的IP地址。
在Linux下我们可以使用此命令来完成:
sort log_txt | uniq -c | sort -nrk1,1 | head -K