如何在线性时间复杂度下建立一个堆

359 阅读8分钟

[

Chris Bao

](medium.com/@organicpro…)

鮑文

关注

6月15日

-

8分钟阅读

[

拯救

](medium.com/m/signin?ac…)

如何在线性时间复杂度内建立一个堆

背景介绍

在这篇文章中,我将重点讨论data structure and algorithms (在我看来,这是软件工程师最重要的技能之一)。有一天,我遇到了这样一个问题:*构建堆的时间复杂度怎么可能是O(n)?*这个问题让我困惑了好一阵子,所以我对它进行了一些调查和研究。本文将分享我在这个过程中所学到的东西,其中包括以下几点。

  • 什么是堆数据结构?堆是如何表现的?
  • 如何在C语言编程中实现一个完整的堆?
  • 如何对构建堆进行时间复杂性分析?

堆的基础知识

在我们深入研究实现和时间复杂度分析之前,首先让我们了解一下heap

作为一种数据结构,heap 很早以前就为heapsort排序算法而生。除了heapsort之外,heaps 在许多著名的算法中都有使用,比如Dijkstra的寻找最短路径的算法。从本质上讲,当你希望能够非常快速地访问最大或最小元素时,堆是你想要使用的数据结构。

在计算机科学中,heap 是一种专门的基于树的数据结构。heap 的一个常见实现是二进制堆,其中的树是二进制树

因此,一个heap 可以被定义为二叉树,但有两个额外的属性(这就是为什么我们说它是一个专门的树)。

  • 形状属性:一个二进制堆是一棵完整的二进制树。那么什么是完整的二叉树呢?那就是树的所有层次,除了可能是最后一层(最深的一层)被完全填充,而且,如果树的最后一层不完整,该层的节点从左到右都被填充。完整二叉树是二叉树的一种类型,详细情况可以参考本文档来了解。
  • 堆属性:每个节点中存储的键要么大于或等于(max-heaps),要么小于或等于(min-heaps)该节点的子节点中的键。

下面的图片显示了一个基于树状表示的二进制最大堆。

heap 是一个强大的数据结构;因为你可以插入一个元素,并从min-heap或max-heap中提取(移除)最小或最大的元素,只需**O(log N)**的时间。这就是为什么我们说,如果你想快速访问最大或最小的元素,你应该转向heaps 。在下一节,我将通过在C语言编程中实现一个堆来研究堆的工作原理。

注意:堆与另一种数据结构密切相关,称为 [priority queue](https://en.wikipedia.org/wiki/Priority_queue).priority queue ,可以用各种方式实现,但heap 是一个最大效率的实现,事实上,优先级队列经常被称为 "堆",不管它们如何实现。

堆的实现

由于堆的shape property ,我们通常把它实现为一个数组,如下所示。

  • 数组中的每个元素代表堆的一个节点。
  • 父/子关系可以通过元素在数组中的索引来定义。给定一个索引为i 的节点,其左边的子节点在索引2*i + 1 ,右边的子节点在索引2*i + 2 ,其父节点在索引⌊(i-1)/2⌋⌊⌋ 表示地板操作)。

基于上述模型,让我们开始实现我们的堆。正如我们提到的,有两种类型的堆:min-heap和max-heap,在这篇文章中,我将研究max-heap 。max-heap和min-heap之间的区别是微不足道的,你可以在理解这篇文章后尝试写出min-heap。

完成的代码实现在这个Githubrepo里面。

首先,让我们在头文件中定义max-heap的接口,如下所示。

