c++ unordered_map 碰撞处理,调整大小和重新散列

Posted

技术标签:

【中文标题】c++ unordered_map 碰撞处理,调整大小和重新散列【英文标题】:c++ unordered_map collision handling , resize and rehash 【发布时间】:2015-09-14 21:07:15 【问题描述】:

我还没有阅读 C++ 标准,但这就是我觉得 c++ 的 unordered_map 应该如何工作。

在堆中分配一个内存块。 对于每个 put 请求,对对象进行哈希处理并将其映射到此内存中的空间 在此过程中,通过链接或开放寻址处理冲突。

我很惊讶我找不到太多关于 unordered_map 如何处理内存的信息。是否有 unordered_map 分配的特定初始内存大小。如果假设我们分配了 50 个 int 内存并最终插入了 5000 个整数会怎样?

这将是很多碰撞,所以我相信应该有一种类似于重新散列和重新调整大小的算法,以在达到一定水平的碰撞阈值后减少碰撞次数。由于它们是作为成员函数显式提供给类的,因此我假设它们也在内部使用。有这样的机制吗?

【问题讨论】:

【参考方案1】:

对于每个 put 请求,对对象进行哈希处理并将其映射到此内存中的空间

很遗憾,这并不完全正确。您指的是 开放寻址封闭散列 数据结构,这不是 unordered_map 的指定方式。

每个unordered_map 实现都会在存储桶数组中存储一个指向外部节点的链表。这意味着插入一个项目将始终分配至少一次(新节点),如果不是两次(调整存储桶数组的大小,然后是新节点)。

不,这根本不是为最常见的用途实现哈希映射的最有效方法。不幸的是,unordered_map 规范中的一个小“疏忽”几乎都需要这种行为。要求的行为是元素的迭代器在插入或删除其他元素时必须保持有效。因为插入可能会导致桶数组增长(重新分配),所以一般不可能有一个迭代器直接指向桶数组并满足稳定性保证。

unordered_map 是一个更好的数据结构,如果您将昂贵的复制项目存储为您的键或值。这是有道理的,因为它的总体设计是从 Boost 的预移动语义设计中提炼出来的。

Chandler Carruth (Google) 在他的 CppCon '14 演讲 "Efficiency with Algorithms, Performance with Data Structures" 中提到了这个问题。

【讨论】:

根据cppreference,如果插入导致重新散列,则可以使迭代器无效。 @K.Kit - 哦,你是对的。然后我记错了unordered_map 出现问题的原因。 Chandler Carruth 的 CppCon 视频详细介绍了这一点。如果我必须非常快地回忆起我的记忆,根据我尝试标准化 flat_map 的经验,这可能与抛出移动操作、异常和强大的异常保证有关。 “这意味着插入一个项目将始终分配至少一次(新节点),如果不是两次(调整存储桶数组的大小,然后是新节点)。”这不是真的,实际上它只是遵循大多数 STL 分配器所做的规则,即在需要时分配 2 个字节的幂。 @DAG:分配器更喜欢二次幂对齐与您引用的位无关。 :) 我认为您将向量的增长模式(通常是两倍当前容量增长因子)与分配器的对齐要求混淆了。基于节点的 unordered_map 仍然必须为每个插入分配一个新节点,完全独立于分配器对齐要求(并且如果它增长其存储桶数组,它可以通过素数列表而不是增长二来做到这一点,具体取决于实施)。 @SeanMiddleditch 你是对的。在我说一些无关的东西之前,我应该仔细阅读。对不起【参考方案2】:

std::unordered_map 包含一个负载因子,用于管理其内部存储桶的大小。 std::unordered_map 使用这个奇怪的因子将容器的大小保持在 0.0 和 1.0 因子之间。这降低了桶中发生碰撞的可能性。在那之后,我不确定他们是否会退回到发现碰撞的存储桶内的线性探测,但我会假设是这样。

【讨论】:

默认的最大负载因子实际上是1.0,随着表格调整大小然后再次增长,实际负载因子通常在~0.5到1.0之间波动。是的 - 线性搜索是通过碰撞元素完成的。 谢谢,Tony D. 更新了。【参考方案3】:

