C语言数据结构——树与堆

C语言数据结构------树与堆

文章目录

一、 树与二叉树

1、什么是树?

这是现实世界的树

这是星露谷里的树

而这个是数据结构中说的树 。现实世界的树是从根开始向上生长的,而数据结构中的树,是从根节点开始向下生长的

和数组链表这种线性结构不同,树是非线性结构 ,就像图片这样,一个根节点(A)连接了3个子结点(B、C、D),是一对多的关系。生活中文件夹就是典型的树结构。

2、树相关术语及特征

树相关术语:

  • 父结点/双亲结点:若一个结点有子结点,则这个结点称为其子结点的父结点(A是B的父结点)
  • 子结点 :结点有上层结点,该结点为上层结点的子节点(B是A的子节点)。所以只要两个结点是直接相连的上下层级关系,就构成父子结点
  • 结点的度:一个结点有几个孩子,它的度就是多少(A的度是6,F的度是2,K的度是0)
  • 叶子结点:度为0的结点称为叶子结点(B、C、H、I等结点都是叶子结点)
  • 兄弟结点:拥有相同的父结的结点互称为兄弟结点(B、C是兄弟结点)
  • 结点的层次:根为第1层,根的子为第2层,以此类推(A在第1层,B第2层,H第3层,P第4层)
  • 树的高度或者深度: 树中结点的最大层次(上图中树的高度为4)
  • 结点的祖先:从根到该结点所经分支上的所有节点(A是所有结点的祖先)
  • 路径:一条从树中任意结点书法,沿着父结点-子结点连接,达到任意结点的序列(A到P的路径为A-E-J-P)

以上概念不需要背,看看就好了,但是要注意一下度的概念

树的特征:

  1. 树有且仅有一个根结点(A),根结点没有前驱结点
  2. 除了根结点外,每个结点有且仅有一个父结点
  3. 子树不相交,就像图中F和G不能连接(如果存在相交就是图了)
  4. 一颗N个结点的树有N-1条边(上图中有16个结点有15条边)

3、什么是二叉树?

二叉树是一种特殊的树,二叉树遵循:

  1. 每个节点最多只有2个子节点,分别为左孩子、右孩子,不存在度大于2的节点
  2. 二叉树是有序树,左右子树不能互换,左孩子和右孩子位置不能颠倒

4、特殊的二叉树

(1)满二叉树

每一层的结点数量都达到最大值 ,没有任何空缺节点,整棵树被完全铺满。

深度为k的满二叉树,节点总数固定为 2^k-1

(2)完全二叉树

完全二叉树是介于普通二叉树和满二叉树之间的结构,遵循:

  1. 除了最后一层,前面所有层节点全部铺满
  2. 最后一层的节点必须从左到右连续排列,中间不能有空缺

包含关系 :满二叉树一定是完全二叉树,但是完全二叉树不一定是满二叉树。

根据完全二叉树的特点可知:

  1. 若规定根结点的层数为1,则一颗非空二叉树的第i层上最多有2^(i-1)
  2. 深度为h的二叉树最大结点数是2^h-1
  3. 具有n个结点的满二叉树的深度h=log2(n+1)(log以2为低,n+1为对数)

5、完全二叉树的储存结构

二叉树一般可以使用两种结构储存,一种顺序结构(数组),一种链式结构(链表)

普通二叉树节点散乱,用数组存储会浪费大量空间;但完全二叉树节点连续无空缺,完美适配数组顺序存储并且只需通过下标就能找到父子节点

我们约定:数组下标从0开始
假设当前节点下标为 i:
• 父节点下标:parent = (i - 1) / 2
• 左孩子下标:left = i * 2 + 1
• 右孩子下标:right = i * 2 + 2

二、堆

1、什么是堆?

堆是一种特殊的二叉树,堆本质是一棵完全二叉树,但同时又有自己的特性

堆分为两大类

  1. 大根堆(大堆)

    树中任意一个父结点的值大于等于左右孩子结点堆顶(根节点)是整颗树的最大值 。大堆不等于降序

  2. 小根堆(小堆)

    树中任意一个父结点的值均小于或等于其左右孩子结点的值堆顶(根节点)是整颗树的最小值 。小堆不等于升序

