聊聊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(二)

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

相关推荐
摇滚侠3 小时前
Spring Boot 3零基础教程,IOC容器中组件的注册,笔记08
spring boot·笔记·后端
程序员小凯6 小时前
Spring Boot测试框架详解
java·spring boot·后端
你的人类朋友6 小时前
什么是断言?
前端·后端·安全
程序员小凯7 小时前
Spring Boot缓存机制详解
spring boot·后端·缓存
i学长的猫8 小时前
Ruby on Rails 从0 开始入门到进阶到高级 - 10分钟速通版
后端·ruby on rails·ruby
用户21411832636028 小时前
别再为 Claude 付费!Codex + 免费模型 + cc-switch,多场景 AI 编程全搞定
后端
茯苓gao8 小时前
Django网站开发记录(一)配置Mniconda,Python虚拟环境,配置Django
后端·python·django
Cherry Zack8 小时前
Django视图进阶:快捷函数、装饰器与请求响应
后端·python·django
爱读源码的大都督9 小时前
为什么有了HTTP,还需要gPRC?
java·后端·架构
码事漫谈9 小时前
致软件新手的第一个项目指南:阶段、文档与破局之道
后端