看图聊算法:堆排序,我们学习它可能并不是为了排序

本文详细介绍了堆排序算法,包括完全二叉树和最大堆的概念,以及如何通过BUILDMAXHEAP和MAXHEAPIFY函数构建和维护最大堆。同时探讨了堆排序在优先队列中的应用和快速堆排序的优化。
摘要由CSDN通过智能技术生成

在众多排序算法中,堆排序占据了一个独特的位置。与其他排序算法直接进入排序动作不同,堆排序要求我们先与一些基础的数据结构概念打好交道。

这里有完全二叉树,有最大堆,它们不仅构成了堆排序的基础,还引领我们进入了一个更深层次的理解。

你可能会问,既然堆排序并非最快的排序方法,为何还要投入大量时间去学习它?

答案在于,当我们探讨堆排序时,实际上是在探索一个更广泛的应用领域。这不仅仅关于排序本身,还涉及到优先队列等多种实用场景。

因此,让我们踏上这段探索之旅,深入堆排序背后的世界,发现它的真正魅力,远不止于简单的排序功能。

1. 完全二叉树

二叉树(Binary Tree)是一种特殊的数据结构。在这种结构中,每个节点都有两个子节点,通常被称为“左子树”和“右子树”。

8a774f73de65fcd7db8bb9a63a00e996.png

二叉树

在这种数据结构中,每个节点都有指向其父节点、左右子节点的三个指针。

当一棵二叉树的特性满足以下条件时,它被称为完全二叉树(Complete Binary Tree):

  • 除最底层外,其他层的节点数均已满。

  • 最底层的节点都集中在左侧。

715fba5533e7a950f7e429fae149de7b.png

完全二叉树

与普通的二叉树不同,完全二叉树可以使用数组进行隐式表示,无需使用指针。

这种表示方法是将树上的所有节点按顺序存放在数组中。节点间的关系可以通过其在数组中的位置来确定。

4e868c54d48718e27c4dc229f9f4ce63.png

完全二叉树数组结构

例如,根节点存放在数组的第 1 位置,其左右子节点分别位于 23 位置。对于任意位置 i 的节点,其父节点和子节点的位置可以通过以下公式计算:

Parent = i / 2
  Left = 2 * i
  Right = 2 * i + 1

其中,Parent 表示节点 i 的父节点位置,Left 和 Right 分别表示其左子节点和右子节点的位置。

b79ea53b6bd13a86c084283be741c7db.png

完全二叉树数组节点公式

以图中的数组为例,当 i=4 时,我们可以直接计算出其父节点和两个子节点的位置。

e9874813684588ee61858d7913e52bdd.png

完全二叉树数组节点 i=4

2. 堆

堆是一种特殊的完全二叉树,它满足一个关键特性:

每个父节点的值都大于或等于其子节点的值。 这意味着在堆的数组表示中,最大的元素总是位于根节点。

e3c2de4dcaed55699f64d35718dccabe.png

最大堆

这种堆被称为最大堆(Max Heap)。而如果每个父节点的值都小于或等于其子节点,那么这样的堆就是最小堆(Min Heap)。

在本文中,我们将重点讨论最大堆。

如何维护最大堆的特性?

当一个子节点的值大于其父节点,这就违反了最大堆的特性。此时,我们需要交换这两个节点。

de6613c683035abcf8d201572ddc0922.gif

动图 节点交换

如果一个节点的左右子树都是最大堆,但该节点的值小于其子节点,该如何操作?

例如,节点 i 的左右子树都是最大堆,但节点 i 的值小于其子节点。为了解决这个问题,我们可以让节点 i 在堆中“逐级下降”,直至找到合适的位置。

4bcaabb143a6abfd15fe48aef3f3d6ec.gif

动图 堆中节点逐级下降

将上述的“逐级下降”过程转化为代码是一个有趣的挑战,你可以先思考一下如何实现。这里是我为维护最大堆特性编写的函数 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