2、堆的底层实现

我们代码中采用动态数组实现堆,可以自动扩容

c 复制代码
#pragma once
#include<stdio.h>
#include<stdlib.h>
#include<assert.h>
#include<stdbool.h>


typedef int HPDataType;
//堆的结构体
typedef struct Heap
{
	HPDataType* arr;	//动态数组,存储堆数据
	int size;	//有效数据个数
	int capacity;	//空间大小
}HP;

3、堆两大核心算法:向上调整 & 向下调整(小根堆版)

堆所有操作插入、删除、建堆、堆排序,全部依赖两个算法

(1)前置知识

我们向堆插入/删除数据后 ,我们需要判断此时的堆是否适配大根堆或者小根堆的结构 ,如果不适配,需要重新调整各个数据的位置,保证这个堆还是一个大根堆或小根堆

要调整数据,就要知道各个数据的位置

对于具有n个结点的完全二叉树,如果按照从上到下从左到右的数组顺序,对所有结点从0开始编号,则对于序号为i的结点有

  1. 若i>0,i位置结点为双亲序号:(i-1)/2;若i=0,i为根节点(子找父)
  2. 若2i+1<n,左孩子序号:2i+1;若2i+1>=n,则无左孩子(父找子)
  3. 若2i+2<n,左孩子序号:2i+2;若2i+2>=n,则无右孩子
(2)向上调整算法AdjustUp(插入数据时)

使用场景

往堆的末尾插入新元素,新元素可能比父节点小,破坏小堆"父小于子"的规则,需要从当前新节点向上遍历,逐层修正堆结构

时间复杂度

堆是高度为log2 n(以2为底,n的对数)的完全二叉树,向上调整最多从叶子走到根,循环次数等于树高。

时间复杂度:O(logn)

算法逻辑

  1. 获取当前节点下标child,计算父节点下标parent
  2. 如果孩子节点数值<父节点,违反小堆规则,交换两者
  3. 将child更新为原parent,parent更新,继续向上比对,直到到达堆顶,或者满足堆规则
c 复制代码
void Swap(int* x, int* y)
{
	int tmp = *x;
	*x = *y;
	*y = tmp;
}

//向上调整算法,child为新插入节点下标
void ADjustUp(HPDataType* arr, int child)
{
	int parent = (child - 1) / 2;
	//循环条件:未走到根节点
	while (child > 0)
	{
		//大堆:>
		//小堆:<,孩子节点更小就交换
		if (arr[child] < arr[parent])
		{
			Swap(&arr[child], &arr[parent]);
			child = parent;
			parent = (child - 1) / 2;
		}
		else
			//满足堆结构,直接退出
			break;
	}
}
(3)向下调整算法 AdjustDown(出堆)

使用场景

在堆结构里,删除数据只能操作堆顶让堆顶与最后一个元素交换,直接删除数组末尾就能完成出堆操作 (如果直接删掉堆顶,数组每个数据都要向前挪动一位,时间复杂度O(n))。堆顶元素被替换后,根节点不再满足堆规则,需要从根节点向下遍历,逐层修复堆结构

时间复杂度为O(logn)

算法逻辑

  1. 以当前父节点为起点,默认左孩子为比较节点
  2. 对比左右孩子,选出数值更小的子节点(小根堆需要和更小的孩子交换)
  3. 若最小子节点 < 父节点,交换父子
  4. 更新父节点为原子节点,继续向下循环,直到子节点越界或符合堆规则
c 复制代码
//n:数组有效元素总个数
//向下调整算法
void AdjustDown(HPDataType* arr, int parent, int n)
{
	//默认取左孩子
	int child = parent * 2 + 1;
	while (child < n)
	{
		//大堆:<
		// 小队:>
		//存在右孩子,小堆选出左右孩子中更小的一个
		if (child + 1 < n && arr[child] > arr[child + 1])
		{
			child++;
		}

		//大堆:>
		// 小队:<
		//小堆中,如果子结点小于父结点,交换修复小根堆
		if (arr[child] < arr[parent])
		{
			Swap(&arr[child], &arr[parent]);
			parent = child;
			child = parent * 2 + 1;
		}
		else
		{
			break;
		}
	}
}

