一、树的概念及结构
1.1 树的概念
🌳现实生活中的树:
👇数据结构中的树:将生活中的树倒置,形成一种结构。
📌树的一些相关概念:
1.2 树的结构特点
- 有一个特殊的节点,称为
根节点
,根节点没有前驱节点(即树的最顶端的节点) - 除根节点外,其余节点被分成M(M>0)个互不相交的集合T1、T2、……、Tm,其中每一个集合Ti(1<= i <= m)又是一棵结构与树类似的子树。每棵子树的根结点有且只有一个前驱节点,可以有0个或多个后继节点。任何一颗树都是由根节点和若干个子树构成。子树又是由一个父节点及若干个子树构成。直到走到叶子节点没有子树为止。
- 因此,树是递归定义的。
⚠️注意:树形结构中,子树之间不能有交集,否则就不是树形结构。
1.3 树的表示
我们这里就简单的了解其中最常用的孩子兄弟表示法。
⌨️代码如下:
struct Node
{
struct Node* _firstChild1; // 第一个孩子结点
struct Node* _pNextBrother; // 指向其下一个兄弟结点
int _data; // 结点中的数据域
};
其下图逻辑就是孩子指针指向下一个孩子结点,进行层次遍历,兄弟指针指向其兄弟结点。
1.4 树在实际中的应用(文件系统的树状目录结构)
二、二叉树概念及结构
2.1 二叉树概念
2.2 二叉树结构图
⚠️ 注意:对于任意的二叉树都是由以下几种情况复合而成的:
2.3 特殊的二叉树
前期数据结构中特殊的二叉树中我们先讲两种:满二叉树和完全二叉树。
简单点说,满二叉树的每一层都是满的,而完全二叉树的前h - 1层是满的,最后一层可以不满,但必须保证连续。
PS1
:假设一颗满二叉树,高度为h,节点数量有多少个呢?
PS2
:高度为h的完全二叉树的节点范围是多少呢?
2.4 二叉树的性质
2.5 二叉树的存储结构
🌷顺序存储形态图:
结论:完全二叉树才适合顺序存储,因为非完全二叉树存在大量的空间浪费。
PS:根据上图完全二叉树的顺序存储,我们可以通过下标建立父亲与孩子之间的关系:
通过父亲下标求孩子:
leftChild = parent * 2 + 1
rightChild = parent * 2 + 1
通过孩子小标找父亲:
parent = (child - 1)/ 2
🌷链式存储形态图:
三、二叉树的顺序结构及实现
3.1 二叉树的顺序结构
3.2 堆的概念及结构
☃️堆的性质:
- 大根堆(大堆): 树中任意一个父节点都大于等于其子节点;
- 小根堆(小堆): 树中任意一个父节点都小于等于其子节点。
- 堆中某个节点的值总是不大于或不小于其父节点的值。
- 堆总是一颗完全二叉树。
3.3 堆的实现
因为堆是一颗完全二叉树,完全二叉树更适合顺序结构存储,所有我们这里可以用顺序表来实现,实现方法类似于之前使用过的顺序表,代码如下:
typedef int HPdataType;
typedef struct Heap
{
HPdataType* arr; //动态数组
int size; //有效元素个数
int capacity; //数组容量
}Heap;
堆的插入
向上调整算法
以小堆为例,如果孩子节点小于父亲节点就进行两两交换,直到调整到根节点后,确保满足完整堆的性质。
🤔ps:
- 向上调整算法一般适合于堆的插入操作
- 前提是左右子树必须为大堆 or 小堆
- 一个节点(根节点、叶子节点)可以看作是大堆 or 小堆
🔗代码如下:
//向上调整
void AdjustUp(HPdataType* a,int child)
{
//根据孩子找父亲
int parent = (child - 1) / 2;
//孩子下标小于等于0就结束
while (child > 0)
{
//小堆情况下 孩子小于父亲就交换
if (a[child] < a[parent])
{
HPdataType tmp = a[child];
a[child] = a[parent];
a[parent] = tmp;
//更新节点,继续往上迭代找
child = parent;
parent = (child - 1) / 2;
}
else
{
//孩子大于等于父亲就结束循环
break;
}
}
}
//堆的插入
void HeapPush(Heap* php, HPdataType x)
{
assert(php);
if (php->capacity == php->size)
{
int newcapacity = php->capacity == 0 ? 4 : php->capacity * 2;
HPdataType* a = (HPdataType*)realloc(php->arr,sizeof(HPdataType)* newcapacity);
if (a == NULL)
{
perror("malloc fail");
return;
}
php->arr = a;
php->capacity = newcapacity;
}
php->arr[php->size] = x;
php->size++;
//为了保证堆的性质,需要进行向上调整
AdjustUp(php->arr,php->size - 1);
}
堆的删除
向下调整算法
以小堆为例,如果孩子节点小于等于父亲节点就进行两两交换,直到调整到叶子节点,确保满足完整堆的性质。
🤔ps:
- 前提是左右子树必须为大堆 or 小堆
🔗代码如下:
//向下调整
void AdjustDown(int* 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 HeapPop(Heap* php)
{
assert(php);
assert(!HeapEmpty(php));
//首尾元素交换
Swap(&php->arr[0], &php->arr[php->size - 1]);
php->size--;
AdjustDown(php->arr, php->size, 0);
}
那么向上调整算法和向下调整算法,他们的时间复杂度是多少呢?看时间复杂度我们需要看最坏的情况,即需要调整完全二叉树的高度次,前面我们计算过高度为h的完全二叉树的节点范围是[ 2h-1 ,2h - 1],那么根据 N = 2h-1 计算出h = logN + 1
, N = 2h - 1 计算出h = log(N+1)
,所以他们的时间复杂度都为logN
堆的创建
💭前面我们提起的向上调整算法和向下调整算法,前提都是左右子树为大堆或者小堆。如果对于任意的完全二叉树,即根节点的左右子树不满足堆的性质,该怎么调整成堆呢?
3.4 堆的应用
Top-K问题
对于Top-K问题,能想到的最简单直接的方式就是排序,但是:如果数据量非常大,排序就不太可取了(可能数据都不能一下子全部加载到内存中)。最佳的方式就是用堆来解决,基本思路如下:
-
1.用数据集合中前K个元素来建堆
前k个最大的元素,则建小堆
前k个最小的元素,则建大堆
用剩余的N-K个元素依次与堆顶元素来比较,不满足则替换堆顶元素(覆盖) -
2.将剩余N-K个元素依次与堆顶元素比完之后,堆中剩余的K个元素就是所求的前K个最小或者最大的元素。
⌨️代码如下:
//创造数据
void CreateDate()
{
int n = 10000;
srand(time(NULL));
FILE* fin = fopen("data.txt", "w");
if (fin == NULL)
{
perror("fopen error");
return;
}
int i = 0;
for (i = 0; i < n; i++)
{
fprintf(fin, "%d\n", rand() % 1000);
}
fclose(fin);
}
void PrintTopK(int k)
{
FILE* fout = fopen("data.txt", "r");
if (fout == NULL)
{
perror("fopen error");
return;
}
int* kminheap = (int)malloc(sizeof(int) * k);
if (kminheap == NULL)
{
perror("malloc fail\n");
return;
}
//读取前k个数到数组中
for (int i = 0; i < k; i++)
{
fscanf(fout, "%d", &kminheap[i]);
}
//前k个数建立小堆
for (int i = (k - 1 - 1) / 2; i >= 0; i--)
{
AdjustDown(kminheap, k, i);
}
//剩余的N-K个元素依次与堆顶元素来比较,不满足则替换堆顶元素(覆盖)
int val = 0;
while (!feof(fout))
{
fscanf(fout, "%d", &val);
if (val > kminheap[0])
{
kminheap[0] = val;
//向下调整
AdjustDown(kminheap, k, 0);
}
}
//打印剩余K个元素就是最大值
for (int i = 0; i < k; i++)
{
printf("%d ", kminheap[i]);
}
}
int main()
{
//CreateDate();
PrintTopK(5);
}
堆排序
堆排序即利用堆的思想来进行排序,总共分为两个步骤:
-
- 建堆
| 排升序 | 建大堆 |
| 排降序 | 建小堆 |
一般利用向下调整算法建堆,思想是倒着调整,不是从根节点开始,而是从第一个非叶子节点开始调整(最后一个节点的父亲),(也可以用向上调整算法建堆,但是效率不如向下调整算法建堆,具体实现见下面的代码和建堆时间复杂度的分析。)
- 建堆
-
- 利用堆删除思想来进行排序
💌堆删除中用到了向下调整,那么我们这里还是以小堆为例,建出小堆之后,数组中的首尾数据进行交换,最小的数据(堆顶)放到了最后的位置,除去最后一个数据,对堆进行向下调整,再把堆顶(此时是次小的数据),与倒数第二个位置的数据交换……以此类推,整个调整完后,数组中的数据依次排列就是一个降序。
💌如果是大堆的话,按照以上思想类比,整个调整完后,数组中的数据依次排列就是一个升序。
- 利用堆删除思想来进行排序
🍁代码实现如下:
堆排序时间复杂度为:O(N + N*logN)
//向上调整
void AdjustUp(HPdataType* a, int child)
{
//根据孩子找父亲
int parent = (child - 1) / 2;
//孩子下标小于等于0就结束
while (child > 0)
{
//小堆情况下 孩子小于父亲就交换
if (a[child] < a[parent])
{
HPdataType tmp = a[child];
a[child] = a[parent];
a[parent] = tmp;
//更新节点,继续往上迭代找
child = parent;
parent = (child - 1) / 2;
}
else
{
//孩子大于等于父亲就结束循环
break;
}
}
}
//向下调整
void AdjustDown(int* 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 Swap(HPdataType* p1, HPdataType* p2)
{
HPdataType tmp = *p1;
*p1 = *p2;
*p2 = tmp;
}
void heapSort(int* a, int n)
{
//法一:向上调整建堆(可以想象堆插入的过程,调整前元素前面已经满足堆的性质)
/*for(int i = 1;i < n;i++)
{
AdjustUp(a, i);
}*/
//法二:向下调整建堆(从最后一个叶子节点的父亲开始倒着调整,因为叶子节点本来就可以看成堆)
//O(N)
for (int i = (n - 1 - 1) / 2; i >= 0; i--)
{
AdjustDown(a, n, i);
}
//首尾交换再向下调整
//O(N*logN)
int end = n - 1;
while(end > 0)
{
Swap(&a[0], &a[end]);
AdjustDown(a, end, 0);
end--;
}
}
int main()
{
int a[] = {8,40,91,14,39,72,4,81};
heapSort(a, sizeof(a) / sizeof(int));
}
3.5 建堆时间复杂度
💡向下调整建堆时间复杂度:O(N)
💡向上调整建堆时间复杂度:O(N*logN)
ps:可以推算一下堆排序的时间复杂度,计算过程与向上调整建堆类似。
按照这样的推理计算,我们很明显观察到向下调整算法建堆比向上调整算法建堆效率要高的多,所以以我们以后选择向下调整算法建堆会更好!
四、二叉树链式结构及实现
4.1 前置说明
在学习二叉树的基本操作前,需先要创建一棵二叉树,然后才能学习其相关的基本操作。由于现在大家对二
叉树结构掌握还不够深入,为了降低大家学习成本,此处手动快速创建一棵简单的二叉树,快速进入二叉树
操作学习,等二叉树结构了解的差不多时,我们反过头再来研究二叉树真正的创建方式。
typedef int BTDataType;
typedef struct BinaryTreeNode
{
BTDataType data;
struct BinaryTreeNode* left;
struct BinaryTreeNode* right;
}BTNode;
BTNode* BuyNode(BTDataType x)
{
BTNode* node = (BTNode*)malloc(sizeof(BTNode));
if (node == NULL)
{
perror("malloc fail\n");
return NULL;
}
node->data = x;
node->left = NULL;
node->right = NULL;
return node;
}
BTNode* CreatBinaryTree()
{
BTNode* node1 = BuyNode(1);
BTNode* node2 = BuyNode(2);
BTNode* node3 = BuyNode(3);
BTNode* node4 = BuyNode(4);
BTNode* node5 = BuyNode(5);
BTNode* node6 = BuyNode(6);
node1->left = node2;
node1->right = node4;
node2->left = node3;
node4->left = node5;
node4->right = node6;
return node1;
}
⚠️注意:上述代码并不是创建二叉树的方式,真正创建二叉树方式后面详解重点讲解。
再看二叉树基本操作前,再回顾下二叉树的概念,二叉树是:
- 空树
- 非空:根节点,根节点的左子树、根节点的右子树组成的。
从概念中可以看出,二叉树定义是递归式的,因此后面基本操作中基本都是按照该概念实现的。
4.2 二叉树的遍历
前中后序遍历
按照规则,二叉树的遍历有:前序/中序/后序的递归结构遍历:
- 前序遍历(Preorder Traversal 亦称先序遍历)——访问根结点的操作发生在遍历其左右子树之前。【
根->左子树->右子树
】 - 中序遍历(Inorder Traversal)——访问根结点的操作发生在遍历其左右子树之中(间)。【
左子树->根->右子树
】 - 后序遍历(Postorder Traversal)——访问根结点的操作发生在遍历其左右子树之后。【
左子树->右子树->根
】
⚠️ps:N代表空树访问
🧐前序遍历代码如下:
//前序遍历
void PrevOrder(BTNode* root)
{
if (root == NULL)
{
printf("N ");
return;
}
printf("%d ", root->data);
PrevOrder(root->left);
PrevOrder(root->right);
}
int main()
{
BTNode* root = CreatBinaryTree();
PrevOrder(root);
}
🤓递归展开图:
😎中序遍历代码如下:
//中序遍历
void InOrder(BTNode* root)
{
if (root == NULL)
{
printf("N ");
return;
}
InOrder(root->left);
printf("%d ", root->data);
InOrder(root->right);
}
int main()
{
BTNode* root = CreatBinaryTree();
InOrder(root);
}
🤗后序遍历代码如下:
//后序遍历
void PostOrder(BTNode* root)
{
if (root == NULL)
{
printf("N ");
return;
}
PostOrder(root->left);
PostOrder(root->right);
printf("%d ", root->data);
}
int main()
{
BTNode* root = CreatBinaryTree();
PostOrder(root);
}
遍历时间复杂度为O(N)
,空间复杂度是O(h)
,h是高度,范围是[logN,N]
🐻ps:中序遍历和后序遍历递归展开图,小伙伴们可以尝试自己画一画,博主这里就不放了。
层序遍历
队列实现(先进先出
):当树的根节点不为空时,让其先进入队列,在队列不为空的情况下,输出队头的元素, 同时且节点孩子存在的情况下入队列。
我们这里拷贝之前队列讲解的代码
Queue.h文件
#include<stdio.h>
#include<stdlib.h>
#include<assert.h>
#include<stdbool.h>
//链式队列
typedef struct BinaryTreeNode* QDataType;
//每个节点
typedef struct QueueNode
{
struct QueueNode* next;
QDataType data;
}QueueNode;
//整个队列的结构
typedef struct Queue
{
QueueNode* phead;//记录链表头
QueueNode* ptail;//记录链表尾
int size;//队列的大小
}Queue;
//队列的初始化
void QueueInit(Queue* pqe);
//队列的销毁
void QueueDestroy(Queue* pqe);
//队尾入队列
void QueuePush(Queue* pqe, QDataType x);
//队头出队列
void QueuePop(Queue* pqe);
//获取队头的数据
QDataType QueueFront(Queue* pqe);
//获取队尾的数据
QDataType QueueBack(Queue* pqe);
//获取队列中有效数据个数
int QueueSize(Queue* pqe);
//判断队列是否为空
bool QueueEmpty(Queue* pqe);
Queue.c文件
#include"Queue.h"
//队列的初始化
void QueueInit(Queue* pqe)
{
assert(pqe);
pqe->phead = pqe->ptail = NULL;
pqe->size = 0;
}
//队列的销毁
void QueueDestroy(Queue* pqe)
{
assert(pqe);
QueueNode* cur = pqe->phead;
while (cur)
{
QueueNode* next = cur->next;
free(cur);
cur = next;
}
pqe->phead = pqe->ptail = NULL;
pqe->size = 0;
}
//队尾入队列
void QueuePush(Queue* pqe, QDataType x)
{
assert(pqe);
QueueNode* newnode = (QueueNode*)malloc(sizeof(QueueNode));
if (newnode == NULL)
{
perror("malloc fail\n");
return;
}
newnode->data = x;
newnode->next = NULL;
//分为空节点和非空节点
if (pqe->phead == NULL)
{
//ptial为断言,为了避免phead与ptail同时指向NULL
assert(pqe->ptail == NULL);
pqe->phead = pqe->ptail = newnode;
}
else
{
pqe->ptail->next = newnode;
pqe->ptail = newnode;
}
pqe->size++;
}
//队头出队列
void QueuePop(Queue* pqe)
{
assert(pqe);
assert(!QueueEmpty(pqe));
//分为一个节点和多个节点
if (pqe->phead->next == NULL)
{
free(pqe->phead);
pqe->phead = pqe->ptail = NULL;
}
else
{
QueueNode* next = pqe->phead->next;
free(pqe->phead);
pqe->phead = next;
}
pqe->size--;
}
//获取队头的数据
QDataType QueueFront(Queue* pqe)
{
assert(pqe);
assert(!QueueEmpty(pqe));
return pqe->phead->data;
}
//获取队尾的数据
QDataType QueueBack(Queue* pqe)
{
assert(pqe);
assert(!QueueEmpty(pqe));
return pqe->ptail->data;
}
//获取队列中有效数据个数
int QueueSize(Queue* pqe)
{
assert(pqe);
return pqe->size;
}
//判断队列是否为空
bool QueueEmpty(Queue* pqe)
{
assert(pqe);
//return pqe->phead == NULL && pqe->ptail == NULL;
return pqe->size == 0;
}
test.c文件
#include "Queue.h"
// 层序遍历
void LevelOrder(BTNode* root)
{
Queue q;
QueueInit(&q);
//根节点入队列
if (root != NULL)
QueuePush(&q, root);
//队列不为空
while (!QueueEmpty(&q))
{
//输出队头的元素
BTNode* front = QueueFront(&q);
printf("%d ", front->data);
QueuePop(&q);
//左孩子和右孩子都存在,就入队列
if (front->left != NULL)
QueuePush(&q, front->left);
if (front->right != NULL)
QueuePush(&q, front->right);
}
QueueDestroy(&q);
}
判断二叉树是否是完全二叉树
// 判断二叉树是否是完全二叉树(层序遍历思想)
bool BinaryTreeComplete(BTNode* root)
{
Queue q;
QueueInit(&q);
if (root == NULL)
QueuePush(&q, root);
//队列不为空
while (!QueueEmpty(&q))
{
//节点入队列
BTNode* front = QueueFront(&q);
QueuePop(&q);
//遇到空树就跳出循环
if (front == NULL)
break;
//左右子树入队列
QueuePush(&q, front->left);
QueuePush(&q, front->right);
}
//继续往后寻找,只要遇到非空说明不是完全二叉树 返回false
//空树后面还是空说明是完全二叉树返回true
while (!QueueEmpty(&q))
{
BTNode* front = QueueFront(&q);
QueuePop(&q);
if (front != NULL)
{
QueueDestroy(&q);
return false;
}
QueuePush(&q, front->left);
QueuePush(&q, front->right);
}
QueueDestroy(&q);
return true;
}
4.3 二叉树的基本操作
求二叉树的节点个数
// 二叉树节点个数
//法一:遍历计数(使用完后需要将size置为0)
//int size = 0;
//void BinaryTreeSize(BTNode* root)
//{
// if (root == NULL)
// return;
// size++;
//
// BinaryTreeSize(root->left);
// BinaryTreeSize(root->right);
//}
//法二:分治算法
int BinaryTreeSize(BTNode* root)
{
if (root == NULL)
return 0;
return BinaryTreeSize(root->left) + BinaryTreeSize(root->right) + 1;
}
求二叉树的叶子节点个数
// 二叉树叶子节点个数
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);
}
求二叉树的高度
//二叉树的高度
int BinaryTreeHeight(BTNode* root)
{
if (root == NULL)
return 0;
int leftHeight = BinaryTreeHeight(root->left);
int RightHeight = BinaryTreeHeight(root->right);
return leftHeight > RightHeight ? leftHeight + 1 : RightHeight + 1;
}
二叉树第k层节点个数
// 二叉树第k层节点个数
int BinaryTreeLevelKSize(BTNode* root, int k)
{
//层数都是从1开始
assert(k > 0);
//root 为空返回0
if (root == NULL)
return 0;
//root为空且k = 1时,返回1
if (k == 1)
return 1;
//分治:左子树和右子树
return BinaryTreeLevelKSize(root->left, k - 1)
+ BinaryTreeLevelKSize(root->right, k - 1);
}
二叉树查找值为x的节点
// 二叉树查找值为x的节点
BTNode* BinaryTreeFind(BTNode* root, BTDataType x)
{
if (root == NULL)
return NULL;
if (root->data == x)
return root;
//分治
BTNode* ret1 = BinaryTreeFind(root->left, x);
if (ret1)
return ret1;
BTNode* ret2 = BinaryTreeFind(root->right, x);
if (ret2)
return ret2;
//子树都没找到返回空
return NULL;
}
4.4 经典题型:二叉树的构建及遍历
👉 牛客网链接
#include <stdio.h>
typedef int BTDataType;
typedef struct BinaryTreeNode
{
BTDataType data;
struct BinaryTreeNode* left;
struct BinaryTreeNode* right;
}BTNode;
BTNode* BuyNode(BTDataType x)
{
BTNode* node = (BTNode*)malloc(sizeof(BTNode));
if (node == NULL)
{
perror("malloc fail\n");
return NULL;
}
node->data = x;
node->left = NULL;
node->right = NULL;
return node;
}
//构建树(前序遍历)
BTNode* CreateTree(char* a,int* pi)
{
if(a[*pi] == '#')
{
(*pi)++;
return NULL;
}
BTNode* root = BuyNode(a[*pi]);
(*pi)++;
root->left = CreateTree(a,pi);
root->right = CreateTree(a,pi);
return root;
}
//中序遍历输出
void InOrder(BTNode* root)
{
if(root == NULL)
return;
InOrder(root->left);
printf("%c ",root->data);
InOrder(root->right);
}
int main() {
char str[100];
scanf("%s",str);
int i = 0;
BTNode* root = CreateTree(str,&i);
InOrder(root);
printf("\n");
return 0;
}
🥰🥰本章节完,后续会补充二叉树进阶内容知识,小伙伴们可以持续关注,若本篇文章对你有帮助的话,可以三连支持博主哦~,另外本篇内容有编写有误的话,可以私聊博主进行纠正!