当前位置: 首页 > news >正文

数据结构:堆排序 (Heap Sort)

目录

连接“排序思想”与“堆结构”

逐步完善代码——从框架开始

填充阶段一:建堆(Build Heap)

推导并填充阶段二:排序 (Sorting)

整合并提供完整的辅助函数

复杂度分析:


我们先忘掉“堆”,思考一下“排序”的终极目标是什么?

如果是“从小到大”排序,那无非就是要把一堆杂乱无章的数字,排成一个 [最小的, ..., 最大的] 的序列。

✅一个非常符合人类直觉的排序方法是这样的:

  1. 从所有数字中,找到最大的那个。

  2. 把它拿出来,放在最终排好序的队伍的最后面。

  3. 然后从剩下的数字中,再找到最大的那个。

  4. 把它拿出来,放在排好序的队伍的次后面。

  5. 不断重复这个过程,直到所有数字都取完。

这个思想很朴素,它叫“选择排序”。但它的问题在哪?

在第1步和第3步——“找到最大的那个”。

❌在一个普通无序数组里,每找一次最大值,你都必须把所有(剩下的)数字都看一遍,这个操作的成本是 O(N)。因为你要找 N 次,所以总成本高达 O(N^2),效率太低。


连接“排序思想”与“堆结构”

现在,我们把目光转回“堆”。我们费了这么大功夫学习堆,它最擅长做什么?