三、堆完整接口封装代码

1、Heap.h

c 复制代码
#pragma once
#include<stdio.h>
#include<stdlib.h>
#include<assert.h>
#include<stdbool.h>


typedef int HPDataType;
//堆的结构体
typedef struct Heap
{
	HPDataType* arr;	//动态数组,存储堆数据
	int size;	//有效数据个数
	int capacity;	//空间大小
}HP;


void Swap(int* x, int* y);

//向上调整算法
void AdjustUp(HPDataType* arr, int child);
//向下调整算法
void AdjustDown(HPDataType* arr, int parent, int x);


//初始化堆
void HPInit(HP* php);
//销毁堆
void HPDestroy(HP* php);
//打印堆
void HPPrint(HP* php);

//入堆
void HPPush(HP* php, HPDataType x);
//出堆
void HPPop(HP* php);
//取堆顶数据
HPDataType HPTop(HP* php);

// 判空
bool HPEmpty(HP* php);

2、Heap.c

c 复制代码
#define _CRT_SECURE_NO_WARNINGS
#include"Heap.h"

//初始化空堆
void HPInit(HP* php)
{
	assert(php);
	php->arr = NULL;
	php->size = php->capacity = 0;
}

void HPDestroy(HP* php)
{
	assert(php);
	if (php->arr)
		free(php->arr);
	php->arr = NULL;
	php->size = php->capacity = 0;
}

void HPPrint(HP* php)
{
	assert(php);
	for (int i = 0; i < php->size; i++)
	{
		printf("%d ", php->arr[i]);
	}
	printf("\n");
}


void Swap(int* x, int* y)
{
	int tmp = *x;
	*x = *y;
	*y = tmp;
}

//向上调整算法,child为新插入节点下标
void ADjustUp(HPDataType* arr, int child)
{
	int parent = (child - 1) / 2;
	//循环条件:未走到根节点
	while (child > 0)
	{
		//大堆:>
		//小堆:<,孩子节点更小就交换
		if (arr[child] < arr[parent])
		{
			Swap(&arr[child], &arr[parent]);
			child = parent;
			parent = (child - 1) / 2;
		}
		else
			//满足堆结构,直接退出
			break;
	}
}

//入堆 O(logn)
void HPPush(HP* php, HPDataType x)
{
	assert(php);
	//空间不足就扩容
	if (php->size == php->capacity)
	{
		int newCapacity = php->capacity == 0 ? 4 : php->capacity * 2;
		HPDataType* tmp = (HPDataType*)realloc(php->arr, newCapacity * sizeof(HPDataType));
		if (tmp == NULL)
		{
			perror("realloc fail");
			exit(-1);
		}
		php->arr = tmp;
		php->capacity = newCapacity;
	}
	//数据放入数组末尾
	php->arr[php->size] = x;
	//向上调整维护堆结构
	ADjustUp(php->arr, php->size);
	php->size++;
}

//判断堆是否为空
bool HPEmpty(HP* php)
{
	assert(php);
	return php->size == 0;
}


//n:数组有效元素总个数
//向下调整算法
void AdjustDown(HPDataType* arr, int parent, int n)
{
	//默认取左孩子
	int child = parent * 2 + 1;
	while (child < n)
	{
		//大堆:<
		// 小队:>
		//存在右孩子,小堆选出左右孩子中更小的一个
		if (child + 1 < n && arr[child] > arr[child + 1])
		{
			child++;
		}

		//大堆:>
		// 小队:<
		//小堆中,如果子结点小于父结点,交换修复小根堆
		if (arr[child] < arr[parent])
		{
			Swap(&arr[child], &arr[parent]);
			parent = child;
			child = parent * 2 + 1;
		}
		else
		{
			break;
		}
	}
}


//出堆(删除堆顶) O(logn)
void HPPop(HP* php)
{
	assert(!HPEmpty(php));
	//堆顶与最后一个元素交换
	Swap(&php->arr[0], &php->arr[php->size - 1]);
	//直接让数组的"长度"-1就好
	php->size--;

	//向下调整算法维护堆结构
	AdjustDown(php->arr, 0, php->size);
}

