Java中的4-ary堆

时间:2012-12-24 00:11:19

标签: java data-structures heap priority-queue

二元堆通常用于例如二元堆中。优先队列。基本思想是不完整的堆排序:你将数据排序“足够”以快速排出顶部元素。

虽然理论上4-ary堆比二进制堆差,但它们也有一些好处。例如,它们将需要较少的堆重组操作(因为堆较浅),而在每个级别上显然需要更多的比较。但是(这可能是他们的主要好处?)他们可能有更好的CPU缓存局部性。所以一些消息来源说3-ary和4-ary堆在实践中胜过斐波那契和二进制堆。 它们不应该更难实现,其他情况只是一些额外的if个案例。

是否有人尝试使用4-ary堆(和3-ary)作为优先级队列并进行了一些基准测试? 在Java中,在广泛地对它们进行基准测试之前,你永远不知道它们是更快还是更慢。 从我通过谷歌找到的所有内容来看,它可能是语言和用例依赖。一些消息来源说他们发现3-ary对他们来说效果最佳。

更多要点:

  • PriorityQueue显然是二进制堆。但是,例如,该类也缺乏批量加载和批量修复支持,或者replaceTopElement可以产生巨大的差异。例如,批量加载是O(n)而不是O(n log n);在添加更多候选人之后,批量修复基本相同。跟踪堆的哪些部分无效可以使用单个整数来完成。 replaceTopElementpoll + add便宜得多(只考虑如何实施民意调查:将最后一个元素替换为最后一个元素)
  • 虽然堆当然很受复杂对象的欢迎,但优先级通常是double值的整数。这不像我们在这里比较字符串。通常它是(原始的)优先级
  • PQ通常仅用于获取前k个元素。例如,A * -search可以在达到目标时终止。然后丢弃所有不太好的路径。所以队列永远不会被彻底清空。在4路堆中, less 顺序:大约一半(父节点的一半)。因此,它将对这些不需要的元素施加较少的顺序。 (如果您打算完全清空堆,例如因为您正在进行堆排序,这当然会有所不同。)

3 个答案:

答案 0 :(得分:2)

根据@ ErichSchubert的建议,我从ELKI获取了实现并将它们修改为4-ary堆。获得正确的索引是一个小技巧,因为许多围绕4-ary堆的出版物使用1索引数组的公式?!

以下是基于ELKI单元测试的一些早期基准测试结果。预先分配了200000个Double个对象(以避免过多地测量内存管理)并进行随机播放。

作为预热,每个堆执行10次迭代,对100次迭代进行基准测试,但我可能会尝试进一步扩展。 10-30秒对于基准测试来说并不是那么明显,而OTOH我也应该尝试测量标准偏差。 在每次迭代中,将200000个元素添加到堆中,然后再次对其中一半元素进行轮询。是的,工作量也可能变得更加复杂。

结果如下:

  • 我的4-ary DoubleMinHeap:10.371
  • ELKI DoubleMinHeap:12.356
  • ELKI Heap<Double>:37.458
  • Java PriorityQueue<Double>:45.875

因此,4-ary堆(可能还没有L1缓存对齐!)和 primitive 的ELKI堆之间的差异不会太大。好吧,10%-20%左右;可能会更糟。

原始double的堆与Double个对象的堆之间的差异要大得多。并且ELKI Heap确实比Java PriorityQueue明显更快(但那个似乎有很大的变化)。 但是ELKI中有一个轻微的“错误” - 至少原始堆还没有使用批量加载代码。它就在那里,它只是没有被使用,因为每个元素立即修复堆而不是将其延迟到下一个poll()。我为实验修正了这个问题,主要是通过删除几行并添加一个ensureValid();调用。此外,我还没有4-ary对象堆,而且我还没有包括ELKI的DoubleObjectMinHeap但是......相当多的基准测试,我可能会给卡尺一个尝试。

答案 1 :(得分:1)

我自己并没有对此进行基准测试,但有几点可以证明它是相关的。

首先,请注意PriorityQueue的标准Java实现使用二进制堆:

