基础堆排序(手把手讲解)
💡一则或许对你有用的小广告
欢迎加入小哈的星球 ,你将获得:专属的项目实战 / 1v1 提问 / Java 学习路线 / 学习打卡 / 每月赠书 / 社群讨论
- 新项目:《从零手撸:仿小红书(微服务架构)》 正在持续爆肝中,基于
Spring Cloud Alibaba + Spring Boot 3.x + JDK 17...
,点击查看项目介绍 ;- 《从零手撸:前后端分离博客项目(全栈开发)》 2 期已完结,演示链接: http://116.62.199.48/ ;
截止目前, 星球 内专栏累计输出 82w+ 字,讲解图 3441+ 张,还在持续爆肝中.. 后续还会上新更多项目,目标是将 Java 领域典型的项目都整一波,如秒杀系统, 在线商城, IM 即时通讯,权限管理,Spring Cloud Alibaba 微服务等等,已有 2900+ 小伙伴加入学习 ,欢迎点击围观
前言
在算法学习的旅程中,排序算法是绕不开的核心内容。从简单的冒泡排序到高效的快速排序,每种算法都有其独特的应用场景和性能特点。今天,我们将聚焦于一种兼具实用性和理论深度的排序算法——基础堆排序。它通过巧妙的堆结构设计,能够在 O(n log n) 的时间复杂度内完成排序任务,尤其适合处理大规模数据场景。本文将从零开始讲解堆排序的底层逻辑、实现步骤,并通过代码示例和实际案例帮助读者掌握这一算法。
核心概念解析
什么是堆?
堆(Heap)是一种特殊的完全二叉树结构,它满足以下两个条件之一:
- 最大堆(Max-Heap):每个父节点的值都大于或等于其子节点的值。
- 最小堆(Min-Heap):每个父节点的值都小于或等于其子节点的值。
可以将堆想象成一个“金字塔家族”:顶端的父辈永远是家族中的“最大值”或“最小值”,而子辈则按照层级依次排列。这种结构使得堆在获取最大值或最小值时,仅需 O(1) 的时间复杂度。
堆的存储方式
堆通常使用数组进行存储,其索引关系遵循以下规则:
- 父节点的索引为
i
,则左子节点的索引为2*i + 1
,右子节点的索引为2*i + 2
。 - 反之,子节点的父节点索引为
(i-1)//2
。
例如,数组 [10, 5, 8, 3, 2]
对应的最大堆结构如图:
10
/ \
5 8
/ \
3 2
通过索引计算,父节点 10(索引 0)的左子节点是索引 1(值为 5),右子节点是索引 2(值为 8)。
堆排序的核心步骤
步骤 1:构建初始堆
堆排序的第一步是将原始数组构建成一个最大堆(或最小堆)。以最大堆为例,我们需要通过“下沉操作”(Sift Down)调整数组元素,确保每个父节点的值都不小于子节点。
下沉操作的比喻
想象堆是一个“权力金字塔”,父节点必须比子节点更“强大”。若某个父节点的值小于子节点,就需要让父节点“下放权力”给子节点,直到整个结构恢复平衡。
具体实现
- 从最后一个非叶子节点(即索引
(n-2)//2
)开始向前遍历,逐个执行下沉操作。 - 对每个节点,比较其与子节点的值,若不符合最大堆条件,则交换值更大的子节点与父节点的位置。
步骤 2:排序过程
构建好最大堆后,堆顶元素(索引 0)即为当前数组的最大值。我们将该最大值与数组末尾元素交换,缩小待排序区域的范围,然后重新调整堆结构,重复这一过程直至排序完成。
具体步骤分解
- 交换元素:将堆顶元素(最大值)与当前未排序区域的最后一个元素交换。
- 缩小范围:将未排序区域的边界前移一位。
- 重新调整堆:对缩小后的堆区域执行下沉操作,确保剩余元素仍满足最大堆的条件。
图解过程
假设初始数组为 [4, 10, 3, 5, 1]
,构建最大堆后变为 [10, 5, 3, 4, 1]
。
- 第一次交换后:
[1, 5, 3, 4, 10]
,未排序区域为前 4 个元素。 - 调整堆后:
[5, 4, 3, 1, 10]
,继续交换得到最终排序结果[1, 3, 4, 5, 10]
。
堆排序的代码实现
Python 代码示例
以下是一个完整的堆排序实现,包含构建堆和排序的核心逻辑:
def heap_sort(arr):
n = len(arr)
# 构建最大堆
for i in range(n//2 - 1, -1, -1):
sift_down(arr, n, i)
# 排序过程
for i in range(n-1, 0, -1):
arr[i], arr[0] = arr[0], arr[i] # 交换堆顶与末尾元素
sift_down(arr, i, 0) # 重新调整堆(排除已排序的末尾元素)
def sift_down(arr, n, root):
largest = root # 初始化最大值为父节点
left = 2 * root + 1
right = 2 * root + 2
# 比较左子节点
if left < n and arr[left] > arr[largest]:
largest = left
# 比较右子节点
if right < n and arr[right] > arr[largest]:
largest = right
# 若父节点不是最大值,交换并递归调整子树
if largest != root:
arr[root], arr[largest] = arr[largest], arr[root]
sift_down(arr, n, largest)
代码解析
- 构建最大堆:通过
sift_down
函数从最后一个非叶子节点开始下沉调整。 - 排序循环:每次交换堆顶元素到末尾后,堆的有效范围缩小,需再次调整堆结构。
- 时间复杂度:构建堆的时间为 O(n),排序循环中每个元素的下沉操作为 O(log n),总时间复杂度为 O(n log n)。
优化与注意事项
- 原地排序:堆排序直接在原数组上操作,空间复杂度为 O(1)。
- 不稳定排序:堆排序不保证相等元素的相对顺序,因此不适合需要稳定性的场景。
- 性能对比:相比快速排序的平均 O(n log n),堆排序的最坏情况性能更稳定(均为 O(n log n)),但常数因子稍大。
实际案例分析
案例 1:排序整数数组
假设输入数组为 [7, 3, 5, 8, 2, 9, 1]
,堆排序过程如下:
- 构建最大堆:通过下沉调整,最终堆结构为
[9, 8, 7, 3, 2, 5, 1]
。 - 排序阶段:
- 第一次交换后:
[1, 8, 7, 3, 2, 5, 9]
,调整堆后变为[8, 5, 7, 3, 2, 1, 9]
。 - 第二次交换后:
[1, 5, 7, 3, 2, 8, 9]
,调整堆后得到[7, 5, 1, 3, 2, 8, 9]
。 - 最终排序结果:
[1, 2, 3, 5, 7, 8, 9]
。
- 第一次交换后:
案例 2:处理大规模数据
假设有一个包含 100 万个随机数的数组,堆排序的性能表现如何?
- 优势:由于其 O(n log n) 的时间复杂度,即使数据量庞大,也能在合理时间内完成排序。
- 对比:与快速排序相比,堆排序在最坏情况下(如已排序数组)表现更稳定,而快速排序可能退化到 O(n²)。
常见问题与解答
Q1:堆排序的“下沉操作”如何避免无限循环?
A1:在 sift_down
函数中,当父节点已经是当前子树的最大值时(即 largest == root
),函数停止递归,因此不会无限循环。
Q2:能否用最小堆实现堆排序?
A2:可以!只需将最大堆的比较条件反转(即父节点小于等于子节点),最终排序结果会是逆序。只需在交换元素时调整逻辑即可。
Q3:堆排序的适用场景有哪些?
A3:
- 需要稳定时间复杂度的场景(如实时系统)。
- 内存有限时的原地排序需求。
- 需要频繁获取最大值/最小值的场景(如优先队列)。
结论
堆排序凭借其简洁的逻辑和稳定的性能,在算法世界中占据重要地位。通过理解堆的结构特性、掌握下沉操作的细节,以及结合代码实现的实践,开发者可以轻松应对各类排序需求。无论是处理数据量庞大的日志文件,还是优化实时系统的排序逻辑,堆排序都是一个值得深入学习的工具。
希望本文能帮助读者建立起对基础堆排序的全面认知,并在实际项目中灵活应用这一算法。通过持续练习与思考,算法学习的挑战终将成为解决问题的钥匙。