构建堆的时间复杂度如何是 O(n)?

Posted

技术标签:

【中文标题】构建堆的时间复杂度如何是 O(n)?【英文标题】:How can building a heap be O(n) time complexity? 【发布时间】:2012-04-03 02:03:05 【问题描述】:

有人可以帮忙解释一下如何构建一个堆O(n) 复杂度?

向堆中插入一项O(log n),插入重复n/2次(其余为叶子,不能违反堆属性)。所以,这意味着复杂度应该是 O(n log n),我想。

换句话说,对于我们“堆积”的每个项目,到目前为止,它有可能必须为堆的每个级别过滤(即筛选)一次(即 log n 级别)。

我错过了什么?

【问题讨论】:

“构建”一个堆究竟是什么意思? 就像在堆排序中一样,取一个未排序的数组并过滤每个上半部分元素,直到它符合堆的规则 我唯一能找到的是这个链接:Buildheap 的复杂性似乎是 Θ(n lg n) – 对 Heapify 的 n 次调用,每次调用的成本为 Θ(lg n),但是这个结果可以改进为 Θ(n) cs.txstate.edu/~ch04/webtest/teaching/courses/5329/lectures/… @Gba 观看麻省理工学院的这段视频:他很好地解释了我们如何得到 O(n),并带有一点数学知识youtube.com/watch?v=B7hVxCmfPtM 直接链接到@CodeShadow提到的解释:youtu.be/B7hVxCmfPtM?t=41m21s 【参考方案1】:

我们知道堆的高度是 log(n),其中 n 是元素的总数。让我们将其表示为 h 当我们执行 heapify 操作时,最后一级(h)的元素不会移动一步。 倒数第二层(h-1)的元素个数为2h-1,它们可以移动在最大 1 级别(在 heapify 期间)。 同样,对于 ith 级别我们有 2i 元素可以移动 hi 位置。

因此总移动次数:

S= 2h*0+2h-1*1+2h-2*2+... 20*h

S=2h1/2 + 2/22 + 3/23+ ... h/2h ------------------------------------ -------------1

这是AGP系列,解决这个问题两边除以2S/2=2h1/22+ 2/23+ ... h/2h+1 ------------------------------------------------- 2

1 中减去方程 2 得到S/2=2h1/2+1/22+ 1/2 3+ ...+1/2h+ h/2 h+1S=2h+11/2+1/22+ 1/23+ ...+1/2h+ h/2强>h+1

现在1/2+1/22+ 1/2 3+ ...+1/2h正在减少GP,其sum小于1(当h趋于无穷时,总和趋于1)。在进一步分析中,让我们对总和取一个上限,即 1。

这给出:S=2h+11+h/2h+1 =2h+1+h  ~2h+h

as h=log(n), 2h=n 因此 S=n+log(n)T(C)=O(n)

【讨论】:

【参考方案2】:

您的分析是正确的。但是,它并不紧。

要解释为什么构建堆是线性操作并不容易,您应该阅读它。

算法的精彩分析可见here。


主要思想是,在build_heap 算法中,实际heapify 成本并不是所有元素的O(log n)

当调用heapify 时,运行时间取决于在进程终止之前元素在树中向下移动的距离。换句话说,它取决于堆中元素的高度。在最坏的情况下,元素可能会一直下降到叶级别。

让我们逐级统计完成的工作。

在最底层,有2^(h)nodes,但我们没有在其中任何一个上调用heapify,所以工作为0。在下一层有2^(h − 1)节点,每个节点都可能向下移动1级。倒数第 3 层,有2^(h − 2) 个节点,每个节点可能下移 2 层。

正如您所见,并非所有 heapify 操作都是 O(log n),这就是您得到 O(n) 的原因。

【讨论】:

这是一个很好的解释......但是为什么堆排序在 O(n log n) 中运行。为什么相同的推理不适用于堆排序? @hba 我认为您问题的答案在于从this article 理解this image。当使用siftDown 完成时,HeapifyO(n),但使用siftUp 完成时是O(n log n)。实际的排序(从堆中一个一个地拉出项目)必须使用siftUp 完成,因此O(n log n) 也是如此。 我真的很喜欢你的外部文档底部的直观解释。 @hba 下面由 Jeremy West 提供的答案以更精细、更易于理解的细节解决了您的问题,并在此处进一步解释了 The111 的评论答案。 一个问题。在我看来,从高度为 h 的树的底部对高度为 i 的节点进行的 # 比较也必须进行 2* log(h-i) 比较,并且也应考虑到 @The111。你怎么看?【参考方案3】:

