目录
[一、认识 "树"](#一、认识 “树”)
[(二)TOP-K 问题(海量数据求前 K 个最值)](#(二)TOP-K 问题(海量数据求前 K 个最值))
一、认识 "树"
(一)树的基础概念
树是一种非线性的数据结构,它是由 n (n>=0) 个有限结点组成一个具有层次关系的集合。把它叫做树是因为它看起来像一棵倒挂的树,也就是说它是根朝上,而叶朝下的。
(二)关键特性与核心术语
1、关键特性
(1)唯一根结点:整个树有且仅有一个根结点,无前驱结点(无父结点)。
**(2)**子树独立性:除根结点外,其余结点构成互不相交的子树集合,子树间无公共节点。

上面的就不是树了,子树是不相交的,如果存在相交就是图了。同时除了根节点外,每个结点有且仅有一个父结点。
**(3)**边数规则:n 个结点的树必有 n-1 条边,少一条则不连通,多一条则出现环(非树结构)。
**Tip:**这是一条用来判断是否为树结构的依据。
**(4)**路径唯一性:任意两个结点间仅存在一条路径,不存在多条路径连通的情况。
**(5)**递归定义:子树的结构与树完全同构,为后续二叉树递归算法提供理论基础。
2、核心术语

**(1)父结点 / 双亲结点:**若一个结点含有子结点,则这个结点称为其子结点的父结点;如上图: A 是 B 的父结点
**(2)子结点 / 孩子结点:**一个结点含有的子树 的根结点 称为该结点的子结点;如上图:B是A的孩子结点
**(3)结点的度:**一个结点有几个孩子,他的度就是多少;比如A的度为6,F的度为2,K的度为0
**(4)树的度:**一棵树中,最大的结点的度称为树的度;如上图:树的度为 6
**(5)叶子结点 / 终端结点:**度为 0 的结点称为叶结点;如上图:B、C、H、I ... 等结点为叶结点
**(6)分支结点 / 非终端结点:**度不为0的结点;如上图:D、E、F、G ... 等结点为分支结点
**(7)兄弟结点:**具有相同父结点的结点互称为兄弟结点(亲兄弟);如上图:B、C 是兄弟结点
**(8)结点的层次:**从根开始定义起,根为第 1 层,根的子结点为第 2 层,以此类推。
**(9)树的高度或深度:**树中结点的最大层次;如上图:树的高度为 4
**(10)结点的祖先:**从根到该结点所经分支上的所有结点;如上图:A 是所有结点的祖先
**(11)路径:**一条从树中任意节点出发,沿父节点 - 子节点连接,达到任意节点的序列;比如 A 到Q 的路径为:A - E - J - Q;H 到 Q 的路径为:H - D - A - E - J - Q
**(12)子孙:**以某结点为根的子树中任一结点都称为该结点的子孙。上图:所有结点都是A的子孙
**(13)森林:**由 m(m>0) 棵互不相交的树的集合称为森林。
(三)树的表示
1、最常用的方法:孩子兄弟表示法
树结构相对线性表就比较复杂了,要存储表示起来就比较麻烦了,既然保存值域,也要保存结点和结点之间的关系。
实际中树有很多种表示方式如:双亲表示法、孩子表示法、孩子双亲表示法以及孩子兄弟表示法等。我们这里就简单的了解其中最常用的孩子兄弟表示法。
2、结构体的构建
cpp
struct TreeNode {
int data;
struct TreeNode *child;
struct TreeNode *brother;
};
typedef struct TreeNode TreeNode;

(四)树形结构实际运用场景
文件系统是计算机存储和管理文件的一种方式,它利用树形结构来组织和管理文件和文件夹。在文件系统中,树结构被广泛应用,它通过父结点和子结点之间的关系来表示不同层级的文件和文件夹之间的关联。

二、二叉树
(一)二叉树的定义与特性
1、二叉树的定义
二叉树是树形结构中最常用的类型,核心限制是每个结点最多有两个分支,即左孩子和右孩子。其结构定义为:
(1)结点的有限集合,可为空;即二叉树可以是没有任何结点的空结构(也就是 "空二叉树")
**(2)**若非空,则由根结点、左子树和右子树组成,左、右子树均为二叉树。
**(3)**二叉树的子树有左右之分,次序不能颠倒,因此二叉树是有序树。
2、二叉树的度特性

结点的度仅能为 0、1 或 2,不存在度大于 2 的情况:
**(1)度为 0:**叶子结点(无左、右孩子);
**(2)度为 1:**仅有左孩子或仅有右孩子(例:结点 2 仅有左孩子 3);
**(3)度为 2:**同时有左、右孩子(例:结点 1 有左孩子 2、右孩子 4)。
(二)二叉树的分类
1、基本构成情况
二叉树仅由以下 5 种情况复合而成:
**(1)**空树(无根结点); **(2)**仅含根结点(无子树);
**(3)根结点 + 左子树(右子树为空);(4)**根结点 + 右子树(左子树为空);
**(5)**根结点 + 左子树 + 右子树(完整结构)。

2、特殊二叉树:满二叉树
**(1)定义:**一个二叉树,如果每一个层的结点数都达到最大值,则这个二叉树就是满二叉树。第 i 层最多有 2^(i-1) 个结点,无空缺位置。
**(2)示例:**第 1 层 1 个结点、第 2 层 2 个结点、第 3 层 4 个结点、第 4 层 8 个结点,符合满二叉树定义。
(3)结点计算

3、特殊二叉树:完全二叉树
**(1)定义:**由满二叉树衍生而来,核心特征为 "非末层饱和 + 末层左连续"。除最后一层外,其他每层结点数均达到最大值;最后一层结点从左到右连续排列,不允许中间有空缺。
**(2)与满二叉树的关系:**满二叉树一定是完全二叉树;但完全二叉树不一定是满二叉树,完全二叉树末层可非饱和。

(3)核心性质
① 已知树高 h 时,最大节点数 n = 2ʰ - 1(与满二叉树一致);
②已知节点数 n 时,树高 h = ⌈log₂(n+1)⌉(向上取整,例:n=5 时,h=3);
③ 无 "仅右孩子无左孩子" 的情况(完全二叉树的排列规则决定)。
4、二叉树的存储结构
(1)顺序结构
顺序结构存储就是使用数组来存储,一般使用数组只适合表示完全二叉树,因为不是完全二叉树会有空间的浪费,完全二叉树更适合使用顺序结构存储。

实现方式:结点按层次遍历顺序映射到数组下标,根结点下标为 0,左孩子下标 = 2i + 1,右孩子下标 = 2i + 2(i 为父结点下标)。
一般来说用顺序存储结构存储二叉树,这个结构称之为堆。
现实中我们通常把堆使用顺序结构的数组来存储,需要注意的是这里的堆和操作系统虚拟进程地址空间中的堆是两回事,一个是数据结构,一个是操作系统中管理内存的一块区域分段。
(2)链式结构(链表存储)
二叉树的链式存储结构是指,用链表来表示一棵二叉树,即用链来指示元素的逻辑关系。
通常的方法是链表中每个结点由三个域组成,数据域和左右指针域,左右指针分别用来给出该结点左孩子和右孩子所在的链结点的存储地址。
链式结构又分为二叉链和三叉链,当前我们学习中一般都是二叉链;后面到高阶数据结构如红黑树等会用到三叉链。
二叉链表的结点结构为 "left_child + data + right_child";仅含左右孩子指针,结构简洁,常用。三叉链表的结点结构为 "left_child + data + right_child + parent";增加父结点指针,便于回溯查找。


三、实现顺序结构二叉树
一般堆使用顺序结构的数组来存储数据,堆是一种特殊的二叉树,具有二叉树的特性的同时,还具备其他的特性。
(一)堆的定义与分类
1、堆的定义
有一个关键码的集合K,把它的所有元素按完全二叉树的顺序存储方式存储,在一个一维数组中,同时满足 "任意一个根结点的值 ≥ 其左右孩子结点的值" 或者 " **任意一个根结点的值 ≤ 其左右孩子结点的值",**那么这个元素则称为堆。
2、堆的分类
**(1)大跟堆:**任意一个根结点的值 ≥ 其左右孩子结点的值
**(2)小根堆:**任意一个根结点的值 ≤ 其左右孩子结点的值

(二)堆的核心性质
**1、**堆中某个结点的值总是不大于或不小于其父结点的值;堆总是一棵完全二叉树。
2、父子下标关系(关键公式)
对于具有 n 个结点的完全二叉树,如果按照从上至下从左至右的数组顺序对所有结点从 0 开始编号,则对于序号为 i 的结点有:
**(1)**若 i > 0,i 位置结点的双亲序号:(i -1) / 2
**Tip:**i = 0时,i 为根结点编号,无双亲结点
**(2)**左孩子序号:2i + 1
**Tip:**保证 2i + 1 < n,如果 2i + 1 >= n 则无左孩子
**(3)**右孩子序号:2i + 2
**Tip:**保证 2i + 2 < n,如果 2i + 2 >= n 则无左孩子
3、堆的结构定义
其实本质上就是顺序表的定义,只不过排列的顺序遵循了堆的方式。
cpp
// 自定义堆存储的数据类型(可替换为int、float等)
typedef int HPDataType;
// 堆的结构体定义
typedef struct Heap {
HPDataType* arr; // 动态数组:存储堆元素
int size; // 有效元素个数
int capacity; // 数组容量(动态扩容用)
} HP;
(三)堆的核心操作实现
1、创建堆与销毁堆
(1)创建堆
数组置空,size 和 capacity 置 0。
cpp
void HPInit(HP* php)
{
assert(php);
php->arr = NULL;
php->size = php->capacity = 0;
}
(2)销毁堆
释放内存,重置成员。
cpp
void HPDestroy(HP* php)
{
assert(php);
if (php->arr)
free(php->arr);
php->arr = NULL;
php->size = php->capacity = 0;
}
2、辅助函数
(1)打印堆
cpp
void HPPrint(HP* php)
{
for (int i = 0; i < php->size; i++)
{
printf("%d ", php->arr[i]);
}
printf("\n");
}
(2)交换元素
cpp
void Swap(int* x, int* y)
{
int tmp = *x;
*x = *y;
*y = tmp;
}
3、入堆操作
当我们在顺序表最后的位置,插入一个数据,即入队操作之后,这个顺序表原本"堆"的结构就被破环了,此时我们要重新让这些元素,再次满足"堆"的结构。
因为新元素在最下面,这个调整的方法就是 ------ 向上调整算法。
先将元素插入到堆的末尾,即最后一个孩子之后;插入之后如果堆的性质遭到破坏,将新插入结点顺着其双双亲往上调整到合适位置即可。

cpp
// 向上调整:arr为堆数组,child为插入元素的下标(小根堆为例)
void AdjustUp(HPDataType* arr, int child)
{
int parent = (child - 1) / 2;
// 循环条件:child > 0(未到根结点)
while (child > 0)
{
// 小堆:子结点 < 父结点,就交换,把小的交换上去
// 大堆:子结点 > 父结点,就交换,把大的交换上去
if (arr[child] < arr[parent])
{
Swap(&arr[child], &arr[parent]);
// 更新child和parent,继续向上比较
child = parent;
parent = (child - 1) / 2;
} else {
break; // 满足堆性质,结束调整
}
}
}
// 入堆操作:插入元素x到堆中
void HPPush(HP* php, HPDataType x)
{
assert(php != NULL);
// 1. 扩容:空间不足时二倍增容(初始容量为0时默认分配4)
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;
}
// 2. 插入元素到数组末尾(堆的最后一个位置)
php->arr[php->size] = x;
php->size++;
// 3. 向上调整,维持堆结构
AdjustUp(php->arr, php->size - 1); // child为插入元素的下标(size-1)
}
4、出堆操作
因为顺序结构是使用顺序表实现的,所以出堆操作,删除的其实是堆顶元素。
我们采取的删除方法是将最后一个元素与堆顶元素进行交换,然后顺序表的有效个数减 1,就实现了这个元素在堆中的删除,此时这个顺序表所满足的 "堆" 的结构就被破环了,此时我们要调整这些元素,使其再次满足堆的结构。
因为新元素在上面,这个调整的方法就是------向下调整算法。


cpp
// 先判空,如果是空堆则没有元素可以出堆
// 判空:堆为空返回true,否则返回false
bool HPEmpty(HP* php)
{
assert(php != NULL);
return php->size == 0;
}
// 向下调整:arr为堆数组,n为有效元素个数,parent为堆顶下标
void AdjustDown(HPDataType* arr, int n, int parent)
{
int child = parent * 2 + 1; // 初始左孩子下标
while (child < n)
{
// 1. 选择大孩子或者小孩子
// 小堆:左孩子 > 右孩子时,选择右孩子
// 大堆:左孩子 < 右孩子时,选择右孩子
// 因为如果目标是小堆,本质是把小的元素换上去;大堆同理
if (child + 1 < n && arr[child] > arr[child + 1])
child++;
// 2. 子结点与父结点进行交换
// 小堆:子结点 < 父结点,就交换,把小的交换上去
// 大堆:子结点 > 父结点,就交换,把大的交换上去
if (arr[child] < arr[parent])
{
Swap(&arr[child], &arr[parent]);
// 更新parent和child,继续向下比较
parent = child;
child = parent * 2 + 1;
} else {
break; // 满足堆性质,结束调整
}
}
}
// 出堆操作:删除堆顶元素(最值)
void HPPop(HP* php)
{
assert(php != NULL);
assert(!HPEmpty(php)); // 断言堆非空
// 1. 交换堆顶与最后一个元素(避免数组整体移动)
Swap(&php->arr[0], &php->arr[php->size - 1]);
php->size--; // 移除最后一个元素(原堆顶)
// 2. 向下调整堆顶,维持堆结构
AdjustDown(php->arr, php->size, 0); // parent为堆顶下标0
}
5、取堆顶元素与取堆大小
(1)取堆顶元素:返回堆顶(最值)
cpp
HPDataType HPTop(HP* php)
{
assert(php != NULL);
assert(!HPEmpty(php)); // 断言堆非空
return php->arr[0];
}
(2)取堆大小:返回有效元素个数
cpp
int HPSize(HP* php)
{
assert(php != NULL);
return php->size;
}
6、调整算法的时间复杂度与区别
(1)向上调整算法的时间复杂度
因为堆是完全二叉树,而满二叉树也是完全二叉树,此处为了简化使用满二叉树来证明(时间复杂度本来看的就是近似值,多几个结点不影响最终结果)。

(2)向下调整算法的时间复杂度

7、向上调整算法与向下调整算法的区别
(1)入堆用向上调整:新元素放在堆的末尾,仅需沿 "自身→根结点" 的路径调整,且向下无结点可对比,向上是唯一可行的修复方式。
(2)出堆用向下调整:堆顶被最后一个结点替换后,仅需沿 "根结点→叶子" 的路径调整,且向上无父结点可对比,向下是唯一可行的修复方式。
四、堆的核心应用
(一)堆排序
1、排序原理
**(1)升序排序:**建大根堆,每次将堆顶(最大值)与堆尾元素交换,调整剩余元素为大根堆,重复直至数组有序;
**(2)降序排序:**建小根堆,每次将堆顶(最小值)与堆尾元素交换,调整剩余元素为小根堆,重复直至数组有序。
2、实现代码(升序,建大根堆)
cpp
void HeapSort(int* arr, int n)
{
// 第一步:将数组直接建堆(向下调整,O(n))
// 从最后一个非叶结点开始
for (int i = (n - 1 - 1) / 2; i >= 0; --i) {
AdjustDown(arr, i, n); // 注意调整函数为建立大堆的
}
/*
for (int i = 0; i < n; i++)
AdjustUp(arr, i);
*/
// 第二步:堆顶与堆尾交换,调整堆(O(n log n))
int end = n - 1;
while (end > 0) {
Swap(&arr[0], &arr[end]); // 堆顶(最大值)放到末尾
AdjustDown(arr, 0, end);// 调整剩余end个元素为大根堆
//AdjustUp(arr, i);
end--; // 缩小堆的范围
}
}
3、关键说明
(1)叶结点无需调整
最后一个非叶结点之后的所有结点都是叶结点,本身满足堆性质,从这里开始能跳过无意义的操作。
(2)时间复杂度
单次向下调整的时间复杂度 O (logn);而 "构建整个堆" 的总时间复杂度是 O (n)。
所以第一个 for 循环的时间复杂度为O(n),第二个 while 循环的时间复杂度为 O(nlogn);所以总时间复杂度为 O(n) + O (nlogn),保留高阶项为 O (nlogn)。
如果换为向上调整建堆,单次的时间复杂度是O (logn),整体是O (nlogn);所以总的时间复杂度为O (nlogn) + O (nlogn),同阶项忽略系数,故依旧为O (nlogn)。
(3)空间复杂度:O (1)
**Tip:**原地排序,无需额外空间。
**(4)稳定性:**不稳定排序,相等元素可能因交换改变相对位置。
(二)TOP-K 问题(海量数据求前 K 个最值)
TOP-K 问题指从海量数据(n 远大于 K)中查找前 K 个最大或最小元素,堆是最优解决方案,当所有元素无法一次性加载到内存中时。
1、核心思路
(1)求前 K 个最大元素
①取前 K 个数据建小根堆(堆顶为当前 K 个元素中的最小值);
② 遍历剩余 n-K 个数据,若数据 > 堆顶,则替换堆顶并向下调整(淘汰当前最小的候选值);
③遍历结束后,堆内 K 个元素即为前 K 个最大元素。
(2)求前 K 个最小元素
① 取前 K 个数据建大根堆(堆顶为当前 K 个元素中的最大值);
②遍历剩余 n-K 个数据,若数据 < 堆顶,则替换堆顶并向下调整(淘汰当前最大的候选值);
③ 遍历结束后,堆内 K 个元素即为前 K 个最小元素。
2、实现代码(海量数据求前 K 个最大元素)
cpp
#define _CRT_SECURE_NO_WARNINGS
#include"Heap.h"
#include<time.h>
// 生成海量测试数据,写入data.txt
void CreateNDate()
{
// 造数据
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)
{
int x = (rand() + i) % 1000000;
fprintf(fin, "%d\n", x);
}
fclose(fin);
}
// 查找前K个最大元素
void Topk()
{
// 这里的k是指取多少个元素
int k = 0;
printf("请输入K:");
scanf("%d", &k);
// 1、打开数据文件
const char* file = "data.txt";
FILE* fout = fopen(file, "r");
if (fout == NULL){
perror("fopen error");
exit(1);
}
// 2. 申请空间存储前K个数据
int* minHeap = (int*)malloc(sizeof(int) * k);
if (minHeap == NULL){
perror("malloc fail!");
exit(2);
}
// 3. 读取前K个数据存入顺序表
for (int i = 0; i < k; i++){
fscanf(fout, "%d", &minHeap[i]);
}
// 4. 对前K个数据建小根堆(向下调整)
for (int i = (k - 1 - 1) / 2; i >= 0; i--){
AdjustDown(minHeap, i, k);
}
// 5. 遍历剩下的n-k个数,跟堆顶进行比较,谁大谁入堆,再调整堆
int x = 0;
while (fscanf(fout, "%d", &x) != EOF){
// 比堆顶大则替换
if (x > minHeap[0]){
minHeap[0] = x;
AdjustDown(minHeap, k, 0);
}
}
// 6. 打印前K个最大元素(堆内元素未排序,仅保证是前K大)
for (int i = 0; i < k; i++){
printf("%d ", minHeap[i]);
}
free(minHeap);
minHeap = NULL;
fclose(fout);
}
int main()
{
CreateNDate();
Topk();
return 0;
}
3、时间复杂度
**(1)建堆:**O (k)(向下调整建堆);
**(2)****遍历剩余数据:**O ((n-K) log k)(每次调整堆的时间为 O (log k));
**(3)**总复杂度: O(k) + O( (n-K) log k) ≈ O (n log k)(n 远大于 K 时)。
4、优势
**(1)内存占用低:**仅需存储 K 个元素的堆,无需加载全部 n 个数据;
(2)效率高: 时间复杂度远优于排序(O (n log n)),尤其适合海量数据场景。
以上即为 一篇文章掌握"树"(上) 的全部内容,创作不易,麻烦三连支持一下呗~
