【C语言】堆排序:从堆构建到高效排序的完整解析

引言

在计算机科学中,排序算法是基础而重要的研究领域。从简单的冒泡排序到高效的快速排序,每种算法都有其独特的优势和适用场景。堆排序作为一种基于完全二叉树结构的排序算法,凭借其O(n log n)的时间复杂度和原地排序的特性,在众多排序算法中占据重要地位。

堆排序的核心思想是利用堆这种数据结构的特性:大根堆的堆顶是最大值,小根堆的堆顶是最小值。通过反复取出堆顶元素并调整堆结构,我们可以实现高效的排序。本文将深入分析堆排序的实现原理,对比不同建堆方式的性能差异,并与传统排序算法进行对比。

目录

引言

堆排序的基本原理

堆排序的核心思想

排序方向与堆类型的关系

用到的函数定义

交换函数

小堆向上调整函数

小堆向下调整函数

大堆向上调整函数

大堆向下调整函数

小堆降序排序的两种实现

方法一:向上调整建小堆(降序排序)

方法二:向下调整建小堆(降序排序)

大堆升序排序的两种实现

方法一:向上调整建大堆(升序排序)

方法二:向下调整建大堆(升序排序)

时间复杂度分析

向上调整建堆的时间复杂度

向下调整建堆的时间复杂度

排序阶段的时间复杂度

与冒泡排序的对比

实际性能测试

测试数据

不同实现的性能差异

堆排序的优势与局限

优势

局限性

应用场景

适合使用堆排序的场景

与其他排序算法的选择

总结


堆排序的基本原理

堆排序的核心思想

堆排序利用堆的特性来实现排序,主要分为两个步骤:

  1. 建堆:将无序数组构建成堆结构

  2. 排序:反复取出堆顶元素,调整剩余元素维持堆性质

排序方向与堆类型的关系

复制代码
//降序,建小堆
//升序,建大堆
//这跟我们的直觉相反

这个看似反直觉的设计其实很有道理:

  • 降序建小堆:小堆的堆顶是最小值,我们每次将堆顶与数组末尾交换,相当于把最小值"沉淀"到数组尾部

  • 升序建大堆:大堆的堆顶是最大值,每次交换将最大值"沉淀"到数组尾部

用到的函数定义

在分析堆排序代码之前,我们先明确用到的核心函数:

交换函数

复制代码
void Swap(int* p1, int* p2)
{
    int temp = *p1;
    *p1 = *p2;
    *p2 = temp;
}

小堆向上调整函数

复制代码
void AdjustUp(int a[], int child)
{
    int parent = (child - 1) / 2;
    while (child > 0)
    {
        if (a[child] < a[parent])  // 小堆:子节点小于父节点则交换
        {
            Swap(&a[child], &a[parent]);
            child = parent;
            parent = (child - 1) / 2;
        }
        else
        {
            break;
        }
    }
}

小堆向下调整函数

复制代码
void AdjustDown(int a[], int n, int parent)
{
    int child = parent * 2 + 1;
    while (child < n)
    {
        // 选择较小的孩子(小堆)
        if (child + 1 < n && a[child + 1] < a[child])
        {
            child++;
        }

        if (a[parent] > a[child])  // 父节点大于子节点,需要调整
        {
            Swap(&a[parent], &a[child]);
            parent = child;
            child = parent * 2 + 1;
        }
        else
        {
            break;
        }
    }
}

大堆向上调整函数

复制代码
void AdjustUp_MaxHeap(int a[], int child)
{
    int parent = (child - 1) / 2;
    while (child > 0)
    {
        if (a[child] > a[parent])  // 大堆:子节点大于父节点则交换
        {
            Swap(&a[child], &a[parent]);
            child = parent;
            parent = (child - 1) / 2;
        }
        else
        {
            break;
        }
    }
}

大堆向下调整函数

复制代码
void AdjustDown_MaxHeap(int a[], int n, int parent)
{
    int child = parent * 2 + 1;
    while (child < n)
    {
        // 选择较大的孩子(大堆)
        if (child + 1 < n && a[child + 1] > a[child])
        {
            child++;
        }

        if (a[parent] < a[child])  // 父节点小于子节点,需要调整
        {
            Swap(&a[parent], &a[child]);
            parent = child;
            child = parent * 2 + 1;
        }
        else
        {
            break;
        }
    }
}

小堆降序排序的两种实现

方法一:向上调整建小堆(降序排序)

复制代码
// 降序排序 - 建小堆(向上调整)
void HeapSortDescending_Up(int a[], int n)
{
    // 建小堆:使用向上调整
    for (int i = 1; i < n; i++)
    {
        AdjustUp(a, i);
    }
    
    // 排序阶段
    int end = n - 1;
    while (end > 0)
    {
        Swap(&a[0], &a[end]);  // 将最小值交换到末尾
        AdjustDown(a, end, 0); // 对剩余元素调整
        end--;
    }
}