我认为这个话题中隐藏着几个问题:

您如何实现buildHeap 使其在O(n) 时间内运行? 如何证明buildHeap 在正确实施的情况下可以在 O(n) 时间内运行? 为什么相同的逻辑不能使堆排序在 O(n) 时间而不是 O(n log n) 时间内运行?

您如何实现buildHeap 使其在O(n) 时间内运行?

通常,这些问题的答案集中在siftUpsiftDown 之间的区别上。在siftUpsiftDown 之间做出正确选择对于获得buildHeapO(n) 性能至关重要,但无助于理解buildHeapheapSort 之间的区别一般来说。事实上,buildHeapheapSort 的正确实现将使用siftDownsiftUp 操作只需要对现有堆执行插入操作,因此它可以用于使用二进制堆实现优先级队列。

我写这篇文章是为了描述最大堆的工作原理。这是通常用于堆排序或优先级队列的堆类型,其中较高的值表示较高的优先级。最小堆也很有用;例如,当检索具有按升序排列的整数键或按字母顺序排列的字符串的项目时。原理完全相同;只需切换排序顺序。

heap 属性 指定二叉堆中的每个节点必须至少与其两个子节点一样大。特别是,这意味着堆中最大的项目在根。向下筛选和向上筛选本质上是相反方向的相同操作:移动一个违规节点,直到它满足堆属性:

siftDown 将一个太小的节点与其最大的子节点交换(从而将其向下移动),直到它至少与它下面的两个节点一样大。 siftUp 将一个太大的节点与其父节点交换(从而向上移动),直到它不大于它上面的节点。

siftDownsiftUp 所需的操作数与节点可能必须移动的距离成正比。对于siftDown,它是到树底部的距离,因此siftDown 对于树顶部的节点来说是昂贵的。使用siftUp,工作与到树顶部的距离成正比,因此siftUp 对于树底部的节点来说是昂贵的。尽管在最坏的情况下这两个操作都是 O(log n),但在堆中,只有一个节点位于顶部,而一半节点位于底层。所以如果我们必须对每个节点应用一个操作,我们会更喜欢siftDown而不是siftUp,这应该不足为奇。

buildHeap 函数接受一个未排序项的数组并移动它们直到它们都满足堆属性,从而产生一个有效的堆。对于buildHeap,使用我们描述的siftUpsiftDown 操作可能有两种方法。

    从堆的顶部(数组的开头)开始,并在每个项目上调用siftUp。在每一步,先前筛选的项目(数组中当前项目之前的项目)形成一个有效的堆,并且向上筛选下一个项目将其放置到堆中的有效位置。筛选完每个节点后,所有项都满足堆属性。

    或者,朝相反的方向走:从阵列的末端开始,向后移动到前面。在每次迭代中,您都会向下筛选一个项目,直到它位于正确的位置。

buildHeap 的哪个实现更高效?

这两种解决方案都会产生一个有效的堆。不出所料,效率更高的是使用siftDown 的第二个操作。

h = log n代表堆的高度。 siftDown 方法所需的工作由总和给出

(0 * n/2) + (1 * n/4) + (2 * n/8) + ... + (h * 1).

总和中的每一项都具有给定高度的节点必须移动的最大距离(底层为零,根为 h)乘以该高度的节点数。相比之下,在每个节点上调用siftUp的总和是

(h * n/2) + ((h-1) * n/4) + ((h-2)*n/8) + ... + (0 * 1).

应该清楚第二个总和更大。单独的第一项是 hn/2 = 1/2 n log n,因此这种方法的复杂度最多 O(n log n)

我们如何证明siftDown 方法的总和确实是O(n)

一种方法(还有其他分析也有效)是将有限和转换为无限级数,然后使用泰勒级数。我们可以忽略第一项,它是零:

如果您不确定为什么每个步骤都有效,以下是该过程的理由:

各项都是正数,因此有限和必须小于无限和。 该级数等于在 x=1/2 处评估的幂级数。 该幂级数等于(常数乘以)泰勒级数 f(x)=1/(1-x) 的导数。 x=1/2 在该泰勒级数的收敛区间内。 因此,我们可以将泰勒级数替换为1/(1-x),微分,求值,求无穷级数。

由于无限和正好是n,我们得出结论,有限和不会更大,因此是O(n)

