堆的结构及函数接口、堆排序,TopK

本篇内容涉及到二叉树的概念及性质,可参考文章 树和二叉树的概念及性质

一、堆的概念

堆是一颗 完全二叉树,并且数据满足如下性质

  • 如果树中 所有父节点 的值都 大于等于 子节点的值,称作 大堆(最大堆、大根堆)
  • 如果树中 所有父节点 的值都 小于等于 子节点的值,称作 小堆(最小堆、小根堆)

在这里插入图片描述

二、堆的存储结构

在上篇 树和二叉树的概念及性质 的最后,介绍了 完全二叉树的编号,以及 通过某个节点的编号可以轻松的找到该节点的父节点和孩子节点,因此可以 根据编号作为下标数组来存储堆

//堆的数据类型
typedef int HeapDataType;

//堆的结构
typedef struct Heap 
{
	HeapDataType* data;
	int size;	//存储的数据个数
	int capacity;	//当前的容量
}Heap;

在这里插入图片描述

三、堆的函数接口

1. 初始化及销毁

创建一个堆之后,堆结构中的成员变量存储的都是一些随机值,所以需要对其进行初始化,这里采用 初始化时不分配空间 的方式,也可以在初始化时就为其分配一些空间

初始化函数如下:

void HeapInit(Heap* pHp)
{
	//pHp 不能为空指针
  	assert(pHp);

	//初始化
	pHp->data = NULL;
	pHp->size = pHp->capacity = 0;
}

在堆中:存储数据的空间是动态开辟的,不使用时应手动释放

销毁函数如下:

void HeapDestroy(Heap* pHp)
{
	//pHp 不能为空指针
    assert(pHp);

    free(pHp->data);
    pHp->data = NULL;
    pHp->size = pHp->capacity = 0;
}

2. 打印函数

为了验证堆的插入、删除等得到的结果是否正确,提供打印堆的函数,这里数据类型以 int 为例,当读者采用的类型不同时,自行更改该函数即可

打印函数如下:

void HeapPrint(Heap* pHp)
{
    assert(pHp);

    for(int i = 0; i < pHp->size; ++i)
    {
        printf("%d ", pHp->data[i]);
    }
    printf("\n");
}

3. 堆的插入

由于 堆是一颗完全二叉树,因此只能在 最后一个编号之后插入数据,以大堆为例

插入的值 小于 父节点的值 时,插入之后的完全二叉树 还是大堆
在这里插入图片描述
插入的值 大于 父节点的值 时,插入之后的完全二叉树就 不是大堆 了,此时便 需要将结构调整为大堆
在这里插入图片描述
调整方法:将插入的结点值和父节点值交换,交换之后,如果该值还大于父节点的值,则 继续和父节点交换,直到 交换后的结点值小于等于父节点值该节点已经是根节点

调整过程中,所需要 判断的所有节点都是插入节点的祖先,因此 称作向上调整
在这里插入图片描述

插入函数如下:

//交换值
void Swap(HeapDataType* p1, HeapDataType* p2)
{
    HeapDataType tmp = *p1;
    *p1 = *p2;
    *p2 = tmp;
}

//向上调整,调整时需要存储堆的数组、调整对象编号
void AdjustUp(HeapDataType* array, int child)
{
    assert(array);

    //计算孩子的父节点
    int parent = (child - 1) / 2;

    //交换到根节点后,便停止交换
    while(child > 0)
    {
        //如果孩子节点值大于父节点值,则需要交换父子节点的值,否则调整完成
        //将这里的 大于 改成 小于,就是小堆的的向上调整
        if(array[child] > array[parent]) 
        {
            Swap(&array[child], &array[parent]);
            child = parent;	//父子节点的值已经交换,需要更新孩子的指向
            parent = (child - 1) / 2;	//计算孩子的父节点
        }
        else 
        {
            break;
        }
    }
}

//插入
void HeapPush(Heap* pHp, HeapDataType x)
{
    assert(pHp);

    //扩容
    if(pHp->size == pHp->capacity)
    {
        int newCapacity = pHp->capacity == 0 ? 4 : 2 * pHp->capacity;
        HeapDataType* tmp = (HeapDataType*)realloc(pHp->data, sizeof(HeapDataType) * newCapacity);
        if(tmp == NULL)
        {
            perror("realloc");
            exit(-1);
        }

        pHp->data = tmp;
        pHp->capacity = newCapacity;
    }

    //插入数据
    pHp->data[pHp->size] = x;
    pHp->size++;

    //向上调整
    AdjustUp(pHp->data, pHp->size - 1);
}

4. 堆的删除

堆只会 删除堆顶的数据,删除其他位置的数据意义不大,以大堆为例

由于 数组尾删的效率很高,因此 为了较易删除堆顶的数据,分三步进行

