
Hello,大家好,今天的这篇博客我们来讲解一下如何封装哈希表从而实现unordered_map和unordered_set这两个容器,众所周知,这两个容器的底层均是哈希表实现的,但是是怎么实现的呢?我们话不多说,接下来就为大家来讲解这一部分的知识。(下面的内容可能涉及到哈希表相关的知识,链接如下:数据结构------哈希表的实现_jhash实现-CSDN博客)
目录
[1 源码及框架分析](#1 源码及框架分析)
[2 模拟实现unordered_map和unordered_set](#2 模拟实现unordered_map和unordered_set)
[2.1 实现出复用哈希表的框架并支持Insert](#2.1 实现出复用哈希表的框架并支持Insert)
[2.2 支持iterator的实现](#2.2 支持iterator的实现)
[2.2.1 iterator的实现思路分析](#2.2.1 iterator的实现思路分析)
[2.2.2 代码模拟实现iterator](#2.2.2 代码模拟实现iterator)
[2.2.2.1 迭代器的大框架实现](#2.2.2.1 迭代器的大框架实现)
[2.2.2.2 迭代器部分操作实现](#2.2.2.2 迭代器部分操作实现)
[2.3 unordered_map支持[ ]](#2.3 unordered_map支持[ ])
1 源码及框架分析
要想模拟实现出unordered_map和unordered_set这两个容器,我们就要先通过源代码来具体地了解一下,这两个容器均是C++11之后才更新的,我们这里为了方便起见,我们这里截取了部分源代码来为大家展示。(这里用哈希表封装unordered_map和unordered_set的过程和我们前面所学过的用红黑树封装map和set地实现过程基本上差不多)

为了方便大家使用代码观看,我将代码放在了下面:
cpp
template < class Value, class HashFcn = hash<Value>, class EqualKey = equal_to<Value>, class Alloc = allocator<Value>>
class unordered_set
{
private HashTable<Value, Value, HashFcn, identity<Value>, EqualKey, Alloc> ht;
ht rep;
};
template < class Key, class Value, class HashFunc = hash<Key>, class EqualKey = equal_to<Key>, class Alloc = allocator<Key>>
class unordered_map
{
private HashTable<Key, pair<Key, Value>, HashFunc, Selectlst<pair<const Key, Value>>, EqualKey, Alloc> ht;
ht rep;
};
template<class Key, class Value, class HashFcn, class KeyOfT, class EqualKey, class Alloc>
class HashTable
{
public:
typedef __hashtable_node<Value> node;
vector<node*, Alloc> buckets;//暂时先不管Alloc这个模板参数,我们这里就当它不存在。
size_t num_elements;
};
template<class Value>
struct __hashtable_node
{
Value val;
__hashtable_node* next;
};
通过我们上述对源代码的剖析可知,在结构上unordered_map和unordered_set跟map和set是完全类似的,复用同一个HashTable,可以同时实现Key和Key/Value这两种结构,unordered_set传给HashTable的是两个Key,而unordered_map传给HashTable的一个是Key,另一个是pair<const Key, Value>。
1>.这里需要注意的是源码跟map/set的源码类似,命名风格比较乱一些,这里比map和set还乱,unordered_set的模板参数居然用Value命名,unordered_map则用的是Key和Value命名,可见大佬有时写代码也不规范,乱弹琴。接下来,我们这里就来自己模拟实现一份unordered_map和unordered_set出来,按照我们自己的命名风格去走。
2>.除此之外,我认为这里有必要再来解释一下unordered_map和unordered_set的后3个模板参数的含义,HashFcn这个模板参数其实相当于是一个仿函数的类型,它的作用在这里就是将Key(关键字)转换成整数类型,因为我们的底层是通过哈希表来实现的,通过我们前面对哈希表的讲解可知,确实是需要一个这样的仿函数来找到某一个元素在哈希表中具体的映射位置;identity这个模板参数的主要作用是找到某一个元素的关键字,我们这里的unordered_map和unordered_set这两个容器它们封装的是同一个哈希表,但是这两个容器中所存储的元素是不一样的,unordered_set中存储的元素就相当于是关键字,我们就可以直接得到unordered_set的关键字,而unordered_map中存储的元素是一个pair<const Key, Value>类类型的变量,要通过解引用操作才可以得到该元素的关键字,介于这种原因,这里选择使用identity这个仿函数去找到某个元素的关键字;EqualKey这个模板参数其实本质上也是一个仿函数,它的作用是判断关键字是否相同,它主要用于查找这个步骤中;Alloc就是一个内存池(后面会讲到,这里我们暂时先不管)。
2 模拟实现unordered_map和unordered_set
2.1 实现出复用哈希表的框架并支持Insert
1>.我们接下来要模拟实现的unordered_map和unordered_set要参考源码框架,unordered_map和unordered_set里面封装的那个哈希表就复用我们前一篇博客所实现的那个哈希表就可以。
2>.我们这里模拟实现的代码相较于源码稍微地去调整一下,Key参数就用K,Value参数就用V,哈希表中的数据类型,我们这里用T来表示。
3>.其次跟map和set相比而言unordered_map和unordered_set在模拟实现类的整体结构时显得更加复杂一些,但是在大致框架和思路上是完全类似的。因为HashTable实现了泛型,导致不知道T参数到底是K,还是pair<const Key, Value>,那么insert内部进行插入时要用K对象转换成整数取模和K比较相等,因为pair类类型的Value不参与计算取模,且默认支持的是Key和Value一起比较相等,我们需要的是任何时候只需要比较K即可,所以我们在eunordered_map和unordered_set层分别实现一个MapKeyOfT和SetKeyOfT的仿函数传给HashTable中的KeyOfT这个参数,然后HashTable中通过KeyOfT仿函数取出T类型对象中的K(关键字),再转换成整形取模和K比较相等,代码如下所示:
cpp
//unordered_set
template < class K, class HashFcn = hash<K>, class EqualKey = equal_to<K>, class Alloc = allocator<K>>
class unordered_set
{
struct SetKeyOfT
{
const K& operator(const K& key)
{
return key;
}
};
public:
bool insert(const K& key)
{
return _ht.Insert(key);
}//...
HashTable<K ,const K, SetKeyOfT, HashFcn> _ht;
};
//unordered_map
template < class K, class V, class HashFcn = hash<K>, class EqualKey = equal_to<K>, class Alloc = allocator<K>>
class unordered_map
{
struct MapKeyOfT
{
const K& operator()(const pair<const K, V>& kv)
{
return kv.first;
}
};
public:
bool insert(const pair<const K, V>& kv)
{
return _ht.Insert(kv);
}//...
HashTable<K, pair<const K, V>, MapKeyOfT, HashFcn> rep;
};
//我们上面模拟实现部分中给HashFcn这个模板参数一个hash<K>这个缺省值,因为我们这里的unordered_map和unordered_set这两个容器中的K有可能本身就为整数类型,是不需要我们在这里进行转换的,也就是说,如果K为int类型的话,是不用给HashFcn传参的,但是为了让其那个兼容Key的所有可能类型,所以这里给HashFcn这个模板参数一个缺省值,这个缺省值就是专门针对Key为整数类型或者是可以直接转换成整数类型的变量用的,代码如下:
template<class K>
struct HashFnc
{
size_t operator()(const K& key)
{
return (size_t)key;
}
};
//我们前一篇博客在实现哈希表时是默认哈希表中存放的是pair<const K, V>类类型的变量,因此,哈希表的角度也需换一下。
template<class T>
struct HashData
{
T _data;
HashDataT<T>* _next;
HashData(const T& data)
:_data(data)
,_next(nullptr)
{ }
};
//剩下的就差不多了,还要将前一篇博客中所有的元素全部换一下,换成data,以兼容unordered_map和unordered_set。
2.2 支持iterator的实现
2.2.1 iterator的实现思路分析
1>.iterator实现的大框架跟list中的iterator的思路是一致的,用一个类型封装一个节点的指针,再通过重载运算符实现,迭代器像指针一样访问的行为,要注意的是哈希表的迭代器是单向迭代器。
2>.这里的难点是对operator++这个运算符的实现。iterator中有一个指向节点的指针,如果当前桶下面还有节点的话,则节点的指针指向下一个节点即可。如果当前这个桶我们走完了,则需要想办法计算找到下一个桶,这里的难点是结构设计的问题,在源码的实现中,我们可以看到iterator中除了有一个节点的指针以外,还有一个哈希表对象类型的指针,这样当桶走完了,要计算下一个桶就相对容易得多了,用Key值计算出当前这个桶得位置,依次往后去找下一个不为空的桶即可。
3>.begin()返回的是第一个桶中得第一个节点指针所构造的迭代器,这里的end()返回的迭代器可以用空指针去表示。
4>.unordered_set的iterator也不支持修改,我们把unordered_set的第二个参数用const修饰一下即可。
cpp
HashTable<K ,const K, SetKeyOfT, HashFcn> _ht;
5>.unordered_map的iterator不支持修改K,但是可以修改V,我们把unordered_map的第二个模板参数pair的第一个参数用const修饰即可。
cpp
HashTable<K, pair<const K, V>, MapKeyOfT, HashFcn> _ht;
2.2.2 代码模拟实现iterator
我们这里的代码模拟实现分为两部分来写,一部分是迭代器的大框架,另一部分则写迭代器中的一些必要条件。
2.2.2.1 迭代器的大框架实现
cpp
template<class K, class V, class Hash = HashFnc<K>, class KeyOfT, class Pred = equal_to<K>, class Alloc = allocator<K>>
class HashTable;//这一步操作是对HashTable这个类进行一个前置声明的操作,这里我们之所以要加上这一句代码主要还是因为我们哈希表的结构造成的,通过我们上述的讲解可以得知,哈希表的迭代器中不仅封装了一个节点类型的指针,还封装了一个哈希表类型的指针(至于这里为什么还要再封装一个哈希表类型的指针,原因我们在后面的讲解中会讲到,这里先知道有这个指针的存在就可以了),根据我们编译器的运行路径(从上往下,我们这里默认是先写Iterator这个类,然后再写HashTable这个类)来看,当编译器运行到Iterator这个类中的定义哈希表类型的这句代码时,编译器它不认识哈希表的那个类型,就会往前去找,但是我们的HashTable这个类是在Iterator这个类的下面定义的,往上找就根本找不到,因此,编译器在这里就会报错,为了解决这个问题,我们要在Iterator这个类的前面对HashTable这个类做一个前置声明即可解决问题。
template<class K,class T,class Ptr,class Ref,class KeyOfT,class Hash>
struct Iterator
{
typedef HashNode<T> Node;
typedef Iterator<K, T, Ptr, Ref, KeyOfT, Hash> Self;
typedef HashTable<K, T, KeyOfT, Hash > HT;
Node* _node;
const HT* _ht;
Iterator(Node* node,HT* ht)
:_node(node)
,_ht(ht)
{ }
//...
};
template<class K, class T, class Hash = HashFnc<K>, class KeyOfT, class Pred = equal_to<K>, class Alloc = allocator<K>>
class HashTable
{
template<class K, class T, class Ptr, class Ref, class KeyOfT, class Hash>
friend struct Iterator;//这里的这两句代码是对Iterator类进行友元声明操作,这里之所以要对Iterator进行友元操作,其本质原因还是哈希表的结构问题,我们在实现operator++这个重载运算符时,如果当前节点的下一个节点为空的话,那么我们++后下一个访问的节点是哈希表的下一个桶的第一个节点,也就是说,如果遇到这种情况时,我们就需要借助哈希表的对象去求出当前节点在哈希表中的映射位置,从这个映射位置开始,继续往后去找下一个桶,我们这里既然要求出当前节点的映射位置,首先就得找到当前所在的这个哈希表,而前面我们在Iterator中定义的那个哈希表类型的指针恰好就可以帮助我们找到,这就是Iterator类中需要定义哈希表类型的指针的原因,找到当前节点所在的哈希表,我们要的是哈希表的大小,也就是HashTable类中的那个顺序表_tables的大小,但是_tables在HashTable中属于是私有成员,外界不可访问(Iterator和HashTable属于两个不同的类),为了解决这个问题,有两个方法,一种是将HashTable中的那个顺序表传到Iterator类中,二种是让Iterator成为HashTable的友元类,这样在Iterator中也可以访问HashTable中的私有成员了。我们这里选择第二种,自我感觉第二种比第一种更方便,模板的友元声明如上述代码所示(我们之前没遇到,这里强调一下)。
typedef HashNode<T> Node;
public:
typedef Iterator<K, T, T*, T&, KeyOfT, Hash> HTIterator;
typedef Iterator<K, T, const T*, const T&, KeyOfT, Hash> ConstHTIterator;
//...
private:
vector<Node*> _tables;
size_t _n = 0;
};
template < class K, class HashFcn = hash<K>, class EqualKey = equal_to<K>, class Alloc = allocator<K>>
class unordered_set
{
struct SetKeyOfT
{
const K& operator(const K& key)
{
return key;
}
};
HashTable<K, const K, HashFcn, SetKeyOfT, EqualKey, Alloc> _ht;
public:
typedef typename HashTable<K, const K, HashFcn, SetKeyOfT, EqualKey, Alloc>::HTIterator iterator;
typedef typename HashTable<K, const K, HashFcn, SetKeyOfT, EqualKey, Alloc>::ConstHTIterator const_iterator;
//...
};
template < class K, class V, class HashFcn = hash<K>, class EqualKey = equal_to<K>, class Alloc = allocator<K>>
class unordered_map
{
struct MapKeyOfT
{
const K& operator()(const pair<const K, V>& kv)
{
return kv.first;
}
};
HashTable<K, pair<const K, V>, HashFcn, SetKeyOfT, EqualKey, Alloc> _ht;
public:
typedef typename HashTable<K, pair<const K, V>, HashFcn, SetKeyOfT, EqualKey, Alloc>::HTIterator iterator;
typedef typename HashTable<K, pair<const K, V>, HashFcn, SetKeyOfT, EqualKey, Alloc>::ConstHTIterator const_iterator;
//...
};
2.2.2.2 迭代器部分操作实现
迭代器中最主要的成员函数其实就是重载operator++操作符,以及解引用操作,因此我们这里只实现重载++运算符和重载解引用操作符这两个操作就可以了。
cpp
Ref operator*()
{
return _node->_data;
}
Self operator++()
{
if (_node->_next)//如果当前节点的下一个节点不为空的话,就说明当前这个桶还没有遍历完,返回下一个节点的迭代器即可。
{
_node = _node->_next;
}
else//当前节点的下一个节点是空节点,那么说明当前这个桶全部都遍历完了,下一个访问的节点在另一个桶上,应该先找到当前这个节点在哈希表中映射的位置,从映射的这个位置开始往后去找下一个桶。
{
KeyOfT kot;//找到_data中的关键字。
Hash hash;//将关键字转换成整数类型。
size_t hashi = hash(kot(_node->_data)) % _ht->_tables.size();//求出当前节点在哈希表中映射的位置。
++hahsi;//我们在求出当前这个节点在哈希表中映射的那个位置后,让其++一下,如果当前节点的下一个位置为空,就说明当前这个节点所在的这个桶已经走完了,hashi在未++前,它代表的是当前这个节点所在的这个桶所在哈希表中映射的位置,也就是说,下一个要访问的节点是在当前这个桶的下一个桶中,hashi++之后,hashi表示的是当前这个桶在哈希表中映射的位置的下一个位置。
while (hashi < _ht->_tables.size())//接下来的操作就是通过循环从哈希表中下标未hashi这个位置开始往后去找下一个桶。
{
_node = _ht->_data->_tables[hashi];
if (_ht->_tables[hashi])//如果_node不为空,则说明中的下一个桶了。
{
break;//结束循环。
}
else//说明哈希表中下标为hashi的位置没有桶,继续往下一个位置去找。
{
hashi++:
}
}//若出了while循环的话,则有2种情况:一种是找到了下一个桶,break出来了,另一个是走到哈希表的末尾(也就是hashi==_tables.size())了,因此我们这里还需要判断一下。
if (hashi == _ht->_tables.size())//说明是走到哈希表的末尾了。
{
_node = nullptr;
}//这个if语句其实在这里可有可无,因为前面有"_node = _ht->_data->_tables[hashi];"这一行代码,无论是以哪种情况出现出while循环,_node它指向的都是我们想要的那个结果。
}
return *this;
}
2.3 unordered_map支持[ ]
1>.unordered_map要支持[ ]主要需要修改insert返回值支持,修改HashTable中的insert返回值为pair<HTIterator,bool> Insert(const T& data)。
2>.有了insert支持,[ ]的思想就很简单了。
OK,今天我们就先讲到这里了,那么,我们下一篇再见,谢谢大家的支持!