为什么堆排序需要O(n log n)时间?

如果可以在线性时间内运行buildHeap,为什么堆排序需要O(n log n)时间?好吧,堆排序由两个阶段组成。首先,我们在数组上调用buildHeap,如果实现最佳,这需要 O(n) 时间。下一阶段是重复删除堆中最大的项并将其放在数组的末尾。因为我们从堆中删除了一个项目,所以在堆的末尾总是有一个空位可以存储该项目。所以堆排序是通过依次取出下一个最大的项并将其放入数组中,从最后一个位置开始向前面移动来实现排序的。最后一部分的复杂性在堆排序中占主导地位。循环看起来像这样:

for (i = n - 1; i > 0; i--) 
    arr[i] = deleteMax();

显然,循环运行 O(n) 次(n - 1 准确地说,最后一项已经到位)。堆的deleteMax 的复杂度是O(log n)。它通常通过删除根(堆中剩余的最大项)并将其替换为堆中的最后一项来实现,这是一个叶子,因此是最小的项之一。这个新的根几乎肯定会违反堆属性,因此您必须调用siftDown,直到您将其移回可接受的位置。这也具有将下一个最大项目向上移动到根的效果。请注意,与buildHeap 相比,对于大多数节点,我们从树的底部调用siftDown,我们现在在每次迭代时从树的顶部调用siftDown虽然树在收缩,但收缩得不够快:树的高度保持不变,直到您移除前半部分节点(当您完全清除底层时)。然后对于下一个季度,高度为 h - 1。所以第二阶段的总工作量是

h*n/2 + (h-1)*n/4 + ... + 0 * 1.

注意开关:现在零工作案例对应于单个节点,h 工作案例对应一半节点。这个总和是 O(n log n),就像使用 siftUp 实现的 buildHeap 的低效版本一样。但在这种情况下,我们别无选择,因为我们正在尝试排序,并且我们要求接下来删除下一个最大的项目。

综上所述,堆排序的工作是两个阶段的总和:O(n)时间构建Heap和O(n log n)按顺序移除每个节点,所以复杂度是 O(n log n)。您可以证明(使用信息论中的一些想法)对于基于比较的排序,O(n log n) 是您所希望的最好的,所以没有理由对此感到失望或者期望堆排序能够达到 buildHeap 所做的 O(n) 时间限制。

【讨论】:

我编辑了我的答案以使用最大堆,因为似乎大多数其他人都在提到它,它是堆排序的最佳选择。 这让我直观地明白:“只有一个节点位于顶部,而一半节点位于底层。因此,如果我们必须申请,应该不会太令人惊讶对每个节点的操作,我们更喜欢 siftDown 而不是 siftUp。” @JeremyWest “一个是从堆的顶部(数组的开头)开始,并在每个项目上调用 siftUp。” - 你的意思是从堆的底部开始吗? @aste123 不,它是正确的。这个想法是在满足堆属性的数组部分和数组的未排序部分之间保持一个屏障。您可以从头开始向前移动并在每个项目上调用siftUp,或者从末尾开始向后移动并调用siftDown。无论您选择哪种方法,您都在选择数组未排序部分中的下一项并执行适当的操作以将其移动到数组有序部分中的有效位置。唯一的区别是性能。 这是我见过的世界上任何问题的最佳答案。解释得很好,我想这真的可能吗...非常感谢。【参考方案4】:

我们通过计算每个节点可以采取的最大移动来获得堆构建的运行时。 所以我们需要知道每一行有多少个节点,每个节点能走多远。

从根节点开始,下一行的节点数是前一行的两倍,因此通过回答我们多久可以将节点数翻倍,直到我们没有任何节点,我们就得到了树的高度。 或者用数学术语来说,树的高度是 log2(n),n 是数组的长度。

要计算一行中的节点,我们从后面开始,我们知道 n/2 个节点在底部,所以除以 2 得到前一行。

基于此,我们得到了 Siftdown 方法的公式: (0 * n/2) + (1 * n/4) + (2 * n/8) + ... + (log2(n) * 1)

最后一个括号中的项是树的高度乘以根节点的一个节点,第一个括号中的项是底行中的所有节点乘以它们可以行进的长度,0。 smart中的相同公式:

将 n 带回我们有 2 * n,2 可以被丢弃,因为它是一个常数,并且我们有 Siftdown 方法的最坏情况运行时:n。

【讨论】:

【参考方案5】:

