【C++】哈希表:开散列和闭散列
- 📝 个人主页 :超人不会飞)
- 📑 本文收录专栏:《C++的修行之路》
- 💭 如果本文对您有帮助,不妨点赞、收藏、关注支持博主,我们一起进步,共同成长!
目录
- 前言
- 一、基于哈希表的两个容器
- 1.1 unordered_map
- 1.2 unordered_set
- 1.3 小试牛刀
- 二、哈希表
- 2.1 哈希的概念
- 2.2 哈希冲突
- 2.3 哈希函数
- 2.4 字符串哈希算法
- 三、哈希冲突的解决
- 3.1 闭散列
- 3.1.1 线性探测
- 3.1.2 二次探测
- 3.2 开散列
- 3.2.1 链地址法的思想
- 3.2.2 哈希桶的模拟实现
- 四、封装unordered_map和unordered_set
- 4.1 哈希表的迭代器
- 4.2 改造哈希表
- 4.3 unordered_set的封装
- 4.4 unordered_map的封装
前言
在前面的文章中,我们学习了红黑树,其查找数据的效率很高,查找的次数最差是树的高度logN次,并且要进行多次比较。而哈希表是一种元素存储位置与元素的关键码建立一一映射关系的结构,无需进行比较,其查找效率更高,最优可以一次直接找到。在C++中,map和set的底层是红黑树结构。那么在详细介绍哈希表之前,我们也要先介绍C++中提供的另外两种关联式容器——
unordered_map
和unordered_set
,它们的底层是哈希表。
unordered_map
和 unordered_set
的功能、接口与map和set都大差不差,所以使用起来感觉一样,但是底层可大不相同。
一、基于哈希表的两个容器
1.1 unordered_map
📃文档介绍
对其概念的解释:
-
Unordered maps是一种存储<key,value>键值对元素的关联式容器,允许通过键值快速地索引到与其对应的实值。
-
在unordered_map中,一个键值通常用于唯一地标识元素,而实值是一个对象,其内容与键值相关联。键值类型和实值类型可能不同。
-
在unordered_map中,元素不以任何特定的顺序排序(与键值和实值都无关),而是根据每个元素的哈希值以桶的形式被组织起来,以达到通过键值(key)快速获取与其对应的实值(value)的目的。
-
unordered_map容器通过key访问单个元素要比map快,但它通常在遍历元素子集的范围迭代方面效率较低。
-
unordered_maps实现了直接访问操作符(
operator[]
),它允许使用key作为参数直接访问value。 -
它的迭代器至少是前向迭代器。
📃unordered_map的接口与map类似,通过查文档学习即可,有以下几个比较特殊的接口。
函数声明 | 接口功能 |
---|---|
size_t bucket_count() const | 返回哈希桶中桶的总个数 |
size_t bucket_size(size_t n) const | 返回n号桶中元素的个数 |
size_t bucket(size_t key) const | 返回元素键值为key的桶编号 |
1.2 unordered_set
📃文档介绍
对其概念的解释:
-
Unordered sets是一种无序的、存储单一元素的容器,允许通过键值快速找到元素。
-
在unordered_set中,元素的实值(value)同时是元素的键值(key),标识唯一的元素。
-
键值是永恒不变的,因此,unordered_set只能插入和删除新元素,不可修改其中已存在的元素。
-
和unordered_map一样,底层是哈希桶。
-
unordered_set容器通过key访问单个元素要比set快,但它通常在遍历元素子集的范围迭代方面效率较低。
-
它的迭代器至少是前向迭代器。
1.3 小试牛刀
💨对于unordered_map和unordered_set,来几道OJ以熟练掌握它们的使用!
1.在长度2N的数组中找出重复N次的元素
class Solution {
public:int repeatedNTimes(vector<int>& nums) {// 统计每个元素出现的个数unordered_map<int,int> countMap;for(auto e:nums){countMap[e]++;}// 找出出现N次的元素int N = nums.size() / 2;for(auto& kv:countMap){if(kv.second == N){return kv.first;}}return -1;}
};
2.两个数组的交集
class Solution {
public:vector<int> intersect(vector<int>& nums1, vector<int>& nums2) {// 交集必然存在于较小集合中// 将较小的数组哈希映射,然后较大数组到较小数组的哈希表中找交集// O(max(n,m,max(n,m))) = O(max(n,m))// 1.找出较小数组vector<int> minNums = nums1;vector<int> maxNums = nums2;if(nums1.size() > nums2.size()){minNums.swap(maxNums);}// 2.较小数组哈希映射unordered_map<int,int> count1;for(auto e:minNums){count1[e]++;}// 3.较大数组计数unordered_map<int,int> count2;for(auto e:maxNums){count2[e]++;}// 4.count2到count1找交集vector<int> ret;for(auto& kv:count2){auto it = count1.find(kv.first);if(it != count1.end()){// 计算出现的次数int times = kv.second < it->second ? kv.second : it->second;// 加入交集ret.resize(ret.size()+times,kv.first);}}return ret;}
};
3.存在重复元素
class Solution {
public:bool containsDuplicate(vector<int>& nums) {unordered_set<int> us;for(auto e:nums){// 插入unordered_set,出现插入失败的情况,即nums数组存在重复元素if(!(us.insert(e).second)){return true;}}return false;}
};
4.两句话中的不常见单词
class Solution {
public:vector<string> uncommonFromSentences(string s1, string s2) {//key值是单词,value值是出现的次数unordered_map<string,int> cnt1;unordered_map<string,int> cnt2;// 词频统计// 统计s1中每个单词出现的次数int begin1 = 0, end1 = 0;while(begin1 < s1.size()){end1 = s1.find(' ',begin1);if(end1 == string::npos){end1 = s1.size();}string word(s1.begin() + begin1,s1.begin() + end1);cnt1[word]++;begin1 = end1+1;}// 统计s2中每个单词出现的次数int begin2 = 0, end2 = 0;while(begin2 < s2.size()){end2 = s2.find(' ',begin2);if(end2 == string::npos){end2 = s2.size();}string word(s2.begin() + begin2,s2.begin() + end2);cnt1[word]++;begin2 = end2+1;}//寻找两句话中的不常见单词vector<string> ret;for(auto& kv:cnt1){if(kv.second == 1 && cnt2.find(kv.first) == cnt2.end()){ret.push_back(kv.first);}}for(auto& kv:cnt2){if(kv.second == 1 && cnt1.find(kv.first) == cnt1.end()){ret.push_back(kv.first);}}return ret;}
};
二、哈希表
💭OK,掌握了unordered_map和unordered_set的上层接口,接着,我们就要着重来研究其底层的哈希表了~
2.1 哈希的概念
🔗顺序结构以及平衡树中,元素关键码与其存储位置之间没有对应的关系,因此在查找一个元素时,必须要经过关键码的多次比较。顺序查找时间复杂度为O(N),平衡树中为树的高度,即O( l o g 2 N log_2 N log2N),搜索的效率取决于搜索过程中元素的比较次数。频繁地比较会降低查询的效率。
📍 哈希表(又名散列表)是一种无需进行比较即可完成查找的数据结构。它通过哈希函数(hashFunc)使元素的存储位置与键值之间建立一一映射的关系,那么在查找时即可迅速找到元素。当向哈希表中插入或查询时,先以元素的键值结合哈希函数计算出该元素的存储位置,这个位置称为哈希地址,在哈希地址上插入或查询即可(可能会发生哈希冲突,后面会讲),最优情况一次就能查找到插入位置/对应元素。
🌰举个栗子,设哈希函数为
hashi(key) = key % capacity,且capacity=10
,并插入一些元素,则有如下哈希表。
2.2 哈希冲突
上面举的例子中哈希函数:hashi = key % capacity
,是一个比较常用的哈希函数,称之为除留余数法求哈希值。延用上面这个例子,两个不同的key,使用除留余数,可能会求出相同的哈希地址。如图:
可见,若想插入键值为13的元素
(方便起见,后面称键值为i的元素为元素i)
,计算hashi(13)=3。但发现hashi=3的位置已经存在元素3,此时发生了冲突,键值13不能直接插入hashi=3的位置,因为该位置已经被占用了。这种冲突称之为哈希冲突。
概念:
- 对于两个数据元素的关键字 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),即:不同关键字通过相同哈希函数计算出相同的哈希地址,该种现象称为哈希冲突或哈希碰撞。
- 把具有不同关键码key而具有相同哈希地址的数据元素称为“同义词”。
2.3 哈希函数
🍖优秀的哈希函数可以尽量避免哈希冲突(注意,只能避免,无法消除),因此哈希函数的设计十分重要
⭕哈希函数的设计原则:
- 哈希函数的定义域必须包括需要存储的全部关键码,而如果散列表允许有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种不同的符号在各位上出现的频率不一定相同,可能在某些位上分布比较均匀,每种符号出现的机会均等,在某些位上分布不均匀只有某几种符号经常出现。可根据散列表的大小,选择其中各种符号分布均匀的若干位作为散列地址。例如:电话号码的后四位、身份证后六位。数字分析法通常适合处理关键字位数比较大的情况,如果事先知道关键字的分布且关键字的若干位分布较均匀的情况
2.4 字符串哈希算法
💭哈希函数hash(key)中,传入的key必须是整型才能计算出哈希地址。而实际中数据的键值不一定是整型,可能是浮点类型、字符串、自定义类型等等,而这些非整型类型的键值就需要先转换成整型再去进行映射(插入时,查询时都需要转换成整型,才能计算hashi)。像浮点数这种能够直接强转成整型了,强转即可。而字符串、自定义类型之类的就需要专门设计算法去转换了,实际中键值为字符串的情况比较常见,因为我们讨论字符串(转整型)的哈希算法。
📍优秀的字符串哈希算法能够尽量少的出现不同字符串转换为重复的整型值,从而降低冲突发生的概率。此处给出比较常用的两种字符串哈希算法。
// 转换整型的哈希算法设计为仿函数,方便通过模板参数传递给哈希表// 通用的转换整型哈希算法,K类型能强制类型转换为int类型
template <class K>
struct Hash
{int operator()(const K& key){return key;}
};// 模板的特化
template <>
struct Hash<string>
{//1.字符串每个字符相加//int operator()(const string& str)//{// int key = 0;// for (auto ch : str)// {// key += ch - 'a';// }// return key;//}//2.BKDR算法int operator()(const string& key){int hash = 0;for (auto ch : key){hash = hash * 131 + ch;}return hash;}
};
参考文章:《各种字符串哈希函数》
三、哈希冲突的解决
📍解决哈希冲突是实现哈希表的关键,常见的解决方法有两种:闭散列和开散列
3.1 闭散列
闭散列:又称开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以把元素存放到冲突位置中的下一个空位置中去。
那如何寻找下一个空位置呢?常见的有两种方法,线性探测和二次探测。我们主要讨论线性探测这种方法,并借此深入了解哈希表的设计。
注意:以下举的例子皆以除留余数法求哈希地址
3.1.1 线性探测
从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止。就像找车位一样,在一排占满的车位中,挨个找到下一个空车位即可
-
插入
💦如下为过程:要插入key值为16的元素,求出hashi为6,发生哈希冲突,线性探测找下一个空位置插入。
-
删除
💦采用闭散列处理哈希冲突时,不能随便物理删除哈希表中已有的元素,若直接删除元素会影响 其他元素的搜索。比如删除元素8,如果直接删除掉,查找元素16可能会受影响,查找的路径断裂了。 因此线性探测采用标记的伪删除法来删除一个元素。
⭕解决方法:给哈希表每一个空间设置一个状态值,标志该位置
为空、有值、已被删除
三种状态。enum State {EMPTY, // 为空EXIST, // 已存在元素DELETE // 元素被删除 };
-
开放定址法的负载因子
🔎哈希表的特性往往会使其陷入两难之境。已知哈希表的空间是一个连续的数组:
若其空间利用率较高,那么经过哈希函数计算的哈希地址很可能已经存有数据了,则哈希冲突发生的概率也会增高,发生哈希冲突需要消耗更多成本去查找,那么插入和查找的效率就会下降。但如果为了降低哈希冲突发生的概率,扩大空间,又会导致空间浪费,空间利用率较低。负载因子的出现就是为了解决这个问题。
负载因子 = 哈希表的有效元素个数 / 表的长度
负载因子表示哈希标志数据元素的填满程度。若负载因子较大,表示哈希表空间利用率较高,但发生冲突的概率也大。若负载因子较小,表示发生冲突的概率较低,但哈希表空间利用率也低了。因此,必须在“冲突的概率”与“空间利用率”之间,寻找一种平衡与折中,也就是求一个合适的负载因子上限,超过这个上限就要扩容,以降低负载因子。(还要考虑上限过低可能增加扩容的次数,降低效率)。
💡综合各种因素,经过大量的实践和数学计算,开放定址法的负载因子应严格控制在
0.7 ~ 0.8
之间。因此,一些采用开放定址法的hash库(C++标准库中的哈希表并不是采用开放定址法),如Java的系统库限制了负载因子为0.75。参考文章:HashMap的负载因子为什么是0.75?
-
扩容的门道
哈希表的扩容也是有讲究的。不像vector的扩容,先开一块大的新空间,再将数据直接拷贝到新空间上。哈希表的扩容需要重新计算每个元素的哈希映射地址,再拷贝到新空间的相应位置上。因为哈希函数总是和空间大小有关,空间变大了,每个元素的哈希地址都有可能改变。
📍哈希冲突频发,使用线性探测处理冲突后可能出现堆积现象,这种堆积现象会随着哈希表的扩容而得到缓解。
🔎如图,负载因子已达到0.7,随便插入一个元素2,会发生扩容。扩容之前,hashi=6的元素堆积在一起,扩容之后堆积现象得到缓解。这个概念和密度的概念很像,想象一杯很浓的咖啡,加水之后变得没那么浓了,因为咖啡因稀释开了,这里是堆积的元素稀释开了,一个道理。
-
💬线性探测的代码实现
namespace closeHash
{// 哈希表空间的状态enum State{EMPTY,EXIST,DELETE};// 哈希表中的元素template <class K, class V>struct hash_data{pair<K, V> _data;// 键值对State _state = EMPTY;// 状态值};// 哈希表主体// HashToKey为键值转换为整型的仿函数类型template <class K, class V, class HashToKey>class hash_table{typedef hash_data<K, V> Data;public:hash_table():_table(10)//调用vector的n个val的构造函数//尽量保持底层vector的size和capacity相等,避免没必要的空间浪费,_n(0){}// 查询键值为k的元素Data* find(const K& k){int hashi = HashToKey()(k) % _table.size();int start = hashi;// 起始位while (_table[hashi]._state != EMPTY){// 存在且key值为k,查找成功if (_table[hashi]._state == EXIST && _table[hashi]._data.first == k){return &_table[hashi];}// 存在但key值不为k/已经被删除,继续查找hashi = (hashi + 1) % _table.size();if (hashi == start)// 极端情况下(无空位,除了EXIST就是DELETE),最多走一圈{break;}}return nullptr;}bool insert(const pair<K, V>& kv){// 已存在不插入if (find(kv.first)){return false;}// 再插入超出负载因子上限(0.7),需扩容后再插入if ((double)_n / _table.size() >= 0.7){//建立新的哈希表(容量为原来的2倍)hash_table<K, V, HashToKey> newHashTable;auto& newTable = newHashTable._table;newTable.resize(_table.size() * 2);// 遍历旧表,在新表中建立新的哈希映射for (auto& e : _table){if (e._state == EXIST){newHashTable.insert(e._data);}}//两个空间交换_table.swap(newTable);}int hashi = HashToKey()(kv.first) % _table.size();// 若发生哈希冲突,用线性探测解决// 碰到 空or删除,即可插入while (_table[hashi]._state == EXIST){hashi = (hashi + 1) % _table.size();}_table[hashi]._data = kv;_table[hashi]._state = EXIST;_n++;return true;}// 删除键值为k的元素bool erase(const K& k){Data* p = find(k);if (p){p->_state = DELETE;_n--;return true;}else{return false;}}private:vector<Data> _table;size_t _n; // 哈希表中有效数据的个数};
}
3.1.2 二次探测
💭二次探测是线性探测的优化,一定程度上缓解了线性探测中因为堆积现象而导致的查找、插入效率低下的问题。
二次探测找下一个空位置的方法为: 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是表的大小。
- 插入
💦拿上面用线性探测解决插入时遇到哈希冲突的例子,试着用二次探测解决。如下为过程:要插入key值为16的元素,求出hashi为6,发生哈希冲突,线性探测找下一个空位置插入,要查找5次才能找到下一个空位置。
而用二次探测只需要三次即可找到下一个空位。
可见,二次探测的效率一般优于线性探测。
🔎研究表明:若使用二次探测,当表的长度为质数且表装载因子a不超过0.5时,新的表项一定能够插入,而且任何一个位置都不会被探查两次。因此只要表中有一半的空位置,就不会存在表满的问题。在搜索时可以不考虑表装满的情况,但在插入时必须确保表的装载因子a不超过0.5,如果超出必须考虑增容。
💬二次探测关键部分代码
int hash0 = HashToKey()(kv.first) % _table.size();
int hashi = 0, i = 0;
while (_table[hashi]._state == EXIST)
{hashi = (hash0 + i * i) % _table.size();i++;
}
3.2 开散列
3.2.1 链地址法的思想
开散列的概念:
💭开散列法又叫链地址法(拉链法),首先对关键码集合用哈希函数计算哈希地址,具有相同地址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表头结点存储在哈希表中。这样的哈希表结构称之为哈希桶。
开散列的思想:
💭总而言之,开散列解决哈希冲突的思想就是将发生冲突的元素都存储在一个集合中,这个存放冲突键值元素的集合称为桶。物理结构上是一个单链表。
⭕哈希桶的结构如图所示:
可见,哈希桶有一个指针数组,数组中的指针指向桶的头结点(无桶则为NULL),每一个桶中存放的都是发生哈希冲突的元素。
3.2.2 哈希桶的模拟实现
💭其实,unordered_map和unordered_set的底层就是用哈希桶封装的,有了红黑树封装map和set的知识储备,接下来我们模拟实现哈希桶时,尽可能考虑设计为方便unordered_map和unordered_set封装的模式。
- 哈希桶的节点
template <class T>
// T是value_type
// unordered_map的T是pair<K,V>
// unordered_set的T是Kstruct hash_node
{hash_node(const T& data):_data(data),_next(nullptr){}T _data; // 数据域hash_node<T>* _next; // 指向同一个桶的下一个节点
};
- 哈希桶的大致框架
// T在set中是K,在map中是pair<K,V>
// Hash是将key值转换为整型的哈希算法
// KeyOfT是用于取出节点数据中的键值key的仿函数类型template <class K, class T, class Hash, class KeyOfT>
class hash_table
{typedef hash_node<T> Node;
public:// 构造函数hash_table():_table(10, nullptr), _n(0){}// 析构函数~hash_table(){for (auto cur : _table){while (cur){Node* next = cur->_next;delete cur;cur = next;}}}//各类接口//...
private:vector<Node*> _table;// 指针数组,存放每一个桶的头指针size_t _n;// 有效元素个数
};
- 哈希桶的插入
- 检测哈希桶中是否存在键值重复的元素,已存在则不插入,不存在则继续下一步;
- 检测负载因子是否到达上限,若是则需扩容后再进行下一步;
- 通过哈希函数,计算插入元素的哈希地址,即元素对应的桶号;
- 元素进入相应的桶,头插(对于单链表,头插的效率比尾插高)。
1️⃣查重
要想找出哈希桶中是否存在键值重复的元素,写一个find函数并复用是比较简单的。哈希桶的查找很简单:若想查找到键值为key的元素,先通过哈希函数计算桶编号,再从对应的桶中遍历找键值为key的元素即可。平均时间复杂度为O(1)
。
💬代码实现
Node* find(const K& key)
{// 计算桶号size_t hashi = Hash()(key) % _table.size();// 遍历桶查找key(遍历链表)Node* cur = _table[hashi];while (cur){if (KeyOfT()(cur->_data) == key) // KeyOfT()用于取出节点数据中的键值key{return cur;}cur = cur->_next;}return nullptr;
}
2️⃣ 哈希桶的扩容
💭开散列(哈希桶)一般规定负载因子为1,即有效个数和指针数组长度相等时,再插入需要先扩容。
🔎tips:如果开散列的扩容采用闭散列的方式(遍历旧表,重新计算旧表中元素的映射位置,插入新表),因为开散列是以链表的形式存在,而非连续空间,那么,创建新节点插入新表,删除旧表的的节点,将会是一个很大的消耗。因此,哈希桶的扩容应该将旧表的节点取而用之,而非先新建再删旧。
后两步都很简单,不必过多解释,直接上最终insert函数的代码。
bool insert(const T& data)
{KeyOfT kot;// 1.已存在不插入if (find(kot(data))){return false;}// 2.当负载因子==1时,扩容if (_n == _table.size()){vector<Node*> newTable(2 * _table.size(), nullptr);for (int i = 0; i < _table.size(); i++){Node* cur = _table[i];while (cur){Node* next = cur->_next;// 将当前节点插到新表对应的位置中size_t new_hashi = Hash()(kot(cur->_data)) % newTable.size();cur->_next = newTable[new_hashi];newTable[new_hashi] = cur;cur = next;}// 清除旧表中遗留的指针,避免二次释放空间_table[i] = nullptr;}//交换两个表_table.swap(newTable);}// 3.计算哈希地址size_t hashi = Hash()(kot(data)) % _table.size();Node* newNode = new Node(data);// 4.头插,有效元素个数+1newNode->_next = _table[hashi];_table[hashi] = newNode;++_n;return true;
}
- 哈希桶的元素删除
bool erase(const K& key)
{size_t hashi = Hash()(key) % _table.size();Node* cur = _table[hashi];Node* prev = nullptr;while (cur){if (KeyOfT()(cur->_data) == key){// 删除if (cur == _table[hashi])//删头要特殊处理{_table[hashi] = cur->_next;}else{prev->_next = cur->_next;}delete cur;--_n;return true;}prev = cur;cur = cur->_next;}return false;
}
四、封装unordered_map和unordered_set
💭接下来我们要模拟C++标准库中,用哈希桶来封装unordered_map和unordered_set。
4.1 哈希表的迭代器
💭与list、rb_tree的迭代器一样,哈希表的迭代器也需要一个指针来指向节点。不同的是,由于哈希表的桶是分散的,即有多条链表,因此迭代器前进时有可能会从一个桶跳转到另一个桶
如图,it指向31时,指向++it,检测到当前桶已经没有下一个元素,故跳转到下一个桶的头结点。
⭕要想做到能找到下一个不为空的桶,迭代器就必须要能访问哈希表的指针数组,下面是hashIterator类的实现
注意:由于哈希桶单链表的结构,所以它的迭代器是单向的,只能++不能--。
template <class K, class T, class Hash, class KeyOfT>
struct hashIterator
{typedef hash_node<T> Node;typedef hashIterator<K, T, Hash, KeyOfT> Self;typedef hash_table<K, T, Hash, KeyOfT> hash_table;hashIterator(Node* node, hash_table* pht) // 构造函数:_node(node), _pht(pht){}T& operator*(){return _node->_data;}T* operator->(){return &_node->_data;}Self& operator++(){if (_node->_next){_node = _node->_next;}else{KeyOfT kot;size_t hashi = Hash()(kot(_node->_data)) % _pht->_table.size();// 找下一条链size_t i = 0;for (i = hashi + 1; i < _pht->_table.size(); i++){if (_pht->_table[i]){_node = _pht->_table[i];break;}}// 找不到if (i == _pht->_table.size()){_node = nullptr;}}return *this;}bool operator==(const Self& it) const{return _node == it._node && _pht == it._pht;}bool operator!=(const Self& it) const{return !operator==(it);}Node* _node; // 指向节点的指针hash_table* _pht;// 指向哈希表指针数组的指针
};
4.2 改造哈希表
💭改造hash_table类,使得其能够适配unordered_map和unordered_set的封装。
template <class K, class T, class Hash, class KeyOfT>
class hash_table
{// 迭代器中可能会访问hash_table类,所以要设置为友元类template <class K, class T, class Hash, class KeyOfT>friend struct hashIterator;template <class K, class T, class Hash, class KeyOfT>friend struct const_hashIterator;typedef hash_node<T> Node;public:// 为什么普通迭代器和const迭代器不封装成一个类?见下文分析typedef hashIterator<K, T, Hash, KeyOfT> iterator;typedef const_hashIterator<K, T, Hash, KeyOfT> const_iterator;hash_table():_table(10, nullptr), _n(0){}~hash_table(){for (auto cur : _table){while (cur){Node* next = cur->_next;delete cur;cur = next;}}}// 迭代器的接口iterator begin(){// 找第一个元素即可for (int i = 0; i < _table.size(); i++){if (_table[i]){return iterator(_table[i], this);}}return iterator(nullptr, this);}iterator end(){// 设空迭代器为endreturn iterator(nullptr, this);}const_iterator begin() const{for (int i = 0; i < _table.size(); i++){if (_table[i]){return const_iterator(_table[i], this);//const迭代器构造函数异常?下面具体分析}}return const_iterator(nullptr, this);}const_iterator end() const{return const_iterator(nullptr, this);}iterator find(const K& key){size_t hashi = Hash()(key) % _table.size();Node* cur = _table[hashi];while (cur){if (KeyOfT()(cur->_data) == key){return iterator(cur, this);}cur = cur->_next;}return end();}const_iterator find(const K& key) const{size_t hashi = Hash()(key) % _table.size();Node* cur = _table[hashi];while (cur){if (KeyOfT()(cur->_data) == key){return const_iterator(cur, this);}cur = cur->_next;}return end();}iterator erase(const K& key){size_t hashi = Hash()(key) % _table.size();Node* cur = _table[hashi];Node* prev = nullptr;while (cur){if (KeyOfT()(cur->_data) == key){// 删除iterator next = ++iterator(cur, this);if (cur == _table[hashi])//删头要特殊处理{_table[hashi] = cur->_next;}else{prev->_next = cur->_next;}delete cur;--_n;return next;}prev = cur;cur = cur->_next;}return end();}//insert的返回值类型要修改为pair<iterator, bool>,与库中一致。pair<iterator, bool> insert(const T& data){KeyOfT kot;// 已存在不插入iterator fit = find(kot(data));if (fit != end()){return make_pair(fit, false);}// 当负载因子==1时,扩容if (_n == _table.size()){vector<Node*> newTable(2 * _table.size(), nullptr);for (int i = 0; i < _table.size(); i++){Node* cur = _table[i];while (cur){Node* next = cur->_next;// 将当前节点插到新表对应的位置中size_t new_hashi = Hash()(kot(cur->_data)) % newTable.size();cur->_next = newTable[new_hashi];newTable[new_hashi] = cur;cur = next;}// 清除旧表中遗留的指针,避免二次释放空间_table[i] = nullptr;}//交换两个表_table.swap(newTable);}size_t hashi = Hash()(kot(data)) % _table.size();Node* newNode = new Node(data);// 头插newNode->_next = _table[hashi];_table[hashi] = newNode;++_n;return make_pair(iterator(newNode, this), true);}private:vector<Node*> _table;size_t _n;// 有效元素个数
};
⭕这里有一个问题:链表、红黑树的const迭代器和普通迭代器用的是同一个类,只不过用Ref和Ptr两个模板参数来区分它们。而哈希表这里const迭代器和普通迭代器用的是两个类,因为无法用Ref和Ptr两个模板参数来区分它们。原因如下:
看这段代码:
const_iterator begin() const
{for (int i = 0; i < _table.size(); i++){if (_table[i]){return const_iterator(_table[i], this);}}return const_iterator(nullptr, this);
}
如果const_iterator底层是hashIterator类,在下面这句代码会出现错误。
return const_iterator(_table[i], this);
因为此时this是const类型的指针,而_table[i]
相当于this->_table.operator[](i)
,由于this是const类型,其指向的类的成员变量也是const类型,所以_table是const类型的vector类,因此_table[i]
取出来的值是const Node*类型的。
两个参数都是const指针类型,但hashIterator类的两个成员变量都是非cons指针t类型,构造函数无法完成构造,存在权限放大。
Node* _node;
hash_table* _pht;
因此,const迭代器另起一个类const_hashIterator。
template <class K, class T, class Hash, class KeyOfT>
struct const_hashIterator
{typedef hash_node<T> Node;typedef const_hashIterator<K, T, Hash, KeyOfT> Self;typedef hashIterator<K, T, Hash, KeyOfT> iterator;typedef hash_table<K, T, Hash, KeyOfT> hash_table;const_hashIterator(const Node* node, const hash_table* pht):_node(node), _pht(pht){}// 普通迭代器构造const迭代器const_hashIterator(const iterator& it):_node(it._node), _pht(it._pht){}const T& operator*(){return _node->_data;}const T* operator->(){return &_node->_data;}Self& operator++(){if (_node->_next){_node = _node->_next;}else{KeyOfT kot;size_t hashi = Hash()(kot(_node->_data)) % _pht->_table.size();// 找下一条链size_t i = 0;for (i = hashi + 1; i < _pht->_table.size(); i++){if (_pht->_table[i]){_node = _pht->_table[i];break;}}// 找不到if (i == _pht->_table.size()){_node = nullptr;}}return *this;}bool operator==(const Self& it) const{return _node == it._node && _pht == it._pht;}bool operator!=(const Self& it) const{return !operator==(it);}const Node* _node;const hash_table* _pht;
};
📍unordered_map和unordered_set的封装与红黑树封装map和set的思路很像,这里就不过多赘述,直接上代码。
4.3 unordered_set的封装
namespace ckf
{template <class K, class HashtoKey = Hash<K>>class unordered_set{struct SetKov{const K& operator()(const K& v){return v;}};public:typedef typename bucketHash::hash_table<K, K, HashtoKey, SetKov>::const_iterator iterator;typedef typename bucketHash::hash_table<K, K, HashtoKey, SetKov>::const_iterator const_iterator;//迭代器iterator begin() const{return _ht.begin();}iterator end() const{return _ht.end();}iterator find(const K& key) const{return _ht.find(key);}pair<iterator, bool> insert(const K& key){pair<iterator, bool> pRet = _ht.insert(key);return make_pair(iterator(pRet.first), pRet.second); // 需要将普通迭代器转化为const迭代器}iterator erase(const K& key){return _ht.erase(key);// 这里自动将普通迭代器转化为const迭代器}private:bucketHash::hash_table<K, K, HashtoKey, SetKov> _ht;};
}
4.4 unordered_map的封装
namespace ckf
{template <class K,class V,class HashtoKey = Hash<K>>class unordered_map{struct MapKov{const K& operator()(const pair<const K, V>& kv){return kv.first;}};public:typedef typename bucketHash::hash_table<K, pair<const K, V>, HashtoKey, MapKov>::iterator iterator;typedef typename bucketHash::hash_table<K, pair<const K, V>, HashtoKey, MapKov>::const_iterator const_iterator;//迭代器iterator begin(){return _ht.begin();}iterator end(){return _ht.end();}const_iterator begin() const{return _ht.begin();}const_iterator end() const{return _ht.end();}pair<iterator, bool> insert(const pair<const K, V>& kv){return _ht.insert(kv);}iterator erase(const K& key){return _ht.erase(key);}V& operator[](const K& key){return (_ht.insert(make_pair(key, V())).first)->second;}private:bucketHash::hash_table<K, pair<const K, V>, HashtoKey, MapKov> _ht;};
}
🍀本文完。如果这篇文章对你有帮助,动动小手点赞收藏加关注支持博主!你的支持是我最大的动力
相关文章:

【C++】哈希表:开散列和闭散列
📝 个人主页 :超人不会飞)📑 本文收录专栏:《C的修行之路》💭 如果本文对您有帮助,不妨点赞、收藏、关注支持博主,我们一起进步,共同成长! 目录 前言一、基于哈希表的两个…...

C技能树:Hello World
Hello World 输出 "Hello, World!" 字符串,请选出错误答案。 小知识:Hello World究竟从何而来? Hello, World最早是由 Brian Kernighan 创建的。1978年,Brian Kernighan写了一本名叫《C程序设计语言》的编程书,在程…...

TryHackMe-Set(Windows渗透测试 | WinDefender免杀)
Set 您再次发现自己在Windcorp公司的内部网络上。上次你去那里的味道真好,你回来了 了解更多。 但是,这次他们设法保护了域控制器,因此您需要找到另一台服务器,并在第一次扫描时发现“Set”。 Set被用作开发人员的平台…...

信安大佬真的用kali吗?
Kali只是现在网络安全和kali比较火的一个操作系统 下面我为大家讲讲kali系统都有那些优点 Kali介绍Kali Linux是基于Debian的Linux发行版, 设计用于数字取证操作系统。面向专业的渗透测试和安全审计。 集成化:预装超过300个渗透测试工具兼容好&#x…...

禁用表单元素:Layui框架下的实践与技巧
引言 在日常的网页开发过程中,有时我们需要禁用表单元素,以防止用户在某些情况下进行输入或更改。在本文中,我们将介绍如何在Layui框架下使用JavaScript禁用表单元素,例如单选按钮(radio)、下拉列表&#…...

spring boot 访问HTML
HTML整合spring boot 简介默认文件路径访问自定义文件路径访问 或通过Controller控制器层跳转访问 简介 SpringBoot默认的页面映射路径(即模板文件存放的位置)为“classpath:/templates/*.html”。静态文件路径为“classpath:/static/”,其中…...
WPF教程(四)--Dispatcher
一、Dispatcher介绍 微软在WPF引入了Dispatcher,那么这个Dispatcher的主要作用是什么呢? 不管是WinForm应用程序还是WPF应用程序,实际上都是一个进程,一个进程可以包含多个线程,其中有一个是主线程,其余的是…...

ijkplayer 编译增加支持更多的音视频格式
ijkplayer是B站开源的一款基于ffmpeg的移动端播放器。但为了减少播放器的体积,很多音视频的格式播放默认都是不支持的,需要自己下载ijkplayer源码进行编译。这里以mac环境下android为例,简述ijkplayer的编译过程,以及为了支持更多…...

TOP命令显示完整命令行信息
TOP 在Linux系统中,可以使用top命令来查看系统的实时性能数据,包括CPU使用率、内存使用率、进程信息等。以下是top命令的常用选项: -d seconds:指定top命令的刷新时间,单位为秒。 -u username:只显示指定…...

Spring6从入门到精通 第一章 带你玩转Spring
这里写目录标题 一 Spring框架产生的原因二 Spring6配置的关键环节 一 Spring框架产生的原因 传统的JavaWeb存在着耦合度较高的问题,而且实现完整的的MVC三层架构,开发成本过大,因此出现了Spring这个轻量级的开发框架,相当于建筑里…...

Apache POI 实现用Java操作Excel完成读写操作
简介 Apache POI是一个用于操作Microsoft Office格式文件(包括xls、docx、xlsx、pptx等)的Java API库。POI全称为Poor Obfuscation Implementation,是Apache Software Foundation的一个开源项目。它提供了一组Java API,使得Java程…...

改善供应商关系的八种方法
与供应商保持良好关系的重要性有很多原因。最重要的是,它使每个人的日常工作更加愉快。它还可以为你获得更好的交易,有助于协作并增强商誉。 但是,每个供应商都是不同的,建立牢固的关系可能很棘手。本文将解释企业如何建立并操持…...

网络安全-CDN绕过寻找真实IP
网络安全-CDN绕过寻找真实IP CDN就是CDN加速,就是根据你的目标让你访问的更快 CDN CDN,即内容分发网络,主要解决因传输距离和不同运营商节点造成的网络速度性能低下的问题。说得简单点,就是一组在不同运营商之间的对接节点上的…...

牛客网 HJ28 素数伴侣【二分图匹配,匈牙利算法】困难
描述 若两个正整数的和为素数,则这两个正整数称之为“素数伴侣”,如2和5、6和13,它们能应用于通信加密。现在密码学会请你设计一个程序,从已有的 N ( N 为偶数)个正整数中挑选出若干对组成“素数伴侣”&am…...

带你畅玩ChatGPT
ChatGPT出来很久了,最近不少朋友还是不太会使用ChatGPT体验与机器人进行聊天,我正好发现有种非常简单的方式帮助大家体验ChatGPT,且响应速度非常快,写代码能力也不错,现在推荐给大家,希望对大家有所帮助。 目录 一、下载专用浏览器 1.1 下载链接 1.2 安装浏览器 二、…...

ChatGPT探索系列之六:思考ChatGPT的未来发展趋势和挑战
文章目录 前言一、未来发展趋势1. ChatGPT重塑数据分析之道2. ChatGPT颠覆企业运用人工智能和机器学习的途径3. ChatGPT颠覆自动化商业流程4. ChatGPT引领企业决策迈向新纪元 二、ChatGPT掀开未来充满机遇和挑战的新篇章总结 前言 ChatGPT发展到目前,其实网上已经有…...

TryHackMe-Year of the Fox(Linux渗透测试)
Year of the Fox 你能熬过狡猾的狐狸吗? 端口扫描 循例nmap 有个域名,加入hosts SMB枚举 smbmap enum4linux -a,枚举到两个账户 Web枚举 进80发现需要登录 上hydra RCE to Getshell 进来可以查看一些文件 bp发现这里存在过滤 burpfuzz一…...

ChatGPT 如何获取API Key
什么是OpenAI API Key? OpenAI是ChatGPT的“开发商”,提供API使得开发者可以在自己的应用程序上调用OpenAI的相关服务(除了ChatGPT,OpenAI还有其他产品)。如果想调用OpenAI的产品服务在自己的应用程序上,我们就需要申…...

明面抵制,暗中布局 对于AI,马斯克的言行为何如此“割裂”?
最近,马斯克创建了一家叫做“X”的空壳公司,目标是将其打造成涵盖各方面的多功能应用集合平台,推特、SpaceX、特斯拉、Neuralink等公司业务都已打包加入其中。如今,“X”公司再添新丁——X.AI,即马斯克新成立的人工智能…...

【微服务中间件学习】redis基础及项目使用
背景 最近跟着大佬学习,发现之前都是一知半解,还是得系统学一下。 重温redis,有一下整理Redis是一种基于内存的高性能键值存储系统,它支持多种数据结构和持久化方式,并提供了许多高级功能,如发布/订阅、事…...

ORA-04021:等待锁定对象时发生超时
现场人员反馈问题,drop表报错,如下图 是个rac环境,处理过程 1、2个节点上查看锁表,没任何输出 SYSorcl2> select name from v$db_object_cache where ownerUSR_DATAI and type in(PROCEDURE,FUNCTION) and locks > 0 and …...

【华为OD机试真题 C++】1066 - 新工号中数字的最短长度 | 机试题+算法思路+考点+代码解析
文章目录 一、题目🔸题目描述🔸输入输出🔸样例1🔸样例2🔸样例3 二、代码参考 作者:KJ.JK 🌈 🌈 🌈 🌈 🌈 🌈 🌈 …...

【数字 IC / FPGA】 有关建立/保持时间计算的思考
引言 最近准备一些数字IC的机试,刷到了一些有关静态时序分析的题目。有一些比较经典的题目,在这里整理分享一下。 有什么疑问可以在评论区交流~互相进步 双D触发器典型电路 假设时钟周期为Tcycle,Tsetup,Thold分别为触发器建立保持时间,为…...

【Fluent】Run can not be started until validation issues are resolved.
一、问题背景 因为在fluent中用Discard Data, Replace Mesh选项替换了网格,但是没有抛弃算例设置等参数。 当时我以为网格是完全一样的,便忽略了产生冲突/错误的可能。 之后在calculate的时候,报错:Run can not be started unt…...

【进阶C语言】有关动态内存管理的经典笔试题(详细图文讲解)
前言 📕作者简介:热爱跑步的恒川,致力于C/C、Java、Python等多编程语言,热爱跑步,喜爱音乐的一位博主。 📗本文收录于C语言进阶系列,本专栏主要内容为数据的存储、指针的进阶、字符串和内存函数…...

1.Java系列之基础面试题总结
1. 构造器是否可重写 首先,构造器是不能被继承的,因为每个类的类名都不相同,而构造器名称与类名相同,所以根本谈不上继承。 又由于构造器不能继承,所以就不能被重写。但是,在同一个类中,构造器…...

Android:usb转232串口通信
准备工作 首先得adb进入盒子root模式,将/dev/ttys1这个文件改为777,使得所有用户可操作 adb root adb remount adb shell 进入设备的root模式,执行 chmod 777 /dev/ttys1 执行完成后退出 exit 再执行 adb shell chmod 666 /dev/ttyS1 如…...

动态设置图片的主题色(保留明暗关系)
github地址 PrimaryColorDemo 效果 原始图片 就是一张普通的png图片 根据选择的主题色动态渲染。 思考 最近在思考怎么实现动态的设置图片的主题色。不是那种渲染透明iocn。而是把图片的明暗关系保留。而改变其中的主题色。终于花了半天的时间研究出来了。和大家共享。 …...

mybatis中#与$的区别
文章目录 区别详细讲解${}sql注入案例 区别 #会进行预编译,安全,通过#{}传入的参数 mybatis会认为是一个字符串,自动加上引号“” $ 不会进行预编译,通过$ 传入的参数会直接取出来使用,可能会产生sql注入风险ÿ…...

CVPR2023论文整理
文章目录 CVPR2023一. Vision and Language / Multimodal CVPR2023 根据官方信息统计,今年共收到 9155 份提交,比去年增加了 12%,创下新纪录,今年接收了 2360 篇论文,接收率为 25.78%。作为对比,去年有 81…...