[数据结构]堆

1.堆的定义

        堆是一种特殊的完全二叉树,它具有以下关键特性:

  • 堆序性:堆中每个节点的值都满足特定的顺序关系(堆一定具有堆序性,否则就是一个普通的完全二叉树)

    • 在大根堆中:每个节点的值都大于或等于其子节点的值

    • 在小根堆中:每个节点的值都小于或等于其子节点的值

  • 完全二叉树:堆总是一棵完全二叉树,这意味着除了最后一层,其他层都是满的,且最后一层的节点都靠左排列(如果你不知道什么是完全二叉树,请翻阅至本文疑难解答处)。

2.堆的存储结构

        堆通常使用数组来实现顺序存储,这种实现方式既节省空间又高效:

typedef int HPDataType;
typedef struct Heap {
    HPDataType* a;    // 存储堆元素的数组
    int size;         // 当前堆中元素个数
    int capacity;     // 堆的容量
} HP;

对于数组中位置为 i 的节点:

  • 父节点位置:(i-1)/2

  • 左孩子位置:2*i+1

  • 右孩子位置:2*i+2

给大家配个图便于理解,Arr是我们存放堆的数组:

        大家好好看一下,应该不难理解。

3.堆的建成

      3.1堆的插入

        因为堆序性的存在,所以我们在数组末尾插入了新的数据后,必须对数据进行调整,使堆具有堆序性。我们先看调整的算法:

        3.1.1向上调整算法

将新数据插入到数组的尾上,再进行向上调整算法,直到满足堆。

void AdjustUp(HPDataType* a, int child)
{
    根据子节点位置,找到父节点的位置
    int parent = (child - 1) / 2;
    //child等于0时,新加入节点已经是根节点,无需再调整,结束循环
    while(child > 0)
    {
        //此时为小根堆,大根堆将小于号换成大于号即可
        if (a[child] < a[parent])
        {
            //swap函数用于交换两个参数的值
            Swap(&a[child], &a[parent]);
            child = parent;
            parent = (parent - 1) / 2;
        }        
        //如果父节点比子节点小,则符合堆排序,无需再进行调整,结束循环
        else
        {
            break;
        }
    }
}

        学会了向上调整算法,我们插入堆的代码就正式登场啦:

void HPPush(HP* php, HPDataType x) 
{
    // 检查容量并可能扩容
    if (php->size == php->capacity) 
    {
        size_t newCapacity = php->capacity == 0 ? 4 : php->capacity * 2;
        HPDataType* tmp = realloc(php->a, sizeof(HPDataType) * newCapacity);
    //如果没有成功开辟空间
        if (tmp == NULL)
        {
            perror("realloc fail");
            return;
        }
        //更新存储容量和地址
        php->a = tmp;
        php->capacity = newCapacity;
    }
    
    // 插入到末尾并向上调整
    php->a[php->size] = x;
    php->size++;
    AdjustUp(php->a, php->size - 1);
}

3.2堆的删除

        删除堆是删除堆顶的数据,将堆顶的数据根最后⼀个数据交换位置,然后删除数组最后⼀个数据,再进行向下调整算法。

        3.2.1向下调整算法

       该算法流程图如下:

 向下调整算法有⼀个前提:左右子树必须是⼀个堆,才能调整。

        过程如下图:

代码如下:

// 小根堆的向下调整算法
void AdjustDown(HPDataType* 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[child] < a[parent]) 
        {  
            Swap(&a[child], &a[parent]);
            parent = child;           // 继续向下调整
            child = parent * 2 + 1;   // 新的左孩子位置
        } 
        else 
        {
            break;  // 已经满足小堆性质,调整结束
        }
    }
}

        OK,学会了向下调整算法,我们一起看一下删除操作的代码吧:

void HPPop(HP* php)
{
    //assert是断言,保证该堆不为空
    assert(php);
    assert(php->size > 0);
    //先将首节点和尾节点交换位置
    Swap(&php->a[0], &php->a[php->size - 1]);
    php->size--;
    AdjustDown(php->a, php->size, 0);
}

现在恭喜你,学会了最难的部分,下面我们将其他堆常用的功能也加入代码,大家理解起来应该不会有太大困难:

4.完整代码

        该代码完整了堆的功能。已给大家详细的注释了(该例子中为大根堆):

#include <stdio.h>
#include <stdlib.h>
#include <stdbool.h>
#include <assert.h>

typedef int HPDataType;

// 堆结构体定义
typedef struct Heap
{
    HPDataType* a;    // 指向存储堆元素的数组
    int size;         // 当前堆中元素个数
    int capacity;     // 堆的容量
} HP;

// 交换函数 - 交换两个元素的值
void Swap(HPDataType* a, HPDataType* b)
{
    HPDataType temp = *a;
    *a = *b;
    *b = temp;
}