4546eda44228c8d5b18c176597a3f81d.png

完全二叉树的最后一个父节点

接下来,我们从最后一个父节点开始,自底向上地对每个父节点调用维护堆特性的 MAXHEAPIFY 函数。这样,我们可以逐步将任意排序的数组转换为满足最大堆特性的数组。

b0199618ba0de848607610543cb778e3.gif

动图 建立最大堆

如何将这个过程转化为代码呢?你可以先尝试自己实现,然后再参考下面的函数:

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 函数,这为我们提供了一个有效的方法将任意数组转化为最大堆。

回顾最大堆的核心特性:每个父节点的值都大于或等于其子节点的值。这确保了在堆的数组表示中,最大的元素始终位于根节点。

bed1724515457f242bb9c935ddff91a7.png

最大堆示意图

现在,我们将利用这个特性和 BUILDMAXHEAP 函数来实现排序。

  1. 建立最大堆: 使用 BUILDMAXHEAP 函数将任意数组转化为最大堆。

  2. 找到最大元素并交换: 最大的元素始终位于数组的第一个位置,将数组的第一个元素与最后一个元素交换。

  3. 重建最大堆: 排除最后一个元素,并在剩余的元素中重新构建最大堆。

f0d5067d6fdd8dd4c1f17aedae81b440.gif

动图 重建最大堆示意图
  1. 重复上述过程: 继续交换、排除和重建,直到堆的大小为 2。

因为对于只有两个节点的堆,我们可以直接通过 MAXHEAPIFY 函数完成排序,再进一步交换即可完成排序。

68bad2bfc6683ac4bd1fff07080c0364.gif

动图 堆排序过程示意图

如何将上述过程转化为代码呢?以下是我为堆排序编写的函数,你可以先尝试自己实现,然后再参考:

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 亿个数进行排序显然不是最佳选择,因为这样的规模会带来巨大的计算和存储压力。这时,最大堆(一种特殊的完全二叉树,其中每个节点的值都大于或等于其子节点的值)就派上了用场。

具体方法如下:

  1. 读取文件中的前 100 万个数,并构建一个最大堆。

  2. 继续读取文件中的数,对于每一个数:如果该数大于堆顶元素,忽略它。如果该数小于堆顶元素,移除堆顶元素,并将新数加入堆中。

  3. 读完文件后,堆中的 100 万个数即为所需的最小的 100 万个数。

为了更直观地理解这个过程,我们可以通过一个简单的数组来演示。假设我们有一个数组包含 10 个元素,我们首先读取数组中的前 7 个元素来构建一个最大堆。

b84408fabb11a8b2fd821b18f9d1967e.gif

动图 优先队列过程建最大堆

接着,我们继续读取数组中的后续元素。如果某个元素大于堆顶元素,我们就忽略它。

555ccfed80680f8d8b294d6206f1d28a.gif

动图 优先队列过程忽略大于堆顶的元素

如果某个元素小于堆顶元素,我们就移除堆顶元素,并将这个新元素加入堆中。

c0701e636c092ca4462eea251abc5aac.gif

动图 优先队列过程插入元素重新建堆

我们重复上述操作,直到处理完数组中的所有元素。最终,优先队列(最大堆)中保存的就是数组中最小的 7 个元素。

1bb097be0a52a662c3acd9ae2914866a.gif

动图 优先队列过程获取最小的 7 个元素

这种方法,即利用最大堆来找出一组数中的最小的 N 个数,实际上是一种应用了“优先队列”数据结构的高效策略。

传统的队列遵循先进先出的原则,而优先队列则不同:它允许基于优先级的顺序出队。在最大优先队列中,优先级最高的元素首先被移除,然后是次高的,以此类推。

优先队列在现实生活中有广泛的应用。例如,计算机系统中,多个任务需要共享有限的资源,如 CPU 或内存。通过优先队列,系统可以确保资源被优先分配给最紧急或最重要的任务。再如,你的手机可能会为来电分配比正在运行的游戏更高的优先级。