已经有一些很好的答案,但我想添加一点视觉解释

现在,看一下图像,n/2^1 绿色节点 高度为 0(此处为 23/2 = 12)n/2^2 红色节点 高度 1(这里 23/4 = 6)n/2^3 蓝色节点 高度2(这里 23/8 = 3)n/2^4 紫色节点高度 3(这里 23/16 = 2) 所以有 n/2^(h+1) 高度节点 h 为了计算时间复杂度,让我们计算每个节点完成的工作量最大迭代次数 现在可以注意到每个节点可以执行(最多)迭代==节点的高度

Green  = n/2^1 * 0 (no iterations since no children)  
red    = n/2^2 * 1 (heapify will perform atmost one swap for each red node)  
blue   = n/2^3 * 2 (heapify will perform atmost two swaps for each blue node)  
purple = n/2^4 * 3 (heapify will perform atmost three swaps for each purple node)   

所以对于任何 高度为 h 的节点,完成的最大功为 n/2^(h+1) * h

现在完成的总工作是

->(n/2^1 * 0) + (n/2^2 * 1)+ (n/2^3 * 2) + (n/2^4 * 3) +...+ (n/2^(h+1) * h)  
-> n * ( 0 + 1/4 + 2/8 + 3/16 +...+ h/2^(h+1) ) 

现在对于 h 的任何值,序列

-> ( 0 + 1/4 + 2/8 + 3/16 +...+ h/2^(h+1) ) 

永远不会超过 1 因此,构建堆的时间复杂度永远不会超过 O(n)

【讨论】:

【参考方案6】:

假设您在堆中有 N 个元素。 那么它的高度将是 Log(N)

现在你要插入另一个元素,那么复杂度是:Log(N),我们要一直比较UP到根。 p>

现在你有 N+1 个元素 & height = Log(N+1)

使用induction技术可以证明插入的复杂度为∑logi

现在使用

log a + log b = log ab

这简化为:∑logi=log(n!)

实际上是 O(NlogN)

但是

我们在这里做错了,因为在所有情况下我们都没有到达顶部。 因此,在大多数情况下执行时,我们可能会发现,我们甚至不会爬到树的一半。因此,可以通过使用上述答案中给出的数学来优化此界限以具有另一个更严格的界限。

在对 Heaps 进行了详细的实验和实验之后,我才意识到这一点。

【讨论】:

【参考方案7】:

Proof of O(n)

证明并不花哨,很简单,我只证明了完全二叉树的情况,结果可以推广到完全二叉树。

【讨论】:

【参考方案8】:

@bcorso 已经展示了复杂性分析的证明。但是为了那些还在学习复杂性分析的人,我要补充一点:

您最初错误的基础是对语句含义的误解,“插入堆需要 O(log n) 时间”。插入堆确实是 O(log n),但你必须认识到,n 是 插入期间堆的大小。

在将 n 个对象插入堆的上下文中,第 i 次插入的复杂度为 O(log n_i),其中 n_i 是插入 i 时堆的大小。只有最后一次插入的复杂度为 O (log n)。

【讨论】:

【参考方案9】:

直观地说:

“复杂度应该是 O(nLog n)...对于我们“堆化”的每个项目,到目前为止,它有可能必须为堆的每个级别(即 log n 级别)过滤一次。 "

不完全是。您的逻辑不会产生严格的限制——它过度估计了每个 heapify 的复杂性。如果从下往上构建,插入(heapify)可以比O(log(n)) 少得多。流程如下:

(步骤 1) 第一个 n/2 元素位于堆的底行。 h=0,所以不需要heapify。

(第 2 步) 接下来的 n/2<sup>2</sup> 元素位于从底部向上的第 1 行。 h=1,heapify 过滤器向下 1 级。

