前言
二叉树是很重要的数据结构,但我们不需要实现它,只要知道它的性质,更多时候,二叉树只是作为其他结构(如AVL树,红黑树,堆)的基础结构,后者才是我们需要手撕以掌握规则的结构,所以这篇博客只介绍二叉树的性质,而后直接开始应用:实现堆结构,完成堆排序
树的概念
树是一种非线性的数据结构,它是由n个节点组成的具有层次关系的集合,因为它像是一颗倒挂着的树所以把它称为树。它的叶子朝下,根却朝上
树有以下几个基本性质:
- 根节点:根节点没有前驱
- 除根节点外,其他节点被分为多个互不相交的集合,每个集合是一颗结构类似树的子树(子树不能相交,若相交则不是树结构)
- 关于子树:每颗子树的根节点只有一个前驱,可以有0个或多个后继。所以树是递归定义的
- 节点的度:一个节点含有子树的个数称为度
- 叶节点:度为0的节点为叶节点
- 分支节点:度不为0的节点为分支节点
- 父节点:除根节点外,其他节点都有一个前驱,该前驱为父节点
- 子节点:若节点有后继,或者节点的子树的根节点,它们被称为子节点
- 兄弟节点:具有相同父节点的子节点互称兄弟节点
- 树的度:最大节点的度被称为树的度
- 节点的层次:从根节点开始,根节点为第一层,根节点的子节点为第二层,以此类推
- 节点的祖先:从根节点到某节点的所有可能路径上遇到的节点,都是该节点的祖先
- 子孙:以某节点为根的子树中所有的节点被称为该节点的子孙
- 森林:m颗不相交的树的集合称为森林
二叉树的概念
- 二叉树为有序树,不能颠倒其左右子树
- 二叉树不存在度为2的节点
- 特殊概念
- 满二叉树:若一颗二叉树每一层的节点树都达到最大值,它就是满二叉树。即第k层的节点数量为2的k-1次方
- 完全二叉树:若一颗k层二叉树的由n个节点,当每个节点都与k层的满二叉树从1~n编号的节点对应时,它就是完全二叉树(明显区别见下图)
(图源网络)
二叉树的性质
- 二叉树第k层的节点数量最多为2^(k-1)
- k层二叉树的节点数量最多为(2^k) -1
- 用n0表示度为0的节点,n2表示度为2的节点,有:n0 = n2 + 1,这个可以用节点总数和边数的关系推导
- 具有n个节点的满二叉树的深度为log2(n+1)
- 若对完全二叉树进行编号,对于编号为i的节点
- 其父节点下标为:(i-1)/2,若i为0,无父节点
- 其左孩子下标为:2 * i + 1,前提是其下标存在
- 其右孩子下标为:2 * i + 2,前提是其下标存在
堆的概念
物理与逻辑结构的转换
图片上的二叉树是一颗完全二叉树,并且它还是一颗满二叉树。而数组能完美的表示像完全二叉树这样的结构,这是二叉树的顺序表示,完全二叉树可以利用数组的所有空间,减少空间的浪费
从根节点开始,根节点为第一层,从上往下,从左往右,将数据存储到数组中。二叉树第i层的元素个数为2的(i - 1)次方,知道每层二叉树的元素个数,就能把数组中的数据转化为二叉树的形式。
第一层二叉树有2的(1 - 1)次方,1个元素,一层一层地将数组转化成二叉树
堆的性质
1.堆总是一颗完全二叉树
2.堆的节点总是不大于或不小于其父节点
若一颗二叉树根节点的值小于其孩子节点,并且该树满足堆的性质,该树称作小堆。反之根节点大于其孩子节点,该树称作大堆。
堆的实现
堆在物理上是一个数组,虽然在逻辑上不是顺序表,但可以用顺序表的结构表示堆。
堆结构的声明
typedef int HDataType;//HDataType表示堆中存储的数据类型
typedef struct Heap
{
HDataType* data; //像顺序表一样,堆结构中有一个data指针指向存储数据的数组
size_t size; //size表示当前堆的数据个数
size_t capacity; //capacity表示堆的容量
}Heap;
堆的基础接口
void HeapInit(Heap* php); //堆的初始化
void HeapDestory(Heap* php); //堆的销毁
void HeapPush(Heap* php, HDataType(Heap* php);//插入数据到堆中
void HeapPop(Heap* php); //堆顶数据的删除
HDataType HeapTop(Heap* php); //堆顶元素的返回
bool HeapEmpty(Heap* php); //堆的判空
size_t HeapSize(Heap* php); //堆元素个数的返回
堆的初始化与销毁
//对堆的成员赋初值
void HeapInit(Heap* php)
{
assert(php);
php->size = 0;
php->capacity = 0;
php->data = NULL;
}
//将malloc申请的空间释放,并且对堆的容量与元素个数置0
void HeapDestory(Heap* php)
{
assert(php);
free(php->data);
php->capacity = 0;
php->data = NULL;
}
堆的Push与Pop
假设往图片中的小堆插入一个9,将9插入数组的最后一位,插入数据时要先检查数组的空间是否足够存储,检查扩容。找到9的父节点,比较其与父节点的大小,如果父节点大于9,则把父节点与9交换,交换后再与交换后的父节点比较,判断是否要再次交换。最坏的情况就是交换到根节点
父节点与子节点的下标关系,(子节点下标 - 1) / 2得到父节点的下标
C的下标为2,- 1 再 / 2得到0,说明A是C的父节点,B的下标是1,-1 再 /2得到0,说明A是B的父节点。
交换节点时,只会影响到插入节点的祖先节点。
//先浏览HeapPush接口
//交换接口
void Swap(HDataType* pa, HDataType* pb)
{
HDataType tmp = *pa;
*pa = *pb;
*pb = tmp;
}
//向上调整接口
void AdjustUp(HDataType* data, size_t child)
{
size_t parent = (child - 1) / 2;
while(child > 0)//孩子节点不是根节点,循环继续
{
//如果孩子节点小于父亲节点,交换
if (data[child] < data[parent])
{
Swap(&data[child], &data[parent]);
child = parent;
parent = (child - 1 ) / 2;
}
else//父亲节点大于孩子节点,满足堆的性质,跳出循环
{
break;
}
}
}
//堆的Push接口
void HeapPush(Heap* php, HDataType x)
{
assert(php);
//检查扩容
if (php->size == php->capacity)
{
//如果数组满了,需要扩容。新的容量根据原来容量是否为0来判断,如果是0,新容量为4,不是0,新容量为原来的两倍
size_t newCapacity = (php->capacity == 0) ? 4 : (php->capacity * 2);
//将原来空间扩容,用tmp接收新开辟空间的地址
HDataTpye* tmp = (HDataTpye*)realloc(php->data,sizeof(HDataTpye) * newCapacity);
if (!tmp)
{
printf("realloc fail\n");//若开辟空间失败,if条件成立,需要结束程序
exit(-1);
}
php->data = tmp;//若开辟成功,将开辟好的空间地址给data
php->capacity = newCapacity;
}
php->data[php->size] = x;
php->size++;
//插入元素后要将元素像上调整,使其始终是一个堆
AdjustUp(php->data, php->size - 1);
}
删除堆顶元素,不能将后面的数据往前覆盖,删掉第一个节点,这样操作会打乱堆中元素的父子关系
如图片中的小堆,逻辑上的表示如下
将堆顶元素删除,得到的小堆是
3的父节点6大于3,很明显不满足小堆的性质,如果删除使用这样的算法,维护堆的成本将会是巨大的。所以删除堆顶元素得用其他算法。
将堆顶元素与数组的最后一个元素交换,再将堆的size减1,此时的堆的堆顶不满足堆的性质,将堆顶元素向下调整:找到两个子节点中小的那个,将两者交换,再找交换后两子节点中小的那个,再交换,直到子节点中小的那个大于自身。
以上作为一个循环,当其没有子节点时,说明到了叶节点,循环结束。细节:两个子节点中,可能只有一个左子节点,所以要判断右节点是否存在,否则会出现越界访问
// 向下调整接口
// size是data数组的大小,root是要向下调整的根节点在data中的下标
void AdjustDown(HDataType* data, size_t size, size_t root)
{
size_t parent = root;//父节点
size_t child = root * 2 + 1;//父节点的左子节点
while (child < size)//若父节点的子节点下标在数组范围内,循环继续
{
//假设左节点是两节点中最小的,交换时只要交换parent与child,但要判断右节点的大小
//若父节点的右节点存在,并且右节点小于左节点,将child换为右节点
if (child + 1 < size && \
data[child + 1] < data[child])
{
child++;//右节点的下标比左节点大1
}
if (data[parent] > data[child])//父节点大于子节点,交换
{
Swap(&data[parent], &data[child]);
//父节点与子节点的更新
parent = child;
child = parent * 2 + 1;
}
else//父节点小于子节点,满足堆的性质,跳出循环
{
break;
}
}
}
//堆的Pop接口
void HeapPop(Heap* php)
{
assert(php);
assert(php->size > 0);//堆中的元素数不能少于1
//将数组中的第一个元素与最后一个元素交换
Swap(&php->data[0], &php->data[php->size - 1]);
AdjustDown(php->data, php->size, 0);//将堆顶元素向下调整
}
堆的判空,堆顶元素的返回与长度的返回
三个接口较简单
//判空接口
bool HeapEmpty(Heap* php)
{
assert(php);
return (php->size == 0);
}
//堆顶元素返回接口
HDataTpye HeapTup(Heap* php)
{
assert(php);
assert(php->size > 0);
return php->data[0];
}
//堆的长度返回
HDataType HeapSize(Heap* php)
{
assert(php);
return php->size;
}
堆排序
堆顶元素总是堆中最小元素,若要将数组以升序的顺序排序,每次只要取出堆顶元素,取出后,调用堆的Pop接口,Pop数据,此时Pop会调整堆中元素的顺序,使得堆顶元素是最小的数,再取堆顶数据,再调Pop接口…
//将arr数组的数据堆排序,size是数组长度
void HeapSort(HDataType* arr, size_t size)
{
Heap hp = { 0 };//定义一个堆结构hp
HeapInit(&hp);
for (int i = 0; i < size; i++)
{
HeapPush(&hp, arr[i]);//将arr中的数据Push到堆中
}
for (int i = 0; i < size; i++)
{
arr[i] = HeapTop(&hp);//将堆顶元素(最小的数)存到arr数组中
HeapPop(&hp);//删除堆顶元素
}
}
运行结果如下