第一步:将 堆顶的数据最后一个数据 交换
在这里插入图片描述
第二步:删除最后一个数据
在这里插入图片描述
第三步:将删除后的完全二叉树 调整为大堆
在这里插入图片描述

调整方法:将较大的孩子结点的值和堆顶节点值交换,交换之后,如果左右孩子中的较大值还大于该值,则 继续将较大的孩子结点的值和该节点值交换,直到 左右孩子的值小于等于交换后的结点值该节点已经是叶节点

调整过程中,所需要 判断的所有节点都是堆顶节点的子孙,因此 称作向下调整
在这里插入图片描述
删除函数如下:

//向下调整,调整时需要存储堆的数组、调整对象编号、堆的数据个数
void AdjustDown(HeapDataType* array, int parent, int n)
{
    assert(array);

    //假设左孩子为需要交换的孩子
    int child = parent * 2 + 1;

	//交换到叶节点,便停止交换
	//完全二叉树中,左孩子不存在,右孩子也就不存在了
    while(child < n)
    {
        //如果假设错误,则需要更新 child 为右孩子
        //需要注意:右孩子可能不存在
        //将这里和下面 if 语句的 大于 改成 小于,就是小堆的向下调整
        if(child + 1 < n && array[child + 1] > array[child]) 
        {
            ++child;
        }

        //如果较大的子节点的值大于父节点的值,则需要交换,否则调整完成
        //将这里和上面 if 语句中的 大于 改成 小于,就是小堆的向下调整
        if(array[child] > array[parent])
        {
            Swap(&array[child], &array[parent]);
            parent = child;	//父子节点的值已经交换,需要更新双亲的指向
            child = parent * 2 + 1;	//假设左孩子为需要交换的孩子
        }
        else 
        {
            break;
        }
    }
}

void HeapPop(Heap* pHp)
{
    assert(pHp);
    assert(!HeapEmpty(pHp));

    //第一步:交换堆顶和最后一个数据
    Swap(&pHp->data[0], &pHp->data[pHp->size - 1]);

    //第二步删除最后一个数据
    pHp->size--;

    //第三步:向下调整
    AdjustDown(pHp->data, 0, pHp->size);
}

5. 取堆顶、判空、数据个数

这些函数较为简单,就不做分析了

函数如下:

//取堆顶
HeapDataType HeapTop(Heap* pHp)
{
    assert(pHp);
    assert(!HeapEmpty(pHp));

    return pHp->data[0];
}

//判空
bool HeapEmpty(Heap* pHp)
{
    assert(pHp);

    return pHp->size == 0;
}

//数据个数
size_t HeapSize(Heap* pHp)
{
    assert(pHp);

    return pHp->size;
}

四、建堆算法和时间复杂度

数组 array { 25, 15, 51, 30, 20, 19 },交换数组元素使之变为堆,要求空间复杂度为 O(1)

1. 向上调整建堆

将数组的元素看做一棵完全二叉树
在这里插入图片描述
在堆的插入中,插入数据之前,数组本身是堆,当插入的数据大于父节点时,通过向上调整,便可以将数组调整为堆

为了可以使用向上调整算法,需要满足调整之前数组本来就是堆

当数组只有一个元素时,可以将其看做一个堆,于是便可以不断的对新数据进行向上调整,最终就可以将整个数组调整为堆
在这里插入图片描述

调整结果:
在这里插入图片描述

向上调整函数 已经在 堆的插入给出建堆循环 如下:

//数组和数组大小
int array[] = { 25, 15, 51, 30, 20, 19 };
int len = sizeof(array)/sizeof(array[0]);

//向上调整建堆
//时间复杂度:O(N * logN)
for(int i = 1; i < len; ++i)
{
	AdjustUp(array, i);	//对新数据进行向上调整
}

向上调整建堆的时间复杂度:
在这里插入图片描述
对于高度为 h 的堆,总节点数 N = 2h - 1,总调整次数:

F(h) = 21 * 1 + 22 * 2 + … + 2h - 1 * (h - 1)
小于 2 * 2h - 1 * (h - 1) = 2h * (h - 1) = (N + 1) * (log2(N + 1) - 1)

因此向上调整建堆的时间复杂度为 O(N * log2N)

2. 向下调整建堆

将数组的数据看做一棵完全二叉树:
在这里插入图片描述
在堆的删除中:先将堆顶和最后一个数据交换,然后删除最后一个数据,此时堆顶的左子树和右子树均是堆,通过向下调整,便可以将数组调整为堆

为了可以使用向下调整算法,需要满足调整的节点的左子树和右子树均是堆

显然数组的第一个数据的左子树和右子树不是堆,此时并不能从第一个开始向下调整,而是需要从最后一个节点开始,从后往前对每一个节点向下调整