建堆过程

  • 从第二个元素开始,逐个进行向上调整

  • 每个新元素都与它的父节点比较,如果违反小堆性质则交换

  • 最终形成完整的小根堆

排序过程

  1. 将堆顶(最小值)交换到数组末尾

  2. 堆大小减1

  3. 对新的堆顶执行向下调整,恢复小堆性质

  4. 重复直到堆中只剩一个元素

方法二:向下调整建小堆(降序排序)

复制代码
// 降序排序 - 建小堆(向下调整)
void HeapSortDescending_Down(int a[], int n)
{
    // 建小堆:使用向下调整
    for (int j = (n - 1 - 1) / 2; j >= 0; j--)
    {
        AdjustDown(a, n, j);
    }
    
    // 排序阶段
    int end = n - 1;
    while (end > 0)
    {
        Swap(&a[0], &a[end]);  // 将最小值交换到末尾
        AdjustDown(a, end, 0); // 对剩余元素调整
        end--;
    }
}

建堆过程

  • 从最后一个非叶子节点开始,向前遍历到根节点

  • 对每个节点执行向下调整,确保以该节点为根的子树满足小堆性质

  • 采用自底向上的方式构建整个小堆

排序过程:与向上调整方法相同

大堆升序排序的两种实现

方法一:向上调整建大堆(升序排序)

复制代码
// 升序排序 - 建大堆(向上调整)
void HeapSortAscending_Up(int a[], int n)
{
    // 建大堆:使用向上调整
    for (int i = 1; i < n; i++)
    {
        AdjustUp_MaxHeap(a, i);
    }
    
    // 排序阶段
    int end = n - 1;
    while (end > 0)
    {
        Swap(&a[0], &a[end]);       // 将最大值交换到末尾
        AdjustDown_MaxHeap(a, end, 0); // 对剩余元素调整
        end--;
    }
}

建堆过程

  • 从第二个元素开始,逐个进行向上调整

  • 每个新元素都与它的父节点比较,如果违反大堆性质则交换

  • 最终形成完整的大根堆

方法二:向下调整建大堆(升序排序)

复制代码
// 升序排序 - 建大堆(向下调整)
void HeapSortAscending_Down(int a[], int n)
{
    // 建大堆:使用向下调整
    for (int j = (n - 1 - 1) / 2; j >= 0; j--)
    {
        AdjustDown_MaxHeap(a, n, j);
    }
    
    // 排序阶段
    int end = n - 1;
    while (end > 0)
    {
        Swap(&a[0], &a[end]);       // 将最大值交换到末尾
        AdjustDown_MaxHeap(a, end, 0); // 对剩余元素调整
        end--;
    }
}

建堆过程

  • 从最后一个非叶子节点开始,向前遍历到根节点

  • 对每个节点执行向下调整,确保以该节点为根的子树满足大堆性质

  • 采用自底向上的方式构建整个大堆

时间复杂度分析

复制代码
//时间复杂度
//堆排序(向上调整)    O(N*logN)
//堆排序(向下调整)    O(N)  // 注:这里指的是建堆阶段,整个堆排序还是O(N*logN)
//冒泡排序    O(N²)

向上调整建堆的时间复杂度

对于向上调整建堆:

  • 第i个元素最多需要比较和交换log₂i次

  • 总操作次数:∑_{i=1}^{n-1} log₂i ≈ O(n log n)

数学证明

设树的高度为h = log₂n

  • 第1层(根):0个节点,调整0次

  • 第2层:最多1个节点,每个调整1次

  • 第3层:最多2个节点,每个调整2次

  • ...

  • 第h层:最多2^{h-1}个节点,每个调整h-1次

总操作次数:∑_{k=1}^{h} (k-1) × 2^{k-1} = O(n log n)

向下调整建堆的时间复杂度

对于向下调整建堆:

  • 从最后一个非叶子节点开始调整

  • 每个节点调整的次数与其高度成正比

  • 总操作次数:∑_{k=0}^{h} (h-k) × 2^k = O(n)

数学证明

设树的高度为h

  • 高度为0的节点:最多2^h个,每个调整0次

  • 高度为1的节点:最多2^{h-1}个,每个调整1次

  • ...

  • 高度为h的节点:1个,调整h次

总操作次数:∑_{k=0}^{h} k × 2^{h-k} = 2^{h+1} - h - 2 = O(n)

排序阶段的时间复杂度

无论使用哪种建堆方式,排序阶段的时间复杂度都是O(n log n):

  • 需要进行n-1次交换和调整操作

  • 每次调整的时间复杂度为O(log n)

