c++ 为啥 std::multimap 比 std::priority_queue 慢

Posted

技术标签:

【中文标题】c++ 为啥 std::multimap 比 std::priority_queue 慢【英文标题】:c++ Why std::multimap is slower than std::priority_queuec++ 为什么 std::multimap 比 std::priority_queue 慢 【发布时间】:2017-01-23 13:38:43 【问题描述】:

我实现了一个使用优先级队列的算法。 我被这个问题所激励: Transform a std::multimap into std::priority_queue

我将存储多达 1000 万个元素及其特定的优先级值。

然后我想迭代直到队列为空。 每次检索一个元素时,它也会从队列中删除。

在此之后我重新计算元素的优先级值,因为之前的迭代它可以改变。

如果该值确实增加了,我会将元素再次插入队列中。 这种情况更经常发生,取决于进度。 (前 25% 不会发生,接下来的 50% 会发生,最后 25% 会发生多次)。

在收到下一个元素并且不重新插入后,我将对其进行处理。因为我不需要这个元素的优先级值,而是这个元素的技术 ID。

这就是我直觉选择std::multimap 来实现这一点的原因,使用.begin() 获取第一个元素,.insert() 插入它,.erase() 删除它。 另外,我没有直观地直接选择std::priority_queue,因为该主题的其他问题回答std::priority_queue 很可能仅用于单个值而不是映射值。

在阅读了上面的链接后,我使用优先级队列来重新实现它,类似于链接中的另一个问题。 我的运行时间似乎并没有那么不平等(10 个 mio 元素大约需要一个小时)。 现在我想知道为什么std::priority_queue 更快。

实际上我希望std::multimap 更快,因为有很多重新插入。 也许问题是多图的重组太多?

【问题讨论】:

我无法理解您的算法。优先级队列和多图在语义上完全不同。一种是排序的关联容器,它可以为同一个键保存多个项目。它必须通过密钥提供相当快速的查找。另一个本质上是一个int, T 对容器,旨在选择极端int。它的约束较少。我希望它会更快。您在多图中的键是否像链接问题中的队列优先级?我猜对了吗? 是的,在 multimap 中,键是特定元素的优先级值,优先级值可以(并且确实)出现多次。如果一个优先级值确实存在多次,那么首先选择“组”中的哪个元素并不重要。 【参考方案1】:

总结一下:您的运行时配置文件涉及从抽象优先级队列中删除和插入元素,您尝试同时使用 std::priority_queuestd::multimap 作为实际实现。

插入优先级队列和多映射的复杂度大致相同:对数。

但是,从多映射中删除下一个元素与从优先级队列中删除有很大的不同。使用优先级队列,这将是一个恒定复杂度的操作。底层容器是一个向量,您要从向量中删除最后一个元素,这将主要是一个空包。

但是对于多图,您要从多图的一个极端中删除元素。

多图的典型底层实现是平衡的红/黑树。从多图的一个极端中重复删除元素很有可能使树倾斜,需要对整个树进行频繁的重新平衡。这将是一项昂贵的操作。

这可能是您看到明显的性能差异的原因。

【讨论】:

"您要从向量中删除最后一个元素" - 为什么要从向量中删除 last 元素?在priority_queue 中没有简单的方法可以做到这一点。你的意思是 top 元素吗?它通常是向量中的第一个(第 0 个)。 (我可能误解了什么) @luk32 顶部元素被交换到容器的末尾(同时保持堆属性),然后从后面弹出。但是,维护堆属性并不具有恒定的复杂性。我知道没有具有不断删除的堆结构。 @user2079303 这就是我的观点。不涉及“删除最后一个元素”。这两个删除都具有O(log size) 复杂性。在优先级队列中,您删除第一个/顶部元素,然后将最后一个放在顶部,然后将其向下推。此外,据说multimaperase 具有恒定的摊销时间。因此,我认为这种分析是错误的。 这是否意味着在交换顶部元素之后,交换的元素需要再次冒泡到向量的末尾?所以删除是不变的,但需要 n 次交换操作?听起来不那么便宜,是吗? 优先级队列将队列中的元素排序后存储,最高优先级在向量的末尾。因此,删除优先级队列中的下一个元素涉及从底层向量中删除最后一个元素。优先队列非常聪明。【参考方案2】:

我认为主要区别来自两个事实:

    优先队列对元素顺序的约束较弱。它不必对整个范围的键/优先级进行排序。 Multimap,必须提供。优先队列只需要保证第一个/顶部元素是最大的。

因此,虽然两者的操作的理论时间复杂度相同 O(log(size)),但我认为 erase 来自 multimap,并且重新平衡 RB-tree 执行更多操作,它只需要移动围绕更多元素。 (注意:RB-tree 不是强制性的,但经常被选为 multimap 的底层容器)

    优先级队列的底层容器在内存中是连续的(默认为vector)。

我怀疑重新平衡也较慢,因为 RB-tree 依赖于节点(相对于向量的连续内存),这使得它容易发生缓存未命中,尽管必须记住堆上的操作不是以迭代方式完成的,它在向量中跳跃。我想真的要确定一个人必须对其进行分析。

以上几点对于插入和擦除都是正确的。我想说区别在于big-O 符号中丢失的常数因子。这是直觉思维。

【讨论】:

【参考方案3】:

map 变慢的抽象的高级解释是它做得更多。它始终使整个结构保持有序。此功能是有代价的。如果您使用的数据结构不能对所有元素进行排序,则无需支付该成本。


算法解释:

为了满足复杂性要求,必须将映射实现为基于节点的结构,而优先级队列可以实现为动态数组。 std::map 的实现是一个平衡的(通常是红黑)树,而 std::priority_queue 是一个以 std::vector 作为默认底层容器的堆。

堆插入通常很快。插入堆的平均复杂度为 O(1),而平衡树的平均复杂度为 O(log n)(但最坏的情况是相同的)。创建具有 n 个元素的优先级队列的最坏情况复杂度为 O(n),而创建平衡树的最坏情况复杂度为 O(n log n)。查看更多深度比较:Heap vs Binary Search Tree (BST)


附加的实现细节:

数组通常比基于节点的结构(如树或列表)更有效地使用 CPU 缓存。这是因为数组的相邻元素在内存中是相邻的(高内存局部性),因此可能适合单个高速缓存行。然而,链接结构的节点存在于内存中的任意位置(低内存位置),并且通常只有一个或很少几个位于单个高速缓存行中。现代 CPU 的计算速度非常快,但内存速度是一个瓶颈。这就是为什么基于数组的算法和数据结构往往比基于节点的算法快得多。

【讨论】:

【参考方案4】:

虽然我同意@eerorika 和@luk32,但值得一提的是,在现实世界中,当使用默认的 STL 分配器时,内存管理成本很容易超过一些数据结构维护操作,例如更新指针以执行树回转。根据实现,内存分配本身可能涉及树维护操作,并可能触发系统调用,这将变得更加昂贵。

multi-map 中,分别与每个insert()erase() 相关联的内存分配和释放,这通常比算法中的额外步骤导致速度更慢。

priority-queue 然而,默认情况下使用vector,它只会在容量耗尽时触发内存分配(虽然是一个更广泛的分配,它涉及将所有存储的对象移动到新的内存位置)。在您的情况下,几乎所有分配都只发生在priority-queue 的第一次迭代中,而multi-map 一直在为每个inserterase 支付内存管理成本。

使用基于内存池的自定义分配器可以减轻 map 内存管理的不利影响。这也使您的缓存命中率可与优先级队列相媲美。当您的对象可以移动或复制时,它甚至可能优于 priority-queue

【讨论】:

以上是关于c++ 为啥 std::multimap 比 std::priority_queue 慢的主要内容,如果未能解决你的问题,请参考以下文章

std::multimap 编译错误

std::multimap::find 将返回哪个元素,类似地 std::multiset::find?

std::multimap<key, value> 和 std::map<key, std::set<value> 有啥区别?

保证 std::unordered_multimap 中的键唯一性

编译器未完成处理

std::multimap 按照key遍历---