在堆中分配一个内存块。

是的 - 有一个“桶”数组的内存块,在 GCC 的情况下,它实际上是能够在前向链接列表中记录位置的迭代器。

对于每个 put 请求,对对象进行哈希处理并将其映射到此内存中的空间

不...当您将更多项目插入/放置到列表中时,会使用节点的next 链接空间和正在插入/放置的值完成额外的动态(即堆)分配。链表被相应地重新连接,因此新插入的元素链接到和/或来自散列到同一存储桶的其他元素,如果其他存储桶也有元素,则该组将链接到和/或来自节点那些元素。

在某些时候,哈希表内容可能如下所示(GCC 以这种方式做事,但可以做一些更简单的事情):

           +------->  head
          /            |
bucket#  /            #503
[0]----\/              |
[1]    /\      /===> #1003
[2]===/==\====/        |
[3]--/    \     /==>  #22
[4]        \   /       |
[5]         \ /        #7
[6]          \         |
[7]=========/ \-----> #177
[8]                    |
[9]                   #100
                   

左边的桶是原始分配的数组:图示数组中有 10 个元素,所以“bucket_count()”== 10。

具有哈希值 X 的键 - 表示为 #x,例如#177 - 散列到桶 X % bucket_count();该存储桶将需要将迭代器存储到单链表元素紧接在散列到该存储桶的第一个元素之前,因此它可以从存储桶中删除最后一个元素并重新连接头部或另一个存储桶的next 指针,跳过被擦除的元素。

虽然桶中的元素需要在前向链接列表中是连续的,但该列表中桶的顺序是容器中元素插入顺序的不重要结果,并且在标准中没有规定。

在此过程中,通过链接或开放寻址来处理冲突。

由哈希表支持的标准库容器始终使用单独的链接

我很惊讶我找不到太多关于 unordered_map 如何处理内存的信息。 unordered_map 是否分配了特定的初始内存大小。

不,C++ 标准没有规定初始内存分配应该是什么;由 C++ 实现来选择。你可以通过打印出.bucket_count() 来查看一个新创建的表有多少个桶,如果你将它乘以指针大小,你很可能会得到无序容器所做的堆分配的大小:myUnorderedContainer.bucket_count() * sizeof(int*) .也就是说,您的标准库实现并没有禁止以任意和奇怪的方式(例如优化级别,取决于 Key 类型)更改初始 bucket_count(),但我无法想象为什么会有。

如果假设我们分配了 50 个 int 内存并且我们结束了会发生什么 插入 5000 个整数?这将是很多碰撞,所以我相信应该有一种类似于重新散列和调整大小的算法,以在达到一定水平的碰撞阈值后减少碰撞次数。

重新散列/调整大小不是由一定数量的冲突触发的,而是由一定的冲突倾向触发的,由 负载因子衡量,即.size() /.bucket_count().

当插入将.load_factor() 推到.max_load_factor() 之上时,您可以更改但C++ 标准要求默认为1.0,然后调整哈希表的大小。这实际上意味着它分配了更多的存储桶——通常接近但不一定是两倍多——然后它将新存储桶指向链表节点,最后删除旧存储桶的堆分配。

由于它们是作为成员函数显式提供给类的,因此我假设它们也在内部使用。有这样的机制吗?

没有关于如何实现大小调整的 C++ 标准要求。也就是说,如果我正在实现resize(),我会考虑创建一个函数本地容器,同时指定新需要的bucket_count,然后遍历*this 对象中的元素,调用extract() 将它们分离,然后merge() 将它们添加到函数本地容器对象中,然后最终在 *this 和函数本地容器上调用交换。

【讨论】:

以上是关于c++ unordered_map 碰撞处理,调整大小和重新散列的主要内容,如果未能解决你的问题,请参考以下文章

C++ 如何清空unordered_map

C++ 错误:“unordered_map”未命名类型

std::unordered_map 如何表现? [C++]

在 C++ 中从 unordered_map 访问值

如何在 C++ 中单独锁定 unordered_map 元素

在 C++ unordered_map 中有效地使用 [] 运算符