在众多排序算法中,堆排序占据了一个独特的位置。与其他排序算法直接进入排序动作不同,堆排序要求我们先与一些基础的数据结构概念打好交道。
这里有完全二叉树,有最大堆,它们不仅构成了堆排序的基础,还引领我们进入了一个更深层次的理解。
你可能会问,既然堆排序并非最快的排序方法,为何还要投入大量时间去学习它?
答案在于,当我们探讨堆排序时,实际上是在探索一个更广泛的应用领域。这不仅仅关于排序本身,还涉及到优先队列等多种实用场景。
因此,让我们踏上这段探索之旅,深入堆排序背后的世界,发现它的真正魅力,远不止于简单的排序功能。
1. 完全二叉树
二叉树(Binary Tree)是一种特殊的数据结构。在这种结构中,每个节点都有两个子节点,通常被称为“左子树”和“右子树”。
在这种数据结构中,每个节点都有指向其父节点、左右子节点的三个指针。
当一棵二叉树的特性满足以下条件时,它被称为完全二叉树(Complete Binary Tree):
除最底层外,其他层的节点数均已满。
最底层的节点都集中在左侧。
与普通的二叉树不同,完全二叉树可以使用数组进行隐式表示,无需使用指针。
这种表示方法是将树上的所有节点按顺序存放在数组中。节点间的关系可以通过其在数组中的位置来确定。
例如,根节点存放在数组的第 1
位置,其左右子节点分别位于 2
和 3
位置。对于任意位置 i
的节点,其父节点和子节点的位置可以通过以下公式计算:
Parent = i / 2
Left = 2 * i
Right = 2 * i + 1
其中,Parent 表示节点 i
的父节点位置,Left 和 Right 分别表示其左子节点和右子节点的位置。
以图中的数组为例,当 i=4
时,我们可以直接计算出其父节点和两个子节点的位置。
2. 堆
堆是一种特殊的完全二叉树,它满足一个关键特性:
每个父节点的值都大于或等于其子节点的值。 这意味着在堆的数组表示中,最大的元素总是位于根节点。
这种堆被称为最大堆(Max Heap)。而如果每个父节点的值都小于或等于其子节点,那么这样的堆就是最小堆(Min Heap)。
在本文中,我们将重点讨论最大堆。
如何维护最大堆的特性?
当一个子节点的值大于其父节点,这就违反了最大堆的特性。此时,我们需要交换这两个节点。
如果一个节点的左右子树都是最大堆,但该节点的值小于其子节点,该如何操作?
例如,节点 i
的左右子树都是最大堆,但节点 i
的值小于其子节点。为了解决这个问题,我们可以让节点 i
在堆中“逐级下降”,直至找到合适的位置。
将上述的“逐级下降”过程转化为代码是一个有趣的挑战,你可以先思考一下如何实现。这里是我为维护最大堆特性编写的函数 MAXHEAPIFY
,以供参考。
你可以在我的 github 仓库中查看源代码:
https://github.com/dingtingli/algorithm/blob/main/Code/heapsort01.py
最后,让我们深入思考一个问题:
面对一个随机数组,如果左右子树都不满足最大堆特性,如何将其转化为最大堆?
3. 如何建立最大堆
面对一个随机数组,如何将其转化为最大堆?
我们可以从完全二叉树中的最后一个父节点开始,自底向上地使用维护堆特性的 MAXHEAPIFY
函数,从而将任意排序的数组转换成最大堆。
为了实现这个思路,首先需要确定完全二叉树的最后一个父节点。回顾我们之前提到的位置 i 父节点和字节的计算公式:
Parent = i / 2
Left = 2 * i
Right = 2 * i + 1
当节点 i = n/2
时,其左子节点为 left = 2 * (n/2) = n
,而 n
即数组中的最后一个元素。
因此,完全二叉树的最后一个父节点的位置为 n/2
。
接下来,我们从最后一个父节点开始,自底向上地对每个父节点调用维护堆特性的 MAXHEAPIFY
函数。这样,我们可以逐步将任意排序的数组转换为满足最大堆特性的数组。
如何将这个过程转化为代码呢?你可以先尝试自己实现,然后再参考下面的函数:
def BUILDMAXHEAP(a):
n = len(a)
i = n // 2
# 从最后一个父节点开始,自底向上维护堆特性
while i >= 1:
MAXHEAPIFY(a, i, n)
i -= 1
你可以在我的 github 仓库中查看源代码:
https://github.com/dingtingli/algorithm/blob/main/Code/heapsort01.py
这次,我们学习了如何从一个随机数组构建最大堆。通过自底向上的方法和调用维护堆特性的 MAXHEAPIFY
函数,我们可以有效地将任意数组转化为最大堆。
在下一章节,我们将进一步探讨如何利用最大堆。
4. 堆排序算法
我们已经成功地实现了建立最大堆的 BUILDMAXHEAP
函数,这为我们提供了一个有效的方法将任意数组转化为最大堆。
回顾最大堆的核心特性:每个父节点的值都大于或等于其子节点的值。这确保了在堆的数组表示中,最大的元素始终位于根节点。
现在,我们将利用这个特性和 BUILDMAXHEAP
函数来实现排序。
建立最大堆: 使用
BUILDMAXHEAP
函数将任意数组转化为最大堆。找到最大元素并交换: 最大的元素始终位于数组的第一个位置,将数组的第一个元素与最后一个元素交换。
重建最大堆: 排除最后一个元素,并在剩余的元素中重新构建最大堆。
重复上述过程: 继续交换、排除和重建,直到堆的大小为 2。
因为对于只有两个节点的堆,我们可以直接通过 MAXHEAPIFY
函数完成排序,再进一步交换即可完成排序。
如何将上述过程转化为代码呢?以下是我为堆排序编写的函数,你可以先尝试自己实现,然后再参考:
def HEAPSORT(a):
# Step 1: Build a max heap
BUILDMAXHEAP(a)
n = len(a)
while n >= 2:
# Step 2: Swap the first and last element
exch(a, 1, n)
n -= 1
# Step 3: Rebuild the max heap
MAXHEAPIFY(a, 1, n)
你可以在我的 github 仓库中查看源代码:
https://github.com/dingtingli/algorithm/blob/main/Code/heapsort01.py
尽管堆排序在理论上很有吸引力,但在实际应用中,它往往不如快速排序高效。
这是因为堆排序在数组中的大范围跳跃可能导致缓存命中率降低。而快速排序,由于其连续的数据访问模式,更适合现代计算机的缓存系统。
但这并不意味着堆没有价值。作为一种数据结构,堆在其他场景,如优先队列中,仍然发挥着关键作用。
5. 优先队列
在我们之前的探讨中,我们了解了堆的基本概念和堆排序。但实际上,堆最常见的使用场景并不是排序,而是实现优先队列。
设想一下:要在一个存有 10 亿个数的文件中找出最小的 100 万个数,如何高效地实现?
直接对 10 亿个数进行排序显然不是最佳选择,因为这样的规模会带来巨大的计算和存储压力。这时,最大堆(一种特殊的完全二叉树,其中每个节点的值都大于或等于其子节点的值)就派上了用场。
具体方法如下:
读取文件中的前 100 万个数,并构建一个最大堆。
继续读取文件中的数,对于每一个数:如果该数大于堆顶元素,忽略它。如果该数小于堆顶元素,移除堆顶元素,并将新数加入堆中。
读完文件后,堆中的 100 万个数即为所需的最小的 100 万个数。
为了更直观地理解这个过程,我们可以通过一个简单的数组来演示。假设我们有一个数组包含 10 个元素,我们首先读取数组中的前 7 个元素来构建一个最大堆。
接着,我们继续读取数组中的后续元素。如果某个元素大于堆顶元素,我们就忽略它。
如果某个元素小于堆顶元素,我们就移除堆顶元素,并将这个新元素加入堆中。
我们重复上述操作,直到处理完数组中的所有元素。最终,优先队列(最大堆)中保存的就是数组中最小的 7 个元素。
这种方法,即利用最大堆来找出一组数中的最小的 N 个数,实际上是一种应用了“优先队列”数据结构的高效策略。
传统的队列遵循先进先出的原则,而优先队列则不同:它允许基于优先级的顺序出队。在最大优先队列中,优先级最高的元素首先被移除,然后是次高的,以此类推。
优先队列在现实生活中有广泛的应用。例如,计算机系统中,多个任务需要共享有限的资源,如 CPU 或内存。通过优先队列,系统可以确保资源被优先分配给最紧急或最重要的任务。再如,你的手机可能会为来电分配比正在运行的游戏更高的优先级。
现在,你已经了解了优先队列的基本概念和其在实际中的应用,为何不尝试自己实现一个呢?
你可以在我的 github 仓库中查看源代码:
https://github.com/dingtingli/algorithm/blob/main/Code/heapsort02.py
6. 快速堆排序
堆排序(HEAPSORT)虽然在理论上很吸引人,但在实际应用中,其比较计数的效率并不高。原因在于它将元素从堆的底部提升到顶部,然后再让它们逐渐下沉,与较大的元素交换位置。
这种做法似乎有些反直觉:为什么要将一个可能很小的元素放在可能很大的元素之上,然后再观察其下沉的过程呢?难道就没有更优雅的方法,直接将两个子堆中的较大元素提升到堆的顶部吗?
考虑以下改进:
优化后的 HEAPSORT(肯定有前人已经探索过这种方法):
将所有元素放入有效的最大堆中
删除堆顶,创建一个空缺 "V"
比较 V 正下方的两个子堆首领,将最大的那个提升到空缺中。
递归重复第 3 步,重新定义 V 为新的空缺,直到堆的底部。
转到步骤 2
这种方法的优势在于,我们实际上是将一个已知较大的元素提升到堆顶,无需进行额外的比较。
我们将这种改进的算法称为 "快速堆排序"(FAST HEAPSORT)。虽然它不是完全原地的排序,但与传统的堆排序(HEAPSORT)相比,它每次从堆顶提取一个排序项,效率更高。
如何将上述过程转化为代码呢?以下是我为快速堆排序编写的函数,你可以先尝试自己实现,然后再参考。
你可以在我的 github 仓库中查看源代码:
https://github.com/dingtingli/algorithm/blob/main/Code/heapsort03.py
7. 总结
通过本文的探索,我们不仅深入理解了堆排序的基本原理和关键步骤,还发现了它在优先队列等领域的广泛应用。
堆排序展现了算法设计的优雅与效率,虽然在某些场景下它可能不及快速排序等算法高效,但其独有的特性在处理特定数据时展现出独特优势。
而其优化版本快速堆排序,更是在效率上超越了许多传统排序算法,成为可能是最接近比较排序理论极限的算法。
正如我们在文章开头所提到的,学习堆排序不仅是为了掌握一种排序技术,更是为了理解其背后的深远意义和广泛应用。
WWH 系列文章列表:
[2] What - 什么是依赖注入?
[3] What - 如何清晰地理解算法复杂度 Big O?
[6] Why - 为什么排序算法复杂度上限是 O(NlogN)?
最近文章列表:
[1] 成就卓越:事业成功的核心要素
[2] C++异常处理的底层机制
[3] .git 目录里到底包含了什么?
[6] 看图聊算法:为什么插入排序效率不高,却是使用频率最高的排序算法?
[7] 看图聊算法:归并排序一个被教科书嫌弃的算法,我们为什么还要学?
[8] 看图聊算法:冯·诺依曼的第一个计算机程序是怎么做出来的?
[9] 看图聊算法:快速排序为什么快?
[10] 不刷题,不面试,来看看算法学习在编程世界中的真正价值
![86e8ce2c1aa2d40b9aa8f97131154bff.jpeg](https://i-blog.csdnimg.cn/blog_migrate/05e145a16534cb06c501ba023b0087c3.jpeg)
喜欢本篇文章,记得动动小手点个在看↓↓