目录
[1. 结构体定义以及函数接口声明](#1. 结构体定义以及函数接口声明)
[2. 初始化 与 销毁](#2. 初始化 与 销毁)
[3. 插入数据(包含向上调整函数和交换函数)](#3. 插入数据(包含向上调整函数和交换函数))
[4. 取堆顶](#4. 取堆顶)
[5. 删除堆顶数据(包含向下调整)](#5. 删除堆顶数据(包含向下调整))
[6. 判空](#6. 判空)
[7. 数组建堆](#7. 数组建堆)
[1. 堆排序](#1. 堆排序)
[2. Top-k问题](#2. Top-k问题)
一、树的基本概念
树 是 个节点的有限集。当
时,称为空树。在任意一颗非空树种应满足:
(1) 有且仅有一个特定的称为根的结点。
(2) 当 时,其余结点可分为
个互不相交的有限集
,其中每个集合本身又是一棵树,称为根的子树。
很显然,树的定义是递归的,树是一种递归的数据结构,具有以下特点:
(1) 树的根结点没有前驱,除了根结点以外的所有结点有且仅有一个前驱。
(2) 树中所有结点都可以有零个和多个后继。
(3) 个结点的树中有
条边。
二、基本术语

(1) 祖先、子孙、双亲、孩子、兄弟
考虑结点,从根
到结点
的唯一路径上的所有其他结点,称为结点
的祖先。如结点
是结点
的祖先,结点
是结点
的子孙。结点
的子孙包括结点
和结点
。路径上最接近结点
的结点
被称为结点
的双亲,而结点
是结点
的孩子。根
是树中唯一没有双亲的结点 。有相同双亲的结点称为兄弟,如结点
和结点
。
(2)结点的层次、深度和高度
结点的层次从树根开始定义 ,根结点为第1层,它的孩子为第2层,以此类推。结点的深度就是结点所在的层次。树的高度(或深度)是树中结点的最大层数 。结点的高度是以该结点为根的子树的高度。
(3) 结点的度和树的度
树中一个结点的孩子个数称为该结点的度,树中结点的最大度数称为树的度。 如结点 的度为2,结点
的度为1,树的度为3。
(4) 分支结点和叶结点
度大于0的结点称为分支结点 (也称非终端结点);度为0(没有孩子结点)的结点称为叶结点 (也称终端结点)。在分支结点中,每个结点的分支数就是该结点的度。
(5) 路径和路径长度
树中两个结点之间的路径是由这两个结点之间所经过的结点序列构成的 ,而路径长度是路径上所经过的边的个数。
(6)有序树和无序树
树中结点的各子树从左到右是有次序的,不能互换,称该树为有序树,否则称为无序树。
(7) 森林
森林是 棵互不相交的树的集合。只要把树的根结点删除就成了森林。反正,只要给
棵互不相交的树加上一个结点,并把这
棵树作为该结点的子树,森林就成了树。
三、二叉树的概念
二叉树是一种特殊的树形结构,其特点是每个结点至多只有两棵子树,并且二叉树的子树有左右之分,次序不能任意修改。
与树相似,二叉树也以递归的形式定义。二叉树是 个结点的有限集:
(1) 当 时,称为空二叉树。
(2) 当 时,由一个根结点 和 两个互不相交的 被称为根的左子树和右子树组成。左子树和右子树又分别是一棵二叉树。
二叉树是有序树,若将其左、右子树颠倒,则成为另一棵不同的二叉树。所以即使树中结点只有一棵子树,也要区分它是左子树还是右子树。

非空二叉树的第 k 层最多有 个结点
。
第 1 层最多有 = 1 个结点(根),第 2 层最多有
= 2 个结点,以此类推,可以证明其为一个公比为 2 的等比数列
。
高度为 h 的二叉树至多有 个结点
。
以上两个性质还可以拓展到 m 叉树的情况,即 m 叉树的第 k 层最多有 ****个结点,高度为 h 的 m 叉树至多有
个结点。
特殊的二叉树
(1) 满二叉树
一个二叉树,如果每一层的结点数都达到最大值,则这个二叉树就是满二叉树。也就是说,如果一个二叉树的层数为 ,且结点总数是
,则他就是满二叉树。
可以对满二叉树按层序编号:约定编号从根结点(根结点编号为1)起,自上而下,自左向右。这样,每个结点对应一个编号,对于编号为 的结点,若有双亲,则其双亲为
,若有左孩子,则左孩子为
;若有右孩子,则右孩子为
。
(2) 完全二叉树
完全二叉树是由 满二叉树引出来的。对于高度为 的、有
个结点的二叉树,当且仅当其每一个结点都与高度为
的满二叉树中编号从
到
的结点一一对应时,称之为完全二叉树。满二叉树是一种特殊的完全二叉树。

具有 个
结点的完全二叉树的高度为
或
。
设高度为 h ,根据上文性质和完全二叉树的定义有
或
得 ,即
,因为 h 为正整数,所以
。同理可证
。
(3) 二叉排序树
左子树上所有结点的关键字均小于根结点的关键字。右子树上所有结点的关键字均大于根结点的关键字。左子树和右子树又各是一棵二叉排序树。

(4) 平衡二叉树
树中任意一个结点的左子树和右子树的高度之差的绝对值不超过1。

四、二叉树的存储结构
(一)二叉树的顺序结构
**二叉树的顺序存储是指用一组连续的存储单元依次自上而下、自左至右存储完全二叉树上的结点元素。**在物理结构上是数组,逻辑结构上是二叉树。
依据二叉树的性质,完全二叉树和满二叉树采用顺序存储比较合适,树中结点的序号可以唯一地反映结点之间的逻辑关系,这样既能最大可能地节省存储空间,又能利用数组元素的下标值确定结点在二叉树中的位置,以及结点之间的关系。
但对于一般的二叉树,为了让数组下标能反映二叉树中结点之间的逻辑关系,只能添加一些并不存在的空结点,让其每个结点与完全二叉树上的结点相对照,再存储到一维数组的相应分量中。

由图中的存储数组也可以得到,父子存储的下标位置规律:
(二)堆的概念及实现
若有一个关键码的集合 ,将其所有元素按完全二叉树的顺序存储方式存于一个一维数组中,并满足以下条件:
且
或者
且
,
则称该集合为小堆(或大堆)。其中,根节点最大的堆称为最大堆(大根堆),根节点最小的堆称为最小堆(小根堆)。
++总之,小堆的任何一个父结点都小于或等于子结点,大堆的任何一个父结点都大于或等于子结点++
堆的性质:
(1) 堆中某个节点的值总是不大于或不小于其父节点的值。
(2) 堆总是一棵完全二叉树。

接下来通过代码来实现堆,以小根堆为例:
1. 结构体定义以及函数接口声明
cpp
typedef struct Heap {
HPDataType* a; //指向动态数组的指针
int size; //已使用的元素数量
int capacity; //总可用容量
}HP;
2. 初始化 与 销毁
初始化:void HPInit(HP* php)
销毁:void HPDestroy(HP* php)
cpp
void HPInit(HP* php) {
assert(php);
php->a = NULL;
php->size = 0;
php->capacity = 0;
}
void HPDestroy(HP* php) {
assert(php);
free(php->a);
php->a = NULL;
php->capacity = 0;
php->size = 0;
}
3. 插入数据(包含向上调整函数和交换函数)
调整堆,在逻辑结构上控制的是完全二叉树,在存储结构上控制的是一维数组。
插入数据后,要调整成小堆,使用向上调整算法,可能会影响祖先。
使用上文讲到的父子存储的下标位置规律可以计算出需要调整的元素的下标位置。

交换:void Swap(HPDataType* px, HPDataType* py)
cpp
void Swap(HPDataType* px, HPDataType* py) {
HPDataType tmp = *px;
*px = *py;
*py = tmp;
}
向上调整:void AdjustUp(HPDataType* a, int child)
cpp
void AdjustUp(HPDataType* a, int child) {
int parent = (child - 1) / 2;
while (child > 0) {
if (a[child] < a[parent]) {
Swap(&a[child], &a[parent]);
child = parent;
parent = (parent - 1) / 2;
}
else {
break;
}
}
}
注意这里的while( )循环的条件:
如果使用父结点进行判断,while(parent >= 0) ,当parent == 0 时,parent = (0 - 1) / 2 = 0,再次进入循环,此时 a[child] = a[parent] = 0,程序走 else 巧合地结束,但是这会导致循环陷入死循环风险,逻辑上不可取;
如果使用子结点进行判断,while(child > 0) ,当 child == 0 时,说明已经调整到了根节点,没有父节点可以继续比较,循环会直接终止。这个条件从 "子节点是否存在父节点" 的角度出发,不会出现死循环。
++这个例子是小堆 ,如果需要大堆只需要将if( )判断条件改为 a[child] > a[parent]++
插入数据:void HPPush(HP* php, HPDataType x)
cpp
void HPPush(HP* php, HPDataType x) {
assert(php);
//判断空间是否足够
if (php->size == php->capacity) {
//扩容
size_t newCapacity = php->capacity == 0 ? 4 : php->capacity * 2;
HPDataType* tmp = realloc(php->a, sizeof(HPDataType) * newCapacity);
if (tmp == NULL) {
perror("reallo fail");
return;
}
php->a = tmp;
php->capacity = newCapacity;
}
//插入新数据,数组下标从零开始,size-1是原数组的最后一个,size则是新数据下标
php->a[php->size] = x;
php->size++;
//size++后,size-1为刚刚插入的新数据的下标也就是新数组的最后一个
//向上调整
AdjustUp(php->a, php->size-1);
}
时间复杂度为 ,因为最大得消耗是向上调整,最坏情况是调整高度次。
4. 取堆顶
取堆顶:HPDataType HPTop(HP* php)
cpp
HPDataType HPTop(HP* php) {
assert(php);
return php->a[0];
}
堆顶左右孩子之间较小的是堆中次小的值,因为堆顶左右孩子的值分别小于他们左右孩子的值。
但是第三小、第四小...是找不到的。
5. 删除堆顶数据(包含向下调整)
若直接通过挪动覆盖的方式删除堆顶数据,会出现以下问题:
(1) 挪动覆盖时间复杂度是 。
(2) 堆的结构被破坏,父子变兄弟,兄弟变父子,需要重新建堆。
所以这里采用以下方式:
先将堆顶数据与最后一个数据进行交换,通过 size-- 将其删除,然后使用向下调整算法,把新堆顶数据与其较小的孩子进行比较和交换,不断检查是否是小堆,不断向下调整,直到这个数据在叶子结点处,判断方式同样使用**父子存储的下标位置规律,**因为堆是完全二叉树,如果没用左孩子就一定没有右孩子,若计算出的左孩子下标超出了 size 的范围,则该结点为叶子结点。
这样新的堆顶就是原本堆的次大值。
向下调整:void AdjustDown(HPDataType* a, int n, int parent)
cpp
void AdjustDown(HPDataType* a, int n, int parent) {
//左孩子
int child = parent * 2 + 1;
while(child < n){
//假设法选出小的孩子
//如果右孩子小于n 且 右孩子 < 左孩子
if (child + 1 < n && a[child + 1] < a[child]) {
++child;
}
//如果小的孩子 < 父结点,进行交换
if (a[child] < a[parent]) {
Swap(&a[child], &a[parent]);
parent = child;
child = parent * 2 + 1;
}
else {
break;
}
}
}
删除堆顶数据:void HPPop(HP* php)
cpp
void HPPop(HP* php) {
assert(php);
assert(php->size-1);
//交换首尾数据,删除尾部
Swap(&php->a[0], &php->a[php->size-1]);
php->size--;
AdjustDown(php->a, php->size, 0);
}
时间复杂度为 ,因为最大得消耗是向下调整,最坏情况是调整高度次。
6. 判空
判空:bool HPEmpty(HP* php)
cpp
bool HPEmpty(HP* php) {
assert(php);
return php->size == 0;
}
7. 数组建堆
数组建堆:void HPInitArray(HP* php, HPDataType* a, int n)
cpp
void HPInitArray(HP* php, int* a, int n) {
assert(php);
//创建数组
php->a = (HPDataType*)malloc(sizeof(HPDataType) * n);
if (php->a == NULL) {
perror("reallo fail");
return;
}
//拷贝赋值
memcpy(php->a, a, sizeof(HPDataType) * n);
php->size = php->capacity = n;
//模拟向上调整,建堆
for (int i = 0; i < php->size; i++) {
AdjustUp(php->a, i);
}
////模拟向下调整,建堆
//for (int i = (php->size-1 - 1) / 2; i >= 0; i--) {
// AdjustDown(php->a, php->size, i);
//}
}
因为堆是完全二叉树,而满二叉树也是完全二叉树,此处为了简化使用满二叉树来证明:
对于向上调整

则需要移动结点总的移动步数为:
错位相减得到
所以向上调整建堆的时间复杂度为 。
对于向下调整

则需要移动结点总的移动步数为:
错位相减得到
所以向下调整建堆的时间复杂度为 。
两种建堆方式的时间复杂度差异,核心原因是调整的起点不同,以及不同层级节点的调整次数权重不同
向上调整法从数组的第一个元素开始,逐个元素向上与父节点比较交换。底层节点(数量最多,占总节点数的一半)的调整次数最多(因为它们在堆的最下层,需要向上移动的层数最多)。这种 "多节点、多层数" 的组合,导致总工作量被底层节点的高权重放大。
向下调整法从最后一个非叶子节点开始,向前逐个节点向下与子节点比较交换。底层节点(数量最多)不需要调整(叶子节点没有子节点);而需要调整的非叶子节点中,根节点(数量最少,仅 1 个)的调整次数最多(需要向下移动 h−1 层),其他节点的调整次数随层级降低而减少。这种 "少节点、多层数" 的组合,使得总工作量被摊平。
(三)堆的应用
1. 堆排序
堆排序的思路很简单:首先将存放在 a[1..n] 中的 n 个元素建成初始堆,因为堆本身的特点(以大根堆为例),所以堆顶元素就是最大值。输出堆顶元素后,通常将堆底元素送入堆顶,此时根结点已不满足大根堆的性质,堆被破坏,将堆顶元素向下调整使其继续保持大根堆的性质,再输出堆顶元素。如此重复,直到堆中仅剩一个元素为止。
可见,堆排序需要解决两个问题:①如何将无序序列构造成初始堆?②输出堆顶元素后,如何将剩余元素调整成新的堆?
升序排序用大根堆 ,降序排序用小根堆,确保每次提取的堆顶元素是当前最值,直接放到序列的一端。
堆排序:void HeapSort(int* a, int n)
cpp
//注意这里的向下调整是大堆的版本
void AdjustDown(HPDataType* a, int n, int parent) {
int child = parent * 2 + 1;
while(child < n){
//假设法选出小的孩子
//如果右孩子小于n 且 右孩子 < 左孩子
if (child + 1 < n && a[child + 1] < a[child]) {
++child;
}
//如果小的孩子 > 父结点,进行交换
if (a[child] > a[parent]) {
Swap(&a[child], &a[parent]);
parent = child;
child = parent * 2 + 1;
}
else {
break;
}
}
}
//升序是建大堆
//O(NlogN)
void HeapSort(int* a, int n) {
//a数组直接建堆 O(N)
for (int i = (n - 1 - 1) / 2; i >= 0; --i) {
AdjustDown(a, n, i);
}
//O(NlogN)
int end = n - 1;
while (end > 0) {
Swap(&a[0], &a[end]);
AdjustDown(a, end, 0);
--end;
}
}
起点选择 :最后一个非叶子结点的下标为 ,因为堆的最后一个元素下标是 ,它的父结点就是最后一个非叶子结点。
调整逻辑:从该结点向前遍历每个结点,进行向下调整,确保每个子树都满足大根堆的性质,最终整个数组成为大根堆。
首尾交换:将堆顶与堆的最后一个元素交换,此时最大值被放到数组末尾,不再参与后续堆的调整。
向下调整:对新的堆顶(原堆尾元素)进行向下调整,调整范围为 [0, end - 1](因为 end 位置已为有序的最大值),使剩余元素重新成为大根堆,选出次大值。
缩小堆范围:将 end 减 1,重复上述过程,直到 end 为 0,此时整个数组已按升序排列。
堆排序的特点:
(1) 堆排序是不稳定排序(交换操作可能导致相同元素的相对位置变化)。

(2) 堆排序是原地排序(仅使用常数级的临时变量,不需要额外辅助空间)。
(3) 堆排序仅适用于顺序存储的线性表。
2. Top-k问题
在处理 100 亿条数据这类海量数据场景时,要找出其中最大的前 10 个数据(Top10问题),直接对所有数据排序显然不现实,不仅内存无法容纳全部数据,排序的时间成本也极高。
此时,小根堆是解决该问题的最优方案之一,核心思路是用"空间换时间",仅维护一个容量为10的小根堆来筛选目标数据,具体操作如下:
(1) 初始化小根堆:从 100 亿条数据中先取出前 10 个数据,将其构建成一个小堆。此时堆顶元素是这 10 个数据中的最小值。
(2) 遍历剩余数据并动态更新堆:依次读取 100 亿条数据中剩余的所有数据,每读取一个数据,就将其与堆顶元素进行比较:
若该数据小于等于堆顶元素,说明它不可能是"最大的前 10 个",直接跳过;
若该数据大于堆顶元素,说明它有机会进入前 10 ,此时用该数据替换堆顶元素,并对堆进行一次"向下调整"操作,让堆重新恢复小堆的特性。
(3)获取最终结果:当所有数据遍历完成后,堆中剩余的 10 个数据,就是 100 亿条数据中最大的前 10 个。
为什么不使用大根堆呢?
大根堆的堆顶是当前堆中最大值,而非"当前候选集的最小值"。遍历后续数据时,若新数据比堆顶小,无法判断它是否能进入前 10,比如堆里是 [100 , 99 , 98 ,..., 91] ,新数据是 95,比堆顶 100小,但比堆里的 91 大,应该替换 91 ,却因比堆顶小被直接跳过;若新数据比堆顶大,替换堆顶后,堆里的"次大值"被顶到堆顶,后续更小的有效数据仍会被误判跳过。
(四)二叉树的链式存储
顺序存储的空间利用率较低,因此二叉树一般都采用链式存储结构,用链表结点来存储二叉树中的每个结点。在二叉树中,结点结构通常包括若干数据域和若干指针域,二叉链表至少包含3个域:数据域 data 、左指针域 lchild 和右指针域 rchild。


二叉树的遍历
二叉树的遍历是指按某条搜索路径访问树中每个结点,使得每个结点均被访问一次,而且仅被访问一次。二叉树是一种非线性结构,每个结点都可能有两棵子树,因此需要寻找一种规律,以便使二叉树上的结点能排列在一个线性队列上,进而便于遍历。
由二叉树的递归定义可知,遍历一棵二叉树便要决定对 根结点 N 、左子树 L 和 右子树 R 的访问顺序。按照先遍历左子树再遍历右子树的原则,常见的遍历次序有先序、中序和后序三种遍历算法,其中序指的是根结点在何时被访问。
结构体定义:
cpp
typedef struct TreeNode {
struct TreeNode* left;
struct TreeNode* right;
int val;
}BTNode;
手搓一棵二叉树:
cpp
BTNode* BuyBTNode(int val) {
BTNode* newnode = (BTNode*)malloc(sizeof(BTNode));
if (newnode == NULL) {
perror("malloc fail");
return NULL;
}
newnode->val = val;
newnode->left = NULL;
newnode->right = NULL;
return newnode;
}
BTNode* CreateTree() {
BTNode* n1 = BuyBTNode(1);
BTNode* n2 = BuyBTNode(2);
BTNode* n3 = BuyBTNode(3);
BTNode* n4 = BuyBTNode(4);
BTNode* n5 = BuyBTNode(5);
BTNode* n6 = BuyBTNode(6);
BTNode* n7 = BuyBTNode(7);
n1->left = n2;
n1->right = n3;
n2->left = n4;
n2->right = n5;
n3->left = n6;
n3->right = n7;
return n1;
}
先序遍历(PreOrder)
若二叉树为空,则什么也不做;否则:
(1) 访问根结点;
(2) 先序遍历左子树;
(3) 先序遍历右子树。

cpp
void PreOrder(BTNode* root) {
if (root == NULL) {
return;
}
printf("%d", root->val);
PreOrder(root->left);
PreOrder(root->right);
}
中序遍历(InOrder)
若二叉树为空,则什么也不做;否则:
(1) 中序遍历左子树;
(2) 访问根结点;
(3) 中序遍历右子树。

cpp
void InOrder(BTNode* root) {
if (root == NULL) {
return;
}
InOrder(root->left);
printf("%d", root->val);
InOrder(root->right);
}
后序遍历(PostOrder)
若二叉树为空,则什么也不做;否则:
(1) 后序遍历左子树;
(2) 后序遍历右子树;
(3) 访问根结点。

cpp
void PostOrder(BTNode* root) {
if (root == NULL) {
return;
}
PostOrder(root->left);
PostOrder(root->right);
printf("%d", root->val);
}
整棵二叉树的节点总数
cpp
int TreeSize(BTNode* root) {
//TreeSize(root->left):递归计算当前节点左子树的节点总数;
//TreeSize(root->right):递归计算当前节点右子树的节点总数;
// + 1:加上当前节点本身(当前节点是有效节点,需计数)。
return root == NULL ? 0 : TreeSize(root->left) + TreeSize(root->right) + 1;
}
二叉树第 K 行结点的个数
cpp
int TreeKLevel(BTNode* root, int k) {
assert(k > 0);
if (NULL == root)
return 0;
if (k == 1)
return 1;
//不等于空,且K>1,说明第k层结点在子树里面,转换成子问题求解
return TreeKLevel(root->left, k - 1) + TreeKLevel(root->right, k - 1);
}
查找 x 所在的结点
cpp
BTNode* TreeFind(BTNode* root, int x) {
// 递归终止条件1:当前节点为空(空树/空子树),没找到x
if (root == NULL)
return NULL;
//找到目标节点:当前节点值等于x,直接返回该节点指针
if (root->val == x)
return root;
//第一步:递归查找左子树
BTNode* ret1 = TreeFind(root->left, x);
//左子树找到则直接返回(不再查右子树)
if (ret1)
return ret1;
//第二步:左子树没找到,递归查找右子树
BTNode* ret2 = TreeFind(root->right, x);
//右子树找到则返回
if (ret2)
return ret2;
//递归终止条件2:当前节点+左子树+右子树都没找到,返回NULL
return NULL;
}
层序遍历(LevelOrder)
按照箭头所指方向,按照1,2,3,4的层次顺序,自上而下、从左至右对二叉树中的各个结点进行逐层访问。

进行层次遍历时,需要借助一个队列。层次遍历的思想如下:①首先将根结点入队。②若队列非空,则队头结点出队,访问该结点,若它有左孩子,则将其左孩子入队;若它有右孩子,则将其右孩子入队。③重复步骤②,直至队列为空。