【C++进阶:哈希--unordered系列的容器及封装】

本课涉及到的所有代码都见以下链接,欢迎参考指正!

practice: 课程代码练习 - Gitee.comhttps://gitee.com/ace-zhe/practice/tree/master/Hash

unordered系列关联式容器

在C++98中,STL提供了底层为红黑树结构的一系列关联式容器,在查询时效率可达到log_2N,即最差情况下需要比较红黑树的高度次,当树中的节点非常多时,查询效率也不理想。最好的查询是,进行很少的比较次数就能够将元素找到,因此在C++11中,STL又提供了4个unordered系列的关联式容器,这四个容器与红黑树结构的关联式容器使用方式基本类似,只是其底层结构不同,这里中只对unordered_map和unordered_set进行介绍,unordered_multimap和unordered_multiset可自行查看文档介绍。

unordered系列mpa、set和普通map、set区别:

unordered_mpa、unordered_set的简单测试:

 使用时与map、set的基本用法几乎一样,观察发现,unordered系列的关联容器只能完成key值的去重,而不能完成排序,那为什么C++11要搞出这个系列呢?原因是综合考虑后unordered系列的容器基于哈希表底层的特性会使整体操作效率更高,下面我们从几个方面来测试set和unordered_set的性能:

性能测试:

性能测试代码如下(这里我们主要测试插入大量不同形式的随机数,二者性能,足够说明问题):

void performance_test()
{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());//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 << 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;
}

 测试结果和分析如下:

由此,我们总结,实际当中大部分情况下我们还是更推荐使用unordered系列的关联式容器,前面提到它的高效率源自于其底层的哈希结构,因此接下来的重点我们就是要学习哈希结构的模拟实现。 

unordered系列容器的底层结构

哈希(又称散列)概念:

顺序结构以及平衡树中,元素关键码与其存储位置之间没有对应的关系,因此在查找一个元素 时,必须要经过关键码的多次比较顺序查找时间复杂度为O(N),平衡树中为树的高度,即 O(log_2 N),搜索的效率取决于搜索过程中元素的比较次数。
理想的搜索方法:可以不经过任何比较,一次直接从表中得到要搜索的元素
如果构造一种存储结构,通过某种函数(hashFunc)使元素的存储位置与它的关键码之间能够建立 一一映射的关系,那么在查找时通过该函数可以很快找到该元素
当向该结构中:
插入元素
根据待插入元素的关键码,以此函数计算出该元素的存储位置并按此位置进行存放
搜索元素
对元素的关键码进行同样的计算,把求得的函数值当做元素的存储位置,在结构中按此位置取元素比较,若关键码相等,则搜索成功
该方式即为哈希(散列)方法,哈希方法中使用的转换函数称为哈希(散列)函数,构造出来的结构称 为哈希表(Hash Table)(或者称散列表)。

哈希函数:

哈希方法中使用的转换函数称为哈希(散列)函数,引起哈希冲突的一个原因可能是:哈希函数设计不够合理。
哈希函数设计原则
1.哈希函数的定义域必须包括需要存储的全部关键码,而如果散列表允许有m个地址时,其值域必2.须在0到m-1之间
3.哈希函数计算出来的地址能均匀分布在整个空间中
4.哈希函数应该比较简单
常见哈希函数:
常用的有两种:直接定制法和除留余数法,分别通过下图来演示分析

哈希冲突的解决:

上述我们了解到,除留余数法是比较好的哈希函数设计方法,但存在哈希冲突,那我们就要着手解决哈希冲突,解决哈希冲突常用的有两种方法:闭散列和开散列,下面我们分别介绍并实现。

闭散列:

     也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以把key存放到冲突位置中的“下一个” 空位置中去。那如何寻找下一个空位置呢?

1. 线性探测概念

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


插入:

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

删除:                                                                                                                                             采用闭散列处理哈希冲突时,不能随便物理删除哈希表中已有的元素,若直接删除元素会影响其他元素的搜索。比如删除元素12,如果直接删除掉,22查找起来可能会受影响。因此线性探测采用标记的伪删除法来删除一个元素。

2.线性探测的模拟实现

这里顺便就尝试搭建哈希表的整体框架,本处我们主要以学习哈希思想及结构为主,因此暂时不需要考虑封装unordered_map和unordered_set时的问题,其中重点将以注释的形式在代码中标注。

整体结构代码如下:

#pragma once
#include<iostream>
#include<vector>
namespace wz
{//定义枚举结构表示哈希表每一个地址空间的状态enum State{EEMPTY,EXIST,DELETE};template<class K, class V>//定义哈希表中每个地址空间的数据类型,包括两部分:数据+状态class HashData{pair<K, V> _kv;State _s = EMPTY;};template<class K,class V>class HashTable{public://...//此处实现其各个成员函数,insert等//...private:vector<HashData<K,V>> _table;//用vector来存储HastData类型的数据来表示哈希表结构size_t _n=0//表示存储的数据};}

insert()代码如下: 

实现的思路:

1、实现核心的线性探测部分,我们会发现只有这部分代码会报错:

//线性探测
size_t hashi = kv.first % _table.size();
size_t index = hashi;
int i = 1;
while (_table[index]._s == EXIST)
{index = hashi + i;index %= _table.size();++i;}
_table[index]._kv = kv;
_table[index]._s = EXIST;
_n++;