我们将最大堆定义为struct _maxheap ,并将其实现隐藏在头文件中。并在接口中通过一个处理程序(是一个指针)暴露这个结构maxheap 。这种技术在C程序中被称为 [opaque type](https://stackoverflow.com/questions/2301454/what-defines-an-opaque-type-in-c-and-when-are-they-necessary-and-or-useful).Opaque type 模拟了OOP编程的封装概念。这样,一个类型的内部细节可以改变,而使用它的代码则不必改变。详细的实现方式如下。

max-heap元素被存储在array 字段内。数组的容量被定义为字段max_size ,数组中的当前元素数为cur_size

接下来,让我们一个一个地看接口(大多数接口都很简单,所以我不会解释太多)。第一个是maxheap_create ,它通过为maxheap 分配内存来构造一个实例。

最大堆的初始容量被设置为64,当有更多的元素需要插入到堆中时,我们可以动态地扩大这个容量。

这是一个内部API,所以我们把它定义为一个 [static](https://www.tutorialspoint.com/static-functions-in-c)函数,将访问范围限制在其对象文件中。

当程序不再使用最大堆的数据时,我们可以按以下方法销毁它。

不要忘记通过调用free 来释放分配的内存。

接下来,我们来研究一下困难但有趣的部分:在**O(log N)**时间内插入一个元素。解决方案如下。

  • 将元素添加到数组的末端。(阵列的末端对应于树的底层的最左边的开放空间)。
  • 比较添加的元素和它的父元素;如果它们的顺序正确(父元素应该大于或等于最大堆中的子元素,对吗?
  • 如果不是,则将该元素与它的父元素交换,并返回上述步骤,直到到达树的顶端(树的顶端对应于数组中的第一个元素)。

在数组末端添加元素的第一步,首先符合形状属性。然后通过向上遍历堆来恢复堆的属性。递归向上的遍历和交换过程被称为heapify-up 。它可以通过下面的伪代码来说明。

实施过程如下。

heapify-up 中所要求的操作数量取决于新元素必须上升多少级以满足堆的特性。所以最坏情况下的时间复杂度应该是二进制堆的高度,也就是对数N。而将一个新元素追加到数组的末端可以通过使用cur_size 作为索引,以恒定的时间完成。因此,插入操作的总时间复杂度应该是O(log N)

同样,接下来,我们来研究:在**O(log N)**时间内,从堆中提取根,同时保留堆的特性。解决方案如下。

  • 将数组的第一个元素替换为最后的元素。然后删除最后一个元素。
  • 比较新的根和它的子元素;如果它们的顺序正确,就停止。
  • 如果不是,则将该元素与它的子元素交换,并重复上述步骤。

这种类似的向下遍历和交换过程被称为heapify-downheapify-down ,比heapify-up ,因为父元素需要与最大堆中较大的子元素交换,所以要复杂一些。其实现过程如下。

基于对heapify-up 的分析,同样地,提取的时间复杂度也是O(log n)

在下一节,让我们回到本文开始时提出的问题。

建立一个堆的时间复杂度

建立一个堆的时间复杂度是多少?我想到的第一个答案是O(n log n)。因为插入一个元素的时间复杂度是O(log n),对于n个元素,插入要重复n次,所以时间复杂度是O(n log n)。对吗?

我们可以用另一个最优方案来建立一个堆,而不是重复插入每个元素。其过程如下。

  • 任意地将n个元素放入数组,以尊重形状属性
  • 从最底层开始,向上移动,像heapify-down 过程一样,将每个子树的根部向下筛选,直到恢复堆的属性

这个过程可以用下面的图片来说明。

这个算法可以按以下方式实现。

接下来,我们来分析一下上述这个过程的时间复杂度。假设堆中有n个元素,堆的高度是h(对于上图中的堆,高度是3)。那么我们应该有以下的关系。

当最后一层只有一个节点时,那么n = 2ʰ。而当树的最后一层被完全填满时,那么n = 2ʰ⁺¹ -1

从底部的第0层开始(根节点是第h层),在第j层,最多有2ʰ-ʲ个节点。而每个节点最多需要进行j次交换操作。所以在第j层,操作的总数是j×2ʰ-ʲ。

所以建立堆的总运行时间与之成正比。

如果我们把2ʰ项去掉,那么我们得到。

正如我们所知,∑j/2ʲ是一个收敛于2的数列(详细情况可以参考这个维基)。

这个维基)。

利用这一点,我们可以得到。

根据条件2ʰ<=n<=2ʰ⁺¹-1,所以我们有。

现在我们证明,建立一个堆是一个线性操作。

小结

在这篇文章中,我们研究了什么是Heap ,并通过实现它来了解它的行为(heapify-upheapify-down)。更重要的是,我们分析了建立堆的时间复杂度,并证明它是一个线性操作。