【发布时间】: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 更快。以下是我在实现中已经涵盖的技术。我自己只想出了最后两个,尽管我怀疑这些是新想法:
(编辑:添加内存优化部分)
查看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 元素并插入一个新元素。然后您可以执行上述任一删除/弹出实现,但不是移动最右边的底部叶子,而是使用您希望插入/推送的新值。 (当大多数操作都是这种类型时,我发现锦标赛树比堆好,但除此之外堆稍微好一些。)
父、左子和右子公式适用于索引,它们不能直接适用于指针值。所以我们将使用索引,这意味着从索引 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。
-
投票结束,因为范围太广。
标签: c++ data-structures performance computer-science priority-queue