(步骤i 下一个n/2<sup>i</sup> 元素从底部向上排在i 行中。 h=i,堆过滤器i 降级。

( 步骤 log(n) ) 最后一个 n/2<sup>log<sub>2</sub>(n)</sup> = 1 元素从底部向上进入行 log(n)h=log(n),heapify 过滤器 log(n) 降级。

注意:在第一步之后,(n/2) 元素中的1/2 已经在堆中,我们甚至不需要调用 heapify 一次。另外,请注意,只有一个元素,即根,实际上会产生完整的 log(n) 复杂性。


理论上:

构建大小为n 的堆的总步骤N 可以用数学方法写出。

i 的高度,我们已经(上图)展示了需要调用heapify 的n/2<sup>i+1</sup> 元素,我们知道在i 的高度的heapify 是O(i)。这给出了:

最后一个求和的解可以通过对众所周知的几何级数方程两边求导得到:

最后,将x = 1/2 代入上述等式得到2。将其代入第一个等式得出:

因此,总步数的大小为O(n)

【讨论】:

非常感谢。为什么你决定插入 x=1/2?【参考方案10】:

在构建堆的情况下,我们从高度开始, logn -1(其中 logn 是 n 个元素的树的高度)。 对于高度为“h”的每个元素,我们将最大高度降低到 (logn -h)。

    So total number of traversal would be:-
    T(n) = sigma((2^(logn-h))*h) where h varies from 1 to logn
    T(n) = n((1/2)+(2/4)+(3/8)+.....+(logn/(2^logn)))
    T(n) = n*(sigma(x/(2^x))) where x varies from 1 to logn
     and according to the [sources][1]
    function in the bracket approaches to 2 at infinity.
    Hence T(n) ~ O(n)

【讨论】:

【参考方案11】:

基本上,在构建堆时仅在非叶节点上完成工作......完成的工作是向下交换以满足堆条件的数量......换句话说(在最坏的情况下)数量是成比例的到节点的高度......总之问题的复杂性与所有非叶节点的高度之和成正比......这是(2 ^ h + 1 - 1)-h-1 = nh -1= O(n)

【讨论】:

【参考方案12】:

"构建Heap的线性时间界限,可以通过计算堆中所有节点的高度之和来表示,即最大虚线数。 对于高度为 h 的完美二叉树,包含 N = 2^(h+1) – 1 个节点,节点高度之和为 N – H – 1。 因此它是 O(N)。”

【讨论】:

【参考方案13】:

连续插入可以这样描述:

T = O(log(1) + log(2) + .. + log(n)) = O(log(n!))

通过八哥近似,n! =~ O(n^(n + O(1))),因此T =~ O(nlog(n))

希望这会有所帮助,O(n) 的最佳方式是对给定集合使用构建堆算法(排序无关紧要)。

【讨论】:

【参考方案14】:

我真的很喜欢 Jeremy west 的解释......这里给出了另一种非常容易理解的方法http://courses.washington.edu/css343/zander/NotesProbs/heapcomplexity

因为,buildheap 依赖于使用取决于 heapify 和 shiftdown 方法,它取决于所有节点的高度之和。因此,要找到由下式给出的节点高度之和 S = 从 i = 0 到 i = h (2^i*(h-i)) 的总和,其中 h = logn 是树的高度 求解 s,我们得到 s = 2^(h+1) - 1 - (h+1) 因为,n = 2^(h+1) - 1 s = n - h - 1 = n- logn - 1 s = O(n),所以 buildheap 的复杂度是 O(n)。

【讨论】:

【参考方案15】:

在构建堆时,假设您采用的是自下而上的方法。

    您获取每个元素并将其与其子元素进行比较,以检查该对是否符合堆规则。因此,叶子被免费包含在堆中。那是因为他们没有孩子。 向上移动,叶子正上方节点的最坏情况是 1 次比较(最多只能与一代子节点进行比较) 再往上走,他们的直系父母最多可以与两代孩子相提并论。 继续朝同一方向前进,在最坏的情况下,您将对根进行 log(n) 比较。 log(n)-1 为其直系子代,log(n)-2 为其直系子代,依此类推。 所以总结一下,你会得到类似 log(n) + log(n)-1*2 + log(n)-2*4 + ..... + 1*2 ^(logn)-1 这不过是 O(n)。

【讨论】:

【参考方案16】:

如果你通过重复插入元素来构建堆,那将是 O(n log n)。但是,您可以通过以任意顺序插入元素然后应用算法将它们“堆”成正确的顺序(当然取决于堆的类型)来更有效地创建新堆。

有关示例,请参见 http://en.wikipedia.org/wiki/Binary_heap,“构建堆”。在这种情况下,您基本上从树的底层开始工作,交换父节点和子节点,直到满足堆条件。

【讨论】:

以上是关于构建堆的时间复杂度如何是 O(n)?的主要内容,如果未能解决你的问题,请参考以下文章

堆的算法时间复杂度

堆的算法时间复杂度

合并具有线性复杂度的堆数组

寻找最小的k个数(大顶堆方法)

堆排序和TOP-K问题

堆排序和TOP-K问题