接着上一篇,我们继续来聊聊Hashtable的实现,包括以下三个方面:
1、rehash的时机、条件、大小选择
2、bucket index的计算及hash函数的使用
3、multihashtable
相关代码我都加了注释、同时只保留了关键代码,方便大家理解,关键部分还会画图来说明。作为开头,还是先放一张整体结构图。
首先看下自动rehash的时机。在insert时会调用need_rehash进行判断,条件成立,则rehash。
cpp
iterator _M_insert_unique_node(size_type __bkt, __hash_code __code,__node_type* __node){
const __rehash_state& __saved_state = _M_rehash_policy._M_state();
//看看是否需要rehash
std::pair<bool, std::size_t> __do_rehash
= _M_rehash_policy._M_need_rehash(_M_bucket_count, _M_element_count, 1);
if (__do_rehash.first){
_M_rehash(__do_rehash.second, __saved_state);
}
}
那么什么条件下会需要rehash呢?这里涉及到Hashtable中的负载因子、最大负载因子、最大元素数、bucket个数、当前元素数等概念。最大负载因子默认为1.0,负载因子是为了防止Hashtable中元素过多,导致某个bucket元素过多,降低查找效率。
最大元素数 = 最大负载因子 * bucket个数;
负载因子 = 当前元素数 / bucket个数;
cpp
std::pair<bool, std::size_t> _M_need_rehash(std::size_t __n_bkt, std::size_t __n_elt,std::size_t __n_ins) const{
if (__n_elt + __n_ins >= _M_next_resize){
long double __min_bkts = (__n_elt + __n_ins)
/ (long double)_M_max_load_factor;
//当前元素数 + 增长个数 大等于最大元素数并且按照此时元素数量计算出来的min_bkts大等于当前bucket时,需要进行rehash。
if (__min_bkts >= __n_bkt)
//期望的bucket数量=max(min_bkts + 1,bkt * _S_growth_factor),_其中S_growth_factor默认为2。也就是取了当前bkt数量加倍后与min_bkts + 1之间的最大值。
//可以看到这里也是翻倍扩容,但具体细节又和vector的翻倍扩容存在差别。
return std::make_pair(true,
_M_next_bkt(std::max<std::size_t>(__builtin_floor(__min_bkts) + 1,
__n_bkt * _S_growth_factor)));
}
}
rehash时,期望的bucket确定了,那么实际的bucket数量又是多少呢?这里涉及到了hash冲突的避免,当hash函数的分母为质数时,hash的冲突相对会比较小,hash后的数据会分布的更加均匀,所以实际的bucket数量需要是质数,具体要取:最小的大等于期望bucket的质数。libstdc++中预先计算好了用到的质数,以数组的形式存在了__prime_list中,其中的质数都是递增的。那么最大的质数会到多少呢?这是通过一个常量形式的质数个数来控制的,保存在_S_n_primes中。为什么会有最大质数的限制呢?因为32位无符号数的最大值为4,294,967,295,32位和64位系统中unsigned long表示的范围不同,为了防止溢出,需要控制质数的范围。
cpp
enum { _S_n_primes = sizeof(unsigned long) != 8 ? 256 : 256 + 48 };
cpp
// Return a prime no smaller than n.
std::size_t _Prime_rehash_policy::_M_next_bkt(std::size_t __n) const{
// Optimize lookups involving the first elements of __prime_list.
// (useful to speed-up, eg, constructors)
static const unsigned char __fast_bkt[12]
= { 2, 2, 2, 3, 5, 5, 7, 7, 11, 11, 11, 11 };
//期望bucket数量小于等于11时,直接使用fast_bkt,根据索引查表得到实际的bucket数量。
if (__n <= 11){
_M_next_resize =
__builtin_ceil(__fast_bkt[__n] * (long double)_M_max_load_factor);
return __fast_bkt[__n];
}
//期望bucket大于11时,因为prime_list是排好序的,所以可以使用二分搜索,在prime_list中查找不小于n的第一个数。
const unsigned long* __next_bkt =
std::lower_bound(__prime_list + 5, __prime_list + _S_n_primes, __n);
_M_next_resize =
__builtin_ceil(*__next_bkt * (long double)_M_max_load_factor);
return *__next_bkt;
}
看到这里,其实下面这张整体结构图更符合实际,不知道大家有没有看出和文章开头那张图的区别,欢迎留言交流。
再看下bucket index的计算及hash函数的使用,首先看下bucket index的计算。是通过类型对应的hash code和bucket count取模得到的。
cpp
//num为类型对应的hash code,den为bucket count.
result_type operator()(first_argument_type __num, second_argument_type __den) const{
return __num % __den;
}
那么不同类型的hash code是怎么计算的呢,这里使用了std::hash模版类。对不同的类型,其实现不同。可以看到下面的代码,对于整型数,c++默认把其本身作为hash code;对于指针,c++默认把其地址作为hash code。
cpp
/// Partial specializations for pointer types.
template<typename _Tp>
struct hash<_Tp*> : public __hash_base<size_t, _Tp*>
{
size_t
operator()(_Tp* __p) const noexcept
{ return reinterpret_cast<size_t>(__p); }
};
// Explicit specializations for integer types.
#define _Cxx_hashtable_define_trivial_hash(_Tp) \
template<> \
struct hash<_Tp> : public __hash_base<size_t, _Tp> \
{ \
size_t \
operator()(_Tp __val) const noexcept \
{ return static_cast<size_t>(__val); } \
};
对于string以及自定义结构体,其hash code是怎么计算的呢?string在basic_string.h有hash的特化版本。其内部使用了Murmur hash来计算hash code。对于自定义类型,需要实现std::hash的特化版本才能使用Hashtable,不然会出现编译期错误。
cpp
template<>
struct hash<string>: public __hash_base<size_t, string>{
size_t operator()(const string& __s) const noexcept{
return std::_Hash_impl::hash(__s.data(), __s.length());
}
};
最后再来说说multihashtable,什么是multihashtable呢?首先其也是hashtable,上面讲过的特性也适用于它;不同的是它可能存在相同的node。multihashtable因为其node相同的特性,所以相同的node总是在同一个bucket,并且总是连续的。
至此,Hashtable中的关键实现都讲完了,c++标准库中的unordered_set、unordered_map、unordered_multiset、unordered_multimap都是基于Hashtable实现的。其中map是把key和value作为一个pair保存的,set只保存了key(也就是其value),这是两者的不同。有兴趣的话大家可以自己去看下源码。
随着我们对某一个事物理解的不断加深,我们之前的理解也在被不断推翻。
公众号原文链接 聊聊c++ Hashtable(二)
曾经沧海难为水,除却巫山不是云