//获取堆顶数据
HPDataType HPTop(HP* php)
{
	assert(php);
	return php->arr[0];
}

3、test.c测试代码

c 复制代码
#define _CRT_SECURE_NO_WARNINGS

#include"Heap.h"

void TestHeap()
{
	HP hp;
	HPInit(&hp);
	HPPush(&hp, 56);
	HPPush(&hp, 10);
	HPPush(&hp, 15);
	HPPush(&hp, 30);
	printf("插入元素后小根堆数组:");
	HPPrint(&hp);

	HPPop(&hp);
	printf("删除堆顶最小值后:");
	HPPrint(&hp);
	HPPop(&hp);
	printf("再次删除堆顶后:");
	HPPrint(&hp);

	HPDestroy(&hp);
}

int main()
{
	TestHeap();
	return 0;
}

四、堆的应用:堆排序(以小根堆为例)

1、借助堆结构排序(O(N))

以升序序列为例

思路

将数组全部元素入小根堆,循环取出堆顶最小值,依次放回原数组,最终得到升序序列

缺点

需要单独开辟堆的动态数组,额外占用内存

复杂度分析

循环插入 n 个元素:O(nlogn)
循环弹出 n 个元素:O(nlogn)
总时间复杂度:O(nlogn)
空间复杂度:O(n)

c 复制代码
void HeapSort1(int* arr, int n)
{
	HP hp;
	HPInit(&hp);
	//全部元素入小根堆
	for (int i = 0; i < n; i++)
	{
		HPPush(&hp, arr[i]);
	}
	//依次取出最小值回填数组
	int idx = 0;
	while (!HPEmpty(&hp))
	{
		arr[idx++] = HPTop(&hp);
		HPPop(&hp);
	}
	HPDestroy(&hp);
}
int main()
{
	//TestHeap();
	int arr[5] = { 4,2,6,1,3 };
	HeapSort1(arr, 5);
	for (int i = 0; i < 5; i++)
	{
		printf("%d ", arr[i]);
	}
	return 0;
}

2、原地堆排序(O(1))

直接复用原数组作为堆,无需额外内存,分两步完成排序

  1. 原地建堆 :从下到上,把每一个parent-child组合都转成小根堆。从最后一个非叶子节点向前遍历
  2. 循环提取极值把堆顶的最小值和数组末尾元素交换,再缩小有效堆的范围,对新堆顶继续向下调整,维持堆结构,重复上述操作直至有序

建堆时间复杂度

很多初学者误以为建堆是O(nlogn),实际数学推导结果为O(n)。

c 复制代码
void PrintArr(int* arr, int n)
{
	for (int i = 0; i < n; i++)
	{
		printf("%d ", arr[i]);
	}
	printf("\n");
}


//原地堆排序(基于小根堆)
void HeapSort(int* arr, int n)
{
	//第一步:原地构建小根堆
	//n-1是数组最后一个元素的位置,parent=(child-1)/2
	for (int i = (n-1-1) / 2; i >= 0; i--)
	{
		ADjustDown(arr, i, n);
	}
	//第二步:不断提取最小值放到数组尾部
	int end = n - 1;
	while (end > 0)
	{
		Swap(&arr[0], &arr[end]);
		ADjustDown(arr, 0, end);
		end--;
	}
}

int main()
{
	int arr[6] = { 19,15,20,17,13,10 };
	printf("排序前数组:");
	PrintArr(arr, 6);

	HeapSort(arr, 6);

	printf("原地堆排序后:");
	PrintArr(arr, 6);
	return 0;
}

补充说明:

  • 原地建小根堆完成排序后,数组为降序
  • 原地建大根堆完成排序后,数组为升序
  • 堆排序属于不稳定排序,最好、平均、最坏时间复杂度均为O(nlogn)

3、TopK问题

(1)什么是TopK问题

