「 每日一句 · Daily Quote 」
"千淘万漉虽辛苦,吹尽狂沙始到金。"
--- 唐·刘禹锡《浪淘沙·其八》
文章目录
- 前言
- 一、树
-
- [1. 树的概念与结构](#1. 树的概念与结构)
- [2. 树相关术语](#2. 树相关术语)
- [3. 树的表示](#3. 树的表示)
- [4. 树形结构实际运用场景](#4. 树形结构实际运用场景)
- 二、二叉树
-
- [1. 概念与结构](#1. 概念与结构)
- [2. 特殊的二叉树](#2. 特殊的二叉树)
-
- [2.1 满二叉树](#2.1 满二叉树)
- [2.2 完全二叉树](#2.2 完全二叉树)
- [3. 二叉树存储结构](#3. 二叉树存储结构)
-
- [3.1 顺序结构](#3.1 顺序结构)
- [3.2 链式结构](#3.2 链式结构)
- 三、实现顺序结构二叉树
-
- [1. 堆的概念与结构](#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 向下调整算法)
- [3. 堆的应用](#3. 堆的应用)
-
- [3.1 堆排序](#3.1 堆排序)
- [3.2 TOP-K问题](#3.2 TOP-K问题)
- 四、实现链式结构二叉树
-
- [1. 前中后序遍历](#1. 前中后序遍历)
-
- [1.1 遍历规则](#1.1 遍历规则)
- [1.2 代码实现](#1.2 代码实现)
- [2. 结点个数以及高度等代码实现](#2. 结点个数以及高度等代码实现)
-
- [2.1 二叉树节点个数](#2.1 二叉树节点个数)
- [2.2 二叉树叶子节点个数](#2.2 二叉树叶子节点个数)
- [2.3 二叉树第k层节点个数](#2.3 二叉树第k层节点个数)
- [2.4 二叉树的深度/高度](#2.4 二叉树的深度/高度)
- [2.5 二叉树查找值为x的结点](#2.5 二叉树查找值为x的结点)
- [2.6 二叉树销毁](#2.6 二叉树销毁)
- [3. 层序遍历](#3. 层序遍历)
- [4. 判断是否为完全二叉树](#4. 判断是否为完全二叉树)
- 总结
前言
树是一种重要的非线性数据结构,而二叉树更是其中应用最广泛的形式。本文从树的基本概念与术语入手,介绍二叉树的定义、特殊类型及存储结构,重点讲解顺序结构堆的实现(包括向上/向下调整算法、堆排序与TOP-K问题),并完整实现链式二叉树的创建、前中后序及层序遍历、结点统计、查找与销毁。掌握树与二叉树,是迈向高级数据结构与算法的关键一步。
一、树
1. 树的概念与结构
树是⼀种非线性的数据结构,它是由 n(n>=0) 个有限结点组成一个具有层次关系的集合。把它叫做树是因为它看起来像一棵倒挂的树,也就是说它是根朝上,而叶朝下的。
- 有⼀个特殊的结点,称为根结点,根结点没有前驱结点。
- 除根结点外,其余结点被分成
M(M>0)个互不相交的集合T1、T2、......、Tm,其中每⼀个集合Ti(1 <= i <= m)又是一棵结构与树类似的子树。每棵子树的根结点有且只有一个前驱,可以有0个或多个后继。因此,树是递归定义的。

树形结构中,子树之间不能有交集,否则就不是树形结构
非树形结构:

- 子树是不相交的(如果存在相交就是图了)
- 除了根结点外,每个结点有且仅有一个父结点
- 一棵N个结点的树有N-1条边
2. 树相关术语

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


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

二、二叉树
1. 概念与结构
在树形结构中,我们最常用的就是二叉树,一棵二叉树是结点的一个有限集合,该集合由一个根结点加上两棵别称为左子树和右子树的二叉树组成或者为空。

从上图可以看出二叉树具备以下特点:
- 二叉树不存在度大于
2的结点 - 二叉树的子树有左右之分,次序不能颠倒,因此二叉树是有序树
注意: 对于任意的二叉树都是由以下几种情况复合而成的

现实中的二叉树

2. 特殊的二叉树
2.1 满二叉树
一个二叉树,如果每一个层的结点数都达到最大值,则这个二叉树就是满二叉树。也就是说,如果⼀个二叉树的层数为 K ,且结点总数是 2 k − 1 2^k - 1 2k−1 ,则它就是满二叉树。

2.2 完全二叉树
完全二叉树是效率很高的数据结构,完全二叉树是由满二叉树而引出来的。对于深度为 K 的,有 n 个结点的二叉树,当且仅当其每一个结点都与深度为K的满二叉树中编号从 1 ⾄ n 的结点一对应时称之为完全二叉树。要注意的是满二叉树是一种特殊的完全二叉树。

二叉树性质
根据满二叉树的特点可知:
-
- 若规定根结点的层数为 1 1 1,则一棵非空二叉树的第 i i i 层上最多有 2 i − 1 2^{i-1} 2i−1 个结点
-
- 若规定根结点的层数为 1 1 1,则深度为 h h h 的二叉树的最大结点数是 2 h − 1 2^h - 1 2h−1
-
- 若规定根结点的层数为 1 1 1,具有 n n n 个结点的满二叉树的深度 h = log 2 ( n + 1 ) h = \log_2(n+1) h=log2(n+1) ( log \log log 以 2 2 2 为底, n + 1 n+1 n+1 为对数)
3. 二叉树存储结构
二叉树一般可以使用两种结构存储,一种顺序结构,一种链式结构。
3.1 顺序结构
顺序结构存储就是使用数组来存储,一般使用数组只适合表示完全二叉树,因为不是完全二叉树会有空间的浪费,完全二叉树更适合使用顺序结构存储。


现实中我们通常把堆(一种二叉树)使用顺序结构的数组来存储,需要注意的是这里的堆和操作系统虚拟进程地址空间中的堆是两回事,一个是数据结构,一个是操作系统中管理内存的一块区域分段。
3.2 链式结构
二叉树的链式存储结构是指,用链表来表示一棵二叉树,即用链来指示元素的逻辑关系。通常的方法是链表中每个结点由三个域组成,数据域和左右指针域,左右指针分别用来给出该结点左孩子和右孩子所在的链结点的存储地址。链式结构又分为二叉链和三叉链,当前我们学习中一般都是二叉链。后面课程学到高阶数据结构如红黑树等会用到三叉链。


三、实现顺序结构二叉树
一般堆使用顺序结构的数组来存储数据,堆是一种特殊的二叉树,具有二叉树的特性的同时,还具备其他的特性。
1. 堆的概念与结构
如果有这样一个关键字码的集合 K = { k 0 , k 1 , k 2 , . . . , k n − 1 } K = \{k_0, k_1, k_2, ..., k_{n-1}\} K={k0,k1,k2,...,kn−1},把它的所有元素按完全二叉树的顺序存储方式存储,在一个一维数组中,并满足: K i ≤ K 2 ∗ i + 1 K_i \le K_{2*i+1} Ki≤K2∗i+1 ( K i ≥ K 2 ∗ i + 1 K_i \ge K_{2*i+1} Ki≥K2∗i+1 且 K i ≤ K 2 ∗ i + 2 K_i \le K_{2*i+2} Ki≤K2∗i+2), i = 0 、 1 、 2... i = 0、1、2... i=0、1、2...,则称为小堆(或大堆)。将根节点最大的堆叫做最大堆或大根堆,根节点最小的堆叫做最小堆或小根堆。

堆具有以下性质
- 堆中某个结点的值总是不大于或不小于其父结点的值;
- 堆总是一棵完全二叉树。
二叉树性质
- 对于具有
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. 堆的实现
本项目用数组来实现堆, 堆的形式为小根堆
2.1 堆的结构
c
typedef int HPDataType;
typedef struct Heap
{
HPDataType* arr; //指向动态数组的元素指针
int size; //有效元素个数
int capacity; //空间容量
}HP;
-
- 堆的结构包括
arr,size,capacity
- 堆的结构包括
-
arr作为指向动态数组的元素指针,size表示有效元素个数,capacity作为空间容量
2.2 初始化
c
//初始化
void HPInit(HP* php)
{
assert(php);
php->arr = NULL;
php->capacity = php->size = 0;
}
-
- 先判断指针不为
NULL,将指向动态数组的指针置为空,并将size和capacity都置为0
- 先判断指针不为
2.3 销毁
c
//销毁
void HPDestroy(HP* php)
{
assert(php);
if (php->arr)
{
free(php->arr);
}
php->arr = NULL;
//将容量和有效空间都置为0
php->capacity = php->size = 0;
}
-
- 先判断指针不为空
-
- 在
arr不为空的情况下,释放arr指向区域的内存空间
- 在
-
- 将
arr指针置为空,并将size和capacity都置为0
- 将
2.4 往堆中插入数据
c
//往堆中插入数据
void HPPush(HP* php, HPDataType x)
{
//检查指针不为空
assert(php);
//如果空间不够,额外申请空间
if (php->capacity == php->size)
{
//定义新的容量空间,用三目操作符
int newcapacity = php->capacity == 0 ? 4 : php->capacity * 2;
//先用临时指针tmp接收,防止扩容失败导致内存泄露
HPDataType* tmp = (HPDataType*)realloc(php->arr, sizeof(HPDataType) * newcapacity);
if (tmp == NULL)
{
perror("realloc fail");
exit(1);
}
//将成功申请到的空间给arr;
php->arr = tmp;
php->capacity = newcapacity;
}
//往堆中插入数据
php->arr[php->size] = x;
//插入数据后要调整堆,保证插入后还是一个堆结构
AdjustUp(php->arr, php->size);//这个向上调整算法后面会讲到
//插入成功后有效数据加一
php->size++;
}
-
- 先检查传入的堆结点指针不为空,检查完后为堆动态申请内存(用
malloc)
- 先检查传入的堆结点指针不为空,检查完后为堆动态申请内存(用
-
- 在堆结构的末尾插入数据
php->arr[php->size] = x;
- 在堆结构的末尾插入数据
-
- 用向上调整算法调整堆的结构,保证插入后还是一个堆结构
-
- 插入成功后让有效数据
size自增1
- 插入成功后让有效数据
2.5 打印堆
c
//打印
void HPPrint(HP* php)
{
assert(php);
for (int i = 0; i < php->size; i++)
{
printf("%d ", php->arr[i]);
}
printf("\n");
}
-
- 先检查传入的指针不为空
-
- 从头遍历整个堆数组,每遍历一个就打印一个
-
- 全部遍历完后打印换行符
\n作为收尾
- 全部遍历完后打印换行符
2.6 判空
c
// 判空
bool HPEmpty(HP* php)
{
assert(php);
return php->size == 0;
}
-
- 检查传入指针不为空
-
- 如果
size为0则说明为空堆,不为0则不是空堆
- 如果
2.7 删除堆的数据
c
//删除堆的数据
void HPPop(HP* php)
{
//检查不为空堆
assert(!HPEmpty(php));
assert(php->size > 0);
//删除的是根节点的数据
//先让根节点和最后一个节点交换位置,方便删除
Swap(&php->arr[0], &php->arr[php->size - 1]);
//通过size--,达到删除的目的
php->size--;
//通过向下调整算法来调整堆
AdjustDown(php->arr, 0, php->size);//这个在后面会有所提到
}
-
- 先检查堆不为空并且堆中有效元素大于0
-
- 删除的是根节点的数据,所以先让根节点和最后一个结点交换位置,方便删除
-
- 通过
size--使得有效元素个数减1,从而达到删除的目的
- 通过
-
- 最后通过向下调整算法来调整堆,使得堆满足小根堆的结构
2.8 取堆顶数据
c
//取堆顶数据
HPDataType HPTop(HP* php)
{
assert(php);
// 检查不为空堆
assert(!HPEmpty(php));
return php->arr[0];
}
-
- 检查传入指针不为空并且不为空堆
-
- 返回堆中首个元素
arr[0]
- 返回堆中首个元素
2.9 向上调整算法(小根堆)
堆的插入
将新数据插入到数组的尾上,再进行向上调整算法,直到满足堆。
向上调整算法
- 先将元素插入到堆的末尾,即最后一个孩子之后
- 插入之后如果堆的性质遭到破坏,将新插入结点顺着其双双亲往上调整到合适位置即可
c
//向上调整算法(此处为小根堆)
void AdjustUp(HPDataType* arr, int child)
{
assert(arr);
//先找到父节点
int parent = (child - 1) / 2;
while (child > 0)
{
//如果子结点比父节点小,则交换位置
//大堆: >
//小堆: <
if (arr[child] < arr[parent])
{
//调整
Swap(&arr[child], &arr[parent]);
//原来parent位置被child替代,新parent位置为(child - 1)/2
//child向上遍历,成为新的parent
child = parent;
parent = (child - 1) / 2;
}
else
{
break;
}
}
}
-
- 向上调整算法是指在二叉堆末尾插入新节点后,将其与父节点逐层向上比较并交换位置,直至满足堆序性(父节点小于等于或大于等于子节点)或抵达堆顶根节点的恢复过程。
-
int child表示当前需要向上调整的节点在底层数组中的下标,为新插入元素的下标,位于数组末尾
-
- 从新插入的孩子结点入手,找到其
parent结点,即int parent = (child - 1) / 2;
- 从新插入的孩子结点入手,找到其
-
- 若
arr[child] < arr[parent],则交换两节点的值;随后child上移至原parent位置(child = parent),并重新计算上一层父节点下标parent = (child - 1) / 2,继续向上比较。
- 若
-
- 若
arr[child] >= arr[parent],则表示已经满足小根堆结构要求,向上调整算法完成,无需交换结点
- 若
向上调整算法示意图


💡 向上调整算法建堆时间复杂度为: O ( n ∗ log 2 n ) O(n * \log_2 n) O(n∗log2n)
证明过程

2.10 向下调整算法
堆的删除
删除堆是删除堆顶的数据,将堆顶的数据根最后⼀个数据⼀换,然后删除数组最后⼀个数据,再进行向下调整算法。
向下调整算法有一个前提:左右子树必须是一个堆的结构,才能调整。
向下调整算法
- 将堆顶元素与堆中最后一个元素进行交换
- 删除堆中最后⼀个元素
- 将堆顶元素向下调整到满足堆特性为止
c
//向下调整算法(此处为小根堆)
void AdjustDown(HPDataType* arr, int parent, int n)
{
assert(arr);
//这里得到的是左孩子
int child = parent * 2 + 1;
while (child < n)
{
//防止child+1越界,
//选出左右孩子中的小节点
//小堆: >
//大堆: <
if (child + 1 < n && arr[child] > arr[child + 1])
{
child++;
}
//开始比较父节点和子节点的大小,并且parent节点也只和两个孩子中较小的节点进行比较并交换
//小堆: <
//大堆: >
if (arr[child] < arr[parent])//父节点比子节点大,交换,把小的节点换上来,最终根节点是最小的
{
Swap(&arr[parent], &arr[child]);
//parent向下遍历,成为新的child
parent = child;
//新child为(parent*2+1)
child = parent * 2 + 1;
}
else
{
break;
}
}
}
-
- 向下调整算法是指在二叉堆的某个节点(通常是堆顶元素被替换,或建堆阶段的非叶子节点)破坏堆序性时,将其与子节点中更优的一方(大根堆取较大孩子,小根堆取较小孩子)逐层向下比较并交换位置,直至满足堆序性(当前节点优于或等于其所有孩子)或抵达叶子节点的恢复过程。
-
- 传入的
parent是需要执行向下调整的子树的根节点 ,n表示当前堆中有效元素个数
- 传入的
-
- 先通过传入的
parent得到其的左孩子,之后比较左孩子和右孩子的大小,选出较小的那一个
- 先通过传入的
-
- 若
arr[child] < arr[parent],则交换两个节点的值,随后parent下移到parent的位置(parent = child),并重新计算下一层左孩子节点下标child = parent*2 + 1,继续向下比较。
- 若
-
- 若
arr[child] >= arr[parent],则表示已经满足小根堆结构要求,向下调整算法完成,无需交换结点
- 若
向下调整算法示意图

💡 向下调整算法建堆时间复杂度为: O ( n ) O(n) O(n)
证明过程

3. 堆的应用
3.1 堆排序
版本一:基于已有数组建堆、取堆顶元素完成排序版本
c
// 1、需要堆的数据结构
// 2、空间复杂度 O(N)
void HeapSort(int* a, int n)
{
HP hp;
for(int i = 0; i < n; i++)
{
HPPush(&hp,a[i]);
}
int i = 0;
while (!HPEmpty(&hp))
{
a[i++] = HPTop(&hp);
HPPop(&hp);
}
HPDestroy(&hp);
}
这段代码利用小根堆的特性:每次 HPPush 通过向上调整将新元素归位并维持小根堆结构,保证堆顶始终是最小值;随后在出堆循环中,依次取出堆顶最小值覆盖回原数组,从而实现升序排序。
不过该版本有⼀个前提,必须提供有现成的数据结构堆
版本二:数组建堆,首尾交换,交换后的堆尾数据从堆中删掉,将堆顶数据向下调整选出次小的数据
堆排序,降序,用小堆
升序,用大堆
此处用到是小堆排降序
c
//堆排序,降序,用小堆
void Heapsort(HPDataType* arr, int n)
{
//空指针检查
assert(arr);
////建堆,,向下调整算法建堆
//for (int i = ((n - 1) - 1) / 2; i >= 0; i--)
//{
// AdjustDown(arr, i, n);
//}
//建堆,向上调整算法建堆
for (int i = 0; i < n; i++)
{
AdjustUp(arr, i);
}
//堆排序
int end = n - 1;
while (end > 0)
{
Swap(&arr[0], &arr[end]);
AdjustDown(arr, 0, end);
end--;
}
}
这里先讲一下建堆时用到的向上调整算法和向下调整算法的代码
向下调整算法建堆
c
//建堆,,向下调整算法建堆
for (int i = ((n - 1) - 1) / 2; i >= 0; i--)
{
AdjustDown(arr, i, n);
}
- 1. 起点选择 :向下调整算法要求当前节点的左右子树都必须是合法的堆。叶子节点没有孩子,天然满足堆序性;因此我们直接跳过叶子节点,从最后一个非叶子节点
((n - 1) - 1) / 2开始,自底向上(倒序)展开调整。 - 2. 循环推进与终止 :每用向下调整算法处理完一棵子树,就执行
i--(即当前子树的根节点下标减 1),去处理上一棵子树。自底向上保证了每次调整时,其下方的左右子树均已被修正为堆;直到i = 0完成整棵树根节点的向下调整后退出循环,此时整棵树的堆结构便建立完成。
向上调整算法建堆
c
//建堆,向上调整算法建堆
for (int i = 0; i < n; i++)
{
AdjustUp(arr, i);
}
- 1. 起点与遍历方向 :从
i = 0到n - 1进行正序遍历。与向下调整的"自底向上"完全相反,向上调整建堆采用的是**自顶向下(正序)**的策略。 - 2. 核心思想(模拟连续 Push) :每次循环将当前索引
i处的元素视为刚刚插入到堆底的新节点,调用AdjustUp(arr, i)让它与父节点逐层向上比较并交换,将其"归位"到合法的堆中。随着循环推进,数组中的元素被逐个纳入堆中,动态维持并扩充了整体的堆结构。
建完堆后就是堆排序的时间了
c
int end = n - 1;
while (end > 0)
{
Swap(&arr[0], &arr[end]);
AdjustDown(arr, 0, end);//堆有效大小是end,下标最大为end-1
end--;
}
- 1. 首尾交换与区间隔离 :循环从
end = n - 1开始递减。每次将当前未排序区间的末尾节点arr[end]与堆顶根节点arr[0]交换;随后调用AdjustDown(arr, 0, end),直接以end作为新的有效元素总数进行向下调整,既将刚刚换到末尾的极值安全隔离在调整范围之外,又在剩余元素中重新选拔出极值浮到堆顶,随后end--缩小区间推进循环。 - 2. 排序结果与终止 :因为建立的是小根堆,所以每次换到未排序区间末尾的都是剩余元素中的最小值;当
end = 0时,只剩一个元素无需再调整,循环终止,此时原数组最终呈现为降序排序(从大到小)。 -
💡 堆排序时间复杂度为: O ( n log n ) O(n \log n) O(nlogn)
3.2 TOP-K问题
TOP-K问题:即求数据结合中前K个最大的元素或者最小的元素,一般情况下数据量都比较大。
比如:专业前10名、世界500强、富豪榜、游戏中前100的活跃玩家等。
对于Top-K问题,能想到的最简单直接的方式就是排序,但是:如果数据量非常大,排序就不太可取了(可能数据都不能一下子全部加载到内存中)。最佳的方式就是用堆来解决,基本思路如下:
-
- 用数据集合中前K个元素来建堆
前k个最大的元素,则建小堆
前k个最小的元素,则建大堆
- 用数据集合中前K个元素来建堆
这样处理的原因
以找前k个最大的元素为例子,堆顶元素为当前这 k k k 个数中的最小值,充当了入围前 k k k 大榜单的门槛(守门员)。
- 遍历后续剩余元素时,只需将其与堆顶比较:若小于或等于 堆顶,说明它比当前门槛还要小,绝无可能进入前 k k k 大,直接舍弃;若大于 堆顶,则说明它有资格入榜,直接替换堆顶元素并执行向下调整(
AdjustDown),使新的最小值浮到堆顶,动态提升门槛。- 遍历结束后,留在堆中的 k k k 个元素就是全局最大的前 k k k 个数。
- 反之,若建大堆,堆顶是这 k k k 个数中的最大值,遇到比堆顶小的元素时,无法判断它与堆内深层其余 k − 1 k-1 k−1 个元素的大小关系,无法进行高效筛选。
-
- 用剩余的N-K个元素依次与堆顶元素来比较,不满足则替换堆顶元素
将剩余N-K个元素依次与堆顶元素比完之后,堆中剩余的K个元素就是所求的前K个最小或者最大的元素
代码如下
- 用剩余的N-K个元素依次与堆顶元素来比较,不满足则替换堆顶元素
c
#define _CRT_SECURE_NO_WARNINGS
#include"heap.h"
#include <time.h> // 补充时间函数声明
void CreateNDate()
{
// 造数据
int n = 10000;
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)
{
int x = rand() % 1000000;
fprintf(fin, "%d\n", x);
}
fclose(fin);
}
void TopK()
{
//把k定下来
int k = 0;
printf("请输入K: ");
if (scanf("%d", &k) != 1 || k <= 0)
{
fprintf(stderr, "输入无效: K 必须为正整数\n");
return;
}
const char* file = "data.txt";
//打开文件
FILE* fout = fopen(file, "r");
if (fout == NULL)
{
perror("fopen fail");
exit(1);
}
//创建存储堆的数组
int* minheap = (int*)malloc(sizeof(int) * k);
if (minheap == NULL)
{
perror("malloc fail");
exit(2);
}
//往数组中插入数据
for (int i = 0; i < k; i++)
{
fscanf(fout, "%d", &minheap[i]);
}
//向下调整建堆,建小堆,创建堆结构
for (int i = (k - 1 - 1) / 2; i >= 0; i--)
{
AdjustDown(minheap, i, k);
}
//将(n-k)个数据依次和对顶元素进行比较
//设每次读取的数为x;
int x = 0;
while (fscanf(fout, "%d", &x) != EOF)
{
//如果x比堆顶(堆最小的元素)小
if (x > minheap[0])
{
minheap[0] = x;
//重新向下调整堆,从头开始调整
AdjustDown(minheap, 0, k);
}
}
//打印
for (int i = 0; i < k; i++)
{
printf("%d ", minheap[i]);
}
printf("\n");
free(minheap);
fclose(fout);
}
int main()
{
//CreateNDate();
TopK();
return 0;
}
时间复杂度: O ( n ) = k + ( n − k ) log 2 k O(n) = k + (n - k)\log_2 k O(n)=k+(n−k)log2k
四、实现链式结构二叉树
用链表来表示一棵二叉树,即用链来指示元素的逻辑关系。通常的方法是链表中每个结点由三个域组成,数据域和左右指针域,左右指针分别用来给出该结点左孩子和右孩子所在的链结点的存储地址,其结构如下:
c
//定义链式结构的二叉树
typedef char BTDataType;
typedef struct BinaryTreeNode
{
BTDataType data;//根节点
struct BinaryTreeNode* left;//左节点
struct BinaryTreeNode* right;//右节点
}BTNode;
二叉树的创建方式比较复杂,为了更好的步入到二叉树内容中,我们先手动创建一棵链式二叉树
c
//创建二叉树节点
BTNode* buyNode(char x)
{
BTNode* node = (BTNode*)malloc(sizeof(BTNode));
if (node == NULL)
{
perror("malloc fail : ");
exit(1);
}
node->data = x;
node->left = NULL;
node->right = NULL;
return node;
}
//手动构造一棵二叉树
BTNode* createTree()
{
BTNode* nodea = buyNode('A');
BTNode* nodeb = buyNode('B');
BTNode* nodec = buyNode('C');
BTNode* noded = buyNode('D');
BTNode* nodee = buyNode('E');
BTNode* nodef = buyNode('F');
nodea->left = nodeb;
nodea->right = nodec;
nodeb->left = noded;
nodec->left = nodee;
nodec->right = nodef;
return nodea;
}
回顾二叉树的概念,二叉树分为空树和非空二叉树,非空二叉树由根结点、根结点的左子树、根结点的右子树组成的

根结点的左子树和右子树分别又是由子树结点、子树结点的左子树、子树结点的右子树组成的,因此二叉树定义是递归式的,后序链式二叉树的操作中基本都是按照该概念实现的。
1. 前中后序遍历
二叉树的操作离不开树的遍历,我们先来看看二叉树的遍历有哪些方式

1.1 遍历规则
按照规则,二叉树的遍历有:前序/中序/后序的递归结构遍历:
- 前序遍历(Preorder Traversal 亦称先序遍历):访问根结点的操作发生在遍历其左右子树之前
访问顺序为:根结点、左子树、右子树
- 中序遍历(Inorder Traversal):访问根结点的操作发生在遍历其左右子树之中(间)
访问顺序为:左子树、根结点、右子树
- 后序遍历(Postorder Traversal):访问根结点的操作发生在遍历其左右子树之后
访问顺序为:左子树、右子树、根结点
1.2 代码实现
c
//前序遍历------根左右
void preOrder(BTNode* root)
{
if (root == NULL)
{
printf("NULL ");
return;
}
//先遍历根节点
printf("%c ", root->data);
//继续遍历左节点
preOrder(root->left);
//再遍历右节点
preOrder(root->right);
}
//中序遍历---左根右
void inOrder(BTNode* root)
{
//遇到空节点返回
if (root == NULL)
{
printf("NULL ");
return;
}
inOrder(root->left);
printf("%c ", root->data);
inOrder(root->right);
}
//后序遍历---左右根
void postOrder(BTNode* root)
{
if (root == NULL)
{
printf("NULL ");
return;
}
postOrder(root->left);
postOrder(root->right);
printf("%c ", root->data);
}
图解遍历:
以前序遍历为例

函数递归栈帧图:

前序遍历结果:A, B, D, NULL, NULL, NULL, C, E, NULL, NULL, F, NULL, NULL
中序遍历结果:NULL, D, NULL, NULL, B, NULL, A, NULL, E, NULL, C, NULL, F, NULL
后续遍历结果;NULL, NULL, D, NULL, B, NULL, NULL, E, NULL, NULL, F, C, A
2. 结点个数以及高度等代码实现
2.1 二叉树节点个数
c
// ⼆叉树结点个数 == 左节点 + 右节点 + 根节点
int BinaryTreeSize(BTNode* root)
{
if (root == NULL)
{
return 0;
}
return 1 + BinaryTreeSize(root->left) + BinaryTreeSize(root->right);
}
二叉树结点个数 == 左节点 + 右节点 + 根节点

2.2 二叉树叶子节点个数
c
// ⼆叉树叶⼦结点个数 == 左子树叶子节点个数 + 右子树叶子节点个数
int BinaryTreeLeafSize(BTNode* root)
{
if (root == NULL)
{
//空节点返回0
return 0;
}
//叶子节点返回1
if (root->left == NULL && root->right == NULL)
{
return 1;
}
return BinaryTreeLeafSize(root->left) + BinaryTreeLeafSize(root->right);
}
二叉树叶子结点个数 == 左子树叶子节点个数 + 右子树叶子节点个数

2.3 二叉树第k层节点个数
c
// ⼆叉树第k层结点个数 == 左子树第k-1层节点个数 + 右子树第k-1层节点个数
int BinaryTreeLevelKSize(BTNode* root, int k)
{
assert(k > 0); //防止调用者误传 k <= 0
if (root == NULL)
return 0;
if (root == NULL)
{
return 0;
}
if (k == 1)//表明已经到第k层并且不为空
{
return 1;
}
return BinaryTreeLevelKSize(root->left, k - 1) + BinaryTreeLevelKSize(root->right, k - 1);
}
二叉树第k层结点个数 == 左子树第k-1层节点个数 + 右子树第k-1层节点个数

2.4 二叉树的深度/高度
c
//⼆叉树的深度/⾼度 = 1 + max(左子树,右子树)
int BinaryTreeDepth(BTNode* root)
{
if (root == NULL)
{
return 0;
}
//收集左右子树的高度与深度
int leftdep = BinaryTreeDepth(root->left);
int rightdep = BinaryTreeDepth(root->right);
return 1 + (leftdep > rightdep ? leftdep : rightdep);
}
二叉树的深度/高度 = 1 + max(左子树,右子树)

2.5 二叉树查找值为x的结点
c
// ⼆叉树查找值为x的结点
BTNode* BinaryTreeFind(BTNode* root, BTDataType x)
{
//如果节点为空,则返回NULL
if (root == NULL)
{
return NULL;
}
//先判断根节点
if (root->data == x)
{
return root;
}
//判断左子树节点是否有要查找的值
BTNode* leftFind = BinaryTreeFind(root->left, x);
if (leftFind)
{
return leftFind;
}
//判断右子树节点是否有要查找的值
BTNode* rightFind = BinaryTreeFind(root->right, x);
if (rightFind)
{
return rightFind;
}
//左右子树都没找到,返回NULL
return NULL;
}
- 该函数采用先序遍历策略递归查找目标节点:首先检查当前节点是否为空,若为空则返回
NULL;若当前节点的值匹配目标值x,则直接返回该节点指针;若不匹配,则优先递归检索整棵左子树,一旦在左子树中命中目标便立即返回(不再遍历右子树);仅当左子树未找到目标时,才递归检索右子树并返回其查找结果。
2.6 二叉树销毁
c
// ⼆叉树销毁
void BinaryTreeDestory(BTNode** root)
{
assert(root); //防止外部传了 NULL 进来导致 *root 解引用崩溃
if (*root == NULL)
{
return;
}
//分别销毁左右子树
BinaryTreeDestory(&(*root)->left);
BinaryTreeDestory(&(*root)->right);
free(*root);
*root = NULL;
}
-
- 参数设计:形参采用二级指针
BTNode** root,为了能在函数内部修改外部指针变量本身的值,以便在释放内存后将其置为 NULL,防止悬空指针(野指针)。
- 参数设计:形参采用二级指针
-
- 入口断言:断言
root != NULL,确保传入的二级指针合法,防止解引用空指针导致程序崩溃。
- 入口断言:断言
-
- 递归基:若
*root == NULL,说明当前子树为空,直接返回终止递归。
- 递归基:若
-
- 递归销毁子树:采用后序遍历思想,先递归销毁左子树,再递归销毁右子树(确保子节点优先于父节点被释放)。
-
- 释放与置空:调用
free(*root)释放当前节点占用的堆内存 ,随后执行*root = NULL将指针置空。
- 释放与置空:调用
3. 层序遍历
除了先序遍历、中序遍历、后序遍历外,还可以对二叉树进行层序遍历。设二叉树的根结点所在层数为1,层序遍历就是从所在二叉树的根结点出发,首先访问第一层的树根结点,然后从左到右访问 第2层上的结点,接着是第三层的结点,以此类推,自上而下,自左至右 逐层访问树的结点的过程就是层序遍历
实现层序遍历需要额外借助数据结构:队列

c
//层序遍历
void leverOrder(BTNode* root)
{
if (root == NULL)
return;
//创建队列并初始化
Queue q;
QueueInit(&q);
//入队列
QueuePush(&q, root);
//队列不为空就一直循环
while (!QueueEmpty(&q))
{
BTNode* top = QueueFront(&q);
printf("%c ", top->data);
QueuePop(&q);
//将队头非空的左右孩子入队列
if (top->left)//判断左孩子不为空
QueuePush(&q, top->left);
if (top->right)//判断右孩子不为空
QueuePush(&q, top->right);
}
//遍历完销毁队列
QueueDestroy(&q);
}
-
- 首先创建队列并进行初始化
-
- 让根节点入队列,
-
- 取队列的头节点,打印并让其出队列,接下来让队头的非空左右孩子入队列
-
- 一直循环上面过程直到队列为空
-
- 遍历完销毁队列
4. 判断是否为完全二叉树
c
//判断完全二叉树
//判断完全二叉树
bool BinaryTreecomplete(BTNode* root)
{
//采用层序遍历的方法判断完全二叉树
// 空树视为完全二叉树
if (root == NULL)
{
return true;
}
//创建队列
Queue q;
QueueInit(&q);
//将根结点放入二叉树
QueuePush(&q, root);
//层序遍历
while (!QueueEmpty(&q))
{
//收集跟头结点
QDataType top = QueueFront(&q);
QueuePop(&q);
//如果弹出的头节点是NULL,则跳出循,进行下一步判断
if (top == NULL)
{
break;
}
//将头结点的左右孩子分别入队列
QueuePush(&q, top->left);
QueuePush(&q, top->right);
}
while (!QueueEmpty(&q))
{
QDataType top = QueueFront(&q);
QueuePop(&q);
if (top != NULL)
{
QueueDestroy(&q);
return false;
}
}
//全部通过,销毁队列
QueueDestroy(&q);
return true;
}
- 判定原理 :采用层序遍历 策略。完全二叉树的节点在层序遍历中具有绝对的连续性。若为完全二叉树,则一旦在遍历中首次遇到
NULL,后续出队的所有节点必须全部为NULL;反之则非完全二叉树。 - 第一阶段(连续性遍历) :按层序遍历规则将节点依次出队,并将左右孩子(包含
NULL)无条件入队。若弹出的节点为NULL,说明有效节点的连续性在此中断,立刻跳出当前循环,进入第二阶段。 - 第二阶段(空洞排查) :继续排空队列中的剩余元素。若弹出的
top元素不为空,说明在第一个NULL之后出现了非空节点(即存在"空洞"),此时立即销毁队列释放内存,并返回false。 - 最终放行 :如果在第二阶段中,队列直到被完全排空都没有出现任何非空节点,说明首个
NULL之后全为NULL占位符,符合完全二叉树的性质,销毁队列并返回true。
总结
以上就是本篇博客的核心内容。本文介绍了树的概念、术语与表示方法,讲解了二叉树的定义、满二叉树与完全二叉树的性质及顺序/链式存储结构。重点实现了堆的初始化、插入、删除、取堆顶及向上/向下调整算法,并应用于堆排序与TOP-K问题。随后实现了链式二叉树的前中后序与层序遍历、结点个数、叶子数、第k层结点数、深度、查找与销毁。掌握这些,为后续学习更复杂的树形结构打下坚实基础。