由于数组只有一个元素时,可以将其看做一个堆,因此可以 从最后一个分支节点开始,在完全二叉树中,最后一个分支节点就是最后一个节点的父节点,最后便可以将数组调整为堆
在这里插入图片描述

调整结果:
在这里插入图片描述

向下调整函数 已经在 堆的删除给出建堆循环 如下:

//数组和数组大小
int array[] = { 25, 15, 51, 30, 20, 19 };
int len = sizeof(array)/sizeof(array[0]);

//向下调整建堆
//时间复杂度O(N)
//从最后一个分支节点开始,从后往前
for(int i = (len - 1 - 1) / 2; i >= 0; --i)
{
	AdjustDown(array, i, len);	//向下调整为堆,为后续向下调整做准备
}

时间复杂度
在这里插入图片描述
对于高度为 h 的堆,总节点数 N = 2h - 1,总调整次数:

F(h) = 20 * (h - 1) + 21 * (h - 2) + … + 2h - 3 * 2 + 2h - 2 * 1
2 * F(h) = 21 * (h - 1) + 22 * (h - 2) + … + 2h - 2 * 2 + 2h - 1 * 1

2 * F(h) - F(h) 错位相减得:
F(h) = - 20 * (h - 1) + 21 * 1 + … + 2h - 3 * 1 + 2h - 2 * 1 + 2h - 1 * 1
F(h) = 2h - 2 - (h - 1) = 2h - 1 - h = N - log2N

因此向下调整建堆的时间复杂度为 O(N)

五、堆排序和 TopK 问题

对数组 array { 25, 15, 51, 30, 20, 19 } 进行原地排序,升序建大堆,降序建小堆

在这里插入图片描述
堆排序时间复杂度:O(N * log2N),计算方法和向上调整建堆相似

堆排序函数如下:

void HeapSort(int* array, int arrayLen)
{
    //升序建大堆
    for(int i = (arrayLen - 1 - 1) / 2; i >= 0; --i)
    {
        AdjustDown(array, i, arrayLen);
    }

	//每一次将堆顶和最后一个数据交换,并且不将最后一个数据看做堆的数据,进行向下调整为堆
	//如果升序建小堆,出的数据没有存放的地方
    int end = arrayLen - 1;
    while(end > 0)
    {
        Swap(&array[0], &array[end]);
        AdjustDown(array, 0, end);
        --end;
    }
}

排序过程:
在这里插入图片描述
选取数据中前 K 个最大数据或最小数据,一般数据量都很大,无法存储在内存中

选取前 K 个最大数据,建 K 个数据的小堆,选取前 K 个最小数据,建 K 个数据的大堆

时间复杂度:K + (N - K) * log2K -> O(N * log2K)
空间复杂度:O(K)

//用于测试
void arrayPrint(int* array, int arrayLen)
{
    for(int i = 0; i < arrayLen; ++i)
    {
        printf("%d ", array[i]);
    }
    printf("\n");
}

void HeapTest5()
{
    int n = 10000;  //数据个数
    int k = 5;  //选取的 K 个数

    //设置随机数种子
    srand((unsigned)time(NULL));

    FILE* fin = fopen("data.txt", "w");
    if(fin == NULL)
    {
        perror("fopen");
        exit(-1);
    }

    //制造数据
    for(int i = 1; i <= n; ++i)
    {
        int val = rand() % 100;
        fprintf(fin, "%d ", val);

        //制造 2k 个较大的数
        if(i % (n / k / 2) == 0)
        {
            fprintf(fin, "%d ", i * 100 + val);
        }
    }

    fclose(fin);

	//创建 k 个空间,用来存储堆
    int* array = (int*)malloc(sizeof(int) * k);
    if(array == NULL)
    {
        perror("malloc");
        exit(-1);
    }

    FILE* fout = fopen("data.txt", "r");
    if(fout == NULL)
    {
        perror("fopen");
        exit(-1);
    }

    //读取前 k 个数据
    for(int i = 0; i < k; ++i)
    {
        fscanf(fout, "%d", &array[i]);
    }

    //arrayPrint(array, k);

    //建小堆
    for(int i = (k - 1 - 1) / 2; i >= 0; --i)
    {
        AdjustDown(array, i, k); 
    }

    //arrayPrint(array, k);
    
    //遍历数据
    int val = 0;
    while(fscanf(fout, "%d", &val) != EOF)
    {
    	//比堆顶大就替换堆顶,然后调整为小堆
        if(array[0] < val)
        {
            array[0] = val;
            AdjustDown(array, 0, k);
        }
    }

    arrayPrint(array, k);

    fclose(fout);
}

int main()
{
    HeapTest5();

    return 0;
}
  • 29
    点赞
  • 27
    收藏
    觉得还不错? 一键收藏
  • 25
    评论
评论 25
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值