// 向上调整算法(大堆) - 从child位置开始向上调整
void AdjustUp(HPDataType* a, int child)
{
    int parent = (child - 1) / 2;  // 计算父节点位置
    while (child > 0)              // 当child不是根节点时
    {
        if (a[child] > a[parent])  // 如果孩子比父节点大(大堆性质)
        {
            Swap(&a[child], &a[parent]);  // 交换父子节点
            child = parent;                // 继续向上调整
            parent = (parent - 1) / 2;
        }
        else
        {
            break;  // 已经满足堆性质,调整结束
        }
    }
}

// 向下调整算法(大堆) - 从parent位置开始向下调整
void AdjustDown(HPDataType* 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[child] > a[parent]) 
        {  
            Swap(&a[child], &a[parent]);
            parent = child;           // 继续向下调整
            child = parent * 2 + 1;   // 新的左孩子位置
        } 
        else 
        {
            break;  // 已经满足堆性质,调整结束
        }
    }
}

// 默认初始化堆 - 创建一个空的堆
void HPInit(HP* php)
{
    assert(php);  // 确保指针有效
    
    php->a = NULL;      // 初始时数组为空
    php->size = 0;      // 初始时元素个数为0
    php->capacity = 0;  // 初始时容量为0
}

// 利用给定数组初始化堆 - 使用已有数组构建堆
void HPInitArray(HP* php, HPDataType* a, int n)
{
    assert(php && a);  // 确保指针和数组有效
    
    // 分配内存存储数组元素
    php->a = (HPDataType*)malloc(sizeof(HPDataType) * n);
    if (php->a == NULL)
    {
        perror("malloc fail");
        return;
    }
    
    // 复制数组元素
    for (int i = 0; i < n; i++)
    {
        php->a[i] = a[i];
    }
    
    php->size = n;          // 设置元素个数
    php->capacity = n;      // 设置容量
    
    // 从最后一个非叶子节点开始,向下调整构建堆
    // 最后一个非叶子节点的索引 = (n-1-1)/2 = (n-2)/2
    for (int i = (n - 1 - 1) / 2; i >= 0; i--)
    {
        AdjustDown(php->a, n, i);
    }
}

// 堆的销毁 - 释放堆占用的内存
void HPDestroy(HP* php)
{
    assert(php);  // 确保指针有效
    
    if (php->a)
    {
        free(php->a);      // 释放数组内存
        php->a = NULL;     // 指针置空,防止野指针
    }
    php->size = 0;         // 元素个数归零
    php->capacity = 0;     // 容量归零
}

// 堆的插入 - 向堆中插入新元素
void HPPush(HP* php, HPDataType x)
{
    assert(php);  // 确保指针有效
    
    // 检查容量,如果不够则扩容
    if (php->size == php->capacity)
    {
        // 如果当前容量为0,则初始化为4,否则翻倍
        size_t newCapacity = php->capacity == 0 ? 4 : php->capacity * 2;
        
        // 重新分配内存
        HPDataType* tmp = (HPDataType*)realloc(php->a, sizeof(HPDataType) * newCapacity);
        if (tmp == NULL)
        {
            perror("realloc fail");
            return;
        }
        
        php->a = tmp;              // 更新数组指针
        php->capacity = newCapacity; // 更新容量
    }
    
    // 插入新元素到数组末尾
    php->a[php->size] = x;
    php->size++;                   // 元素个数加1
    
    // 向上调整,恢复堆性质
    AdjustUp(php->a, php->size - 1);
}

// 获取堆顶元素 - 返回堆顶的元素值
HPDataType HPTop(HP* php)
{
    assert(php);                    // 确保指针有效
    assert(php->size > 0);          // 确保堆不为空
    
    return php->a[0];              // 返回堆顶元素
}

// 删除堆顶的数据 - 移除堆顶元素
void HPPop(HP* php)
{
    assert(php);                    // 确保指针有效
    assert(php->size > 0);          // 确保堆不为空
    
    // 将堆顶元素与最后一个元素交换
    Swap(&php->a[0], &php->a[php->size - 1]);
    php->size--;                   // 元素个数减1(相当于删除原堆顶)
    
    // 从根节点开始向下调整,恢复堆性质
    AdjustDown(php->a, php->size, 0);
}

// 判空 - 检查堆是否为空
bool HPEmpty(HP* php)
{
    assert(php);  // 确保指针有效
    
    return php->size == 0;  // 如果大小为0则为空
}

// 求size - 返回堆中元素个数
int HPSize(HP* php)
{
    assert(php);  // 确保指针有效
    
    return php->size;  // 返回元素个数
}

5.堆的应用

      5.1堆排序

        版本一:基于已有数组建堆、取堆顶元素完成排序版本,该版本容易理解,就是建堆,利用堆序性将对顶元素循环输出直至堆为空即可。