与冒泡排序的对比

特性 堆排序 冒泡排序
平均时间复杂度 O(n log n) O(n²)
最坏时间复杂度 O(n log n) O(n²)
空间复杂度 O(1) O(1)
稳定性 不稳定 稳定
适用场景 大数据量 小数据量或基本有序

实际性能测试

测试数据

复制代码
int a[] = { 4,2,8,1,5,6,9,7,3,23,55,232,66,222,33,7,1,66,3333,999 };

不同实现的性能差异

  1. 向上调整建堆

    • 建堆:O(n log n)

    • 排序:O(n log n)

    • 总复杂度:O(n log n)

  2. 向下调整建堆

    • 建堆:O(n)

    • 排序:O(n log n)

    • 总复杂度:O(n log n),但常数因子更小

虽然两种方法的渐近复杂度相同,但向下调整建堆在实际运行中更快,因为它的常数因子更小。

堆排序的优势与局限

优势

  1. 时间复杂度稳定:最坏、平均情况都是O(n log n)

  2. 空间效率高:原地排序,只需要O(1)额外空间

  3. 适用于大数据:相比O(n²)算法,在大数据量时优势明显

  4. 缓存友好:数组存储具有良好的局部性

局限性

  1. 不稳定排序:相同元素的相对位置可能改变

  2. 常数因子较大:相比快速排序,实际运行可能稍慢

  3. 不适合小数据:对于小数组,简单排序可能更快

应用场景

适合使用堆排序的场景

  1. 内存受限环境:需要原地排序且不能使用递归

  2. 实时系统:需要保证最坏情况性能

  3. 大数据排序:数据量太大无法全部加载到内存时,可以使用外部堆排序

  4. 优先级队列实现:堆是优先级队列的自然实现

与其他排序算法的选择

  • 小数据量:插入排序、冒泡排序

  • 一般情况:快速排序(平均性能最好)

  • 需要稳定性:归并排序

  • 内存受限:堆排序

  • 外部排序:多路归并排序

总结

堆排序是一种优雅而高效的排序算法,它巧妙地将完全二叉树的性质应用于排序问题。通过分析我们可以得出以下结论:

  1. 小堆降序排序的两种建堆方式

    • 向上调整建堆:从第二个元素开始逐个调整,时间复杂度O(n log n)

    • 向下调整建堆:从最后一个非叶子节点开始调整,时间复杂度O(n)

  2. 大堆升序排序的两种建堆方式

    • 向上调整建堆:从第二个元素开始逐个调整,时间复杂度O(n log n)

    • 向下调整建堆:从最后一个非叶子节点开始调整,时间复杂度O(n)

  3. 时间复杂度优势:堆排序的O(n log n)时间复杂度在大数据量时相比O(n²)算法有巨大优势

  4. 实际工程考量:虽然堆排序的理论性能优秀,但在实际应用中需要根据具体场景选择,考虑数据特征、稳定性要求等因素

堆排序的价值不仅在于其作为一个独立的排序算法,更在于它展示了如何将数据结构特性与算法设计完美结合的思想。理解堆排序的原理和实现,有助于我们更好地掌握其他基于比较的排序算法,培养分析算法复杂度的能力。

从堆的构建到排序的完整流程,堆排序体现了计算机科学中"分而治之"和"利用数据结构特性"的重要思想,这些思想在解决更复杂的计算问题时同样适用。

相关推荐
+VX:Fegn08954 小时前
计算机毕业设计|基于springboot + vue律师咨询系统(源码+数据库+文档)
java·数据库·vue.js·spring boot·后端·课程设计
努力的小郑4 小时前
2025年度总结:当我在 Cursor 里敲下 Tab 的那一刻,我知道时代变了
前端·后端·ai编程
翔云 OCR API4 小时前
发票查验接口详细接收参数说明-C#语言集成完整示例-API高效财税管理方案
开发语言·c#
Chasing Aurora4 小时前
Python后端开发之旅(三)
开发语言·python·langchain·protobuf
kong79069285 小时前
Java基础-Lambda表达式、Java链式编程
java·开发语言·lambda表达式
码农水水5 小时前
小红书Java面试被问:Online DDL的INSTANT、INPLACE、COPY算法差异
算法
lixzest5 小时前
C++上位机软件开发入门深度学习
开发语言·c++·深度学习
iAkuya5 小时前
(leetcode)力扣100 34合并K个升序链表(排序,分治合并,优先队列)
算法·leetcode·链表
我是小狼君5 小时前
【查找篇章之三:斐波那契查找】斐波那契查找:用黄金分割去“切”数组
数据结构·算法
于越海5 小时前
材料电子理论核心四个基本模型的python编程学习
开发语言·笔记·python·学习·学习方法