第16章保持优先级与堆的平衡
既然我们已经了解了树,就解锁了许多新的数据结构。在上一章中,我们专注于二叉搜索树,但还有许多其他类型的树。和所有数据结构一样,每种类型的树都有其优点和缺点,关键在于在特定情况下选择合适的树。
在本章中,我们将探讨堆,这是一种特殊的树形数据结构,具有特殊的功能,特别适用于当我们想要不断监视数据集中的最大或最小数据元素的情况。
为了欣赏堆的作用,让我们来看一个完全不同的数据结构:优先队列。
优先队列
你在第9章学习了队列,并发现队列是一个按照先进先出(FIFO)原则处理项目的列表。基本上,这意味着数据只在队列的末尾插入,数据只从队列的前端访问和删除。在访问队列数据时,我们优先考虑数据插入的顺序。
优先队列是一个列表,其删除和访问方式类似于经典队列,但其插入方式类似于有序数组。也就是说,我们只能从优先队列的前端删除和访问数据,但在插入数据时,我们始终确保数据保持在特定顺序的排序状态。
优先队列有一个经典的应用场景是管理医院急诊室的分诊系统。在急诊室,我们不是严格按照患者到达的顺序来治疗人员。相反,我们根据症状的严重程度来治疗患者。如果有人突然遭受了危及生命的伤害,即使得了流感的人几个小时前就到了,那个有生命危险的患者也会被放在队列的前面。
假设我们的分诊系统根据患者病情的严重程度进行排名,评分标准是从1到10,10表示最严重。我们的优先队列可能是这样的:
在确定下一个要治疗的患者时,我们总是选择优先队列前面的患者,因为这个人最急需治疗。在这种情况下,我们下一个要治疗的患者是患者C。
如果现在有一个新的患者到来,病情严重程度为3,我们将会把这个患者放到优先队列中适当的位置。我们称之为患者E:
优先队列是抽象数据类型的一个例子。也就是说,可以利用其他更基本的数据结构来实现它。一个直接的实现优先队列的方法是使用有序数组。也就是说,我们使用一个数组,并应用以下约束:
- 当插入数据时,确保始终保持正确的顺序。
- 数据只能从数组的末尾移除(这将表示优先队列的前端)。
虽然这种方法很直接,但让我们来分析一下它的效率。
优先队列有两个主要操作:删除和插入。
我们在《为什么数据结构很重要》一文中看到,从数组的前端删除是O(N),因为我们必须将所有数据移动以填补在索引0处创建的空白。但是,我们巧妙地调整了我们的实现,以便将数组的末尾视为优先队列的前端。这样,我们总是从数组的末尾删除,时间复杂度为O(1)。有了O(1)的删除操作,我们的优先队列目前看起来还不错。但插入操作呢?
你了解到,向有序数组插入数据的时间复杂度是O(N),因为我们必须检查数组的所有N个元素,以确定我们新数据应该放置在哪里。(即使我们提前找到正确的位置,我们还需要将所有剩余的数据右移。)
因此,基于数组的优先队列具有O(1)的删除操作和O(N)的插入操作。如果我们预期优先队列中有很多项目,O(N)的插入操作可能会在应用程序中造成一些不必要的延迟。
因此,计算机科学家发现了另一种数据结构,作为优先队列更高效的基础。这种数据结构就是堆。
堆
有几种不同类型的堆,但我们将重点关注二叉堆。
二叉堆是特定类型的二叉树。作为提醒,二叉树是一种每个节点最多有两个子节点的树。(上一章中的二叉搜索树就是二叉树的一种特定类型。)
现在,即使二叉堆也分为两种类型:最大堆和最小堆。我们暂时将使用最大堆,但稍后您会看到,两者之间的区别微不足道。
在接下来的讨论中,我将简称此数据结构为堆,尽管我们实际上是在使用二叉最大堆。
堆是一种二叉树,满足以下条件:
- 每个节点的值必须大于其所有后代节点的值。这个规则被称为堆条件。
- 树必须是完全的。(我马上会解释这句的含义。)
让我们逐一解释这两个条件,首先从堆条件开始。
堆条件
堆的条件规定每个节点的值必须大于其所有后代节点的值。
例如,以下树满足堆的条件,因为每个节点都大于其所有后代节点,如第282页的图表所示。
在此示例中,根节点为100的节点没有比它更大的后代节点。类似地,88大于其两个子节点,25也是如此。下面的树不是有效的堆,因为它不满足堆的条件:
如您所见,92大于其父节点88,这违反了堆的条件。
请注意,堆的结构与二叉搜索树非常不同。在二叉搜索树中,每个节点的右子节点都大于它。然而,在堆中,节点永远没有比它更大的后代节点。正如人们所说,“二叉搜索树不构成堆。”(或类似的说法。)
我们还可以构建一个具有相反堆条件的堆,即每个节点的值必须小于其所有后代节点的值。这样的堆被称为最小堆,我之前提到过。我们将继续关注最大堆,其中每个节点必须大于其所有后代节点。最终,堆是最大堆还是最小堆在很大程度上是次要的,因为两者的其他方面都是相同的;它们只是具有相反的堆条件。另外,基本思想是相同的。
完全树
现在,让我们来看看堆的第二个规则——即树必须是完全的。完全树是一棵被节点完全填充的树;没有缺失的节点。因此,如果你从左到右阅读树的每一层,所有的节点都在那里。然而,底部行可以有空位置,只要这些空位置的右侧没有节点即可。这最好通过示例来演示。
以下树是完全的,因为树的每一层(即每一行)都被节点完全填充了:
以下树不是完全的,因为它在第三层缺少一个节点:
接下来的树实际上被认为是完全的,因为它的空位置仅限于底部行,并且在空位置的右侧没有任何节点:
因此,堆是一个满足堆条件并且是完全的树。这里只是堆的另一个示例:
这是一个有效的堆,因为每个节点都大于其所有后代节点,并且树也是完全的。虽然底部行确实有一些间隙,但这些间隙仅限于树的最右侧。
堆属性
既然你知道堆是什么,让我们来看一下它的一些有趣的属性。
虽然堆的条件规定了堆必须以某种方式排序,但在搜索堆中的值时,这种排序仍然是无用的。例如,假设在上面的堆中,我们想要搜索值为3的节点。如果我们从根节点100开始,应该在其左侧还是右侧的后代中搜索呢?在二叉搜索树中,我们会知道3必须在100的左侧后代之间。然而,在堆中,我们只知道3必须是100的后代,不能是其祖先。但我们不知道要搜索哪个子节点。事实上,3恰好是在100的右侧后代中,但它也可能轻松地在其左侧后代中。因此,与二叉搜索树相比,堆被认为是弱有序的。虽然堆有一些顺序,因为后代不能大于其祖先,但这并不足以使堆值得搜索。
堆还有另一个可能现在显而易见但值得注意的属性:在堆中,根节点将始终具有最大的值(在最小堆中,根将包含最小的值)。这将是为什么堆是实现优先队列的好工具的关键。在优先队列中,我们总是希望访问具有最高优先级的值。通过堆,我们始终知道我们可以在根节点找到这个值。因此,根节点将代表具有最高优先级的项。
堆有两个主要操作:插入和删除。正如我们注意到的那样,在堆中进行搜索需要检查每个节点,因此搜索通常不是堆上下文中实现的操作。(堆还可以具有可选的“读取”操作,它只会查看根节点的值。)
在我们进入堆的主要操作是如何工作之前,让我定义一个术语,因为它将在即将介绍的算法中被广泛使用。堆有一个称为“最后节点”的东西。堆的最后一个节点是其底层的最右边的节点。
看一下下图的堆。在这个堆中,3是最后一个节点,因为它是底行最右边的节点。接下来,让我们深入研究堆的主要操作。
堆插入
要将新值插入堆中,我们执行以下算法:
- 我们创建一个包含新值的节点,并将其插入到底层的下一个可用的最右边的位置。因此,这个值成为堆的最后一个节点。
- 接下来,我们将这个新节点与其父节点进行比较。
- 如果新节点大于其父节点,我们交换新节点和父节点。
- 我们重复步骤3,有效地将新节点通过堆上移,直到它的父节点的值大于它。
让我们看看这个算法是如何运作的。如果我们要将40插入堆中,以下是会发生的情况:
步骤1:我们将40添加为堆的最后一个节点:
请注意,以下操作是不正确的:
将40放置为12节点的子节点会使树变得不完整,因为我们现在在空位置的右侧有一个节点。为了保持堆的性质,堆必须始终是完整的。
步骤2:我们将40与其父节点进行比较,这恰好是8。由于40大于8,我们交换这两个节点:
步骤3:我们将40与其新父节点25进行比较。由于40大于25,我们交换它们:
步骤4:我们将40与其父节点100进行比较。由于40小于100,我们完成了!
将新节点上移通过堆的过程称为“trickling”,有时它向右移动,有时向左移动,但它总是一直上移,直到安置到正确的位置。
插入堆的效率是O(log N)。正如你在前一章中看到的,对于任何二叉树中的N个节点,该树大约被组织成log(N)行。由于最多只需将新值上移到顶层行,这将最多花费log(N)步骤。
寻找最后节点
虽然插入算法似乎相当直接,但有一个小问题。第一步要求我们将新值放置为堆的最后一个节点。但这引出了一个问题:我们如何找到将成为最后一个节点的位置?
让我们再次看看在插入40之前的堆:
通过观察图表,我们知道要使40成为最后一个节点,我们将其作为8的右子节点,因为这是底层的下一个可用位置。
然而,计算机没有眼睛,不会将堆视为一堆行。它只看到根节点,并可以沿着链接查看子节点。那么,我们如何为计算机创建一个算法来找到新值的位置呢?以我们的示例堆为例。当我们从根节点100开始时,我们是否告诉计算机在100的右侧后代中查找新的最后一个节点的下一个可用位置?
虽然在我们的示例堆中,下一个可用位置确实在100的右侧后代中,但看看以下替代堆:
在这个堆中,新的最后一个节点的下一个可用位置将是88的右子节点,它位于100的左侧后代之间。
因此,就像无法通过堆进行高效搜索一样,无法在不检查每个节点的情况下有效地找到堆的最后一个节点(或下一个可用于容纳新的最后一个节点的位置)。
那么,我们如何找到下一个可用节点呢?我稍后会解释这个问题,但现在,让我们称之为“最后节点的问题”。我保证我们会回头解决这个问题。与此同时,让我们探讨堆的另一个主要操作,即删除。
堆删除
关于从堆中删除值的第一件事是,我们只会删除根节点。这与优先队列的工作方式完全一致,因为我们只访问和移除最高优先级的项。
删除堆的根节点的算法如下:
- 将最后一个节点移动到根节点的位置,从而有效地删除原始的根节点。
- 将根节点向其正确的位置下移。稍后我会解释下移是如何工作的。
假设我们要从下图的堆中删除根节点。
在这个例子中,根节点是100。为了删除它,我们通过将最后一个节点放置在那里来覆盖根节点。在这种情况下,最后一个节点是3。因此,我们移动3并将其放置在100的位置:
现在,我们不能保持堆的现状,因为堆的条件已被违反,并且因为3当前小于一些(实际上,大多数)其后代。为了再次变得正确,我们需要将3下移,直到其堆条件得以恢复。
下移比上移略微复杂,因为每次我们将节点下移时,我们有两个可能的方向可以将其下移。也就是说,我们可以与其左子节点或右子节点交换它。(另一方面,当上移时,每个节点只有一个父节点可以交换。)
以下是下移的算法。为了清晰起见,我们将下移的节点称为“下移节点”(听起来有点恶心,我知道):
- 我们检查下移节点的两个子节点,并查看其中哪一个较大。
- 如果下移节点小于两个子节点中较大的那一个,我们将下移节点与较大的子节点交换。
- 我们重复步骤1和2,直到下移节点没有大于它的子节点为止。
让我们看看这个过程。
步骤1:3,即下移节点,当前有两个子节点,88和25。88是其中较大的一个,由于3小于88,我们将下移节点与88交换:
步骤2:下移节点现在有两个新子节点,87和16。87是较大的那个,它大于下移节点。因此,我们将下移节点与87交换:
步骤3:下移节点的子节点目前是86和50。86是其中较大的一个,它也大于下移节点,所以我们将86与下移节点交换:
此时,下移节点没有大于它的子节点了(实际上,它根本没有子节点)。因此,我们完成了,因为堆的条件已经得到恢复。
之所以总是将下移节点与其两个子节点中较大的那一个交换,是因为如果我们将其与较小的那一个交换,我们将立即违反堆的条件。看看当我们尝试将下移节点与较小的子节点交换时会发生什么。
让我们再次以3为根节点开始:
让我们将3与25交换,即较小的子节点:
现在,我们将25放在了是88的父节点的位置。由于88大于其父节点,堆的条件已被破坏。
与插入一样,从堆中删除的时间复杂度是O(log N),因为我们必须将一个节点从根节点下移到堆的所有log(N)级别。
堆VS有序数组
现在你知道堆的效率之后,让我们看看为什么它是实现优先队列的绝佳选择。
这里是有序数组和堆的效率对比:
乍一看,似乎两者之间差不多。有序数组在插入方面比堆慢,但在删除方面比堆快。
然而,堆被认为是更好的选择,原因如下。虽然O(1)非常快,但与之相比,O(log N)仍然非常快。而O(N)则相对较慢。考虑到这一点,我们可以重新编写前面的表格如下:
从这个角度来看,为什么堆被认为是更好的选择就变得更加明确了。我们宁愿使用一种一直非常快的数据结构,而不是一种有时非常快,有时较慢的数据结构。
值得指出的是,优先队列通常在插入和删除方面执行的操作大致相等。想象一下急诊室的例子,我们希望治疗每位前来的患者。因此,我们希望插入和删除都很快。如果其中一个操作很慢,我们的优先队列将是低效的。
因此,通过使用堆,我们确保优先队列的两个主要操作——插入和删除——都以非常快的速度执行。
再一次,最后节点问题
虽然堆删除算法似乎很简单,但它再次引出了“最后节点的问题”。
我解释了删除的第一步要求我们移动最后一个节点并将其变成根节点。但是,首先我们如何找到最后一个节点呢?
在解决“最后节点的问题”之前,让我们首先探讨为什么插入和删除如此依赖于最后一个节点。为什么我们不能在堆的其他位置插入新值?在删除时,为什么我们不能用除最后一个节点之外的其他节点替换根节点?
现在,如果你仔细考虑一下,你会意识到如果我们使用其他节点,堆将变得不完整。但这引出了下一个问题:为什么完整性对于堆是重要的呢?
完整性之所以重要,是因为我们希望确保我们的堆保持良好平衡。
为了更清晰地看到这一点,让我们再次看看插入。假设我们有以下堆:
如果我们想将5插入到这个堆中,保持堆平衡的唯一方法是将5作为最后一个节点,此时将其作为10的子节点:
对此算法的任何替代方法都会导致不平衡。在另一个可能的宇宙中,算法是将新节点插入到最左边的底层节点,我们可以通过遍历左子节点直到底层来轻松找到它。这将使5成为15的子节点:
我们的堆现在有点不平衡,很容易看出如果我们继续在最左边的底层位置插入新节点,它会变得更加不平衡。
同样,从堆中删除时,我们总是将最后一个节点变成根节点,因为否则堆可能会变得不平衡。再次看看我们的例子堆:
在我们的替代宇宙中,如果我们总是将最右下方的节点移动到根位置,10将成为根节点,我们最终会得到一个不平衡的堆,有大量的左后代和零右后代。
现在,平衡之所以如此重要的原因在于它允许我们实现O(log N)的操作。在像下面这样严重不平衡的树中,遍历可能需要O(N)步:
但这让我们回到“最后节点的问题”。有什么算法可以让我们一直找到任何堆的最后一个节点呢?(再次强调,不需要遍历所有N个节点。)
这就是我们情节突然发生变化的地方。
数组作为堆
由于找到最后一个节点对于堆的操作非常关键,并且我们希望确保找到最后一个节点的效率很高,堆通常使用数组来实现。
尽管直到现在我们总是假设每个树都由彼此用链接连接的独立节点组成(就像链表一样),你现在将看到我们也可以使用数组来实现堆。也就是说,堆本身可以是一个抽象数据类型,实际上在底层使用数组。
下面的图表显示了如何使用数组来存储堆的值。
工作原理是我们将每个节点分配给数组中的一个索引。在前面的图表中,每个节点的索引都在节点下方的一个方框中。如果你仔细看,你会发现我们根据特定的模式分配每个节点的索引。
根节点始终存储在索引0处。然后,我们向下移动一层,从左到右,将每个节点分配到数组中的下一个可用索引。因此,在第二层上,左节点(88)成为索引1,右节点(25)成为索引2。当我们到达一层的末尾时,我们向下移动到下一层并重复此模式。
现在,我们之所以使用数组来实现堆,是因为它解决了“最后节点的问题”。怎么解决呢?
当我们以这种方式实现堆时,最后一个节点将始终是数组的最后一个元素。由于我们在分配每个值到数组时是自上而下且从左到右移动的,最后一个节点将始终是数组中的最后一个值。在先前的示例中,你可以看到3,即最后一个节点,是数组中的最后一个值。
因为最后一个节点将始终在数组的末尾,找到最后一个节点变得非常简单:我们只需要访问最后一个元素。此外,在将新节点插入堆时,我们会在数组的末尾插入,以使其成为最后一个节点。
在我们深入研究基于数组的堆如何工作的其他细节之前,我们已经可以开始编写其基本结构的代码。以下是我们在Ruby中堆实现的开始部分:
class Heap
def initialize
@data = []
end
def root_node
return @data.first
end
def last_node
return @data.last
end
end
如你所见,我们将堆初始化为空数组。我们有一个root_node
方法,它返回该数组的第一个项,还有一个last_node
方法,它返回该数组的最后一个值。
遍历一个数组结构的堆
正如你所见,堆的插入和删除算法要求我们能够通过堆进行渗透。而渗透又要求我们能够通过访问节点的父节点或子节点来遍历堆。但是当所有的值仅存储在数组中时,我们如何从一个节点移动到另一个节点呢?如果我们能够简单地遵循每个节点的链接,遍历堆本应是直截了当的。但现在堆在底层是一个数组,我们如何知道哪些节点彼此连接呢?
有一个有趣的解决方案。事实证明,当我们根据前面描述的模式分配堆的节点的索引时,堆的以下特性总是成立:
- 要找到任何节点的左子节点,我们可以使用公式 (index * 2) + 1
- 要找到任何节点的右子节点,我们可以使用公式 (index * 2) + 2
再次看一下前面的图表,重点关注索引为4的16。要找到其左子节点,我们将其索引(4)乘以2并加1,得到9。这意味着索引9是索引4处节点的左子节点。同样,要找到索引4的右子节点,我们将4乘以2并加2,得到10。这意味着索引10是索引4处节点的右子节点。由于这些公式始终有效,我们能够将数组视为一棵树。
让我们将这两个方法添加到我们的 Heap 类中:
def left_child_index(index)
return (index * 2) + 1
end
def right_child_index(index)
return (index * 2) + 2
end
这两个方法接受数组中的索引并分别返回左子索引或右子索引。
这是基于数组的堆的另一个重要特性:
- 要找到节点的父节点,我们可以使用公式 (index - 1) / 2
请注意,此公式使用整数除法,意味着我们抛弃小数点后的任何数字。例如,3 / 2 被视为 1,而不是更精确的 1.5。再次在我们的示例堆中,关注索引4。如果我们取该索引,减去1,然后除以2,我们得到1。正如你在图表中看到的,索引4处节点的父节点位于索引1。
因此,现在我们可以在我们的 Heap 类中添加另一个方法:
def parent_index(index)
return (index - 1) / 2
end
此方法接受索引并计算其父节点的索引。
代码实现:堆插入
现在我们已经将堆的基本元素放置好了,让我们实现插入算法:
def insert(value)
# Turn value into last node by inserting it at the end of the array:
@data << value
# Keep track of the index of the newly inserted node:
new_node_index = @data.length - 1
# The following loop executes the "trickle up" algorithm.
# If the new node is not in the root position,
# and it's greater than its parent node:
while new_node_index > 0 &&
@data[new_node_index] > @data[parent_index(new_node_index)]
# Swap the new node with the parent node:
@data[parent_index(new_node_index)], @data[new_node_index] =
@data[new_node_index], @data[parent_index(new_node_index)]
# Update the index of the new node:
new_node_index = parent_index(new_node_index)
end
end
让我们一如既往地分解这段代码。
我们的 insert
方法接受我们要插入堆中的值。首先,我们通过将其添加到数组的末尾使这个新值成为最后一个节点:
@data << value
接下来,我们跟踪新节点的索引,因为我们稍后会用到它。现在,该索引是数组中的最后一个索引:
new_node_index = @data.length - 1
接下来,我们使用 while 循环将新节点渗透到其正确的位置:
while new_node_index > 0 &&
@data[new_node_index] > @data[parent_index(new_node_index)]
此循环在满足两个条件的情况下运行。主要条件是新节点大于其父节点。我们还加了一个条件,即新节点的索引必须大于0,因为如果我们尝试将根节点与其不存在的父节点进行比较,可能会出现一些奇怪的情况。
每次此循环运行时,我们都交换新节点与其父节点,因为新节点目前大于父节点:
@data[parent_index(new_node_index)], @data[new_node_index] =
@data[new_node_index], @data[parent_index(new_node_index)]
然后,我们适当更新新节点的索引:
new_node_index = parent_index(new_node_index)
由于此循环仅在新节点大于其父节点时运行,因此一旦新节点位于其正确的位置,循环就会结束。
代码实现:堆删除
这是从堆中删除项的 Ruby 实现。主要方法是 delete
方法,但为了使代码更简单,我们创建了两个辅助方法,has_greater_child
和 calculate_larger_child_index
:
def delete
# We only ever delete the root node from a heap, so we
# pop the last node from the array and make it the root node:
@data[0] = @data.pop
# Track the current index of the "trickle node":
trickle_node_index = 0
# The following loop executes the "trickle down" algorithm:
# We run the loop as long as the trickle node has a child
# that is greater than it:
while has_greater_child(trickle_node_index)
# Save larger child index in variable:
larger_child_index = calculate_larger_child_index(trickle_node_index)
# Swap the trickle node with its larger child:
@data[trickle_node_index], @data[larger_child_index] =
@data[larger_child_index], @data[trickle_node_index]
# Update trickle node's new index:
trickle_node_index = larger_child_index
end
end
def has_greater_child(index)
# We check whether the node at index has left and right
# children and if either of those children are greater
# than the node at index:
(@data[left_child_index(index)] &&
@data[left_child_index(index)] > @data[index]) ||
(@data[right_child_index(index)] &&
@data[right_child_index(index)] > @data[index])
end
def calculate_larger_child_index(index)
# If there is no right child:
if !@data[right_child_index(index)]
# Return left child index:
return left_child_index(index)
end
# If right child value is greater than left child value:
if @data[right_child_index(index)] > @data[left_child_index(index)]
# Return right child index:
return right_child_index(index)
else # If the left child value is greater or equal to right child:
# Return the left child index:
return left_child_index(index)
end
end
让我们首先深入了解 delete
方法。
delete
方法不接受任何参数,因为我们只删除根节点。该方法的工作方式如下。
首先,我们从数组中删除最后一个值,并将其设置为第一个值:
@data[0] = @data.pop
这一简单的行实际上删除了原始的根节点,因为我们正在用最后一个节点的值覆盖根节点的值。
接下来,我们需要将新的根节点渗透到其正确的位置。我们之前称之为 “渗透节点”,我们的代码也反映了这一点。
在我们开始实际进行渗透之前,我们跟踪渗透节点的索引,因为我们稍后会用到它。当前,渗透节点在索引 0:
trickle_node_index = 0
然后,我们使用 while 循环执行渗透算法。循环运行的条件是渗透节点有任何比它大的子节点:
while has_greater_child(trickle_node_index)
这一行使用 has_greater_child
方法,该方法返回给定节点是否具有任何比该节点大的子节点。
在此循环中,我们首先找到渗透节点的两个子节点中较大的那个的索引:
larger_child_index = calculate_larger_child_index(trickle_node_index)
此行使用 calculate_larger_child_index
方法,该方法返回渗透节点较大子节点的索引。我们将此索引存储在名为 larger_child_index
的变量中。
接下来,我们交换渗透节点与其较大的子节点:
@data[trickle_node_index], @data[larger_child_index] =
@data[larger_child_index], @data[trickle_node_index]
最后,我们更新渗透节点的索引,这将是它刚刚与之交换的索引:
trickle_node_index = larger_child_index
替代的堆实现
我们的堆实现现在已经完成。值得注意的是,虽然我们在内部使用数组来实现堆,但也可以使用链接节点来实现堆。 (这种替代实现使用了一种涉及二进制数的不同技巧来解决“最后节点问题”)。
然而,数组实现是更常见的方法,因此这是我在这里呈现的内容。看到数组如何用于实现树也是非常有趣的。
实际上,可以使用数组来实现任何类型的二叉树,比如上一章中介绍的二叉搜索树。然而,堆是二叉树的第一个情况,其中数组实现提供了优势,因为它帮助我们轻松找到最后的节点。
堆作为优先队列
现在您了解了堆是如何工作的,我们可以回到我们的老朋友——优先级队列。
再次强调,优先级队列的主要功能是允许我们立即访问队列中优先级最高的项目。在急诊室的例子中,我们希望首先处理问题最严重的人。
正因为这个原因,堆是优先级队列实现的自然选择。堆使我们能够立即访问最高优先级的项目,该项目始终位于根节点。每次我们处理了最高优先级的项目(然后将其从堆中删除),下一个最高优先级的项目就会浮到堆的顶部,并准备下一步处理。而且,堆在保持非常快速的插入和删除操作的同时实现了这一点,这两者的时间复杂度都是O(log N)。
与之形成对比的是有序数组,为了确保每个新值都处于其正确的位置,需要较慢的O(N)插入操作。
事实证明,堆的弱排序正是其优势所在。它不需要完全有序的特性,使得我们可以在O(log N)的时间内插入新值。同时,堆的排序足够强大,以便我们始终可以在任何给定时刻访问到我们需要的一个项目——堆中最大的值。
总结
到目前为止,我们已经看到不同类型的树是如何优化不同类型的问题的。二叉搜索树使搜索保持快速,同时最小化了插入的成本,而堆则是构建优先级队列的理想工具。
在下一章中,我们将探讨另一种树,它用于支持你日常使用的一些最常见的基于文本的操作。
练习
- 在将值 11 插入后,以下堆的结构将如何呈现:
-
在删除根节点后,先前的堆怎么变化:
-
想象一下,您按照特定顺序将以下数字插入堆中:55、22、34、10、2、99、68。如果您然后逐个从堆中弹出它们,并将这些数字插入新数组,那么这些数字将以什么顺序出现?
答案
- 插入值为 11 后,堆将如下所示:
- 删除根节点后,堆将如下所示:
- 这些数字将以完全降序的顺序排列。(这适用于最大堆。对于最小堆,它们将以升序排列。)您意识到了这意味着什么吗?这意味着您刚刚发现了另一种排序算法!
堆排序 是一种排序算法,它将所有值插入堆中,然后逐个弹出。正如您从这个练习中看到的,这些值总是以排序顺序结束。
与 快速排序 一样,堆排序 的时间复杂度是 O(N log N)。这是因为我们需要将 N 个值插入堆中,而每次插入都需要 log N 步。
虽然有更高级的 堆排序版本试图最大化其效率,但这就是基本思想。