// 1、需要堆的数据结构
// 2、空间复杂度 O(N)
void HeapSort(int* a, int n)
{
    HP hp;
    //将数组a中的元素循环入堆hp中
    for(int i = 0; i < n; i++)
    {
        HPPush(&hp,a[i]);
    }
    int i = 0;
    //循环出堆顶元素知道堆为空
    while (!HPEmpty(&hp))
    {
        a[i++] = HPTop(&hp);
        HPPop(&hp);
    }    
    //销毁堆
    HPDestroy(&hp);
}

        版本二:大多数时候,我们的堆都是由数组存储的。这个版本其实是借用的堆的思想进行排序

// 升序,建⼤堆
// 降序,建⼩堆
// O(N*logN)
void HeapSort(int* a, int n)
{
    // a数组直接建堆 O(N)
    //i的初值为最后一个节点的父节点位置
    //i<0则终止循环,此时已经完成排序,再循环会越界
    for (int i = (n-1-1)/2; i >= 0; --i)
    {
        AdjustDown(a, n, i);
    }
    // O(N*logN)
    //此时完成堆排序
    int end = n - 1;
    while (end > 0)
    {
        //交换堆顶元素和末尾元素
        Swap(&a[0], &a[end]);
        //再次对数组排序,使其重新具有堆序性
        AdjustDown(a, end, 0);
        //end可以理解为记录需要排序的数组
        //数组末尾是最大值,已完成排序,end--,下次即可不用再排
        //这里前后倒置了一下,所以大堆升序,小堆降序
        --end;
    }
}

         堆的时间复杂度为O(n log n)。

        想了解堆排序的时间复杂度如何计算的同学可以去看后文的疑难解答。

5.2TOPK问题

        顾名思义,就是在一堆数据当中,找到前k个最大(最小/自定义关系)的值。

void CreateNDate()
{
    // 造数据
    int n = 100000;
    //利用时间戳造随机数
    srand(time(0));
    const char* file = "data.txt";
    //写入文件
    FILE* fin = fopen(file, "w");
    //如果无法成功打开并写入
    if (fin == NULL)
    {
        //报错返回
        perror("fopen error");
        return;
    }
    //将随机数控制在0-999999内,并打印在文件中
    for (int i = 0; i < n; ++i)
    {
        //可以不加i,加i只是为了增强随机性
        int x = (rand()+i) % 1000000;
        fprintf(fin, "%d\n", x);
    }
    fclose(fin);
}
void topk()
{
    printf("请输⼊k:>");
    int k = 0;
    scanf("%d", &k);
    const char* file = "data.txt";
    FILE* fout = fopen(file, "r");
    if (fout == NULL)
    {
        perror("fopen error");
        return;
    }
    int val = 0;
    //建小堆,小堆为降序
    //创建k个空间,这里是整型空间
    int* minheap = (int*)malloc(sizeof(int) * k);
    if (minheap == NULL)
    {
        perror("malloc error");
        return;
    }
    for (int i = 0; i < k; i++)
    {
        //从文件读取数据到minheap
        fscanf(fout, "%d", &minheap[i]);
    }
    // 建k个数据的⼩堆并堆排序
    for (int i = (k - 1 - 1) / 2; i >= 0; i--)
    {
        AdjustDown(minheap, k, i);
    }
    int x = 0;
    while (fscanf(fout, "%d", &x) != EOF)
    {
        // 读取剩余数据,⽐堆顶的值⼤,就替换他进堆
        if (x > minheap[0])
        {
            minheap[0] = x;
            //重新对堆进行排序
            AdjustDown(minheap, k, 0);
        }
    }
    for (int i = 0; i < k; i++)
    {
        printf("%d ", minheap[i]);
    }
    fclose(fout);
}

        这里需要提一下,最后输出的代码是按堆中的顺序进行输出的,如果大家需要按需输出,可以自行对minheap数组进行排序。

6.疑难解答

6.1什么是完全二叉树?

完全二叉树是一种特殊的二叉树,它满足以下条件:

  1. 除了最后一层外,所有层的节点数都达到最大值(即第 k 层有 2^(k-1) 个节点)

  2. 最后一层的所有节点连续集中在该层的最左边

        是不是有点抽象?我们给张图理解一下:

        讲人话就是:完全二叉树是从上到下、从左到右依次填充节点的二叉树。

6.2什么是堆序性?

        堆序性要结合二叉树理解,找了个例子给大家看一下:

        上图是一个小根堆,其子节点全部大于或等于其父节点。(至于那个节点为什么是红的?当然是我因为我懒随便找了个图)

6.3堆排序的时间复杂度计算

        通过分析发现,堆排序第⼆个循环中的向下调整与建堆中的向上调整算法时间复杂度计算⼀致,此处不再赘述。因此,堆排序的时间复杂度为 O(n + n ∗ log n) ,即 O(n log n)


———(如有问题,欢迎评论区提问)———

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Bit_Le

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值