博客
关于我
强烈建议你试试无所不能的chatGPT,快点击我
STLPort 哈希表 hash_map/hash_multimap 删除速度慢
阅读量:2172 次
发布时间:2019-05-01

本文共 4661 字,大约阅读时间需要 15 分钟。

KeyWords: STLPort hash_map hash_multimap erase操作慢

本文使用的STL版本是STLPort5.2.1的最新release版本,配合G++4.5版本使用。我在项目中发现一个性能的瓶颈,最终定位到的原因是使用STLPort的hash_multimap(C++11之后哈希表改为unordered_map和unordered_multimap)中erase函数消耗时间长导致的。STLPort目前已经基本不怎么更新了,C++新标准C++11/14/17基本是不支持了。对比了STL的其他版本,G++4.5自带的版本和G++7.2自带的版本发现,STLPort的erase操作和G++自带的STL unordered_multimap::erase 有200倍的执行时间的差距。

测试的方法是使用了20W节点的哈希表,然后在循环中遍历删除每一个节点。G++自带STL库,开启了O3优化,在i7 6700K的平台上面执行时间大约只有0.02S,而STLPort的执行时间有4S之多。

说个题外话,如果使用STLPort5.2.1版本的哈希表的话,最好自己打一个patch。这个版本的哈希表存在一个BUG,在删除节点时,会减少哈希表的桶的数量,有些情况会rehash,造成迭代器的失效。在循环中删除哈希表的迭代器节点,有可能得到和预期不一致的结果。BUG的地址:

STLPort hash_multimap数据结构

STLPort哈希表底层使用的数据结构是slist(C++11中为forward_list),是一种单向链表的结构。所有哈希表的节点都保存在这个链式的结构上,哈希表的桶使用vector<_Slist_node_base*> _BucketVector来表示,桶的每一个节点保存了slist类型的基类指针。该指针指向了桶内第一个元素在slist中的位置。所以这个桶是一个逻辑的概念,只是一个类似于游标或者迭代器的东西,并没有在原始的数据结构中单独拿出一条链表来保存桶。获取桶内元素(假设第n个桶)的方式就是使用_BucketVector[n],_BucketVector[n + 1]确定slist中桶内元素的首尾指针,这两个指针类似于begin(),end()迭代器的概念。然后遍历该区间就可以找到桶内的所有元素。下图是一个哈希表的示意图。

hashtable.png

 

通过Key来查找的过程,首先需要将Key转换为桶的索引,知道数据位于哪个桶内。转换的过程是hash(Key)%bucket_count(),先用哈希函数算出Key的哈希值,然后模上桶的数量得到桶的索引(桶的数量一般是一个素数,在STLPort中也保存了一张素数表来决定桶需要分配的大小)。得到桶的索引后,就执行上面一段讲到的内容,通过首尾指针遍历桶内元素,然后比较桶内元素的Key和查找元素的Key是否相等,返回第一个相等的元素。

hash_map和hash_multimap其实并没有太多区别,都是对STLPort底层数据结构hashtable的封装,区别只是插入元素时,使用insert_unique函数还是insert_equal函数而已。

STLPort hash_multimap erase操作