尽管n-a堆的缓存局部性有益,但二进制堆仍然是平均的最佳解决方案,这似乎是合情合理的。以下是为什么会出现这种情况的一些略带手工的原因:

  • 对于大多数有趣的对象,比较成本可能比堆数据结构本身中的缓存局部效应更重要。 n-a堆需要更多的比较。这可能足以超过堆本身中的任何缓存局部效应。
  • 如果您只是在制作一堆数字(即支持 一系列的int或双打)然后我可以看到chache的地方 这将是一个有价值的好处。但事实并非如此:通常你会有一个对象引用堆。然后,对象引用自身的缓存局部性就不那么有用了 比较将需要至少一个额外的参考 检查引用的对象及其字段。
  • 优先级堆的常见情况可能是一个很小的堆。如果你从性能的角度来看它足以让你关心它,那么无论如何它都可能在L1缓存中。因此,无论如何,n-ary堆都没有缓存局部性的好处。
  • 使用按位操作处理二进制堆更容易。当然,这不是一个很大的优势,但每一点都有帮助......
  • 更简单的算法通常比更复杂的算法更快,其他条件相同,只是因为较低的常量开销。您可以获得诸如较低指令缓存使用率,编译器能够找到智能优化的更高可能性等优点。再次,这有利于二进制堆。

显然你当然需要对自己的数据做自己的基准测试,然后才能得出真正的结论,看看它是否表现最佳(如果差异足以让人关心,我个人怀疑...... 。)

修改

另外,我确实使用原始密钥数组编写了一个优先级堆实现,这可能是令人感兴趣的,因为原始海报在下面的评论中提到了原始密钥:

如果有人对运行测试感兴趣,可能会相对容易地将其破解为n-ary版本以进行基准测试。

答案 2 :(得分:1)

我还没有对4-ary堆进行基准测试。我目前正在尝试优化我们自己的堆实现,而且我也在尝试4-ary堆。你是对的:我们需要仔细地对此进行基准测试,因为很容易因实施差异而误导,而热点优化会严重影响结果。另外,小堆可能会显示出与大堆不同的性能特征。

Java PriorityQueue是一个非常简单的简单堆实现,但这意味着Hotspot会很好地优化它。它一点都不差:大多数人会实现更糟糕的堆。但是,例如,它确实没有进行有效的批量加载或批量添加(批量修复)。然而,在我的实验中,即使在重复插入的模拟中,也很难始终如一地击败这种实现,除非你去寻找真正的大堆。此外,在许多情况下,更换堆中的顶部元素而不是poll() + add()是值得的; java&#39; s PriorityQueue不支持此功能。

ELKI(以及我认为您是ELKI用户)在某些版本中的性能提升实际上是由于改进了堆实现。但它起伏不定,很难预测哪种堆变化在实际工作负载中表现最佳。我们实施的主要好处可能是有一个&#34; replaceTopElement&#34;功能。你可以在这里检查代码:

SVN de.lmu.ifi.dbs.elki.utilities.heap package

你会注意到我们在那里有一整套堆。它们针对不同的东西进行了优化,需要更多的重构。其中许多类实际上是从模板生成的,类似于GNU Trove所做的。原因是Java在管理盒装基元时可能会非常昂贵,因此拥有原始版本确实有所回报。 (是的,有计划将其拆分为一个单独的库。它只是没有高优先级。)

请注意,ELKI故意不认可java.util.Collections API。我们特别发现java.util.Iterator课程成本很高,因此鼓励人们在整个ELKI中使用C++-style iterators

for (Iter iter = ids.iter(); iter.valid(); iter.advance()) {

通常会在java.util.Iterator API上节省大量不必要的对象创建。另外,这些迭代器可以有多个(和原始)值getter;其中Iterator.next()是吸气剂和高级操作符的混合物。

好的,我现在已经过多了,回到4-ary堆的主题:

如果您打算尝试4-ary堆,我建议您从那里的ObjectHeap课开始。

更新:我一直是微基准测试,但目前的结果尚无定论。一贯难以击败PriorityQueue。特别是批量加载和批量修复似乎没有削减我的基准测试中的任何内容 - 可能它们会导致HotSpot优化更少,或者在某些时候进行去优化。通常,更简单的Java代码比复杂逻辑更快。到目前为止,没有批量加载的4-ary堆似乎效果最好。我还没有尝试过5-ary。 3-ary大约相当于4-ary堆;并且4-ary的内存布局更好一些。我也在考虑尝试堆积堆安全阵列大小调整的方法。但我预计代码复杂性的增加意味着它在实践中会运行得更慢。