现在,你已经了解了优先队列的基本概念和其在实际中的应用,为何不尝试自己实现一个呢?

你可以在我的 github 仓库中查看源代码:

https://github.com/dingtingli/algorithm/blob/main/Code/heapsort02.py

6. 快速堆排序

堆排序(HEAPSORT)虽然在理论上很吸引人,但在实际应用中,其比较计数的效率并不高。原因在于它将元素从堆的底部提升到顶部,然后再让它们逐渐下沉,与较大的元素交换位置。

46d66e87d58f209bab13d53ec1cddecd.gif

动图 重建最大堆示意图

这种做法似乎有些反直觉:为什么要将一个可能很小的元素放在可能很大的元素之上,然后再观察其下沉的过程呢?难道就没有更优雅的方法,直接将两个子堆中的较大元素提升到堆的顶部吗?

考虑以下改进:

优化后的 HEAPSORT(肯定有前人已经探索过这种方法):

  1. 将所有元素放入有效的最大堆中

  2. 删除堆顶,创建一个空缺 "V"

  3. 比较 V 正下方的两个子堆首领,将最大的那个提升到空缺中。

  4. 递归重复第 3 步,重新定义 V 为新的空缺,直到堆的底部。

  5. 转到步骤 2

视频 快速堆排序示意图

这种方法的优势在于,我们实际上是将一个已知较大的元素提升到堆顶,无需进行额外的比较。

我们将这种改进的算法称为 "快速堆排序"(FAST HEAPSORT)。虽然它不是完全原地的排序,但与传统的堆排序(HEAPSORT)相比,它每次从堆顶提取一个排序项,效率更高。

如何将上述过程转化为代码呢?以下是我为快速堆排序编写的函数,你可以先尝试自己实现,然后再参考。

你可以在我的 github 仓库中查看源代码:

https://github.com/dingtingli/algorithm/blob/main/Code/heapsort03.py

7. 总结

通过本文的探索,我们不仅深入理解了堆排序的基本原理和关键步骤,还发现了它在优先队列等领域的广泛应用。

堆排序展现了算法设计的优雅与效率,虽然在某些场景下它可能不及快速排序等算法高效,但其独有的特性在处理特定数据时展现出独特优势。

而其优化版本快速堆排序,更是在效率上超越了许多传统排序算法,成为可能是最接近比较排序理论极限的算法。

正如我们在文章开头所提到的,学习堆排序不仅是为了掌握一种排序技术,更是为了理解其背后的深远意义和广泛应用。

WWH 系列文章列表:

[1] Why - 为什么 JS 更像一门编译型语言?

[2] What - 什么是依赖注入?

[3] What - 如何清晰地理解算法复杂度 Big O?

[4] How - 不同的语言都如何处理错误?

[5] How - 面向对象语言如何处理异常?

[6] Why - 为什么排序算法复杂度上限是 O(NlogN)?

最近文章列表:

[1] 成就卓越:事业成功的核心要素

[2] C++异常处理的底层机制

[3] .git 目录里到底包含了什么?

[4] 看图聊算法:一个游戏让你理解二分法的本质

[5] 看图聊算法:还是一个游戏,让你理解三分法的本质

[6] 看图聊算法:为什么插入排序效率不高,却是使用频率最高的排序算法

[7] 看图聊算法:归并排序一个被教科书嫌弃的算法,我们为什么还要学

[8] 看图聊算法:冯·诺依曼的第一个计算机程序是怎么做出来的?

[9] 看图聊算法:快速排序为什么快?

[10] 不刷题,不面试,来看看算法学习在编程世界中的真正价值

86e8ce2c1aa2d40b9aa8f97131154bff.jpeg

喜欢本篇文章,记得动动小手点个在看↓↓

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值