C语言数据结构------树与堆
文章目录
- C语言数据结构------树与堆
-
- [一、 树与二叉树](#一、 树与二叉树)
- 二、堆
-
- 1、什么是堆?
- 2、堆的底层实现
- [3、堆两大核心算法:向上调整 & 向下调整(小根堆版)](#3、堆两大核心算法:向上调整 & 向下调整(小根堆版))
-
- (1)前置知识
- (2)向上调整算法AdjustUp(插入数据时)
- [(3)向下调整算法 AdjustDown(出堆)](#(3)向下调整算法 AdjustDown(出堆))
- 三、堆完整接口封装代码
- 四、堆的应用:堆排序(以小根堆为例)
-
- 1、借助堆结构排序(O(N))
- 2、原地堆排序(O(1))
- 3、TopK问题
-
- (1)什么是TopK问题
- [(2)生成海量测试数据函数 CreateNData](#(2)生成海量测试数据函数 CreateNData)
- [(3)TopK 核心函数(求最大前 K 值,小根堆实现)](#(3)TopK 核心函数(求最大前 K 值,小根堆实现))
一、 树与二叉树
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)
以上概念不需要背,看看就好了,但是要注意一下度的概念
树的特征:
- 树有且仅有一个根结点(A),根结点没有前驱结点
- 除了根结点外,每个结点有且仅有一个父结点
- 子树不相交,就像图中F和G不能连接(如果存在相交就是图了)
- 一颗N个结点的树有N-1条边(上图中有16个结点有15条边)
3、什么是二叉树?

二叉树是一种特殊的树,二叉树遵循:
- 每个节点最多只有2个子节点,分别为左孩子、右孩子,不存在度大于2的节点
- 二叉树是有序树,左右子树不能互换,左孩子和右孩子位置不能颠倒
4、特殊的二叉树
(1)满二叉树
每一层的结点数量都达到最大值 ,没有任何空缺节点,整棵树被完全铺满。
深度为k的满二叉树,节点总数固定为 2^k-1

(2)完全二叉树
完全二叉树是介于普通二叉树和满二叉树之间的结构,遵循:
- 除了最后一层,前面所有层节点全部铺满
- 最后一层的节点必须从左到右连续排列,中间不能有空缺
包含关系 :满二叉树一定是完全二叉树,但是完全二叉树不一定是满二叉树。

根据完全二叉树的特点可知:
- 若规定根结点的层数为1,则一颗非空二叉树的第i层上最多有2^(i-1)
- 深度为h的二叉树最大结点数是2^h-1
- 具有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、什么是堆?
堆是一种特殊的二叉树,堆本质是一棵完全二叉树,但同时又有自己的特性
堆分为两大类
-
大根堆(大堆) :
树中任意一个父结点的值大于等于左右孩子结点 ,堆顶(根节点)是整颗树的最大值 。大堆不等于降序

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

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的结点有:
- 若i>0,i位置结点为双亲序号:(i-1)/2;若i=0,i为根节点(子找父)
- 若2i+1<n,左孩子序号:2i+1;若2i+1>=n,则无左孩子(父找子)
- 若2i+2<n,左孩子序号:2i+2;若2i+2>=n,则无右孩子
(2)向上调整算法AdjustUp(插入数据时)
使用场景
往堆的末尾插入新元素,新元素可能比父节点小,破坏小堆"父小于子"的规则,需要从当前新节点向上遍历,逐层修正堆结构
时间复杂度
堆是高度为log2 n(以2为底,n的对数)的完全二叉树,向上调整最多从叶子走到根,循环次数等于树高。
时间复杂度:O(logn)
算法逻辑
- 获取当前节点下标child,计算父节点下标parent
- 如果孩子节点数值<父节点,违反小堆规则,交换两者
- 将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)
算法逻辑
- 以当前父节点为起点,默认左孩子为比较节点
- 对比左右孩子,选出数值更小的子节点(小根堆需要和更小的孩子交换)
- 若最小子节点 < 父节点,交换父子
- 更新父节点为原子节点,继续向下循环,直到子节点越界或符合堆规则


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))
直接复用原数组作为堆,无需额外内存,分两步完成排序:
- 原地建堆 :从下到上,把每一个parent-child组合都转成小根堆。从最后一个非叶子节点向前遍历
- 循环提取极值 :把堆顶的最小值和数组末尾元素交换,再缩小有效堆的范围,对新堆顶继续向下调整,维持堆结构,重复上述操作直至有序
建堆时间复杂度
很多初学者误以为建堆是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 个元素的内存空间,不需要一次性加载全部数据
- 求最大的前 K 个数字 → 构建小根堆
堆顶是当前 K 个数据里的最小值;遍历后续数据时,只要新数字比堆顶大,就替换堆顶,重新调整堆。最终堆内留存的就是全局最大的 K 个数 - 求最小的前 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 值,小根堆实现)
逻辑流程:
- 读取用户输入 K 值,打开存储海量数据的文件
- 动态开辟 K 长度数组,读取前 K 个数字存入数组
- 原地建小根堆(复用ADjustDown向下调整函数)
- 循环读取文件剩余所有数字,逐个和堆顶对比:
若当前数字 > 堆顶 (minHeap 0):替换堆顶,重新向下调整维护小根堆
若当前数字 ≤ 堆顶:直接跳过,不处理; - 文件读取完毕后,堆内存储的就是全局最大的 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文件,手动输入几个最大值,验证函数正确性

