聊聊c++ Hashtable(二)

接着上一篇,我们继续来聊聊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(二)

曾经沧海难为水,除却巫山不是云

相关推荐
间彧20 分钟前
什么是Region多副本容灾
后端
爱敲代码的北21 分钟前
WPF容器控件布局与应用学习笔记
后端
爱敲代码的北21 分钟前
XAML语法与静态资源应用
后端
清空mega23 分钟前
从零开始搭建 flask 博客实验(5)
后端·python·flask
爱敲代码的北27 分钟前
UniformGrid 均匀网格布局学习笔记
后端
一只叫煤球的猫1 小时前
从1996到2025——细说Java锁的30年进化史
java·后端·性能优化
喵个咪1 小时前
开箱即用的GO后台管理系统 Kratos Admin - 数据脱敏和隐私保护
后端·go·protobuf
我是天龙_绍1 小时前
Java Object equal重写
后端
虎子_layor2 小时前
实现异步最常用的方式@Async,快速上手
后端·spring
一米阳光zw2 小时前
Spring Boot中使用 MDC实现请求TraceId全链路透传
java·spring boot·后端·traceid·mdc