数据结构--堆

目录

一、堆的概念与结构

[1.1 堆的定义](#1.1 堆的定义)

[1.2 堆的存储结构](#1.2 堆的存储结构)

[1.2.1 物理存储:数组](#1.2.1 物理存储:数组)

[1.2.2. 数组下标与树节点的对应关系](#1.2.2. 数组下标与树节点的对应关系)

二、堆的代码实现

[2.1 定义结构体成员以及方法](#2.1 定义结构体成员以及方法)

[2.2 初始化堆](#2.2 初始化堆)

[2.3 销毁堆](#2.3 销毁堆)

[2.4 交换两个元素的值](#2.4 交换两个元素的值)

[2.5 向堆中插入元素](#2.5 向堆中插入元素)

[2.6 向上调整算法](#2.6 向上调整算法)

[2.7 删除堆顶元素](#2.7 删除堆顶元素)

[2.8 向下调整算法](#2.8 向下调整算法)

[2.9 获取堆顶元素](#2.9 获取堆顶元素)

[2.10 判断堆是否为空](#2.10 判断堆是否为空)

[2.11 获取堆中元素个数](#2.11 获取堆中元素个数)

[2.12 测试](#2.12 测试)

三、堆排序

[3.1 堆排序的基本思想](#3.1 堆排序的基本思想)

[3.2 堆排序的步骤详解](#3.2 堆排序的步骤详解)

[3.2.1 建堆](#3.2.1 建堆)

[3.2.2 排序过程](#3.2.2 排序过程)

[3.3 堆排序的代码实现](#3.3 堆排序的代码实现)

四、TOP-K问题

五、总结


(本文章所用语言均为 C 语言)

一、堆的概念与结构

1.1 堆的定义

堆(Heap) 是一种特殊的完全二叉树,它满足以下性质:

1. 堆是完全二叉树

  • 除了最后一层,其他层都是满的

  • 最后一层节点从左到右连续排列

2. 堆序性(大堆/小堆)

  • 大根堆(大堆):每个节点的值 ≥ 其子节点的值

  • 小根堆(小堆):每个节点的值 ≤ 其子节点的值

1.2 堆的存储结构

1.2.1 物理存储:数组

虽然堆是树形结构,但因为它是完全二叉树,可以用数组进行存储。

代码定义如下:

cpp 复制代码
typedef int HPDataType;

typedef struct Heap
{
	HPDataType* a;	// 指向动态数组
    int size;       // 堆中元素个数
    int capacity;   // 数组容量
}HP;

1.2.2. 数组下标与树节点的对应关系

对于数组中下标为 i 的节点:

父节点下标 = (i - 1) / 2
左孩子下标 = i * 2 + 1
右孩子下标 = i * 2 + 2

因为堆是特殊的完全二叉树,自然符合二叉树的公式定义。我们可以画图验证一下。

(这里就以求父节点为例子)

好的,在了解完这些之后,我们就可以用代码来实现堆

二、堆的代码实现

  • 项目结构

2.1 定义结构体成员以及方法

代码如下:

cpp 复制代码
//Heap.h
#pragma once
#include<stdio.h>
#include<assert.h>
#include<stdlib.h>
#include<stdbool.h>
#include<time.h>

typedef int HPDataType;

typedef struct Heap
{
    HPDataType* a;  // 动态数组,存储堆元素
    int size;       // 堆中元素的个数(也相当于堆顶位置)
    int capacity;   // 数组的容量(当size == capacity时需要扩容)
} HP;

//初始化堆
void HeapInit(HP* php);
//销毁堆
void HeapDestroy(HP* php);
//交换两个元素的值
void Swap(HPDataType* p1, HPDataType* p2);
//向堆中插入元素(入堆)
void HeapPush(HP* php, HPDataType x);
//删除堆顶元素(出堆)
void HeapPop(HP* php);
//获取堆顶元素
HPDataType HeapTop(HP* php);
//判断堆是否为空
bool HeapIsEmpty(HP* php);
//获取堆中元素个数
int HeapSize(HP* php);
//向上调整算法(用于插入操作)
void AdjustUp(HPDataType* a, int child);
//向下调整算法(用于删除操作和建堆)
void AdjustDown(HPDataType* a, int n, int parent);

2.2 初始化堆

代码如下:

cpp 复制代码
//Heap.c
void HeapInit(HP* php)
{
	assert(php);
    // 开辟初始容量为4的动态数组
	HPDataType* tmp = (HPDataType*)malloc(sizeof(HPDataType) * 4);
	if (tmp == NULL)
	{
		perror("malloc error");
		return;
	}
	php->a = tmp;
	php->size = 0;
	php->capacity = 4;
}

2.3 销毁堆

代码如下:

cpp 复制代码
//Heap.c
void HeapDestroy(HP* php)
{
	assert(php);            // 检查堆指针是否为空
    free(php->a);           // 释放动态数组空间
    php->a = NULL           //防止变为野指针      
    php->size = 0;          // 堆元素个数置为0
    php->capacity = 0;      // 堆容量置为0
}

2.4 交换两个元素的值

代码如下:

cpp 复制代码
//Heap.c
void Swap(HPDataType* p1, HPDataType* p2)
{
	HPDataType tmp = *p1;
	*p1 = *p2;
	*p2 = tmp;
}

2.5 向堆中插入元素

代码如下:

cpp 复制代码
//Heap.c
//这里我们以大根堆的形式来入堆
void HeapPush(HP* php, HPDataType x)
{
	assert(php);
    //判断空间是否满了
	if (php->size == php->capacity)
	{
        //满了的话,就扩容
		HPDataType* tmp = (HPDataType*)realloc(php->a,sizeof(HPDataType) * php->capacity * 2);
		if (tmp == NULL)
		{
			perror("realloc error");
			return;
		}
		php->a = tmp;
		php->capacity *= 2;
	}
    //然后将值放进去
	php->a[php->size] = x;
    //堆中元素个数 +1
	php->size++;
    //之后进入这个函数,让堆以大根堆的形式来排列
    //为什么要传 php->size - 1 作为参数
    //因为向上调整是从最后一个节点来进行调整的
    //所有需要传入最后一个节点的位置,也就是 php->size - 1
	AdjustUp(php->a, php->size - 1);
}

2.6 向上调整算法

我先给大家举个例子来理解向上调整是怎么运行的:

复制代码
比如说我现在要插入15                
        10               10                    10                          15
       /  \    插入15    / \     向上调整      /  \    循环继续,向上调整    / \
      7    8    →       7   8       →        15   8         →            10  8
     /                 / \                  / \                         / \
    5                 5   15               5   7                       5   7

符合要求,退出循环

代码如下:

cpp 复制代码
//Heap.c
//向上调整算法(用于插入操作)
void AdjustUp(HPDataType* a, int child) // 向上调整(建大根堆)
{
    // 找到当前结点的父节点(前面讲的公式)
    int parent = (child - 1) / 2;        
    // 直到调整到根节点
    while (child > 0)                    
    {
        // 如果子节点大于父节点
        //如果想改成小根堆的形式,只需要将
        // a[parent] < a[child] 改为:
        // a[parent] > a[child]

        if (a[parent] < a[child])        
        {
            // 然后交换父子节点
            Swap(&a[parent], &a[child]); 
            // 子节点移动到父节点位置
            child = parent; 
            // 更新父节点位置             
            parent = (child - 1) / 2;    
        }
        else
        {
            // 满足堆结构,就结束
            break;                       
        }
    }
}

2.7 删除堆顶元素

代码如下:

cpp 复制代码
//Heap.c
void HeapPop(HP* php)                    // 删除堆顶元素
{
    assert(php);                         // 检查堆指针是否为空
    assert(!HeapIsEmpty(php));           // 判断堆是否为空

    //因为堆顶元素是数组的最后一个元素
    //所以我们只需要与数组第一个元素交换就可以
    Swap(&php->a[0], &php->a[php->size - 1]); // 堆顶与最后一个元素交换
    php->size--;                              // 删除最后一个元素
    
    //为什么向下调整要传三个参数
    //如果只传两个参数(如数组和父节点),就无法知道数组的边界,可能导致越界访问;
    //如果只传数组和大小,则无法指定从哪个节点开始调整。
    //所以要传三个参数
    AdjustDown(php->a, php->size, 0);         // 从根节点开始向下调整
}

2.8 向下调整算法

代码如下:

cpp 复制代码
//Heap.c
//向下调整算法(用于删除操作和建堆)
void AdjustDown(HPDataType* a, int n, int parent) // 向下调整(建大根堆)
{
    // 默认左孩子最大的,
    int child = 2 * parent + 1;               
    //防止越界
    while (child < n)                         
    {
        // 找左右孩子中较大的那个
        //如果想改成小根堆的形式,只需要将
        //a[child] < a[child + 1] 、 a[child] > a[parent]
        //改为:a[child] > a[child + 1] 、 a[child] < a[parent]
        if (child + 1 < n && a[child] < a[child + 1]) 
        {
            //不是左孩子大,就移到右孩子这边
            ++child;
        }
        // 如果孩子大于父节点,就进行交换
        if (a[child] > a[parent])             
        {
            Swap(&a[child], &a[parent]);      
            parent = child;                   
            child = 2 * parent + 1;          
        }
        else
        {
            break;                            
        }
    }
}

这里的交换思路和向上调整算法差不多,只不过 向上调整算法 是从最后一个节点开始移动的,而 向下调整算法 是从头节点开始移动的,就像图中这样

复制代码
现在的栈顶元素是15                
        15                                7                                 7
       /  \       栈顶和栈尾交换位置      /  \        删除栈顶元素            / \ 
     10    8              →            10   8             →               10   8
    / \                                / \                               / 
   5   7                              5   15                            5  
复制代码
                        
   然后向下调整              10
(记住是从头开始的)          /  \       (符合要求,退出循环)
        →                7    8
                        /  
                       5

2.9 获取堆顶元素

代码如下:

cpp 复制代码
//Heap.c
HPDataType HeapTop(HP* php)
{
	assert(php);
    //直接返回堆顶的第一个元素
	return php->a[0];
}

2.10 判断堆是否为空

代码如下:

cpp 复制代码
//Heap.c
bool HeapIsEmpty(HP* php)
{
	assert(php);
    //看元素个数为是否为 0,0的话就是空的
	return php->size == 0;
}

2.11 获取堆中元素个数

代码如下:

cpp 复制代码
//Heap.c
int HeapSize(HP* php)
{
	assert(php);
    //直接返回元素个数即可
	return php->size;
}

2.12 测试

  • 测试代码:
cpp 复制代码
//test.c
#define _CRT_SECURE_NO_WARNINGS 1
#include "Heap.h"

int main()
{
	HP hp;
	HeapInit(&hp);
	HeapPush(&hp, 4);
	HeapPush(&hp, 18);
	HeapPush(&hp, 42);
	HeapPush(&hp, 12);
	HeapPush(&hp, 21);
	HeapPush(&hp, 3);

	int k = 0;
	scanf("%d", &k);
	//取出 K 个栈顶元素
	while (!HeapIsEmpty(&hp) && k--)
	{
		printf("%d ", HeapTop(&hp));
		HeapPop(&hp);
	}
	printf("\n");

	return 0;
}
  • 测试结果:

我们验证一下看看对不对:

我们可以看到是符合要求的。

三、堆排序

堆排序是一种利用 这种数据结构所设计的选择排序算法。它的核心思想是将待排序的序列构造成一个大堆(或小堆),然后利用堆的特性(堆顶元素为最大或最小值)反复取出堆顶元素,从而实现排序。

3.1 堆排序的基本思想

堆排序分为两个主要阶段:

  1. 建堆:将无序的数组构建成一个堆(大堆或小堆)。

  2. 排序:反复将堆顶元素(最大或最小值)与堆的最后一个元素交换,然后对堆顶元素执行向下调整,将剩余的元素重新调整为堆。

堆排序的时间复杂度为 O(n log n) ,空间复杂度为 O(1)

3.2 堆排序的步骤详解

3.2.1 建堆

建堆有两种常用方法:

  • 向上调整建堆:从第二个元素开始,依次将每个元素向上调整,使其满足堆的性质。时间复杂度为 O(n log n)

  • 向下调整建堆:从最后一个非叶子节点开始,依次向下调整。时间复杂度为 O(n),效率更高。

一般情况下向下调整建堆是更优的选择。

3.2.2 排序过程

  1. 将堆顶元素(即 a[0])与堆的最后一个元素 a[end] 交换。

  2. end--,将最后一个元素从堆中排除(此时最大或最小值已放在数组末尾)。

  3. 对新的堆顶元素执行向下调整,使其重新满足堆的性质。

  4. 重复上述步骤,直到堆中只剩下一个元素。

3.3 堆排序的代码实现

以下以升序排序(建大堆)为例:

cpp 复制代码
void HeapSort(int* a, int n)
{
	// 建堆 -- 向上调整建堆 -- O(n log n)
	for (int i = 1; i < n; ++i)
	{
		AdjustUp(a, i);
	}
    //也可以使用向下调整建堆
    // 建堆 -- 向下调整建堆 -- O(N)
    //为啥要 i = (n - 1 - 1) / 2?
    //因为你要从父节点开始出发,而父节点下标 = (i - 1) / 2
    //n 代表当前数组长度,n-1就代表数组最后一个元素的下标
    //所以代入进去就是 i = (n - 1 - 1) / 2
    /*for (int i = (n - 1 - 1) / 2; i >= 0; --i)
    {
	    AdjustDown(a, n, i);
    }*/
	int end = n - 1;
	while (end > 0)
	{
        //将堆顶元素(即 a[0])与堆的最后一个元素 a[end] 交换。
		Swap(&a[0], &a[end]);
        //交换完之后,使用向下调整,维持大根堆
		AdjustDown(a, end, 0);
        //将 end--,将最后一个元素从堆中排除(此时最大或最小值已放在数组末尾)。
		end--;
	}
}

(这里选择用 建大堆 来 升序排序 的理由是:升序排序 需要每次将当前最大的元素放到数组末尾,而大堆的堆顶恰好是最大值 ,交换堆顶和末尾元素后,剩余部分可以快速通过向下调整恢复成新的大堆,从而高效地依次选出剩余最大值。如果建小堆堆顶是最小值,只能依次选最小值放到数组开头,这会破坏数组后续元素的原有堆结构,导致需要额外维护或重新建堆,增加了复杂度和时间开销,所以升序排序用建大堆更直接高效。大家只需要记住:升序排序 → 建大根堆;降序排序 → 建小根堆。)

写完这个功能函数之后,我们来查看一下结果:

  • 测试代码:
cpp 复制代码
int main()
{
	int a[10] = { 2, 1, 5, 7, 6, 8, 0, 9, 4, 3 }; // 对数组排序
	HeapSort(a, 10);
	for (int i = 0; i < 10; i++)
	{
		printf("%d ", a[i]);
	}
	printf("\n");
	return 0;
}
  • 测试结果:

完整版代码如下:

cpp 复制代码
void HeapSort(int* a, int n)
{
	// 建堆 -- 向上调整建堆 -- O(n log n)
	for (int i = 1; i < n; ++i)
	{
		AdjustUp(a, i);
	}
    //也可以使用向下调整建堆
    // 建堆 -- 向下调整建堆 -- O(N)
    //为啥要 i = (n - 1 - 1) / 2?
    //因为你要从父节点开始出发,而父节点下标 = (i - 1) / 2
    //n 代表当前数组长度,n-1就代表数组最后一个元素的下标
    //所以代入进去就是 i = (n - 1 - 1) / 2
    /*for (int i = (n - 1 - 1) / 2; i >= 0; --i)
    {
	    AdjustDown(a, n, i);
    }*/
	int end = n - 1;
	while (end > 0)
	{
        //将堆顶元素(即 a[0])与堆的最后一个元素 a[end] 交换。
		Swap(&a[0], &a[end]);
        //交换完之后,使用向下调整,维持大根堆
		AdjustDown(a, end, 0);
        //将 end--,将最后一个元素从堆中排除(此时最大或最小值已放在数组末尾)。
		end--;
	}
}

int main()
{
	int a[10] = { 2, 1, 5, 7, 6, 8, 0, 9, 4, 3 }; // 对数组排序
	HeapSort(a, 10);
	for (int i = 0; i < 10; i++)
	{
		printf("%d ", a[i]);
	}
	printf("\n");
	return 0;
}

四、TOP-K问题

TOP-K问题:即求数据结合中前K个最大的元素或者最小的元素,一般情况下数据量都比较大

比如:专业前10名、世界500强、富豪榜、游戏中前100的活跃玩家等。

对于Top-K问题,能想到的最简单直接的方式就是排序,但是:如果数据量非常大,排序就不太可取了(可能数据都不能一下子全部加载到内存中)。最佳的方式就是用堆来解决,基本思路如下:

  1. 用数据集合中前K个元素来建堆

前k个最大的元素,则建小堆

前k个最小的元素,则建大堆

  1. 用剩余的N-K个元素依次与堆顶元素来比较,不满足则替换堆顶元素

那为什么要这样做,理由如下:

  1. 需要前 k 个 最大 元素时,建一个大小为 k 的 小堆(堆顶是堆中最小的值)。
  2. 遍历数据时,若当前元素 大于 堆顶,则替换堆顶并向下调整,使堆始终保持当前已遍历数据中最大的 k 个元素。
  3. 最终堆中留下的就是最大的 k 个元素,堆顶是这 k 个中的最小值,但整体已保留最大的 k 个。
  4. 同理,需要前 k 个 最小 元素时,建 大堆 (堆顶是最大值),遍历时若当前元素 小于 堆顶则替换并调整,最终堆中留下最小的 k 个元素。
  5. 这种做法的核心在于: 用小堆淘汰小值来保留大值,用大堆淘汰大值来保留小值 。

如果还不理解,我再举一个通俗易懂的例子,比如说:这份数据前 k 个元素,里面已经有元素的最大值了,这份数据找不出比它大的了,那我要找到前k个最大的元素,那如果我采用 建大堆的方式来收集的话,我就要向下调整,向下调整之后了,那这个最大值就作为堆顶了,那我遍历后面的数据时,只要没有大于 最大值的,就无法进入堆里面,所以就无法找到前k个最大的元素,而用 建小堆 的方式不一样,当前k个的元素里面即使有了最大值了,那它也只能呆在堆尾,不会影响到后续的替换堆顶并向下调整操作。

代码实现(这里以前K个最大的元素为例):

cpp 复制代码
void PrintTopK(const char* file, int k)
{
	// 1. 建堆--用a中前k个元素建小堆
	int* topk = (int*)malloc(sizeof(int) * k);
	assert(topk);

	FILE* fout = fopen(file, "r");
	if (fout == NULL)
	{
		perror("fopen error");
		return;
	}

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

	for (int i = (k-2)/2; i >= 0; --i)
	{
        //记得是小根堆,如果用我的代码的话,记得改里面的判断条件
        //我这个函数里面写的是大根堆的
		AdjustDown(topk, k, i);
	}

	// 2. 将剩余n-k个元素依次与堆顶元素交换,不满则则替换
	int val = 0;
	int ret = fscanf(fout, "%d", &val);
	while (ret != EOF)
	{
		if (val > topk[0])
		{
			topk[0] = val;
            //记得是小根堆,如果用我的代码的话,记得改里面的判断条件
            //我这个函数里面写的是大根堆的
			AdjustDown(topk, k, 0);
		}

		ret = fscanf(fout, "%d", &val);
	}

	for (int i = 0; i < k; i++)
	{
		printf("%d ", topk[i]);
	}
	printf("\n");

	free(topk);
	fclose(fout);
}

void CreateNDate()
{
	// 造一万条数据
	int n = 10000000;
	srand(time(0));
	const char* file = "data.txt";
	FILE* fin = fopen(file, "w");
	if (fin == NULL)
	{
		perror("fopen error");
		return;
	}

	for (size_t i = 0; i < n; ++i)
	{
        //让它生成的数字小于等于 10000;
		int x = rand() % 10000;
		fprintf(fin, "%d\n", x);
	}

	fclose(fin);
}

int main()
{

	CreateNDate();
	PrintTopK("data.txt", 10);

	return 0;
}

运行结果:

1.为了方便测试,我先把 **PrintTopK("data.txt", 10)这个函数先注释掉,只需要执行CreateNDate()**这个函数,让它生成一份数据,如图中所示:

2.然后将 CreateNDate()这个函数先注释掉,只需要执行PrintTopK("data.txt", 10)

这个函数,得到的结果,如图中所示:

可以看到返回了这一万份数据里面,最大的前10个数据了。

五、总结

以上便是我对堆部分的全部理解了。有啥不好的地方也欢迎大家积极指出,希望大家早日被自己喜欢的offer录取。那我们就下一个博客见。

相关推荐
雨落在了我的手上5 小时前
Java数据结构(六):链表的介绍
java·开发语言·数据结构
Angle.寻梦5 小时前
数据结构--栈与队列
数据结构
热爱前端的小张6 小时前
第六章 图
数据结构
hold?fish:palm7 小时前
11 滑动窗口最大值
数据结构·算法
xvhao20137 小时前
T750414 【游戏】计算24点 题解
数据结构·c++·算法·游戏
自然常数e8 小时前
顺序表专题
c语言·数据结构·visual studio
小七在进步8 小时前
数据结构:树的遍历
数据结构
j7~8 小时前
【数据结构初阶】队列的实现(链式队列 + 循环队列)--详解
c语言·开发语言·数据结构·学习·队列·queue·c\c++
king_linlin9 小时前
算法基础——算法复杂度
c语言·开发语言·数据结构·算法