【数据结构】二叉树的顺序结构,详细介绍堆以及堆的实现,堆排序

目录

[1. 二叉树的顺序结构](#1. 二叉树的顺序结构)

[2. 堆的概念及结构](#2. 堆的概念及结构)

[3. 堆的实现](#3. 堆的实现)

[3.1 堆的结构](#3.1 堆的结构)

[3.2 堆的初始化](#3.2 堆的初始化)

[3.3 堆的插入](#3.3 堆的插入)

[3.4 堆的删除](#3.4 堆的删除)

[3.5 获取堆顶数据](#3.5 获取堆顶数据)

[3.6 堆的判空](#3.6 堆的判空)

[3.7 堆的数据个数](#3.7 堆的数据个数)

[3.8 堆的销毁](#3.8 堆的销毁)

[4. 堆的应用](#4. 堆的应用)

[4.1 堆排序](#4.1 堆排序)

[4.1.1 向下调整建堆的时间复杂度](#4.1.1 向下调整建堆的时间复杂度)

[4.1.2 向上调整建堆的时间复杂度](#4.1.2 向上调整建堆的时间复杂度)

[4.2 TOP-K问题](#4.2 TOP-K问题)

[5. 选择题](#5. 选择题)


1. 二叉树的顺序结构

  1. 普通的二叉树是不适合用数组来存储的,因为可能会存在大量的空间浪费。

  2. 完全二叉树更适合使用顺序结构存储。

  3. 通常把堆(一种二叉树)使用顺序结构的数组来存储。


2. 堆的概念及结构

  1. 将根节点最大的堆叫做大堆或大根堆,根节点最小的堆叫做小堆或小根堆。

  2. 大堆:树的任何一个父亲都大于等于他的孩子。

  3. 小堆:树的任何一个父亲都小于等于他的孩子。

  4. 堆总是一棵完全二叉树。


3. 堆的实现

3.1 堆的结构

  1. 堆本质是一个数组。
cpp 复制代码
typedef int HeapDataType;
typedef struct Heap
{
	HeapDataType* arr; //指向数组
	size_t size;	   //数据个数
	size_t capacity;   //容量
}Heap;

3.2 堆的初始化

  1. 传入的堆指针不能为空。

  2. 将数组指针置空,容量和个数置0。

cpp 复制代码
void HeapInit(Heap* ph)
{
	//1. 传入的堆指针不能为空。
	assert(ph);

	//2. 将数组指针置空,容量和个数置0。
	ph->arr = NULL;
	ph->size = ph->capacity = 0;
}

3.3 堆的插入

  1. 插入一个数就是在数组尾插,插入前是堆,插入后不一定是堆,需要检测。

  2. 插入一个数只会对它所在的路径有影响,需要用向上调整算法。

  3. 假设原本是小堆,那么新插入的数和他的父亲比较,比他父亲小就交换,然后继续往上比较,直到变成根位置。

  4. 最好的情况是不用调整,最坏的情况是调整到根位置。


代码实现:

  1. 传入的堆指针不能为空。

  2. 插入前判断需不需要扩容。

  3. 最后一个位置插入,然后size++。

  4. 插入后使用向上调整算法。

向上调整算法:

  1. 下标关系:parent = (child-1) / 2。

  2. 假设是小堆,当孩子小于父亲,孩子和父亲的值交换,孩子再走到父亲的位置继续向上比较,如果孩子大于等于父亲就直接break不用比较了,或者孩子到根位置结束。

cpp 复制代码
void Swap(HeapDataType* h1, HeapDataType* h2)
{
	HeapDataType tmp = *h1;
	*h1 = *h2;
	*h2 = tmp;
}

void AdjustUp(HeapDataType* arr, int child)
{
	int parent = (child - 1) / 2;
	//孩子到根位置结束
	while (child)
	{
		//1. 小堆:当孩子小于父亲,孩子和父亲的值交换。
		if (arr[child] < arr[parent]) Swap(&arr[child], &arr[parent]);
		//2. 如果孩子大于等于父亲就直接break不用比较了
		else break;

		//3. 孩子再走到父亲的位置继续向上比较
		child = parent;
		parent = (child - 1) / 2;
	}
}

void HeapPush(Heap* ph, HeapDataType data)
{
	//1. 传入的堆指针不能为空。
	assert(ph);

	//2. 插入前判断需不需要扩容。
	size_t capacity = ph->capacity == 0 ? 4 : ph->capacity * 2;
	HeapDataType* tmp = realloc(ph->arr, sizeof(HeapDataType) * capacity);
	if (tmp == NULL)
	{
		perror("realloc");
		return;
	}
	ph->arr = tmp;
	ph->capacity = capacity;

	//3. 最后一个位置插入,然后size++。
	ph->arr[ph->size++] = data;

	//4. 插入后使用向上调整算法。
	AdjustUp(ph->arr, ph->size - 1);
}

插入的时间复杂度:

  1. 插入的最坏情况就是从叶子一直走到根,也就是树的高度,假设节点数为N,完全二叉树的节点范围是2^(h-1)到2^h - 1,2^(h-1) = N 或 2^h - 1 = N, 可得h = logN(2为底) + 1 或 h = log(N+1)(2为底),所以O(N) = logN(2为底)。

  2. 插入的时间复杂度主要看向上调整算法,同理删除主要看向下调整算法,它们的时间复杂度都是logN,以2为底。

3.4 堆的删除

  1. 传入的堆指针不能为空。

  2. 堆不能为空。

  3. 删除是删堆顶的数据,先将堆顶数据和最后一个数据交换,然后删除最后一个数据。

  4. 从堆顶数据开始向下调整,使用向下调整算法的前提:左子树和右子树是堆。

向下调整算法:

  1. 假设是小堆,child = parent*2 + 1,先假设左孩子是小的,再将左孩子和右孩子比一下,谁小谁就是child。记得判断一下右孩子是否存在。

  2. parent 和 child 比较,parent比child大就交换,然后parent走到child的位置继续往下比较,直到比child小或没有child。

cpp 复制代码
void AdjustDown(HeapDataType* arr, int size, int parent)
{
	//小堆:先假设左孩子是小的,再将左孩子和右孩子比一下,谁小谁就是child。记得判断一下右孩子是否存在。
	int child = parent * 2 + 1;
	while (child < size)
	{
		//1. 小堆:parent和较小的孩子比较
		if (child + 1 < size && arr[child + 1] < arr[child]) child++;

		//2. parent 和 child 比较,parent比child大就交换
		if (arr[parent] > arr[child]) Swap(&arr[parent], &arr[child]);
		else break;

		//3. 然后parent走到child的位置继续往下比较
		parent = child;
		child = parent * 2 + 1;
	}
}

void HeapPop(Heap* ph)
{
	//1. 传入的堆指针不能为空。
	assert(ph);

	//2. 堆不能为空。
	assert(!HeapEmpty(ph));

	//3. 删除是删堆顶的数据,先将堆顶数据和最后一个数据交换,然后删除最后一个数据。
	Swap(&ph->arr[0], &ph->arr[ph->size - 1]);
	ph->size--;

	//4. 从堆顶数据开始向下调整。
	AdjustDown(ph->arr, ph->size, 0);
}

3.5 获取堆顶数据

  1. 传入的堆指针不能为空。

  2. 堆不能为空。

  3. 返回数组第一个元素。

cpp 复制代码
HeapDataType HeapTop(Heap* ph)
{
	//1. 传入的堆指针不能为空。
	assert(ph);
	//2. 堆不能为空。
	assert(!HeapEmpty(ph));

	//3. 返回数组第一个元素。
	return ph->arr[0];
}

3.6 堆的判空

  1. 传入的堆指针不能为空。

  2. 判断size是否为0,空返回真。

cpp 复制代码
bool HeapEmpty(Heap* ph)
{
	//1. 传入的堆指针不能为空。
	assert(ph);

	//2. 判断size是否为0,空返回真。
	return ph->size == 0;
}

3.7 堆的数据个数

  1. 传入的堆指针不能为空。

  2. 返回size。

cpp 复制代码
int HeapSize(Heap* ph)
{
	//1. 传入的堆指针不能为空。
	assert(ph);

	//2. 返回size。
	return ph->size;
}

3.8 堆的销毁

cpp 复制代码
void HeapDestroy(Heap* ph)
{
	assert(ph);

	free(ph->arr);
	ph->size = ph->capacity = 0;
}

完整代码:Heap/Heap · 林宇恒/DataStructure - 码云 - 开源中国 (gitee.com)


4. 堆的应用

4.1 堆排序

堆排序利用堆的思想来进行排序,总共分为两个步骤:

第一步:建堆。

用向上调整算法建堆

  1. 从第二个元素开始,将每个元素看作堆的插入向上调整。

  2. 升序建大堆,降序建小堆。

用向下调整算法建堆

  1. 叶子节点是不需要向下调整的,所以是从最后一个父亲节点开始向下调整。

  2. 然后继续找前面的父亲节点向下调整直到第一个节点调整完。

第二步:利用堆删除的思想来进行排序。

  1. 将首尾数据交换。

  2. 交换到后面的数据是我们想要的不动,交换到堆顶的数据开始做向下调整。

  3. 每交换一次,需要调整的个数就减一,直到剩下一个时就不用调整了。

cpp 复制代码
void HeapSort(int* arr, int len)
{
	//第一步:建堆。
	//从第二个元素开始,将每个元素看作堆的插入向上调整。
	//升序建大堆,降序建小堆。
	for (int i = 1; i < len; i++) AdjustUp(arr, i);

	//向下调整建堆
	//parent = (child-1) / 2, 这里最后一个child是len-1
	for (int i = (len - 1 - 1) / 2; i >= 0; i--) AdjustDown(arr, len, i);

	//第二步:利用堆删除的思想来进行排序。
	int end = len - 1;
	while (end>0)
	{
		//首尾数据交换
		Swap(&arr[0], &arr[end]);
		//首数据向下调整,这里传入end指的是调整end前面的数据。
		AdjustDown(arr, end, 0);
		//调整完后最后一个位置交换后不用调整。
		end--;
	}
}

**排升序不建小堆的原因:**当我们建完小堆,也就是找出了最小的一个,那我们要从剩下的数据找第二小的,这样又需要重新建小堆。

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

从最后一个父亲节点开始则所有节点移动的总步数为:

F(n) = 2^0*(h-1) + 2^1*(h-2) + ... + 2^(h-2)*1 + 2^(h-1)*0

错位相减可得:F(n) = -2^0*h + 2^0*1 + 2^1*1 + ... + 2^(h-2)*1 + 2^(h-1)*1

再次错位相减可得:F(n) = 2^h - 1 - h

又因节点个数n和高度h的关系是(视为满二叉树):n = 2^h -1,h = log(n+1)(2为底)

所以F(n) = n - log(n+1)(2为底)

时间复杂度也就是所有节点移动的总步数:O(n) = n

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

从第二个节点开始,则所有节点移动的总步数为:

F(n) = 2^1*1 + 2^2*2 + 2^3*3 + ... + 2^(h-1)*(h-1)

错位相减得:F(n) = -2^1 - 2^2 - 2^3 - ... - 2^(h-1) - 2^h + 2^h*h

再次错位相减得:F(n) = 2^h*(h-2) + 2

又因节点个数n和高度h的关系是(视为满二叉树):n = 2^h -1

所以F(n) = (n+1)(log(n+1)(2为底) - 2) + 2

时间复杂度也就是所有节点移动的总步数:O(n) = n*logn(2为底)

4.2 TOP-K问题

TOP-K问题:求前K个最大或最小的元素,比如:专业前10名、游戏中前100的活跃玩家等。

  1. 将前K个数据建堆,求前K个最大的数据就建小堆,反之亦然。

  2. 从第K个数据开始往后,每个数据都和堆顶比较。

  3. 假设求前K个最大的数据,那么当后面的数据比堆顶数据大时就覆盖堆顶数据然后向下调整,反之亦然。

cpp 复制代码
//造数据
void CreateData()
{
	//打开文件
	FILE* mtof = fopen("data.txt", "w");
	if (mtof == NULL)
	{
		perror("fopen");
		return;
	}

	//随机生成数据写入文件
	srand((unsigned int)time(NULL));
	for (int i = 0; i < 10; i++) fprintf(mtof, "%d\n", rand() % 100);
	
	//关闭文件
	fclose(mtof);
}

//求前K个最大
void TopK(int k)
{
	//打开文件
	FILE* ftom = fopen("data.txt", "r");
	if (ftom == NULL)
	{
		perror("fopen");
		return;
	}

	//将文件数据读进内存
	int* HeapArr = (int*)malloc(sizeof(int) * k);
	if (HeapArr == NULL)
	{
		perror("malloc");
		return;
	}
	for (int i=0; i<k; i++) fscanf(ftom, "%d", &HeapArr[i]);

	//1. 将前K个数据建堆。
	for (int i=((k-1)-1)/2; i>=0; i--) AdjustDown(HeapArr, k, i);

	//2. 从第K个数据开始,每个数据都和堆顶比较
	int val;
	while (!feof(ftom))
	{
		fscanf(ftom, "%d", &val);
		//如果比堆顶大就覆盖堆顶,然后向下调整
		if (val > HeapArr[0])
		{
			HeapArr[0] = val;
			AdjustDown(HeapArr, k, 0);
		}
	}

	//关闭文件
	fclose(ftom);
}

5. 选择题

1.下列关键字序列为堆的是:()

A 100,60,70,50,32,65

B 60,70,65,50,32,100

C 65,100,70,32,50,60

D 70,65,100,32,50,60

E 32,50,100,70,65,60

F 50,100,70,65,60,32

答:A
2.已知小根堆为8,15,10,21,34,16,12,删除关键字 8 之后需重建堆,在此过程中,关键字之间的比较次数是()。

A 1

B 2

C 3

D 4

答:C
3.一组记录排序码为(5 11 7 2 3 17),则利用堆排序方法建立的初始堆为

A(11 5 7 2 3 17)

B(11 5 7 2 17 3)

C(17 11 7 2 3 5)

D(17 11 7 5 3 2)

E(17 7 11 3 5 2)

F(17 7 11 3 2 5)

答:C
4.最小堆[0,3,2,5,7,4,6,8],在删除堆顶元素0之后,其结果是()

A[3,2,5,7,4,6,8]

B[2,3,5,7,4,6,8]

C[2,3,4,5,7,8,6]

D[2,3,4,5,6,7,8]

答:C

相关推荐
算法歌者4 分钟前
[算法]入门1.矩阵转置
算法
林开落L19 分钟前
前缀和算法习题篇(上)
c++·算法·leetcode
远望清一色20 分钟前
基于MATLAB边缘检测博文
开发语言·算法·matlab
tyler_download22 分钟前
手撸 chatgpt 大模型:简述 LLM 的架构,算法和训练流程
算法·chatgpt
Prejudices32 分钟前
C++如何调用Python脚本
开发语言·c++·python
单音GG35 分钟前
推荐一个基于协程的C++(lua)游戏服务器
服务器·c++·游戏·lua
SoraLuna42 分钟前
「Mac玩转仓颉内测版7」入门篇7 - Cangjie控制结构(下)
算法·macos·动态规划·cangjie
我狠狠地刷刷刷刷刷1 小时前
中文分词模拟器
开发语言·python·算法
鸽鸽程序猿1 小时前
【算法】【优选算法】前缀和(上)
java·算法·前缀和
qing_0406031 小时前
C++——多态
开发语言·c++·多态