二叉堆的高效实现
Posted
技术标签:
【中文标题】二叉堆的高效实现【英文标题】:Efficient implementation of binary heaps 【发布时间】:2011-09-25 18:27:57 【问题描述】:我正在寻找有关如何有效实施binary heaps 的信息。我觉得应该有一篇关于有效实现堆的好文章,但我还没有找到。事实上,除了将堆存储在数组中等基础知识之外,我一直无法找到关于 高效 实现的任何资源。除了我在下面描述的之外,我正在寻找用于制作快速二进制堆的技术。
我已经编写了一个 C++ 实现,它比 Microsoft Visual C++ 和 GCC 的 std::priority_queue 或使用 std::make_heap、std::push_heap 和 std::pop_heap 更快。以下是我在实现中已经涵盖的技术。我自己只想出了最后两个,尽管我怀疑这些是新想法:
(编辑:添加内存优化部分)
从 1 开始索引 查看Wikipedia implementation notes 的二进制堆。如果堆的根放在索引 0 处,则索引 n 处的节点的父、左子和右子的公式分别为 (n-1)/2、2n+1 和 2n+2。如果您使用基于 1 的数组,则公式将变得更简单 n/2、2n 和 2n + 1。因此,使用基于 1 的数组时,父级和左子级效率更高。如果 p 指向一个基于 0 的数组并且 q = p - 1 那么我们可以将 p[0] 作为 q[1] 访问,因此使用基于 1 的数组没有开销。 在用叶子替换之前使弹出/删除移动元素到堆底部 堆上的弹出经常被描述为用最左边的底部叶子替换顶部元素,然后将其向下移动直到堆属性恢复。这需要我们经过的每个级别进行 2 次比较,并且由于我们将叶子移到了堆的顶部,因此我们可能会在堆的下方走得很远。所以我们应该期待少于 2 log n 的比较。相反,我们可以在顶部元素所在的堆中留下一个洞。然后我们通过迭代地将较大的孩子向上移动来将那个洞向下移动。这只需要我们过去的每个级别进行 1 次比较。这样,洞就会变成一片叶子。此时我们可以将最右边的底部叶子移动到孔的位置,并将该值向上移动,直到堆属性恢复。由于我们移动的值是一片叶子,我们不希望它在树上移动很远。所以我们应该期待比 log n 多一点的比较,这比以前更好。
支持替换顶部 假设您要删除 max 元素并插入一个新元素。然后您可以执行上述任一删除/弹出实现,但不是移动最右边的底部叶子,而是使用您希望插入/推送的新值。 (当大多数操作都是这种类型时,我发现锦标赛树比堆好,但除此之外堆稍微好一些。) 将 sizeof(T) 设为 2 的幂 父、左子和右子公式适用于索引,它们不能直接适用于指针值。所以我们将使用索引,这意味着从索引 i 查找数组 p 中的值 p[i]。如果 p 是 T* 并且 i 是整数,则&(p[i]) == static_cast<char*>(p) + sizeof(T) * i
并且编译器必须执行这个计算来得到 p[i]。 sizeof(T) 是编译时常数,如果 sizeof(T) 是 2 的幂,则乘法可以更有效地完成。通过添加 8 个填充字节将 sizeof(T) 从 24 增加到 32,我的实现变得更快了。缓存效率降低可能意味着这对于足够大的数据集来说不是一个胜利。
预乘索引 这使我的数据集的性能提高了 23%。除了查找父、左子和右子之外,我们对索引所做的唯一事情就是在数组中查找索引。因此,如果我们跟踪 j = sizeof(T) * i 而不是索引 i,那么我们可以进行查找 p[i] 而不需要计算 p[i] 时隐含的乘法,因为&(p[i]) == static_cast<char*>(p) + sizeof(T) * i == static_cast<char*>(p) + j
那么 j 值的左子和右子公式分别变为 2*j 和 2*j + sizeof(T)。父公式有点棘手,除了将 j 值转换为 i 值并像这样返回之外,我还没有找到其他方法:
parentOnJ(j) = parent(j/sizeof(T))*sizeof(T) == (j/(2*sizeof(T))*sizeof(T)
如果 sizeof(T) 是 2 的幂,那么这将编译为 2 个班次。这比使用索引 i 的通常父级多 1 个操作。但是,我们在查找时保存 1 个操作。所以最终的结果是,以这种方式查找父对象所花费的时间相同,而查找左孩子和右孩子变得更快。
内存优化TokenMacGuy 和 templatetypedef 的答案指出了基于内存的优化,可以减少缓存未命中。对于非常大的数据集或不经常使用的优先级队列,操作系统可以将部分队列换出到磁盘。在这种情况下,增加大量开销以优化缓存的使用是值得的,因为从磁盘换入非常慢。我的数据很容易放入内存并持续使用,因此队列的任何部分都不会被交换到磁盘。我怀疑优先级队列的大多数用途都是这种情况。
还有其他优先级队列旨在更好地利用 CPU 缓存。例如,一个 4 堆应该有更少的缓存未命中并且额外开销的数量不会那么多。 LaMarca and Ladner 在 1996 年报告说,他们通过使用对齐的 4 堆获得了 75% 的性能提升。然而,Hendriks 在 2010 年报告说:
问题 还有比这些更多的技术吗?还测试了 LaMarca 和 Ladner [17] 建议的对隐式堆的改进,以改善数据局部性并减少缓存未命中。我们实现了一个四向堆,对于非常倾斜的输入数据,它确实显示出比双向堆稍微更好的一致性,但仅适用于非常大的队列大小。分层堆可以更好地处理非常大的队列。
【问题讨论】:
如果不是秘密,您也可以在某处发布您的实现,并询问是否有人可以找到使其更快的方法。 在 C/C++ 中,我认为即使为数组a
创建指向 a[-1]
的指针在技术上也是非法的。它可能适用于您的编译器 - 哎呀,它可能或多或少适用于所有编译器 - 但在技术上是不允许的。仅供参考。
@Nemo 我怀疑你是对的。我在 comp.std.c++ 上就该主题发起了discussion。
@Nemo comp.std.c++ 的人确认了这个问题。现在的问题是它是否真的是我需要担心的事情。我做到了a question。
投票结束,因为范围太广。
【参考方案1】:
关于第一点:即使为基于数组的实现有一个“备用点”也不是浪费。无论如何,许多操作都需要一个临时元素。与其每次都初始化一个新元素,不如在索引 [0] 处有一个专用元素很方便。
【讨论】:
【参考方案2】:作为@TokenMacGuy 帖子的详细说明,您可能需要查看cache-oblivious data structures。这个想法是为任意缓存系统构建数据结构,以最小化缓存未命中的数量。它们很棘手,但从您的角度来看,它们实际上可能很有用,因为它们即使在处理多层缓存系统(例如,寄存器/L1/L2/VM)时也表现良好。
实际上a paper detailing an optimal cache-oblivious priority queue 可能很有趣。这种数据结构在速度方面具有各种优势,因为它会尽量减少每个级别的缓存未命中次数。
【讨论】:
无缓存算法更具理论性,在实践中通常不如缓存感知数据结构好。当他们写到他们的方法“与......一样有效”时,他们谈论的是渐近复杂性而不是实际性能。无论如何,为了避免缓存未命中而付出非常大的开销通常只有在您要避免的未命中来自磁盘时才会得到回报。我将用关于缓存使用的部分来修改我的问题。 确实如此。但是,我发现其他相关结构的论文确实具有非常好的性能数字。如果我记得我在哪里看到这些数字,我会告诉你的......【参考方案3】:关于这个主题的一篇有趣的论文/文章考虑了缓存/分页对堆整体布局的行为;这个想法是,与数据结构实现的几乎任何其他部分相比,为缓存未命中或页面输入付出代价要高得多。本文讨论了解决此问题的堆布局。
You're Doing It Wrong by Poul-Henning Kamp
【讨论】:
这里的改进是基于每批访问后都会有大量数据被换出到磁盘的使用场景。这使得缓存未命中可能浪费数百万个周期。我的数据非常适合存储在内存中,并且从头到尾都在持续使用,因此它永远不会被换出,因此缓存行为对我的情况不太重要。我在大学做过一次小组作业,我们试图通过更好地使用 CPU 缓存来使这样的阻塞堆更快,但是开销吞噬了性能提升。这仍然是一篇有趣的文章 - 谢谢。 尽管如此 Bjarke,如果您正在寻找“终极”堆实现,那么如果没有缓存友好的设计,您就不能称其为“终极”;) 我认为这是我将得到的最佳答案。以上是关于二叉堆的高效实现的主要内容,如果未能解决你的问题,请参考以下文章