在海量数据里,找出前 K 个最大元素 / 前 K 个最小元素

  • 典型场景:电商找出销量前 10 商品、热搜前 20 关键词、统计海量日志中出现最多的 K 个 IP
  • 海量数据痛点 :内存不足
    假设数据量极大(10 万、上亿条数字),全部一次性读入内存会直接内存溢出:
    如1 亿个 int 整数,单个 int 占 4 字节,总占用约 400MB;如果数据量扩大到几十亿,普通电脑内存完全存不下全部数据
  • 解决方案:借助堆,仅占用 K 个元素的内存空间,不需要一次性加载全部数据
  1. 求最大的前 K 个数字 → 构建小根堆
    堆顶是当前 K 个数据里的最小值;遍历后续数据时,只要新数字比堆顶大,就替换堆顶,重新调整堆。最终堆内留存的就是全局最大的 K 个数
  2. 求最小的前 K 个数字 → 构建大根堆
    堆顶是当前 K 个数据里的最大值;遍历后续数据时,只要新数字比堆顶小,就替换堆顶,重新调整堆。最终堆内留存的就是全局最小的 K 个数
    整体总时间复杂度:O(nlogK)
(2)生成海量测试数据函数 CreateNData

自动生成 10 万随机数字,写入data.txt文件,模拟海量外部文件数据,避免手动造测试样例

c 复制代码
// 生成海量测试数据写入data.txt
void CreateNData()
{
	//造10万条随机数据
	int n = 100000;
	srand(time(0));
	const char* file ="data.txt";
	FILE* fin = fopen(file, "w");
	if (fin == NULL)
	{
		perror("fopen error");
		return;
	}
	for (int i = 0; i < n; i++)
	{
		// 生成0~999999随机整数
		int x = (rand() + i) % 1000000;
		fprintf(fin, "%d\n", x);
	}
	fclose(fin);
	printf("测试数据生成完成!\n");
}
(3)TopK 核心函数(求最大前 K 值,小根堆实现)

逻辑流程:

  1. 读取用户输入 K 值,打开存储海量数据的文件
  2. 动态开辟 K 长度数组,读取前 K 个数字存入数组
  3. 原地建小根堆(复用ADjustDown向下调整函数)
  4. 循环读取文件剩余所有数字,逐个和堆顶对比:
    当前数字 > 堆顶 (minHeap 0):替换堆顶,重新向下调整维护小根堆
    当前数字 ≤ 堆顶:直接跳过,不处理;
  5. 文件读取完毕后,堆内存储的就是全局最大的 K 个数字,循环打印结果
c 复制代码
// 海量数据求最大前K个元素(小根堆实现)
void TopK()
{
	int k = 0;
	printf("请输入需要筛选的K值:");
	scanf("%d", &k);

	const char* file = "data.txt";
	FILE* fout = fopen(file, "r");
	if (fout == NULL)
	{
		perror("fopen fail!");
		exit(1);
	}
	// 动态分配K个空间存储小根堆
	int* minHeap = (int*)malloc(sizeof(int) * k);
	if (minHeap == NULL)
	{
		perror("malloc fail!");
		exit(2);
	}
	// 读取前K个数据放入堆数组
	for (int i = 0; i < k; i++)
	{
		fscanf(fout, "%d", &minHeap[i]);
	}
	// 从最后一个非叶子节点向前遍历,原地构建小根堆
	for (int i = (k - 2) / 2; i >= 0; i--)
	{
		ADjustDown(minHeap, i, k);
	}
	// 遍历文件剩余全部数据,和堆顶对比筛选
	int x = 0;
	while (fscanf(fout, "%d", &x) != EOF)
	{
		// 新数字比堆顶更大,替换堆顶并修复小根堆
		if (x > minHeap[0])
		{
			minHeap[0] = x;
			ADjustDown(minHeap, 0, k);
		}
	}
	// 输出最终最大的K个数字
	printf("海量数据中最大的前%d个数字:", k);
	for (int i = 0; i < k; i++)
	{
		printf("%d ", minHeap[i]);
	}
	printf("\n");
	fclose(fout);
	free(minHeap);
}



int main()
{
	// 首次运行打开注释,生成data.txt测试数据
	 CreateNData();
	TopK();
	return 0;
}

我们可以手动打开data.txt文件,手动输入几个最大值,验证函数正确性