—— 它最擅长的,就是以 O(1) 的时间告诉你谁是最大值!(它永远在堆顶 arr[0]

这个特性简直是为我们上面那个朴素的排序思想量身定做的! 如果我们能把待排序的数组先变成一个大顶堆,那么“找到最大值”这个操作的成本就从 O(N) 变成了 O(1)。这预示着一个巨大性能提升的可能性。

基于这个核心洞察,我们得到了一个全新的、更高效的排序算法框架:

  1. 准备阶段:将整个无序的数组,首先转换成一个大顶堆。

  2. 排序阶段:利用堆的特性,不断取出当前的最大值,并把它放到正确的位置。

这就是“堆排序”的根本思想。它本质上是一种“聪明的选择排序”。


逐步完善代码——从框架开始

我们先搭起 heapSort 函数的骨架,把上面两个阶段用注释表示出来。

// 堆排序函数 (版本 1:只有框架)
// arr: 待排序的数组
// n: 数组的元素个数
void heapSort(int arr[], int n) {// 阶段一:将无序数组构建成一个大顶堆。// (这部分代码我们后面来填充)// 阶段二:不断地从堆中取出最大值,并放到它最终应该在的位置。// (这部分代码我们后面也来填充)}

这个框架非常清晰,接下来我们一步步实现它。

填充阶段一:建堆(Build Heap)

在上一节,我们已经深入探讨并实现了最高效的建堆方法——自底向上的 siftDown。我们直接把这个逻辑拿过来,填充到框架中。

数据结构:创建堆(或者叫“堆化”,Heapify)-CSDN博客

我们需要一个 siftDown 辅助函数。我们先假设它已经写好了,专注于 heapSort 的逻辑。

// (假设我们已经有了 swap 和 siftDown 函数)// 堆排序函数 (版本 2:完成阶段一)
void heapSort(int arr[], int n) {// 阶段一:将无序数组构建成一个大顶堆。// 我们从最后一个非叶子节点 (n/2 - 1) 开始,一路向前到根节点,// 对每一个节点都执行 siftDown 操作。for (int i = n / 2 - 1; i >= 0; i--) {siftDown(arr, n, i); // siftDown需要知道数组、数组大小和要操作的节点索引}// 阶段二:不断地从堆中取出最大值,并放到它最终应该在的位置。// (代码待填充)
}

现在,for 循环执行完毕后,arr 数组就已经是一个合法的大顶堆了,arr[0] 就是整个数组的最大值。


推导并填充阶段二:排序 (Sorting)

现在 arr[0] 是最大值。在最终排好序的数组里,它应该在哪里?

—— 应该在最后一位,也就是 arr[n-1] 的位置。

怎么把它放过去?最简单的原地操作就是:交换 arr[0]arr[n-1]

// 交换前: [最大值, ... , 某个值]
swap(&arr[0], &arr[n-1]);
// 交换后: [某个值, ... , 最大值]

交换之后,arr[n-1] 这个位置上的值已经就位了,它是正确的、最终的值。我们可以把它看作“已排序区”,不再动它。剩下的 arr[0]arr[n-2]n-1 个元素,是我们的“待排序区”。

⚠️但是,出现了一个新问题: 

我们把原来在数组末尾的那个“某个值”(通常是一个较小的值)换到了堆顶 arr[0],这几乎百分之百破坏了堆的结构!

如何修复? 这个问题我们再熟悉不过了。

当我们有一个不满足堆序的根节点,但它的两个子树都是合法的堆时,我们应该怎么办? —— 对根节点执行 siftDown

所以,排序阶段的每一步操作应该是这样的:

  1. 将堆顶 arr[0] 与当前堆的最后一个元素 arr[i] 交换。

  2. 此时,最大值被放到了 arr[i] 位置,已排序区扩大。我们将堆的逻辑大小减一(即,我们只考虑 0i-1 的范围)。

  3. 对新的堆顶 arr[0]新的、缩小的范围内执行 siftDown,使其恢复堆序。

我们把这个逻辑写成一个循环:

// 堆排序函数 (版本 3:完成阶段二)
void heapSort(int arr[], int n) {// 阶段一:建堆 (代码同上)for (int i = n / 2 - 1; i >= 0; i--) {siftDown(arr, n, i);}// 阶段二:开始排序循环// 循环从最后一个元素 (n-1) 开始,一直到第二个元素 (1)for (int i = n - 1; i > 0; i--) {// 步骤 1: 将堆顶(当前最大值)与当前堆的末尾元素交换swap(&arr[0], &arr[i]);// 步骤 2: 逻辑上将堆的大小减一,并对新的堆顶(0)在[0, i-1]的范围内进行siftDown// 注意 siftDown 的第二个参数传的是 i,而不是 n。// 这告诉 siftDown,当前的有效堆大小是 i。siftDown(arr, i, 0);}
}

整合并提供完整的辅助函数

到目前为止,heapSort 函数的逻辑已经非常完整和清晰了。

最后,我们把之前一直“假设存在”的辅助函数 siftDownswap 等代码一并给出,形成一个可以完整运行的程序。

#include <iostream>// 辅助函数:交换两个整数的值
void swap(int* a, int* b) {int temp = *a;*a = *b;*b = temp;
}// 核心辅助函数:siftDown (下沉)
// arr: 数组 (我们的堆)
// n:   堆的有效大小 (这个参数很关键,在排序阶段会变化)
// i:   要进行下沉操作的节点的索引
void siftDown(int arr[], int n, int i) {int currentIndex = i;// 循环直到当前节点成为叶子节点while (2 * currentIndex + 1 < n) {int leftChildIndex = 2 * currentIndex + 1;int rightChildIndex = 2 * currentIndex + 2;int largerChildIndex = leftChildIndex; // 先假设左孩子是较大的那个// 如果右孩子存在,并且比左孩子还大if (rightChildIndex < n && arr[rightChildIndex] > arr[leftChildIndex]) {largerChildIndex = rightChildIndex;}// 如果当前节点的值已经比它最大的孩子还大,则满足堆序,停止下沉if (arr[currentIndex] >= arr[largerChild-Index]) {break;}// 否则,与较大的孩子交换swap(&arr[currentIndex], &arr[largerChildIndex]);// 更新当前索引,准备继续向下检查currentIndex = largerChildIndex;}
}// =======================================================
// 最终版的堆排序函数
// =======================================================
void heapSort(int arr[], int n) {// 阶段一:将无序数组构建成一个大顶堆// 从最后一个非叶子节点 (n/2 - 1) 开始,自底向上进行siftDownfor (int i = n / 2 - 1; i >= 0; i--) {siftDown(arr, n, i);}// 阶段二:循环地将堆顶元素(最大值)与末尾元素交换,并重新调整堆// 循环 n-1 次,每次将当前的最大值放到它最终的位置for (int i = n - 1; i > 0; i--) {// 将堆顶(arr[0])与当前堆的末尾元素(arr[i])交换swap(&arr[0], &arr[i]);// 缩小堆的范围,并对新的堆顶(0)进行siftDown,恢复堆的属性siftDown(arr, i, 0);}
}// (可以添加 main 函数来测试)
int main() {int arr[] = {3, 5, 80, 100, 70, 60};int n = sizeof(arr) / sizeof(arr[0]);std::cout << "Original array: ";for (int i = 0; i < n; ++i)std::cout << arr[i] << " ";std::cout << "\n";heapSort(arr, n);std::cout << "Sorted array:   ";for (int i = 0; i < n; ++i)std::cout << arr[i] << " ";std::cout << "\n";return 0;
}

通过以上六个步骤,我们从最基本的排序思想出发,结合堆的特性,逐步设计、推导并最终实现了一个完整、高效的堆排序算法。每一步都建立在前一步的逻辑之上,清晰地展示了从框架到细节的完善过程。

复杂度分析:

  • 建堆是 O(N)。

  • 排序阶段,循环了 N-1 次,每次 siftDown 是 O(logN),所以这部分是 O(NlogN)。

  • 总的时间复杂度是 O(N)+O(NlogN)=O(NlogN)。

至此,我们从堆最基本的操作出发,推导出了一个高效的、原地的排序算法。这就是从第一性原理理解数据结构与算法的威力。

http://www.dtcms.com/a/352682.html

相关文章:

  • 基于单片机光照强度检测(光敏电阻)系统Proteus仿真(含全部资料)
  • 华为鸿蒙HarmonyOS Next基础开发教程
  • uniapp+vue+uCharts开发常见问题汇总
  • uniapp npm安装形式 全局分享和按钮分享设置
  • Spring Boot:统一返回格式,这样搞就对了。
  • HMM简单拓展-HSMM与高阶HMM
  • 视频号存在争议了...
  • 软件开发技术栈
  • JVM之【运行时数据区】
  • 深度学习-----ptorch框架认识-手写数字识别.py项目解读
  • 2025年渗透测试面试题总结-34(题目+回答)
  • three.js+WebGL踩坑经验合集(9.2):polygonOffsetFactor工作原理大揭秘
  • Langchian-chatchat私有化部署和踩坑问题以及解决方案[v0.3.1]
  • More Effective C++ 条款10:在构造函数中防止资源泄漏
  • 二维费用背包 分组背包
  • 小范围疫情防控元胞自动机模拟matlab
  • 深入剖析容器文件系统:原理、实现与资源占用分析
  • 游戏空间划分技术
  • 家庭财务规划与投资系统的设计与实现(代码+数据库+LW)
  • 声网RTC稳定连麦、超分清晰,出海直播技术不再难选
  • AT_abc403_f [ABC403F] Shortest One Formula
  • 【44页PPT】极简架构MES系统解决方案介绍(附下载方式)
  • 【Python】雷达簇类ply点云仿真生成,以及聚类算法的簇类目标检测
  • flutter专栏--dart基础知识
  • WebGIS开发智慧校园(6)JavaScript
  • 破解VMware迁移难题的技术
  • SSH密钥登录全流程详解
  • LeetCode-221. 最大正方形
  • 多模块 Starter 最佳实践(强烈推荐!!!)
  • Quarkus OIDC 安全链路时序图