为啥在由数组实现的堆中,索引 0 未被使用?
Posted
技术标签:
【中文标题】为啥在由数组实现的堆中,索引 0 未被使用?【英文标题】:Why in a heap implemented by array the index 0 is left unused?为什么在由数组实现的堆中,索引 0 未被使用? 【发布时间】:2014-05-18 23:39:47 【问题描述】:我正在学习数据结构,每个来源都告诉我在实现堆时不要使用数组的索引 0,但没有给出任何解释。我搜索了网络,搜索了 StackExchange,但找不到答案。
【问题讨论】:
我从未听说过不在堆中使用索引 0。它稍微改变了计算索引(左/右孩子,父母)的算法,但它非常微不足道。我已经多次实现堆并且从未避免使用 0。 虽然问题很老,但我检查了以下类 - org.apache.commons.collections.BinaryHeap,它从索引 1 开始堆实现。 【参考方案1】:没有理由为什么在数组中实现的堆必须将索引 0 处的项保留为未使用。如果您将根置于 0,则位于 array[index]
的项目在 array[index*2+1]
和 array[index*2+2]
有其子项。 array[child]
的节点在array[(child-1)/2]
有其父节点。
让我们看看。
root at 0 root at 1
Left child index*2 + 1 index*2
Right child index*2 + 2 index*2 + 1
Parent (index-1)/2 index/2
因此,将根设为 0 而不是 1 会花费您一个额外的加法来找到左孩子,并额外花费一个减法来找到父项。
对于更一般的情况,它可能不是二进制堆,而是 3 堆、4 堆等,其中每个节点有 NUM_CHILDREN 个子节点而不是 2 个,公式为:
root at 0 root at 1
Left child index*NUM_CHILDREN + 1 index*NUM_CHILDREN
Right child index* NUM_CHILDREN + 2 index*NUM_CHILDREN + 1
Parent (index-1)/NUM_CHILDREN index/NUM_CHILDREN
我看不出这几条额外的指令对运行时间有很大影响。
为什么我认为在具有基于 0 的数组的语言中从 1 开始是错误的,请参阅 https://***.com/a/49806133/56778 和我的博客文章 But that's the way we've always done it!
【讨论】:
看看 Java 或 C++ 如何在其 API 中实现堆(无论它们从 0 还是 1 开始)会很有趣(如果它们首先提供堆 api) 其实大部分地方都是这样实现的。在支持它的语言中,例如 C 或 C++,一种可能性是减少指向数组的指针。然后你不能直接取消引用它,因为没有分配该位置,但是你可以取消引用索引为 1 而不是零的数组的第一个位置。您实际上是在将数组从零开始转换为从一开始。 @Juan:你确定吗?我正在查看priority_queue
的 C++ STL 代码,它是基于 0 的。我不知道您认为“大多数地方”是什么,但我记得 Java 和 Python 堆实现也是基于 0 的。在实践中,我看到基于 1 的堆的唯一地方是在大学生项目中,并且很少有人使用自己的堆而不是使用提供的库。
对不起@Jim,我写它的方式会导致混乱。我的意思是在大多数地方它确实是基于 0 的。当我说“以这种方式”实施时,我的意思是您在回答中解释的方式。除此之外,我认为减少数组的基指针(或它的副本)并使用基于 1 的数组并不是一个坏主意。当然,你不能在 Java 中做到这一点 :)【参考方案2】:
正如我在 CLRS 书中发现的那样,它在性能方面具有一定的意义,因为通常轮班操作员的工作速度非常快。
在大多数计算机上,LEFT 过程可以在一条指令中计算
2*i
只需将 i 的二进制表示形式左移一位。同样, RIGHT 程序可以通过移动二进制表示来快速计算2*i+1
的 i 左移一位,然后添加 1 作为低位。这 PARENT 过程可以通过将 i 右移一位来计算i/2
。
因此,从索引 1 开始堆可能会更快地计算父、左和右子索引。
【讨论】:
这对于过去 20 年构建的任何 CPU 来说都无关紧要。对于一个访问任何元素的人来说,访问任何元素的时间都比添加时间长数百倍,如果是缓存未命中则数千倍。此外,由于添加无条件发生,它永远不会停止管道。至于用移位而不是除法,这可能很有用,因为它可以释放执行单元,但任何值得考虑的编译器都知道/2
可以用移位代替,如果你写 i/2
补充一点,如果默认情况下分配是对齐的,在位置 1 而不是 0 处执行 peekMin()
可能(取决于数据类型)很容易使访问比添加更昂贵。【参考方案3】:
正如 AnonJ 所观察到的,这是一个品味问题,而不是技术上的必要性。从 1 而不是 0 开始的一个好处是二进制字符串 x 和正整数之间存在双射,将二进制字符串 x 映射到以二进制形式写入 1x 的正整数。字符串 x 给出了从根到索引节点的路径,其中 0 表示“取左孩子”,1 表示“取右孩子”。
另一个考虑因素是,否则未使用的“第零”位置可以保存一个值减去无穷大的哨兵,在没有分支预测的架构上,由于在筛选中只有一个测试,这可能意味着运行时间的不可忽略的改进循环而不是两个。
【讨论】:
【参考方案4】:(在搜索过程中,我想出了一个自己的答案,但我不知道它是否正确。)
如果索引0
用于根节点,则无法继续对其子节点的后续计算,因为我们有indexOfLeftChild = indexOfParent * 2
和indexOfRightChild = indexOfParent * 2 + 1
。但是0 * 2 = 0
和0 * 2 + 1 = 1
,不能代表我们想要的父子关系。因此我们必须从1
开始,这样由数组表示的树符合我们想要的数学属性。
【讨论】:
我们不必必须从 1 开始,因为没有什么迫使我们按原样使用这些方程,但从 0 开始会添加一些-1
s 和+1
s 到方程式。
@Dukeling 好的,所以从数学上(概念上)定义的堆应该有一个索引为“1”的根(整个结构从 1 开始)。我们可能会选择用 array[0] 来实现这个根,但如果是这样,我们必须做一些+1
,-1
,这会有点烦人。所以通常我们从数组[1]开始。我的这种解释是对的吗?以上是关于为啥在由数组实现的堆中,索引 0 未被使用?的主要内容,如果未能解决你的问题,请参考以下文章