目录
4.2:二叉树的总节点个数, 叶子节点的个数,第K层的节点个数,二叉树的深度,判断一棵树是不是完全二叉树,二叉树的销毁
1:树的概念及结构
树是一种非线性的数据结构,它是由n(n>=0)个有限节点组成一个具有层次关系的集合。把它叫做树是因为它看起来像一棵倒挂的树,也就是说它是根朝上,而叶朝下的。
就像这样:
它的结构有以下特点:
A:有一个特殊的节点,称为根节点,根节点没有前驱节点。
B:除根节点外,其余结点被分成M(M>0)个互不相交的集合,其中每一个集合又是一棵结构与树类似的子树。每棵子树的根结点有且只有一个前驱,可以有0个或多个后继节点。
因此,树是递归定义的。
而关于树的常见概念有:
注意:树形结构中,子树之间不能有交集,否则就不是树形结构
比如:
2:二叉树概念及结构
2.1:基本认识
二叉树是在满足树的结构上要求:当树不为空时,树的度是2;如果树是空的,也可以称为空二叉树,如下:
注意:. 二叉树的子树有左右之分,次序不能颠倒,因此二叉树是有序树。
2.2:特殊的二叉树
1. 满二叉树:一个二叉树,如果每一个层的节点数都达到最大值,则这个二叉树就是满二叉树。也就是说:如果一个二叉树的层数为K,且节点总数是2^K - 1 ,则它就是满二叉树。
2. 完全二叉树:完全二叉树是效率很高的数据结构,是由满二叉树而引出来的,也就是满二叉树的最后一层节点数不必达到最大值,但是要求节点从左往右必须是连续的。所以,满二叉树是一种特殊的完全二叉树。
如下:
2.3:二叉树的性质
1. 若规定根节点的层数为1,则一棵非空二叉树的第i层上最多有 2^(i - 1)个节点
2. 若规定根节点的层数为1,则深度为h的二叉树的最大节点数是2^h - 1
3. 对任何一棵二叉树, 如果度为0其叶节点个数为N , 度为2的分支节点个数为M ,则有 N= M+1
4. 若规定根节点的层数为1,具有n个结点的满二叉树的深度:h= log(n + 1) (log以2为底)
了解了这些,那你知道我们为什么要单独把二叉树拿出来深入学习吗?因为,前面我们提过:完全二叉树是效率很高的数据结构。举个例子:假设有10亿个数据,如果用完全二叉树来存储处理,那么树的深度只有30层,即使树是递归定义的,对计算机来说也轻轻松松的事,所以说它是高效率的,值得我们作为数据结构入门去深入学习!
2.4:二叉树的存储结构
二叉树一般可以使用两种结构存储:顺序结构,链式结构。
2.4.1:顺序存储
顺序结构存储就是使用数组来存储,一般使用数组只适合表示完全二叉树,因为不是完全二叉树会有空间的浪费。
如下:
通过上图(左边)可以总结一个重要的规律:
使用顺序存储的完全二叉树的任意节点可以通过下标找到它的父节点和子节点。
任意节点的左孩子节点:leftchild = parent * 2 + 1
任意节点的右孩子节点:rightchild = parent * 2 + 2
任意节点的父节点:parent = (child - 1) / 2 或者 (child - 2) / 2
2.4.2:链式存储
用链表来表示一棵二叉树,即用指针来指示元素间的逻辑关系。 通常的方法是: 链表中每个结点由三个域组成,数据域和左右指针域,左右指针分别存储该节点的左孩子节点和右孩子节点的地址 。
链式结构又分为二叉链和三叉链,如红黑树等复杂的数据结构会用到三叉链,现在我们学习二叉树,使用的是二叉链。
如下:
3:二叉树的顺序结构实现及应用
3.1:堆的概念及结构
堆,是一颗完全二叉树,根据节点的值之间的关系分为大堆和小堆,现实中我们通常把堆使用顺序结构的数组来存储,需要注意的是这里的堆和操作系统虚拟进程地址空间中的堆是两回事,一个是数据结构,一个是操作系统中管理内存的一块区域分段。
大堆:每个节点的值 >= 所对应的孩子节点的值,根节点最大
小堆:每个节点的值 <= 所对应的孩子节点的值,根节点最小
比如:
由上可以发现:小堆在物理存储结构上不一定是升序,对应的,大堆在物理存储结构上也不一定就是降序。
3.2:堆的实现
3.2.1:堆的插入
假设已有一个大堆,如下:
现在要插入一个数据,假设是100,要保证插入后依旧是大堆,该怎么办?
单从物理存储结构来看,它就是一个普通数组,可结合堆本身在逻辑结构上的关系后,有了向上调整算法,具体过程如下:
接下来,我们用代码实现一下这个过程:
首先,我们先定义一个堆的结构,使用动态数组
typedef int HPDateType;
typedef struct Heap
{
HPDateType* _arry;
int _size;
int _capacity;
}Heap;
然后,我们定义函数:void HeapPush(Heap* hp, HPDateType x)来实现堆的插入,其中调用AdgustUp函数实现向上调整算法,如下:
//值交换
void Swap(HPDateType* x, HPDateType* y)
{
HPDateType tmp = *x;
*x = *y;
*y = tmp;
}
//向上调整
void AdgustUp(HPDateType* arry, int child)
{
assert(arry);
int parent = (child - 1) / 2;
while (parent >= 0)//这里的循环条件还可以是:child > 0
{
if (arry[parent] < arry[child])
{
//交换
Swap(&arry[parent], &arry[child]);
child = parent;
parent = (child - 1) / 2;
}
else
{
break;
}
}
}
//堆的插入
void HeapPush(Heap* hp, HPDateType x)
{
assert(hp);
//检查是否进行数组扩容
if (hp->_size == hp->_capacity)
{
int newcapacity = (hp->_capacity == 0 ? 10 : 2 * hp->_capacity);
HPDateType* tmp = (HPDateType*)realloc(hp->_arry, newcapacity * sizeof(HPDateType));
assert(tmp);
hp->_arry = tmp;
hp->_capacity = newcapacity;
}
//将插入数据x尾插
hp->_arry[hp->_size] = x;
//向上调整
AdgustUp(hp->_arry, hp->_size);
++hp->_size;
}
上里的重点是:AdgustUp()函数的实现,一次调整的时间复杂度是:O(logN)
为了快速验证这个算法的正确性,我们在上述示例的测试中可以这样写:
最后的结果输出和上述我们的示例推演结果一致。
小堆的插入也是同理如上,这里就不重复赘述了。
需要注意的是:向上调整的前提是:前面的数据是一个堆
3.2.2:堆的创建
学习了上面堆的插入后,当堆是空的时候,依次插入随机数据后也能得到一个大堆或小堆,也相当于创建了一个堆。
但是:这里要讲的 “堆的创建” 是说给你一个已知数组,逻辑上可以看做一颗完全二叉树,但是不是堆,然后用方法把它调整成满足堆要求的存储结构
比如:
要解决这个问题,我们有两种方法,一种是前面已经学习过的向上调整算法,另一种就是即将要学习的向下调整算法 ,我们依次往下看:
3.2.2.1:向上调整建堆
由于向上调整的前提是前面的数据也是堆,那么,我们从已给定数组的第一个元素开始(也可以从第二个元素开始),把这个元素及其之后的所有元素都当成向一个堆插入元素,那么就可以直接循环调用向上调整算法: 函数AdgustUp。以此完成在原数组上建堆。
对应的的代码实现也就很容易了,比如:
3.2.2.2:向下调整建堆
向下调整算法的前提也是:左右子树都是堆。
具体流程如下:
从倒数第一个父节点开始start = (size - 2) / 2,size是数组元素个数,依次往前,start--作为向下调整的起始点,直到根节点向下调整完为止,进行多次向下调整算法。
如果是建大堆,每次向下调整的过程是:parent = start,找到parent的左右孩子节点值的较大者,如果该节点的值 <= 父节点的值,停止本次调整;如果该节点的值 > 父节点的值,就将两者交换,然后更新parent = 左右孩子节点值较大的位置,往下继续重复上面的过程。
还是使用上面的例子图示此过程:
对应的代码实现:
//值交换
void Swap(HPDateType* x, HPDateType* y)
{
HPDateType tmp = *x;
*x = *y;
*y = tmp;
}
//一次向下调整算法
void AdgustDowm(int* a, int size, int parent)
{
assert(a);
int max_child = parent * 2 + 1;//假设左右孩子节点值的较大者是左孩子
while (max_child <= size - 1)
{
//如果右孩子节点存在且先前的假设错误,就修改一下max_child的值
if (max_child + 1 <= size - 1 && a[max_child] < a[max_child + 1])
{
max_child += 1;
}
//判断是否交换
if (a[parent] < a[max_child])
{
Swap(&a[parent], &a[max_child]);
parent = max_child;
max_child = parent * 2 + 1;
}
else
{
break;
}
}
}
int main()
{
int a[] = { 14,1,20,3,15,26 };
int size = sizeof(a) / sizeof(a[0]);
for (int start = (size - 2) / 2; start >= 0; start--)
{
AdgustDowm(a, size, start);
}
return 0;
}
我们发现:即使是对同一组数据进行建大堆,两种算法虽然都完成了建大堆,但是最后的结果在逻辑结构和存储结构上也会存在差异。
建小堆也是同样的道理,这里不再赘述。
那么两种方法用哪种好?它们的区别在哪呢?
要解答这个问题,我们要先来分别看看它俩的时间复杂度。
3.2.2.3:两种算法的时间复杂度对比
因为堆是完全二叉树,而满二叉树也是完全二叉树,为了简化使用满二叉树来证明,因为时间复杂度本来看的就是近似值,多几个节点不影响最终结果,特别是在面对大量数据时。
1:向上调整建堆的时间复杂度:
2:向下调整建堆的时间复杂度
所以,通过以上严格的数学计算后发现:在对大量数据进行建堆时,向下调整建堆的算法效率明显优于向上调整建堆的算法。
其实除了通过数学计算最坏的时间复杂度来比较外,我们还可以靠逻辑推理来比较,具体的思路如下:
不知道你发现了没有,特别是对一颗满二叉树而言,最后一层的节点总数在整棵树的节点总数中的占比超过50%,加上倒数第二层后的占比超过80%,那么对于向上调整来说,刚开始节点数量少,要调整的层数也少,属于 “少 * 少”;可越往后,层数越深,要向上调整的层数就越来越多,节点数也越来越多,属于 “多 * 多”,而这恰好是整个建堆过程影响最大的部分。
反观向下调整算法,整个建堆过程的前期和后期都属于 “多 * 少” 或 “少 * 多”。
所以,在大量数据建堆的相比下,向下调整更胜一筹。
总之,最重要的不是那种方法好,而是通过分析比较来锻炼我们的思维,让我们思考和解决问题时更加灵活。
3.2.3:堆的删除
这里所说的堆的删除是指:删除堆顶的元素,而不是说销毁堆。
可能大家会觉得挺容易的呀,直接把堆顶后面的元素整体向前挪动一个元素位置就行了,可事实真的是这样吗?
所谓实践是检验真理的唯一标准,我们就来试试吧,如下示例:
所以,这里用一个新思想来解决这个问题,具体的思路是:将堆顶数据和最后一个数据交换,然后删除堆顶数据,接着对堆顶数据进行一次向下调整算法。
如下图示:
对应的代码实现,如下示例:
//堆的结构
typedef int HPDateType;
typedef struct Heap
{
HPDateType* _arry;
int _size;
int _capacity;
}Heap;
//值交换
void Swap(HPDateType* x, HPDateType* y)
{
HPDateType tmp = *x;
*x = *y;
*y = tmp;
}
//堆的判空
#include<stdbool.h>
bool HeapEmpty(Heap* hp)
{
return hp->_size == 0;
}
//堆的删除
void HeapPop(Heap* hp)
{
assert(hp);
assert(!HeapEmpty(hp));
//首尾值交换
Swap(&hp->_arry[0], &hp->_arry[hp->_size - 1]);
//尾删
--hp->_size;
//向下调整
AdgustDowm(hp->_arry, hp->_size, 0);
}
以上堆的创建,插入,删除就是需要掌握的重点,接下来,将利用它们来进一步学习堆的几个重要实际运用。
3.2.4:堆排序和TOP-K问题
堆排序
要对一组无序的数据排序,分为两步:
1:建堆:
升序:建大堆
降序:建小堆
2:进行堆的删除
之所以这样做是因为:大堆的根节点的值是整棵树中最大的,小堆的根节点的值是整棵树中最小的,而每次删除时都将根节点换到了末尾,--hp->_size只是缩小了堆中有效数据的个数范围,末尾的数据依旧存在。那么每进行一次堆的删除,最大或最小的节点就会被交换到末尾,并且生成一个新的堆,--hp->_size,下一次堆的删除操作的对象是上一次生成的新堆,以此类推,当堆为空时就完成了排序。
代码示例,如下:
TOP-K问题
求数据集合中前K个最大或最小的元素,一般情况下数据量比较大。比如:世界前500强公司,某个大型游戏的前100名高分玩家等。
最容易想到的方式就是直接排序,但是当数据量很大的时候,直接排序就不太可取了。比如:找出1亿个随机数据里的前100个最大的数据,如果直接排序,那么首先要将这1亿个数据加载到内存中,这个工作量不是一下子就可以完成的,而且计算机的内存是有限的,所以一般的大数据集合都是放到磁盘文件里,等要用的时候再去读取文件。除此之外,即使完成了对1亿个数据的排序,但是我们只取前100个,这无疑是一种巨大的消耗浪费呀。
所以,我们要利用前面已经学习的堆来解决这个问题,主要思路如下:
第一步:取数据集合总元素数为N的前K个元素建堆
求前K个最大元素:建小堆
求前K个最小元素:建大堆
第二步:用剩下的N- K个元素依次和堆顶元素比较:如果是大堆,并且剩余元素 < 堆顶元素,就让堆顶元素 = 剩余元素,然后向下调整保持是大堆;如果是小堆,并且剩余元素 > 堆顶元素,就让堆顶元素 = 剩余元素,然后向下调整保持是小堆。
当剩余的元素依次与堆顶元素比完之后,堆中的K个元素就是所求的前K个最小或者最大的元素。
关于建大小堆时为什么和我们所求是相反的,这里再解释一下:比如,明明是求前K个最大元素,却建小堆,这是因为:小堆的根节点是K个元素中最小的,通过和剩余N - K个元素的不断比较,在不断更新堆中这K个数据的最小值的同时,通过向下调整,整个堆的结构元素也会不断更新,最后比较完了,堆中的K个元素就是前K个最大元素,简而言之:这就是个不断剔除最小数据,加入较大数据的过程,所以建小堆。
接下来,看示例代码:
先生成N个随机数据,存入Date.txt中,假设这里N = 100万,找前10个最大数
然后,为了方便测试结果的正确性,在上面的代码运行完之后,打开Date.txt文件,随机手动修改其中跨度较大的10个数据为大于N的数。
接着我们查找Date.txt文件中前K个最大的数:
int* Top_K(FILE* pf, int K)
{
//申请空间,读入数据建堆
int* p = (int*)malloc(K * sizeof(int));
assert(p);
for (int i = 0; i < K; i++)
{
fscanf(pf, "%d", &p[i]);
}
for (int start = (K - 2) / 2; start >= 0; start--)
{
AdgustDowm(p, K, start);
}
//用剩余元素进行比较
int tmp = 0;
while (fscanf(pf, "%d", &tmp) == 1)
{
if (tmp > p[0])
{
p[0] = tmp;
AdgustDowm(p, K, 0);
}
}
return p;
}
int main()
{
//生成N个随机数据,存入Date.txt中
FILE* pf = fopen("Date.txt", "r");
assert(pf);
srand((unsigned int)time(NULL));
int N = 1000000;
int tmp = 0;
for (int i = 0; i < N; i++)
{
tmp = rand() % N + 1;
fprintf(pf, "%d\n", tmp);
}
int K = 0;
scanf("%d", &K);
int* arry = Top_K(pf, K);
for (int i = 0; i < K; i++)
{
printf("%d ", arry[i]);
}
//关闭文件,释放内存
fclose(pf);
pf = NULL;
free(arry);
arry = NULL;
return 0;
}
4:对链式二叉树的基本操作
在对链式二叉树进行操作前,需先要创建一棵二叉树,但是基于当前我们掌握的知识有限,要通过算法从无到有创建一颗链式二叉树有一定的困难,所以这里我们手动创建一颗链式二叉树,即手动调整每个节点左右指针的指向。
假设手动创建以下示例:
接下来,我们的所有操作都以上面的示例链式二叉树为例。
4.1:二叉树的前序,中序,后序及层序遍历
先看看不同的遍历方式到底是什么意思:
接下来,用代码实现:
首先是:前序,中序,后序遍历:
文章开头我们就提到过:树是递归定义的。
而通过上图示例不难发现:在前,中,后序遍历的时候,不断把一颗树分为根和左右子树两颗树,然后以左右子树继续划分,遍历的深度也随着在不断加深,当子树是空树时,即根节点root = NULL,此时无法再划分为左右子树,就停止遍历,然再向上返回。
显然,这就是一个递归的过程,终止条件为:root = NULL。当我们理解了这个过程后,直接使用递归来实现的代码就很简洁了,如下:
//先序遍历
void PrevOrder(BTNode* root)
{
if (root == NULL)
return;
printf("%d ", root->val);//根
PrevOrder(root->left);//左树
PrevOrder(root->right);//右树
}
//中序遍历
void InOrder(BTNode* root)
{
if (root == NULL)
return;
InOrder(root->left);//左树
printf("%d ", root->val);//根
InOrder(root->right);//右树
}
//后序遍历
void PostOrder(BTNode* root)
{
if (root == NULL)
return;
PostOrder(root->left);//左树
PostOrder(root->right);//右树
printf("%d ", root->val);//根
}
层序遍历的实现方式就不太一样,需要用到队列先入先出的性质。
具体思路是:如果树不是空树,就先将根节点的指针变量入队;然后进行出队,直到队列为空,每次出队一个元素x后,就把x的不为空的左右子树的根入队。
如下图示:
代码实现示例:
//队列的结构
typedef BTNode* QDateType;
typedef struct QListNode
{
QDateType val;
struct QListNode* next;
}QNode;
typedef struct Queue
{
QNode* head;
QNode* tail;
int size;
}Queue;
//层序
void LayerOrder(QDateType root)
{
//创建队列
Queue src;
//初始化队列
QueueInit(&src);
//如果不为空树,先将根入队
if (root != NULL)
QueuePush(&src, root);
while (!QueueEmpty(&src))
{
//输出队头
QDateType front = QueueFront(src);
printf("%d ", front->val);
//将队头出队
QueuePop(&src);
//再将左右子树的根入队
if (front->left != NULL)
QueuePush(&src, front->left);
if (front->right != NULL)
QueuePush(&src, front->right);
}
}
(关于上述队列的初始化,入队,出队,获取队头元素,队列的判空等函数接口实现,这里不赘述,如需要,可参考:https://gitee.com/a-clear-meaning/c-language/blob/master/%E9%87%8D%E7%82%B9%E7%9F%A5%E8%AF%86%E6%80%BB%E7%BB%93/%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84%EF%BC%88%E5%88%9D%E9%98%B6%EF%BC%89/%E9%98%9F%E5%88%97/Queue/Queue/test.chttps://gitee.com/a-clear-meaning/c-language/blob/master/%E9%87%8D%E7%82%B9%E7%9F%A5%E8%AF%86%E6%80%BB%E7%BB%93/%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84%EF%BC%88%E5%88%9D%E9%98%B6%EF%BC%89/%E9%98%9F%E5%88%97/Queue/Queue/test.c)
4.2:二叉树的总节点个数, 叶子节点的个数,第K层的节点个数,二叉树的深度,判断一棵树是不是完全二叉树,二叉树的销毁
总节点个数
int BinaryTreeSize(BTNode* root)
{
if (root == NULL)
return 0;
return 1 + BinaryTreeSize(root->left) + BinaryTreeSize(root->right);
}
叶子节点的个数
int BinaryTreeLeafSize(BTNode* root)
{
if (root == NULL)
return 0;
if (root->left == NULL && root->right == NULL)//叶子节点的左右子树都是空
return 1;
return BinaryTreeLeafSize(root->left) + BinaryTreeLeafSize(root->right);
}
第K层的节点个数
int BinaryTree_K_Size(BTNode* root, int k)
{
//当前树的第K层 = 左子树的K - 1层 + 右子树的 K - 1层,当递归到k==1时停止
assert(k > 0);
if (root == NULL)
return 0;
if (k == 1)
return 1;
return BinaryTree_K_Size(root->left, k - 1) + BinaryTree_K_Size(root->right, k - 1);
}
二叉树的深度
int MaxDepth(BTNode* root)
{
//根的深度1 + max(左子树,右子树)
if (root == NULL)
return 0;
return 1 + max(MaxDepth(root->left), MaxDepth(root->right));
}
二叉树的销毁
//销毁(后序)
void BinaryTreeDestroy(BTNode** root)
{
if (*root == NULL)
return;
BinaryTreeDestroy(&((*root)->left));
BinaryTreeDestroy(&((*root)->right));
free(*root);
*root = NULL;
}
判断一棵树是不是完全二叉树(利用队列)
bool IsFullBinaryTree(QDateType root)
{
//创建队列
Queue src;
//初始化队列
QueueInit(&src);
//先将根节点入队
if (root == NULL)
return false;
QueuePush(&src, root);
while (!QueueEmpty(&src))
{
//获取队头,判断是否为NULL
QDateType front = QueueFront(src);
//将队头出队
QueuePop(&src);
//判断
if (front == NULL)
break;
//再将左右子树的根入队(NULL也要入队)
QueuePush(&src, front->left);
QueuePush(&src, front->right);
}
//接着判断NULL 是否连续
while (!QueueEmpty(&src))
{
QDateType front = QueueFront(src);
QueuePop(&src);
if (front != NULL)
break;
}
if (QueueEmpty(&src))
{
return true;
}
else
{
QueueDestroy(&src);//销毁队列
return false;
}
}
综上,我们不难发现:在解决二叉树这类问题时,都采用分治的方法,即将问题拆分为一个个子问题来逐一解决。
这里可以得到的一个经验是:大部分的链式二叉树递归终止条件都是root == NULL,可能有些问题在此基础上多了一些返回条件,但最终还是要实际问题实际分析,不可一概而论,在递归较复杂时,要勤动手画递归展开图,这有利于理清思路。
4.3:相关习题链接
1:单值二叉树。OJ链接
2:检查两颗树是否相同。OJ链接
3:对称二叉树。OJ链接
4:另一颗树的子树。OJ链接
本篇分享到这就结束了,如果对你有帮助就是对小编最大的鼓励和支持,如果可以的话,点赞,关注+收藏并分享给你的好友一起学习吧。
关注小编,持续更新!