 原因是刚开始并没有给_table开辟空间,因此扩容发生在刚开始插入的时候,另外当哈希表中数据量占整体空间过大时,哈希冲突会增加,因此在现有空间即将存满的时候也会进行扩容,我们怎么衡量哈希表即将存满呢,这时候就要提出一个概念叫负载因子,负载因子=_n/_table.size(),这里扩容我们采用的是重新构造一个新容量的哈希表,将现有哈希表中的数据入新表,新表旧表数据互换,运行并测试如下:

观察上述代码我们发现在扩容后旧表数据入新表时,也需要每插入一次,线性探测一次,与后续插入新元素时的线性探测代码几乎一样,考虑C++代码的高复用性,我们需对整体代码加以改造,通过分析得到扩容后对新表的操作也可以看作是插入操作,因此这里其实直接复用insert函数即可,具体实现如下:

//扩容
if (_table.size() == 0 || _n * 10 / _table.size() > 7)
{size_t newsize = _table.size() == 0 ? 10 : _table.size() * 2;HashTable<K,V> newht;newht._table.resize(newsize);for (auto& data : _table){if (data._s== EXIST){newht.insert(data._kv);}}_table.swap(newht._table);
}

 另外重要的一点是不能忘记还要考虑去重问题,去重就需要查找,因此我们先来实现查找函数,查找的思路也是以线性探测为核心

find()代码如下: 

HashData<K, V>* find(const K& key)
{
//如果当前哈希表没开空间,当然不会找到,返回空指针
if (_table.size() == 0)
{return nullptr;
}// 线性探测size_t hashi = key% _table.size();size_t i = 1;size_t index = hashi;//如果当前哈希表中数据状态不为空,则可能有与要插入的值相等的值while (_table[index]._s != EMPTY){//存在且相等,表示该值已经插入到表中了,返回该数据所在表中的地址if (_table[index]._s == EXIST&& _table[index]._kv.first == key){return &_table[index];}index = hashi + i;index %= _table.size();++i;// 如果已经查找一圈,那么说明全是存在+删除if (index == hashi){break;}}
//到这里就说明没找到,返回空指针即可return nullptr;
}

完善insert()代码如下:

bool insert(const pair<K, V>& kv)
{//判断表里原来有没有if (find(kv.first)){return false;}//扩容if (_table.size() == 0 || _n * 10 / _table.size() > 7){size_t newsize = _table.size() == 0 ? 10 : _table.size() * 2;HashTable<K,V> newht;newht._table.resize(newsize);for (auto& data : _table){if (data._s== EXIST){newht.insert(data._kv);}}_table.swap(newht._table);}//线性探测size_t hashi = kv.first % _table.size();size_t index = hashi;int i = 1;while (_table[index]._s == EXIST){index = hashi + i;index %= _table.size();++i;}_table[index]._kv = kv;_table[index]._s = EXIST;_n++;
return true;
}

测试结果如下:

erase()代码如下:

删除部分我们采取直接用状态标识的假删除,因此实现起来比较简单,如下:

bool erase(const K& key)
{HashData<K, V>* ret = find(key);if (ret){ret->_s = DELETE;--_n;return true;}else{return false;}

 测试结果如下:

综合测试:

 3.二次探测概念

 上面我们用代码实现了线性探测一次来解决哈希碰撞的问题,但解决方式不止一种,二次探测也是常用的方法

线性探测的缺陷是产生冲突的数据堆积在一块,容易形成踩踏这与其找下一个空位置有关系,因为找空位置的方式就是挨着往后逐个去找,因此二次探测为了避免该问题,找下一个空位置的方法为:hashi = (H_0 + i^2 )% m, 或者:hashi = (H_0 - i^2 )% m。其中:i = 1,2,3…, H_0是通过散列函数Hash(x)对元素的关键码 key 进行计算得到的位置,m是表的大小。

研究表明:当表的长度为质数且表装载因子a不超过0.5时,新的表项一定能够插入,而且任何一个位置都不会被探查两次。因此只要表中有一半的空位置,就不会存在表满的问题。在搜索时可以不考虑表装满的情况,但在插入时必须确保表的装载因子a不超过0.5,如果超出必须考虑增容。
因此:闭散列最大的缺陷就是空间利用率比较低,这也是哈希的缺陷,我们实际上不常以闭散列的方式实现哈希表,接下来介绍常用的开散列实现方式。

开散列:

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

 本质可以认为哈希表中存的是一个个的结点指针,以这些结点指针为头指针后面还链接这其它结点指针,从上图可以看出,开散列中每个桶中放的都是发生哈希冲突的元素

开散列的模拟实现

这里仍然不考虑封装的问题,先实现功能,首先哈希桶整体结构如下:

namespace HashBucket
{
template<class K,class V>
struct HashNode
{HashNode<K,V>* _next;pair<K, V> _kv;HashNode(const pair<K, V>& kv):_next(nullptr), _kv(kv){}
};template<class K,class V>
class HashTable
{typedef HashNode<K,V> Node;
//...
//成员函数部分
//...private:vector<Node*> _table;size_t _n=0;
};}
析构函数:
这里与闭散列不同,需要自己实现析构函数,原因是闭散列的时候,存在表里的是一个个数据本身,而开散列存的则是一个个结点指针,关键是头结点指针后面可能还链接着其它结点指针,闭散列在程序结束后,默认的析构函数就够用了,而开散列调用默认析构函数指针释放各个桶的头结点指针,每个桶后面挂着的结点指针还在,造成内存泄露,代码如下:
~HashTable(){for (auto cur : _table){while (cur){Node* next = cur->next;delete cur;cur = next;}cur = nullptr;}}

Find()查找函数:

无论是删除还是插入都需要用到查找函数,因此我们先来实现它,代码如下:

	Node* Find(const K& key){if (_table.size() == 0)return nullptr;size_t hashi = key% _table.size();Node* cur = _table[hashi];while (cur){if (cur->_kv.first == key){return cur;}cur = cur->_next;}return nullptr;}

Insert()插入函数:

插入函数的的思想其实和闭散列的实现相类似,都是先判断表中有没有,再判断是否需要扩容,最后在插入到合适的位置,只是在扩容部分有些区别,开散列的扩容是可以在负载因子为1时扩容的,其次在闭散列时,我们推荐的是复用思想,即构造一个新的哈希表,就哈希表中元素依次插入新哈希表后,新旧表内容交换,但这里不推荐这种方法,原因是消耗太大,假若一共有N个数据在哈希表中,那么构造一个新的哈希桶就要构造再N个结点,析构N个结点,完全没必要,因此我们选择定义一个newtable,直接将_table中的结点指针按照规则插入链接即可,如下:

bool Insert(const pair<K, V>& kv){if (find(kv.first)){return false;}if (_table.size() == _n){size_t newsize = _table.size() == 0 ? 10 : _table.size() * 2;vector<Node*> newtable(newsize, nullptr);//for (Node*& cur : _table)for (auto& cur : _table){while (cur){Node* next = cur->_next;size_t hashi = cur->_kv.first% newtable.size();// 头插到新表cur->_next = newtable[hashi];newtable[hashi] = cur;cur = next;}}_table.swap(newtable);}size_t hashi = kv.first % _table.size();Node* newnode = new Node(kv);newnode->_next = _table[hashi];_table[hashi] = newnode;_n++;return true;}

Erase()删除函数:

开散列删除函数的实现就不想闭散列那么简单了,它没有状态的概念,只能实打实的删除,并且设计到指针的链接修改问题,实现如下,需要注意的点都以注释的形式标注了:

bool erase(const K& key)
{int ret = Finf(key);if (ret)//ret不为空,找到了,开始删除{size_t hashi = key % _table.size();Node* cur = _table[hashi];Node* prev = nullptr;while (cur)//从头结点开始往下找,知道碰到nullptr这个桶就走完了{if (cur->_kv.first == key){if (prev == nullptr)//说明该节点为头结点,头结点直接更新为cur的下一个即可{_table[hashi] = cur->_next;}else//不是头结点,让prev即cur的前一个节点的_next指向cur的_next{prev->_next = cur->_next;}delete cur;return true;}else{prev = cur;cur = cur->_next;}}}return false;
}

测试结果如下:

 综上,我们自己以开散列形式实现的哈希表基本功能已经实现,接下来就是要考虑细节。

代码完善

1.key值类型不确定,如何解决

我们现在实现的代码,只适用于key值为整型的数据类型,因为在定位hashi中都涉及到取模的运算,并不是所有类型都能隐式转换为整型再进行取模运算,比如说字符串类型,字符数组是无法自动转换为整型的,这个时候,我们采用的是设置仿函数,如下:

template<class K>
struct Hashfunc
{size_t operator()(const K& key){return key;}
};
template<class K,class V,class Hash= Hashfunc<K>>
class HashTable
{
//..//
};

使用过程中,只需要在要进行取模运算之前定义一个Hash仿函数对象,使用时按照函数的方式使用即可,以Find()函数中的应用为例,如下:

Node* Find(const K& key){if (_table.size() == 0)return nullptr;Hash hash;size_t hashi = hash(key)% _table.size();Node* cur = _table[hashi];while (cur){if (cur->_kv.first == key){return cur;}cur = cur->_next;}return nullptr;}

如果类似于字符串这样不能自动转为整型的类型就手动实现一个,使用时传自己实现的即可

注意:对于将字符串转换为整型存在以下问题

1.如果转换方式选择返回字符串首字符对应的ASCII值,那就会存在,首字符相同的值会被认为是相同的值,如:"student"和"string";

2.如果转换方式选择返回字符串所有字符对应的ASCII值相加,那就会存在,字母组成相同只有顺序不同的会被认为是相同的值,如:“ate”和“eat”;

要想解决这个问题,我们就要找到尽可能转换结果不会重复的转换方式,为此专门有人研究为我们提供了字符串相关的哈希算法,实现方式很多,具体可以参考以下链接,我们这里只以其中一种常用的为例:

https://www.cnblogs.com/-clq/archive/2012/05/31/2528153.htmlhttps://www.cnblogs.com/-clq/archive/2012/05/31/2528153.html我们选择的方式是每加一个字符,就给结果*31,实现如下:

struct HashStr{// BKDR,该算法的名称,是发明的两名作者名字首字母的缩写组合size_t operator()(const string& s){size_t hash = 0;for (auto ch : s){hash += ch;hash *= 31;}return hash;}};

 测试结果如下:

由于字符串类型经常作为key值被使用,每次用的时候都要传一次未免过于麻烦,而且之前测试使用unordered_map时发现也是不用自己传仿函数的,这是因为库里针对key值为字符串类型的情况对仿函数模板进行了特化,我们也来特化一下:

	template<class K>struct Hashfunc{size_t operator()(const K& key){return key;}};// 特化template<>struct Hashfunc<string>{// BKDRsize_t operator()(const string& s){size_t hash = 0;for (auto ch : s){hash += ch;hash *= 31;}return hash;}};

这样我们使用的时候,以字符串类型做key值类型的时候就不用自己传了,编译器会自动识别匹配。

2.除留余数法,最好模一个素数,如何每次快速取一个类似两倍关系的素数?

具体原因其实没有找到特别权威准确的描述,但有人提出来,可能是做了测试,认为素数造成的碰撞会少一些,我们就参考SGI库里的理解一下即可,代码如下:

size_t GetNextPrime(size_t prime)
{// SGIstatic const int __stl_num_primes = 28;static const unsigned long __stl_prime_list[__stl_num_primes] ={53, 97, 193, 389, 769,1543, 3079, 6151, 12289, 24593,49157, 98317, 196613, 393241, 786433,1572869, 3145739, 6291469, 12582917, 25165843,50331653, 100663319, 201326611, 402653189, 805306457,1610612741, 3221225473, 4294967291};size_t i = 0;for (; i < __stl_num_primes; ++i){if (__stl_prime_list[i] > prime)return __stl_prime_list[i];}return __stl_prime_list[i];
}//使用方法: size_t newsize = GetNextPrime(_tables.size());

总的来说就是初始容量为53开始,每次扩容都是上一次容量2倍附近的一个值,这些数都是无符号长整型类型的数,直至最大无符号长整型为止【事实上根本不可能到这么大,从内存角度理解】。

3.性能优化

理论上,最坏情况下,哈希表的查找效率是O(N),这种情况就是所有的数据都挂在一个桶上,但实际上这种最坏的情况几乎不会发生,因为我们有上述负载因子的控制,哈希表每个桶的长度不会过长,以一组随机数为例来测试:

size_t MaxBucketSize()
{size_t max = 0;for (size_t i = 0; i < _table.size(); ++i){auto cur = _table[i];size_t size = 0;while (cur){++size;cur = cur->_next;}//printf("[%d]->%d\n", i, size);if (size > max){max = size;}}return max;
}

测试结果如下:

插入9万个数最多数据的桶才2,足可见,效率其实可以几乎达到O(1)

 即使几乎不可能达到最坏的情况,但不排除会有人故意难为你,问你如何解决单个桶元素过多的情况,这里提供一种思想,了解一下就好,不需要去实现,我们可以给设置一个标准值,当一个桶链接的节点个数超过标准值是,就不在一个个的按照原来的方式往下链接,而是转换为红黑树插入,这样就能够解决每个桶链接数据量过大导致效率低下的问题。

模拟实现

基本框架的搭建:

首先要按照封装map和set的方式改造哈希表结构,先搭架子。

//HashTable.h#pragma once
#include<vector>
#include<string>
template<class T>
struct HashNode
{HashNode<T>* _next;T _data;HashNode(const T& data):_next(nullptr), _data(data){}
};template<class K>
struct Hashfunc
{size_t operator()(const K& key){return key;}
};
// 特化
template<>
struct Hashfunc<string>
{// BKDRsize_t operator()(const string& s){size_t hash = 0;for (auto ch : s){hash += ch;hash *= 31;}return hash;}
};
namespace HashBucket
{template<class K, class T,class KeyOfT, class Hash = Hashfunc<K>>class HashTable{typedef HashNode<T> Node;public:~HashTable(){for (auto cur : _table){while (cur){Node* next = cur->_next;delete cur;cur = next;}cur = nullptr;}}Node* Find(const K& key){if (_table.size() == 0)return nullptr;KeyOfT kot;Hash hash;size_t hashi = hash(key) % _table.size();Node* cur = _table[hashi];while (cur){if (kot(cur->_data) == key){return cur;}cur = cur->_next;}return nullptr;}size_t GetNextPrime(size_t prime){// SGIstatic const int __stl_num_primes = 28;static const unsigned long __stl_prime_list[__stl_num_primes] ={53, 97, 193, 389, 769,1543, 3079, 6151, 12289, 24593,49157, 98317, 196613, 393241, 786433,1572869, 3145739, 6291469, 12582917, 25165843,50331653, 100663319, 201326611, 402653189, 805306457,1610612741, 3221225473, 4294967291};size_t i = 0;for (; i < __stl_num_primes; ++i){if (__stl_prime_list[i] > prime)return __stl_prime_list[i];}return __stl_prime_list[i];}bool Insert(const T& data){KeyOfT kot;//	if (Find(kot(data)){return false;}if (_table.size() == _n){//size_t newsize = _table.size() == 0 ? 10 : _table.size() * 2;size_t newsize = GetNextPrime(_table.size());vector<Node*> newtable(newsize, nullptr);//for (Node*& cur : _table)for (auto& cur : _table){while (cur){Node* next = cur->_next;Hash hash;KeyOfT kot;size_t hashi = hash(kot(cur->_data)) % newtable.size();// 头插到新表cur->_next = newtable[hashi];newtable[hashi] = cur;cur = next;}}_table.swap(newtable);}Hash hash;size_t hashi = hash(kot(data)) % _table.size();Node* newnode = new Node(data);// 头插到新表newnode->_next = _table[hashi];_table[hashi] = newnode;_n++;return true;}bool erase(const K& key){auto ret = Find(key);if (ret)//ret不为空,找到了,开始删除{Hash hash;KeyOfT kot;size_t hashi = hash(key) % _table.size();Node* cur = _table[hashi];Node* prev = nullptr;while (cur)//从头结点开始往下找,知道碰到nullptr这个桶就走完了{if (kot(cur->_data) == key){if (prev == nullptr)//说明该节点为头结点,头结点直接更新为cur的下一个即可{_table[hashi] = cur->_next;}else//不是头结点,让prev即cur的前一个节点的_next指向cur的_next{prev->_next = cur->_next;}delete cur;_n--;return true;}else{prev = cur;cur = cur->_next;}}}return false;}size_t MaxBucketSize(){size_t max = 0;for (size_t i = 0; i < _table.size(); ++i){auto cur = _table[i];size_t size = 0;while (cur){++size;cur = cur->_next;}//printf("[%d]->%d\n", i, size);if (size > max){max = size;}}return max;}private:vector<Node*> _table;size_t _n = 0;};}
//unordered_map#pragma once
#include "HashTable.h"
namespace wz
{template<class K,class V,class Hash= Hashfunc<K>>class unordered_map{public:struct MapKeyofT{const K& operator()(const pair<K, V>& kv){return kv.first;}};bool Insert(const pair<const K,V>& kv){return _ht.Insert(kv);}bool Find(const K& key){return _ht.Find(key);}bool Erase(const K& key){return _ht.erase(key);}private:HashBucket::HashTable<K, pair<const K, V>, MapKeyofT, Hash> _ht;};void test_unordered_map1(){unordered_map<int, int> m;m.Insert(make_pair(1, 1));m.Insert(make_pair(2, 2));m.Insert(make_pair(3, 3));}
}//unordered_set.h#pragma once
#include "HashTable.h"
namespace wz
{template<class K, class Hash = Hashfunc<K>>class unordered_set{public:struct SetKeyofT{const K& operator()(const K& key){return key;}};bool Insert(const K& key){return _ht.Insert(key);}bool Find(const K& key){return _ht.Find(key);}bool Erase(const K& key){return _ht.erase(key);}private:HashBucket::HashTable<K, K, SetKeyofT, Hash> _ht;};void test_unordered_set1(){unordered_set<int> s;s.Insert(1);s.Insert(2);s.Insert(3);}
}

以上完成了哈希表基本结构的改造和unordered_map和unordered_set基本框架的编写及测试,测试结果都显示是没有什么问题的,实现方式和用红黑树封装map和set大同小异,接下来的重点依然是如何实现哈希表的迭代器进而如何封装出unordered_map和unordered_set的迭代器。

迭代器的实现:

哈希表的迭代器实现如下:

//因为要在里面定义哈希表,因此最好做前置声明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 __HashIterator{typedef HashNode<T> Node;typedef HashTable<K, T, KeyOfT, Hash> HT;typedef __HashIterator<K, T, Ref, Ptr, KeyOfT, Hash> Self;typedef __HashIterator<K, T, T&, T*, KeyOfT, Hash> Iterator;//模拟遍历哈希表的行为,思考作为哈希表的迭代器需要的成员变量Node* _node;//当前结点指针const HT* _ht;//当前所在哈希表__HashIterator(Node* node, const HT* ht):_node(node), _ht(ht){}__HashIterator(const Iterator& it):_node(it._node), _ht(it._ht){}Ref operator*(){return _node->_data;}Ptr operator->(){return &_node->_data;}bool operator!=(const Self& s){return _node != s._node;}Self& operator++(){if (_node->_next != nullptr){_node = _node->_next;}else{// 找下一个不为空的桶KeyOfT kot;Hash hash;// 算出我当前的桶位置size_t hashi = hash(kot(_node->_data)) % _ht->_table.size();++hashi;while (hashi < _ht->_table.size()){if (_ht->_table[hashi]){_node = _ht->_table[hashi];break;}else{++hashi;}}// 没有找到不为空的桶if (hashi == _ht->_table.size()){_node = nullptr;}}return *this;}};

在HashTable中定义begin()、end()、const_begin()、const_end(),如下:

//友元声明,因为需要访问到迭代器的成员
template<class K, class T, class Ref, class Ptr, class KeyOfT, class Hash>
friend struct __HashIterator;typedef __HashIterator<K, T, T&, T*, KeyOfT, Hash> iterator;
typedef __HashIterator<K, T, const T&, const T*, KeyOfT, Hash> const_iterator;iterator begin(){Node* cur = nullptr;for (size_t i = 0; i < _table.size(); ++i){cur = _table[i];if (cur){break;}}return iterator(cur, this);}iterator end(){return iterator(nullptr, this);}const_iterator begin() const{Node* cur = nullptr;for (size_t i = 0; i < _table.size(); ++i){cur = _table[i];if (cur){break;}}return const_iterator(cur, this);}const_iterator end() const{return const_iterator(nullptr, this);}

unordered_map和unordered_set的迭代器封装哈希表中的迭代器如下:

//unordered_map.htypedef typename HashBucket::HashTable<K, pair<const K, V>, MapKeyofT, Hash>::iterator iterator;typedef typename HashBucket::HashTable<K, pair<const K, V>, MapKeyofT, Hash>::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();
}//unordered_set.htypedef typename HashBucket::HashTable<K, K, SetKeyofT, Hash>::const_iterator iterator;
typedef typename HashBucket::HashTable<K, K, SetKeyofT, Hash>::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();}

测试基本功能如下,可见目前我们的实现没有问题:

进一步完善:

到这一步,我们对unordered_set的封装就已经差不多了,unordered_map还差一个重要的部分就是实现[ ],类比map和set的[ ]分析与实现,我们同样可以快速完成,与此同时要做一件事情就是将所有Insert()的返回值类型都修改为pair<iterator,bool>类型

//unordered_map.hV& operator[](const K& key)
{pair<iterator, bool> ret = Insert(make_pair(key, V()));return ret.first->second;
}pair<iterator, bool> Insert(const pair<const K,V>& kv)
{return _ht.Insert(kv);
}//unordered_set.hpair<iterator, bool> Insert(const K& key)
{return _ht.Insert(key);
}//HashTable.hpair<iterator, bool> Insert(const T& data)
{KeyOfT kot;iterator it = Find(kot(data));if (it != end()){return make_pair(it, false);}if (_table.size() == _n){//size_t newsize = _table.size() == 0 ? 10 : _table.size() * 2;size_t newsize = GetNextPrime(_table.size());vector<Node*> newtable(newsize, nullptr);//for (Node*& cur : _table)for (auto& cur : _table){while (cur){Node* next = cur->_next;Hash hash;KeyOfT kot;size_t hashi = hash(kot(cur->_data)) % newtable.size();// 头插到新表cur->_next = newtable[hashi];newtable[hashi] = cur;cur = next;}}_table.swap(newtable);}Hash hash;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);}

修改Find()返回值类型为iterator,记得所有需要用到Find返回值的都需要修改:

//unordered_map.hiterator Find(const K& key)
{return _ht.Find(key);
}//unordered_map.hiterator Find(const K& key)
{return _ht.Find(key);
}//HashTable.hiterator Find(const K& key)
{	if (_table.size() == 0)return end();KeyOfT kot;Hash hash;size_t hashi = hash(key) % _table.size();Node* cur = _table[hashi];while (cur){if (kot(cur->_data) == key){return iterator(cur, this);;}cur = cur->_next;}return end();
}

综上,我们的代码基本完善,我来来用自定义类型作为key的方式来测试一下,这里就用我们之前实现过的日期类的基本代码即可,日期类如下:

	class Date{friend struct HashDate;public:Date(int year = 1900, int month = 1, int day = 1): _year(year), _month(month), _day(day){}bool operator<(const Date& d)const{return (_year < d._year) ||(_year == d._year && _month < d._month) ||(_year == d._year && _month == d._month && _day < d._day);}bool operator>(const Date& d)const{return (_year > d._year) ||(_year == d._year && _month > d._month) ||(_year == d._year && _month == d._month && _day > d._day);}bool operator==(const Date& d) const{return _year == d._year&& _month == d._month&& _day == d._day;}friend ostream& operator<<(ostream& _cout, const Date& d);private:int _year;int _month;int _day;};ostream& operator<<(ostream& _cout, const Date& d){_cout << d._year << "-" << d._month << "-" << d._day;return _cout;}

作为key值的话必须能支持转换成整型,才能参与哈希底层的取模运算,日期类不支持自行转换,因此我们自己实现HashDate,实现如下,需要注意的是,在这过程中,由于需要访问日期类内部私有成员变量,因此可以将其在日期类中声明为友元类,HashDate实现代码如下:

struct HashDate{size_t operator()(const Date& d){//这里要访问私有成员,因此将其在日期类里声明为友元类size_t hash = 0;hash += d._year;hash *= 31;hash += d._month;hash *= 31;hash += d._day;hash *= 31;return hash;}};

做一个简单的测试,测试代码和结果如下,说明我们封装的unordered_map基本上没什么问题:

综上,我关于以哈希为底层的unordered_set和unordered_map总结的就差不多了,内容很多,细节也不少,多看几遍,多敲代码,以便加深理解!

本课涉及到的所有代码都见以下链接,欢迎参考指正!

practice: 课程代码练习 - Gitee.comhttps://gitee.com/ace-zhe/practice/tree/master/Hash

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/14895.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

拓扑排序详解(带有C++模板)

目录 介绍&#xff1a; 实现原理&#xff1a; 简答来说&#xff1a; 例子 模板&#xff08;C&#xff09; 介绍&#xff1a; 拓扑排序&#xff08;Topological Sorting&#xff09;是一种针对有向无环图&#xff08;DAG&#xff09;的节点进行排序的算法。DAG是一个图&…

PHP数据库

PHP MySQL 连接数据库 MySQL 简介MySQL Create 免费的 MySQL 数据库通常是通过 PHP 来使用的。 连接到一个 MySQL 数据库 在您能够访问并处理数据库中的数据之前&#xff0c;您必须创建到达数据库的连接。 在 PHP 中&#xff0c;这个任务通过 mysql_connect() 函数完成。 …

Linux环境安装MySQL(详细教程)

1、下载MySQL MySQL官网&#xff1a;MySQLhttps://www.mysql.com/ 下载社区版&#xff08;免费&#xff0c;但不提供技术支持&#xff09; 简单说明一下rpm和tar包的区别&#xff1a; tar 只是一种压缩文件格式&#xff0c;所以&#xff0c;它只是把文件压缩打包 rpm&#xf…

【字节跳动青训营】后端笔记整理-3 | Go语言工程实践之测试

**本文由博主本人整理自第六届字节跳动青训营&#xff08;后端组&#xff09;&#xff0c;首发于稀土掘金&#xff1a;&#x1f517;Go语言工程实践之测试 | 青训营 目录 一、概述 1、回归测试 2、集成测试 3、单元测试 二、单元测试 1、流程 2、规则 3、单元测试的例…

我的第一个flutter项目(Android Webview)

前言&#xff1a;flutter开发环境搭建Flutter的开发环境搭建-图解_☆七年的博客-CSDN博客 第一个flutter简单项目&#xff0c;内容是一个主界面&#xff0c;其中&#xff1a; 1.内容点击数字自增 2.跳转一个空页&#xff0c; 3.跳转一个WebView界面 其中涉及添加主键&#xf…

QT: 用定时器完成闹钟的实现

闹钟项目&#xff1a; widget.h #ifndef WIDGET_H #define WIDGET_H#include <QWidget> #include <QTimerEvent> #include <QTime> #include <QDebug> #include <QTextToSpeech> #include <QMessageBox> #include <QTimer>QT_BEGIN…

Quartz项目搭建与任务执行源码分析

数据库准备 准备一个MySQL数据库&#xff0c;版本为8.0&#xff0c;然后创建一个库&#xff0c;并从quartz官方的版本包中找到名称为tables_mysql_innodb.sql的脚本执行进去&#xff08;脚本内容文后也有提供&#xff09;。 项目依赖说明 创建一个Maven项目&#xff0c;引入…

gitignore文件使用方法(gitignore教程)(git status --ignored)(git check-ignore -v <file>)

文章目录 Gitignore文件使用描述Gitignore基本语法1. 基本语法★★★★★2. 配置方法 匹配示例示例1示例2示例3 其他命令git status --ignored&#xff08;用于显示被Git忽略的文件和文件夹的状态&#xff09;git check-ignore -v <file>&#xff08;用于检查指定文件是否…

一个灵活、现代的Android应用架构

一个灵活、现代的Android应用架构 学习Android架构的原则&#xff1a;学习原则&#xff0c;不要盲目遵循规则。 本文旨在通过示例演示实际应用&#xff1a;通过示范Android架构来进行教学。最重要的是&#xff0c;这意味着展示出如何做出各种架构决策。在某些情况下&#xff0…

网络层IP协议的基本原理 数据链路层ARP协议 域名解析以及一些重要技术

目录 1 网络层IP协议协议头格式网段划分DHCPCIDR&#xff1a;基于子网掩码的划分方式特殊的IP号IP地址的数量限制私有IP地址和公网IP地址路由路由表 2 数据链路层 — 局域网的转发问题以太网认识以太网以太网帧格式局域网通信原理 MTUMTU对IP协议的影响MTU对UDP协议的影响MTU对…

人类文明进入下个纪元奇点:UFO听证会-恒温超导发现-GPT大模型

今年以来&#xff0c;科技领域出圈的事件频繁发生&#xff0c;每一个事件都意味着一个领域的重大突破的可能。这些事件是UFO听证会、恒温超导LK99的论文、GPT类大模型的广泛应用&#xff0c;我常将这些事件串在一起思考&#xff0c;细思极恐&#xff0c;一种”火鸡与农场主“的…

C语言手撕顺序表

目录 一、概念 1、静态顺序表&#xff1a;使用定长数组存储元素。 2、动态顺序表&#xff1a;使用动态开辟的数组存储 二、接口实现 1、对顺序表的初始化 2、对数据的销毁 3、对数据的打印 4、检查是否需要扩容 5、尾插 6、头插 7、尾删 8、头删 9、在pos位置插入x …

使用ComPDFKit PDF SDK 构建iOS PDF阅读器

在当今以移动为先的世界中&#xff0c;为企业和开发人员创建一个iOS应用程序是必不可少的。随着对PDF文档处理需求的增加&#xff0c;使用ComPDFKit这个强大的PDF软件开发工具包&#xff08;SDK&#xff09;来构建iOS PDF阅读器和编辑器可以让最终用户轻松查看和编辑PDF文档。 …

IDEA 模块不加载依旧是灰色 没有变成小蓝色的方块

Settings > Build, Execution, Deployment > Build Tools > Maven > Ignored Files下降对应的模块勾选掉 但通常在Maven的配置中&#xff0c;您会找到一个名为“ignoredFiles”的列表&#xff0c;其中包含被忽略的文件和目录。您可以通过取消选中所需的文件或目录…

本地非文字资源无法加载

目录 方法A.静态/动态绑定路径 方法B.require导入&#xff08;运行时加载&#xff09; 方法C.import导入&#xff08;x&#xff09;&#xff08;编译时加载&#xff09; 方法D.ref直接操作元素赋值&#xff08;x&#xff09; 相关知识 import和requir区别 模板路径&#…

基于opencv与机器学习的摄像头实时识别数字!附带完整的代码、数据集和训练模型!!

前言 使用摄像头实时识别数字算是目标检测任务&#xff0c;总体上分为两步&#xff0c;第一步是检测到数字卡片的位置&#xff0c;第二步是对检测到的数字卡片进行分类以确定其是哪个数字。在第一步中主要涉及opencv的相关功能&#xff0c;第二步则使用机器学习的方式进行分类…

源码学习初章-基础知识储备

文章目录 学前准备源码地址引言extern "C" 宏定义平台宏跨平台宏vstdio平台禁用警告宏 连接、双层宏定义函数宏系统函数宏自定义函数宏多语句执行宏do while0 普通宏定义 C的一些必备函数知识回调函数和函数指针回调函数wireshark-4.0.7源码例子函数指针wireshark4.0…

通讯录的实现(超详细)——C语言(进阶)

目录 一、创建联系人信息&#xff08;结构体&#xff09; 二、创建通讯录&#xff08;结构体&#xff09; 三、define定义常量 四、打印通讯录菜单 五、枚举菜单选项 六、初始化通讯录 七、实现通讯的的功能 7.1 增加加联系人 7.2 显示所有联系人的信息 ​7.3 单独查…

《MySQL45讲》笔记—索引

索引 索引是为了提高数据查询效率&#xff0c;就像书的目录一样。如下图&#xff0c;索引和数据就是位于存储引擎中&#xff1a; 索引常见模型 哈希表 以键值对存储的数据结构。适用于只有等值查询的场景。 有序数组 在等值查询和范围查询场景中性能都特别优秀。但是有…

开放自动化软件的硬件平台

自动化行业的产品主要以嵌入式系统为主&#xff0c;历来对产品硬件的可靠性和性能都提出很高的要求。最典型的产品要数PLC。PLC 要求满足体积小&#xff0c;实时性&#xff0c;可靠性&#xff0c;可扩展性强&#xff0c;环境要求高等特点。它们通常采用工业级高性能嵌入式SoC 实…