template 
void hashtable<_Val,_Key,_HF,_Traits,_ExK,_EqK,_All> ::erase(const_iterator __it) { const size_type __n = _M_bkt_num(*__it);//通过迭代器来获取桶的索引 _ElemsIte __cur(_M_buckets[__n]);//cur节点指向桶__n的第一个元素 size_type __erased = 0; //删除节点的个数 if (__cur == __it._M_ite) { //如果删除的节点是桶内第一个元素size_type __prev_b = __n;_ElemsIte __prev = _M_before_begin(__prev_b)._M_ite; //获取__n桶的前一个节点,__prev_b返回第一个和__n桶指向同一节点的索引,这里具体后面解释fill(_M_buckets.begin() + __prev_b, _M_buckets.begin() + __n + 1, _M_elems.erase_after(__prev)._M_node);//将该节点删除,并且将所有指向该节点的桶,更新指向的节点。后面具体解释++__erased;//增加删除节点计数 } else { //删除的节点不是桶内第一个元素/* 遍历__n桶,删除与入参__it相同的节点,这部分比较好理解,也没什么毛病 */_ElemsIte __prev = __cur++;_ElemsIte __last(_M_buckets[__n + 1]);for (; __cur != __last; ++__prev, ++__cur) { if (__cur == __it._M_ite) {_M_elems.erase_after(__prev);++__erased;break; }} } _M_num_elements -= __erased; //哈希节点计数更新}

上面是STLPort的erase迭代器的过程,有两个分支,删除节点如果不是桶的首节点的话比较容易,直接找到要删除的节点删掉就好了。复杂的是要删除的节点是桶的首节点,需要找到该节点的前一个节点,并且要更新桶的首指针的指向,具体内容已经在注释中写出了。经过分析和加入调试信息打印,耗时的根源在查找待删除节点前一个节点这个函数上,_M_before_begin。

让我们一起看一下这个函数的代码。

template 
__iterator__hashtable<_Val,_Key,_HF,_Traits,_ExK,_EqK,_All> ::_M_before_begin(size_type &__n) const { return _S_before_begin(_M_elems, _M_buckets, __n); //实际的执行函数是_S_before_begin} template
__iterator__hashtable<_Val,_Key,_HF,_Traits,_ExK,_EqK,_All> ::_S_before_begin(const _ElemsCont& __elems, const _BucketVector& __buckets,size_type &__n) { _ElemsCont &__mutable_elems = __CONST_CAST(_ElemsCont&, __elems);//哈希的slist链表去除const标记 typename _BucketVector::const_iterator __bpos(__buckets.begin() + __n);//指向__n桶的迭代器,这个迭代器是vector存储的桶的迭代器 _ElemsIte __pos(*__bpos);//__n桶首元素指向的slist节点的迭代器,该迭代器是指向哈希表slist链表的 if (__pos == __mutable_elems.begin()) {//该函数就是如果删除的元素是整个slist的首节点,就把slist的首节点之前的虚拟节点返回。因为slist是单链表,只有erase_after操作,如果要删除链表节点,必须找到前面的元素。__n = 0;return __mutable_elems.before_begin(); } typename _BucketVector::const_iterator __bcur(__bpos);//__bcur指向__n桶的迭代器。 _BucketType *__pos_node = __pos._M_node; //__pos_node是指向slist中__n桶首元素的指针。该类型和桶内存储的指针是同一类型。 for (--__bcur; __pos_node == *__bcur; --__bcur);//向前遍历桶,找到第一个和桶指向不同元素的节点。为什么这样做,下面讲。这里是耗时的根源 __n = __bcur - __buckets.begin() + 1;//更新入参__n的位置,返回与__n桶指向同一slist节点的第一个桶的索引。 _ElemsIte __cur(*__bcur);//后面的过程就是从找到的前一个桶的指针开始往回遍历,找到待删除节点的前一个节点。 _ElemsIte __prev = __cur++; for (; __cur != __pos; ++__prev, ++__cur); return __prev;}

上面这部分代码主要是找到待删除节点的前一个节点,但是为什么这么复杂,而且在我的项目中执行这么慢呢?最后在for (--__bcur; __pos_node == *__bcur; --__bcur);这一行代码中加了一个计数发现,在哈希节点20W时,循环删除迭代器,在删除到后面的节点时,该循环每次删除要执行5W次以上。最后考虑了一下发现这样做的原因是,因为有可能临近的很多桶中没有任何元素,所以这些临近的桶都指向了一个节点。如下图所示,是一种多个桶中没有元素指向同一节点的状态。

查找before.png

 

上图中,1-39999桶都没有元素,如果本次删除第40000桶的节点时,for (--__bcur; __pos_node == *__bcur; --__bcur);该循环__bcur节点要循环40000次左右查找,才能找到桶0的位置,也就是第一个与40000桶指向不同元素的桶。所以这种情况下再使用erase操作,性能会极具下降。

为什么要找到桶1并且返回呢?因为如果将__pos所指向的slist节点删除掉了,1--40000桶的指针全部都失效了,所以要找到第一个和40000桶相同指向的桶,然后把1号索引也返回。

fill(_M_buckets.begin() + __prev_b, _M_buckets.begin() + __n + 1,_M_elems.erase_after(__prev)._M_node);

这句代码就是把1号到40000号桶的指向全部更新为__pos的下一个节点。

总结

STLPort的哈希表设计决定了如果哈希表过大时,而且存在很多空桶的情况下,删除效率会下降明显。因为每个桶并不是单独一条链表,而且通过一个索引共享一条链表,如果空桶过多时,会有很多桶指向同一节点,将这个节点删除时,需要将所有指向该节点的桶全部更新。由于历史以及和其他组件结合等原因,在我的项目中,更换STL库或者更改STL代码几乎不太可能了。明白了效率低的具体原因之后,我们将可以规避掉这个做法,不要在循环中频繁遍历删除节点,导致空桶过多。

转载地址:http://xjezb.baihongyu.com/

你可能感兴趣的文章
Leetcode C++《热题 Hot 100-44》102.二叉树的层次遍历
查看>>
Leetcode C++《热题 Hot 100-47》236.二叉树的最近公共祖先
查看>>
Leetcode C++《热题 Hot 100-48》406.根据身高重建队列
查看>>
《kubernetes权威指南·第四版》第二章:kubernetes安装配置指南
查看>>
Leetcode C++《热题 Hot 100-49》399.除法求值
查看>>
Leetcode C++《热题 Hot 100-51》152. 乘积最大子序列
查看>>
Leetcode C++ 《第181场周赛-1》 5364. 按既定顺序创建目标数组
查看>>
Leetcode C++ 《第181场周赛-2》 1390. 四因数
查看>>
阿里云《云原生》公开课笔记 第一章 云原生启蒙
查看>>
阿里云《云原生》公开课笔记 第二章 容器基本概念
查看>>
阿里云《云原生》公开课笔记 第三章 kubernetes核心概念
查看>>
阿里云《云原生》公开课笔记 第四章 理解Pod和容器设计模式
查看>>
阿里云《云原生》公开课笔记 第五章 应用编排与管理
查看>>
阿里云《云原生》公开课笔记 第六章 应用编排与管理:Deployment
查看>>
阿里云《云原生》公开课笔记 第七章 应用编排与管理:Job和DaemonSet
查看>>
阿里云《云原生》公开课笔记 第八章 应用配置管理
查看>>
阿里云《云原生》公开课笔记 第九章 应用存储和持久化数据卷:核心知识
查看>>
linux系统 阿里云源
查看>>
国内外helm源记录
查看>>
牛客网题目1:最大数
查看>>