二叉堆的高效实现

52
我正在寻找关于如何有效实现二叉堆的信息。我觉得应该有一篇很好的文章介绍如何高效地实现堆,但我还没有找到。事实上,除了将堆存储在数组中之类的基础知识外,我无法找到关于高效实现的任何资源。我正在寻找用于使二叉堆更快的技术,超出了我下面描述的技术。
我已经编写了一个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比较,这比以前更好。
Support replace-top: 假设您想要删除最大元素并插入一个新元素。然后,您可以执行上述任一删除/弹出实现之一,但是不使用右下角叶子节点,而是使用要插入/推入的新值。(当大多数操作都是这种类型时,我发现锦标赛树比堆更好,但否则堆略微更好。)
Make sizeof(T) a power of 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和Ladner在1996年报告称,他们通过采用对齐的4-堆获得了75%的性能提升。然而,Hendriks在2010年报告说:

    为了改善数据局部性并减少缓存未命中,LaMarca和Ladner建议对隐式堆进行改进[17]。我们实现了一个四路堆,在非常倾斜的输入数据情况下确实显示出比二路堆略微更好的一致性,但仅适用于非常大的队列大小。非常大的队列大小最好由分层堆处理。

  • 问题
    除了这些技术,还有其他技术吗?

  • 4
    如果不是机密,您可以在某个地方发布您的实现,并询问是否有人能够找到使其更快的方法。 - ShreevatsaR
    1
    在C/C++中,我相信即使为数组a创建指向a[-1]的指针在技术上也是非法的。它可能在您的编译器上工作 - 甚至可能在所有编译器上都能工作,或多或少 - 但从技术上讲是不允许的。只是提供信息。 - Nemo
    @Nemo 我认为你是对的。我在comp.std.c++上开始了一场关于这个话题的讨论 - Bjarke H. Roune
    @Nemo,comp.std.c++的人确认了这个问题。现在的问题是它是否真的是我需要担心的事情。我把它变成了一个问题(https://dev59.com/bljUa4cB1Zd3GeqPVNuy)。 - Bjarke H. Roune
    投票关闭,原因是过于宽泛。 - Ciro Santilli OurBigBook.com
    显示剩余2条评论
    3个回答

    10
    这个主题的一篇有趣的论文/文章考虑了缓存/分页对堆布局的整体影响;其核心思想是,付出缓存未命中或页面调入的代价比数据结构实现的几乎任何其他部分都要高昂得多。 该论文讨论了一种可以解决这个问题的堆布局。
    链接:You're Doing It Wrong by Poul-Henning Kamp

    那里的改进是基于一个大量数据的使用场景,每次访问批处理后都会被交换到磁盘上。这使得缓存未命中可能浪费数百万个周期。我的数据非常适合在内存中连续使用,因此它永远不会被交换出去,所以缓存行为对我的情况来说不太重要。我曾经在大学做过一个小组作业,我们试图通过更好地利用CPU缓存来使像这样的堆更快,但开销吞噬了性能增益。这仍然是一篇有趣的文章-谢谢。 - Bjarke H. Roune
    3
    不过,Bjarke,如果你想要制作“终极”的堆实现,你不能称之为“终极”而没有一个缓存友好的设计 ;) - Qwertie
    我认为这是我能得到的最好的答案。 - Bjarke H. Roune

    3
    作为对@TokenMacGuy帖子的阐述,你可能想研究缓存无关数据结构。其思想是构建数据结构,针对任意缓存系统,最小化缓存未命中次数。它们很棘手,但从你的角度来看,它们实际上可能会有用,因为即使处理多层缓存系统(例如,寄存器/L1/L2/VM),它们的性能也很好。

    实际上,有一篇详细介绍最优缓存无关优先队列的论文可能会引起兴趣。这种数据结构在速度方面具有各种优势,因为它会尝试在每个级别上最小化缓存未命中次数。


    1
    缓存无感知算法在理论上更为先进,但实际上通常不如缓存感知数据结构表现得好。当他们说他们的方法“和……一样有效”时,他们指的是渐近复杂度而非实际性能。总之,为了避免缓存未命中而支付极高的开销,只有当你要避免的未命中来自磁盘时才会有回报。我将在我的问题中添加一个关于缓存使用的部分。 - Bjarke H. Roune
    没错。不过,我找到了其他相关结构的论文,其中有相当不错的性能数据。如果我记得读到这些数据的地方,我会告诉你的... - templatetypedef

    0
    在第一个问题上,即使为基于数组的实现预留“备用位置”也不是浪费。许多操作都需要一个临时元素。不必每次初始化一个新元素,而是在索引 [0] 处拥有一个专用元素确实很方便。

    网页内容由stack overflow 提供, 点击上面的
    可以查看英文原文,
    原文链接