剖析 std::unordered_map 插入过程
观前提醒,横屏有助于提升阅读体验。
上一节剖析 std::unordered_map 具有O(1)时间复杂度的原理中讲解了std::unordered_map
应对hash冲突、退化,实现O(1)时间复杂度的原理。这一节从源码角度看看它底层实现细节。
在本节,由于C++的STL中模板参数较多,为便于描述:
- 将模板参数较多的返回类型使用
auto
替代 - 将类的成员函数实现中的类模板参数以
...
替代
insert
下面从insert
方法开始:
1 | int main(int argc, char const *argv[]) { |
insert
函数,内部是调用_Hashtable
对象_M_h.insert(...)
来实现的。为避免不必要的复制,会使用移动语义将{1,1}
作为_M_h.inser
的参数。
1 | std::pair<iterator, bool> insert(value_type&& __x) { |
_M_h.insert
函数是由_Hashtable
的父类_Insert
实现,因此该hash_map.insert(...)
函数实际上调用的是hashtable_policy.h/_Insert::insert(...)
方法:
1 | template<...> |
函数__unique_keys()
的值是std::true_type
类型,其值在std::unordered_map
初始化时就已确定,是用于区分 std:;unordered_map
和 std::unordered_multimap
:
- 对于
std::unordered_map
,__unique_keys
是std::true_type
类型 - 对于
std::unordered_multimap
,__unique_keys
是std::false_type
类型
因此,__unique_keys()
默认构造函数,就能确定当前是哪种类型。 其初始化过程见下四步:
1 | template <bool _Cache_hash_code, bool _Constant_iterators, bool _Unique_keys> |
_Hashtable::_M_emplace
因此,insert
函数内部将会调用如下实例化版本:
1 | __h._M_emplace(std::true_type, std::forward<_Pair>(__v)); |
_M_emplace
函数的执行步骤如下:
- 构造新的节点
__node
:先为新的节点分配内存,并以传入的参数调用构造函数 - 使用
__node->key
的来计算hashcode - 根据hashcode计算将要插入的桶索引
__bkt
- 为了确保
__node
这个节点在_Hashtable
中并没有存储过,因此要先在桶的链表中查找- 如果找到,则不插入
- 如果没有找到,则调用
_M_insert_unique_node
函数插入节点。在此过程中可能会发送 Rehash行为
先看整个流程,再分叙各个细节。
1 | auto _Hashtable<_...>::_M_emplace(std::true_type, _Args && ...__args) { |
_Hashtable::_M_find_node
_M_find_node
函数,在_M_bucket[__btk]
指向的链表中查找是否有hashcode是__code
的键__k
,时间复杂度是O(N)。其内部是调用_M_find_before_node
函数来查找待插入节点__node
:
- 如果已存在
__node
,则_M_find_before_node
函数返回__node
的前一个位置, - 否则返回
nullptr
_M_find_node
函数实现如下:
1 | __node_type* _M_find_node(size_type __bkt, const key_type &__key, __hash_code __c) const { |
_M_find_before_node
函数,即在_M_buckets[__btk]
中查找是否存在__node
,由_M_equals
函数判断两个节点是否相同。如果遍历完整个链表也没发现存在__node
,则返回nullptr
。
其中,当前节点不存在此链表,有两种可能:
_M_bucket_index(__p->_M_next()) != __n
:表示下一个节点的位置不位于当前桶中。这是因为,为了能遍历
std::unordered_map
,每个桶_M_bucket[__btk]
指向的链表的最后一个节点,是另一个桶的哨兵节点(这在后面Rehash函数中会更加详细的说明),当_M_bucket_index(__p->_M_next()) != __n
时,即当前链表中不存在__node
节点。__p->_M_nxt == nullptr
:这个整个_Hashtable
的最后一节点
因此,当没有load_factor <= max_load_factor
限制,这个链表可能会很长,使得_Hashtable
的时间复杂度恶化至O(N)
.
1 | auto _Hashtable<...>::_M_find_before_node(size_type __n, const key_type& __k, __hash_code __code){ |
视线再回到_Hashtable<...>::_M_emplace
函数:
- 如果已经存在待插入节点
__node
,则直接返回std::make_pair(iterator(__p), false);
,iterator(__p)
指向__node
的位置 - 如果不存在,则先调用
_M_insert_unique_node
函数,将__node
插入到_Hashtable
中,再返回std::make_pair(iterator(__p), true);
,其中iterator(__p)
是_M_insert_unique_node
函数的返回值。
_M_emplace
函数剩余过程如下:
1 | auto _Hashtable<_...>::_M_emplace(std::true_type, _Args && ...__args) { |
_Hashtable::_M_insert_unique_node
当决定要插入一个节点时,如下步骤:
- 先调用
_M_need_rehash
函数,判断_Hashtable
是否要Rehash
- 如果需要,则调用
_M_rehash
函数进行Rehash
,然后重新计算要插入的桶索引__btk
- 如果不需要,则直接使用原来的桶索引
__btk
- 如果需要,则调用
- 将待插入节点
__node
插入到桶_M_bucket[__btk]
的头部 - 更新一些参数
- 将
__node
迭代器,返回给_M_emplace
函数,使其进一步返回std::make_pair(iterator(__node), true);
插入一个节点的流程如下。
1 | auto _Hashtable<...>::_M_insert_unique_node(size_type __bkt, |
_Power_rehash_policy::_M_need_rehash
_M_need_rehash
函数,判断是否需要Rehash
,其函数的参数含义如下:
__n_bkt
:_Hashtable
的桶的个数__n_elt
:_Hashtable
的元素个数__n_ins
:此次要插入的节点数
__n_elt + __n_ins
则是此次插入完成后_Hashtable
的节点数。那么根据最大负载因子max_load_factor
,可以计算出存储__n_elt + __n_ins
个节点需要的至少需要的桶数是 __min_btks
。
1 | long double __min_bkts = (__n_elt + __n_ins) / (long double)_M_max_load_factor; // 新的桶个数 |
进一步,如果这个至少需要的桶数__min_btks >= __n_btks
,则表示当前桶容量不够,无法满足load_factor <= max_load_factor
的限制条件,就需要对_Hashtable
执行Rehash,返回std::make_pair(true, count)
,count
是新的桶数。
如果以上条件都不满足,不需要Rehash,则直接返回std::make_pair(false, 0)
。
1 | auto _M_need_rehash(std::size_t __n_bkt, std::size_t __n_elt, std::size_t __n_ins) noexcept { |
_Hashtable::_M_rehash
_M_rehash
函数,内部是调用_M_rehash_aux
实现的,__unique_keys()
由前面可知,在std::unordered_map
中初始化为std::true_type
类型。
1 | void _Hashtable<...>::_M_rehash(size_type __n, const __rehash_state &__state) { |
_Hashtable::_M_rehash_aux
_M_rehash_aux
函数,执行步骤如下:
1) 分配内存
为__n
个新桶分配内存__new_buckets
1 | void _Hashtable<>::_M_rehash_aux(size_type __n, std::true_type) { |
2) 数据迁移
将旧桶__M_bucket
中的节点数据重新插入到__new_buckets
中,在插入的过程中需要维持__new_buckets
各个桶之间的联系,即当前空桶指向的链表的最后一个节要指向前一个空桶的哨兵节点。没明白吗?不急,往下看。
_Hashtable
有一个哨兵节点_M_before_begin
, 它总是最后一个插入节点的空桶的哨兵节点:当一个节点__p
要插入一个空桶__new_buckets[__btk]
时,_M_before_begin
就会指向变成__new_buckets[__btk]
的哨兵节点,即:
1 | __new_buckets[__btk] = &_M_before_begin; |
_Hashtable
需要借助 _M_before_begin
节点在当前空桶和上一个空桶之间建立联系,那么当数据插入完毕,整个_Hashtable
才能变成一条“链表”:_M_before_begin._M_nxt
指向的就是最后一个空桶的首届点,从_M_before_begin._M_nxt
开始遍历,最后一个节点的__lastNode->_M_next
即nullptr
。
begin()
和end()
迭代器即如此实现:
1 | // _Hashtable::begin() |
下面是最精彩的部分!!!
空桶中插入节点
当待插入节点__p
将要插入到一个空桶时,那么需要在这个空桶和上一个空桶(已经不是空桶了)之间建立联系,建立步骤如下三步:
1 | __p->_M_nxt = _M_before_begin._M_nxt; // 1 |
由于采用头部插入法在每个链表中插入新节点,因此第一个插入到空桶
_new_buckets[__bkt]
的节点__p
将会成为这个链表的最后一个节点。因此第一步的代码,就实现了让空桶_new_buckets[__bkt]
的最后一个节点指向前一个空桶的首节点。1
__p->_M_nxt = _M_before_begin._M_nxt; // 前一个空桶的哨兵节点是_M_before_begin
_M_before_begin
总是作为哨兵节点,当有一个节点__p
插入空桶时,_M_before_begin
就将作为新空桶的哨兵节点,那么_M_before_begin->next
就应该是此刻空桶__new_buckets[__btk]
第一个节点,即__p
,因此就有了第2、3步。1
2_M_before_begin._M_nxt = __p; // 指向第一个节点
_new_buckets[__bkt] = &_M_before_begin; // 将 _M_before_begin 作为哨兵节点那还有什么没有完成?
_M_before_begin
已经是当前空桶的哨兵节点,那么前一个空桶的哨兵节点就不能再是_M_before_begin
。由于在上面的第一步中,将__p->next
指向了前一个空桶的首节点,则顺理成章地将__p
变成前一个空桶的哨兵节点。当然,当
__p
是整个_Hashtable
的第一个节点,那么当前空桶就是第一个空桶,此时__p->_M_next
就是nullptr
。1
2
3if (__p->_M_nxt)
__new_buckets[__bbegin_bkt] = __p; // 将 __p 作为前一个桶的哨兵节点
__bbegin_bkt = __bkt; // __bbegin_bkt 记录前一个空桶索引
非空桶中插入节点
如果_new_buckets[__btk]
中已经有节点,则直接在头部插入即可,没有复杂性。
1 | __p->_M_nxt = __new_buckets[__bkt]->_M_nxt; |
3) 数据迁移完成
当数据迁移完成,即_M_buckets
中节点全更新到_new_buckets
中,需要释放_M_buckets
的内存,并且将_M_buckets
指向_new_buckts
,再更新其他参数即可。
完整的代码细节过程如下。
1 | void _Hashtable<>::_M_rehash_aux(size_type __n, std::true_type) { |
至此,已完全讲解了hash_map
是如何插入一个节点。