目录
1.二叉树概念及结构
1.1概念
一棵二叉树是结点的一个有限集合,该集合:
1.为空
2.由一个根节点加上两棵分别称为左子树和右子树的二叉树组成
从上图可以看出:
1.二叉树不存在度大于2的结点
2.二叉树的子树有左右之分,次序不能颠倒,所以二叉树是有序树
注意:对于任意的二叉树都是由以下几种情况复合而成的:
1.2特殊的二叉树
1.满二叉树:一个二叉树,如果每一层的结点都到达最大值,则这个二叉树就是满二叉树。也就是说如果二叉树的层次是k,而结点总数是2^k-1,则它就是满二叉树。
2.完全二叉树:完全二叉树是效率很高的数据结构,完全二叉树是由满二叉树而引出来的。一个有k层的二叉树,当此二叉树的前k-1层都是满的,并且最后一层满或不满都行但是必须是从左到右连续的,则此二叉树称为完全二叉树。要注意的是满二叉树是一种特殊的完全二叉树。并且通过计算可得完全二叉树的结点数量范围为[ 2^(k-1) , 2^k-1 ]
1.3二叉树的性质
1. 若规定根节点的层数为1,则一棵非空二叉树的第i层上最多有个结点.
2. 若规定根节点的层数为1,则深度为h的二叉树的最大结点数是-1(计算方法:)
3. 对任何一棵二叉树,如果度为0的节结点个数计为n0,度为2的分支结点个数计为n2
则有n0 = n2+n1
4.在完全二叉树中,假设总节点数为n,度为1的结点数计为n1,则当n为偶数时,n1 = 1;但n为奇数时,n1 = 0
5. 若规定根节点的层数为1,具有n个结点的满二叉树的深度h = log(n+1)(ps:log(n+1)是log以2为底,n+1为对数)
6.对于具有n个结点的完全二叉树,如果按照从上至下从左至右的数组顺序对所有节点从0开始编号,则对于序号为i的结点有:
1. 若i>0,i位置节点的双亲序号:(i-1)/2;i=0,i为根节点编号,无双亲节点
2. 若2i+1<n,左孩子序号:2i+1,2i+1>=n否则无左孩子
3. 若2i+2<n,右孩子序号:2i+2,2i+2>=n否则无右孩子
📚小试牛刀
1. 某二叉树共有 399 个结点,其中有 199 个度为 2 的结点,则该二叉树中的叶子结点数为( )
A 不存在这样的二叉树
B 200
C 198
D 199
2.一棵完全二叉树的节点数位为531个,那么这棵树的高度为( )
A 11
B 10
C 8
D 12
3.一个具有767个节点的完全二叉树,其叶子节点个数为()
A 383
B 384
C 385
D 386
答案 :BBB
2.二叉树的顺序结构及实现
2.1二叉树的顺序结构
普通的二叉树是不适合用数组来存储的,因为可能会存在大量的空间浪费。而完全二叉树更适合使用顺序结构存储。现实中我们通常把堆(一种二叉树)使用顺序结构的数组来存储,需要注意的是这里的堆和操作系统虚拟进程地址空间中的堆是两回事,一个是数据结构,一个是操作系统中管理内存的一块区域分段。
2.2堆的概念及结构
堆是一种特殊的二叉树
- 堆总是一颗完全二叉树
- 堆中某节点的值总是不大于或不小于其父节点的值
将根结点最大的堆叫做大堆或者大根堆,同理根节点最小的堆叫小堆或者小根堆
1.小根堆
2.大根堆
2.3堆的实现
typedef int HPDataType;
typedef struct Heap
{
HPDataType* a;
int size;
int capacity;
}Hp;
// 堆的构建
void HeapCreate(Hp* hp, HPDataType* a, int n);
// 堆的销毁
void HeapDestory(Hp* hp);
// 堆的插入
void HeapPush(Hp* hp, HPDataType x);
// 堆的删除
void HeapPop(Hp* hp);
// 取堆顶的数据
HPDataType HeapTop(Hp* hp);
// 堆的数据个数
int HeapSize(Hp* hp);
// 堆的判空
int HeapEmpty(Hp* hp);
我们将堆的底层结构通过顺序表来实现,也就是使用一个动态开辟的数组。虽说是通过数组结构来操作堆的数据,但是在逻辑结构上是二叉树(堆)的结构。
2.3.1堆的调整算法
我们任意给出一个数组后,都可以将其看作是一棵二叉树,但是可能不能称之为堆,因为不符合堆的概念结构。所以我们就需要对其数据进行调整将其变成堆。
或者将我们的堆初始化为NULL时,然后后续需要插入数据,你也需要将数据调整到符合堆的结构
✏️初始化代码:
void HeapInit(HP* php)
{
assert(php);
php->a = NULL;
php->size = php->capacity = 0;
}
于是乎便有了数据结构:堆 的一个重要算法:调整算法
1️⃣向上调整算法(堆的插入)
插入的步骤图解如下:
🚩主要分为两步:
1.先将元素插入到堆的末尾,即最后一个孩子之后
2.插入之后如果堆的性质遭到破坏,将新插入的节点顺着双亲向上调整到合适位置即可
插入时需要注意内存空间是否已经满了,记得扩容,具体代码下文展示。
✏️向上调整
void AdjustUp(HPDateType* a, int child)
{
//找出父节点
int parent = (child - 1) / 2;
while (child > 0)
{
if (a[child] < a[parent])
{
Swap(&a[child], &a[parent]);
child = parent;
parent = (child - 1) / 2;
}
else
break;
}
}
2️⃣向下调整算法(堆的调整)
有了向上调整算法,同样也有重要的向下调整算法
当确定某节点的左右子树都同为大堆或小堆时,但是本身此节点与子树结合时不符合堆的结构时,我们可以使用向下调整算法将其调整为一个新的堆。向下调整算法有一个前提:左右子树必须是一个堆,才能调整
现在我们给出一个数组,逻辑上可以看做一棵完全二叉树,但是不符合堆的结构。 我们可以通过从根节点开始的向下调整算法将其调整成一个小堆。
int array[] = {27,15,19,18,28,34,65,49,25,37};
✏️代码实现
void Swap(HPDateType* a1, HPDateType* a2)
{
HPDateType tmp = *a1;
*a1 = *a2;
*a2 = tmp;
}
void AdjustDown(HPDateType* a, int n,int parent)
{
assert(a);
int minChild = parent * 2 + 1;
while (minChild < n)//孩子的范围必须在数组内
{
//找出小孩子
if ((minChild + 1 < n ) && (a[minChild + 1] < a[minChild]))
minChild++;
//判断父亲和小孩子是否需要交换
if (a[minChild] < a[parent])
{
Swap(&a[minChild], &a[parent]);
//迭代交换下标
parent = minChild;
minChild = parent * 2 + 1;
}
else
break;
}
}
2.3.2堆的删除
删除堆的第一个数据其实是为了能够拿到次大或次小的数。我们可将队顶的数据跟最后一个数据交换,然后删除数组的最后一个数据,再进行向下调整算法
2.3.3代码展示
🚀Heap.c
void HeapPrint(HP* php)
{
for (int i = 0; i < php->size; i++)
printf("%d ", php->a[i]);
printf("\n");
}
void HeapInit(HP* php)
{
assert(php);
php->a = NULL;
php->size = php->capacity = 0;
}
void HeapDestory(HP* php)
{
assert(php);
free(php->a);
php->capacity == php->size == 0;
}
void Swap(HPDateType* a1, HPDateType* a2)
{
HPDateType tmp = *a1;
*a1 = *a2;
*a2 = tmp;
}
void AdjustUp(HPDateType* a, int child)
{
//找出父节点
int parent = (child - 1) / 2;
while (child > 0)
{
if (a[child] < a[parent])
{
Swap(&a[child], &a[parent]);
child = parent;
parent = (child - 1) / 2;
}
else
break;
}
}
//插入x之后继续保持堆形态
void HeapPush(HP* php,HPDateType x)
{
assert(php);
if (php->size == php->capacity)
{
int newcapacity = php->capacity == 0 ? 4 : php->capacity * 2;
HPDateType* tmp = (HPDateType*)realloc(php->a, sizeof(HPDateType) * newcapacity);
if (tmp == NULL)
{
perror("realloc fail");
exit(-1);
}
php->a = tmp;
php->capacity = newcapacity;
}
php->a[php->size] = x;
php->size++;
//保持堆形态
AdjustUp(php->a, php->size - 1);
}
void AdjustDown(HPDateType* a, int n,int parent)
{
assert(a);
int minChild = parent * 2 + 1;
while (minChild < n)//孩子的范围必须在数组内
{
//找出小孩子
if ((minChild + 1 < n ) && (a[minChild + 1] < a[minChild]))
minChild++;
//判断父亲和小孩子是否需要交换
if (a[minChild] < a[parent])
{
Swap(&a[minChild], &a[parent]);
//迭代交换下标
parent = minChild;
minChild = parent * 2 + 1;
}
else
break;
}
}
//删除堆顶元素
void HeapPop(HP* php)
{
assert(php);
assert(!HeapEmpty(php));
Swap(&php->a[0], &php->a[php->size - 1]);
php->size--;
AdjustDown(php->a, php->size, 0);
}
//返回堆顶元素
HPDateType HeapTop(HP* php)
{
assert(php);
assert(!HeapEmpty(php));
return php->a[0];
}
bool HeapEmpty(HP* php)
{
assert(php);
return php->size == 0;
}
int HeapSize(HP* php)
{
assert(php);
return php->size;
}
2.4堆的创建
堆除了像上文那样初始化为NULL再通过插入去创建以外,其实比较经常的是给一个数组,然后我们通过对数组数据进行调整从而创建堆。
比如我们给了一个数组,这个数组逻辑上可以看作是一棵完全二叉树,但是还不是一个堆,现在我们通过算法,把它构建成一个堆。
int a[] = {1,5,3,8,7,6};
但是问题来了,我们要如何创建呢?用向上调整算法还是向下调整算法去创建堆呢?
建堆时间复杂度计算
因为堆是完全二叉树,而满二叉树也是完全二叉树,此处为了简化使用满二叉树来证明(时间复杂度本来看的就是近似值,多几个节点不影响最终结果):
1️⃣向上调整时间复杂度
2️⃣ 向下调整时间复杂度
综上比较,使用向下调整算法的效率会高很多
其实时间复杂度的比较不难理解,我们发现二叉树的节点数量是根据层数递增的,并且增加的速度很快。其实在一棵满二叉树中,最后一层的节点个数就占了二叉树的二分之一左右。而我们向上调整算法,越深的层需要向上调整的次数越多,也就是说随着层数的增加 -> 需要调整的节点个数越多,需要调整的次数越多。而向下调整算法,随着层数的减少 -> 调整的次数越多,但是需要调整的节点个数越少
🚩因此我们创建堆时使用向下调整算法,步骤如下所示
2.5堆的应用
2.5.1堆排序
堆排序即是使用堆的思想对数据进行排序,那如何实现?
排序也就是有升序和降序。那既然使用堆的思想,那要使用什么堆?
有兄弟就觉得升序那就建个小堆咯,然后就依次取堆顶元素也即是堆中(所剩数据中)最小的数,那依次取完不就升序,然后降序也同理。
但是真是如此吗?
假如我们现在给一个数组要求实现升序
int a[] = {20,17,4,16,5,3};
按照我们一开始的 升序建小堆,降序建大堆的思想我们发现好像行不通
建堆的时间复杂度是O(N) ,也就是说到最后排序的时间复杂度是O(N*N) 呃...那我还不如直接遍历呢
所以最优做法是 ----> 升序建大堆,降序建小堆
✈️步骤如图所示(升序)
简单来说就是:先建个大堆,然后将堆顶(最大)和最后一个节点进行交换,然后除去最后一个节点以外的节点再重新调整
✏️代码实现
void HeapSort(int* a, int n)
{
//首先将数据建堆
for (int i = (n - 2) / 2; i >= 0; i--)//出于效率选择向下调整
AdjustDown(a, n, i);
//选数
int i = 1;
while (i < n)
{
Swap(&a[0], &a[n - i]);
AdjustDown(a, n - i, 0);
i++;
}
}
注:堆排序需要调用向下调整算法的函数,在前文已经实现
有的兄弟到这有些许疑问,我们在前文的实现堆之后,运用“返回堆顶数据”的接口,依次调用不就能实现排序了吗?那为什么不这样做
出于2个原因
1.若在平时需要进行排序的话,你需要先自己敲出数据结构—堆 才能使用其接口,那这不是更加复杂了吗
2.使用堆的话会造成额外的空间复杂度
因此
我们学会了堆的结构后,在排序时运用堆的思想建堆便能高效率进行排序
2.5.2Top-K问题
TOP-K问题:即求数据结合中前K个最大的元素或者最小的元素,一般情况下数据量都比较大。
比如:专业前10名、世界500强、富豪榜、游戏中前100的活跃玩家等。
对于Top-K问题,能想到的最简单直接的方式就是排序,但是:如果数据量非常大,排序就不太可取了(可能数据都不能一下子全部加载到内存中)。
最佳的方式就是使用堆来选数,那该如何使用?建大堆还是小堆?
方法1:建大堆
既然要选前k个最大,那么我们可以将数据建成大堆,然后再利用Pop接口的思想挑选出前K大的数。那么这样的时间复杂度是 ---> O(N+logN*K)
好像听上去能行,但是假如是应用于世界500强、富豪榜,N将会是巨大无比,所以这或许不是最优的方法
方法2:建小堆
1.用数据集合中的前K个元素来建堆
- 要选前K个最大的元素 - 建小堆
- 要选前K个最小的元素 - 建大堆
2. 用剩余的N-K个元素依次跟堆顶元素来比较,不满足则替换堆顶元素再重新调整堆
将剩余N-K个元素依次跟堆顶元素比较完之后,堆中剩余的K个元素就是所求的前K个最小或者最大的元素。
✏️前K个最大代码实现
void PrintTopK(const char* filename, int k)
{
assert(filename);
FILE* fout = fopen(filename, "r");
if (fout == NULL)
{
perror("fopen fail");
return;
}
//建小堆
int* minHeap = (int*)malloc(sizeof(int) * k);
if (minHeap == NULL)
{
perror("malloc fail");
return;
}
for (int i = 0; i < k; i++)
fscanf(fout, "%d ", &minHeap[i]);
for (int i = (k - 2) / 2; i >= 0; i--)
AdjustDown(minHeap, k, i);
//继续读取后N-K
int val = 0;
while (fscanf(fout, "%d ", &val) != EOF)
{
if (val > minHeap[0])
{
minHeap[0] = val;
AdjustDown(minHeap, k, 0);
}
}
//打印
for (int i = 0; i < k; i++)
printf("%d ", minHeap[i]);
free(minHeap);
fclose(fout);
}
🚀复杂度解析
空间复杂度:O(K)
时间复杂度:O(K+(N-K)logK)
3.二叉树的链式结构及实现
在学习二叉树的基本操作前,需先要创建一棵二叉树,然后才能学习其相关的基本操作。而创建二叉树需要利用一个二叉树最核心的思想——递归,但是在对二叉树结构掌握得不够深刻时是比较难能够掌握这个思想。所以此处先手动快速创建一棵简单的二叉树,快速进入二叉树操作学习,等二叉树结构了解的差不多时,我们反过头再来研究二叉树真正的创建方式。
typedef int BTDataType;
typedef struct BinaryTreeNode
{
BTDataType _data;
struct BinaryTreeNode* _left;
struct BinaryTreeNode* _right;
}BTNode;
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;
}
创建出的二叉树如图所示:
3.1二叉树的遍历
3.1.1前序、中序、后序遍历
🚩所谓二叉树遍历(Traversal)是按照某种特定的规则,依次对二叉树中的节点进行相应的操作,并且每个节点只操作一次。访问结点所做的操作依赖于具体的应用问题。 遍历是二叉树上最重要的运算之一,也是二叉树上进行其它运算的基础。
1. 前序遍历(Preorder Traversal 亦称先序遍历)——访问根结点的操作发生在遍历其左右子树之前。
2. 中序遍历(Inorder Traversal)——访问根结点的操作发生在遍历其左右子树之中(间)。
3. 后序遍历(Postorder Traversal)——访问根结点的操作发生在遍历其左右子树之后。
🎈🎈🎈我们看到二叉树的结构,是由根和子树构成的。第一层根的子树中的两个孩子,又是作为它们的子树的根。就像是一个家族,子子孙孙传宗接代一样,彼此之间的关系远近亲疏却又是串联在一起。所以假设在古代有一个通知在一个大家族想不聚集地传达下去,那么最好的方法便是通过家族中的一代一代一个家庭一个家庭传下去。总不能族长去挨家挨户找吧??而此时我们要遍历,也就像是将通知传达到每个人那里,那么我们是不是可以采用类似的思想呢?
✏️代码实现
//前序遍历
void PreOrder(BTNode* root)
{
if(root == NULL)
return;
printf("%d ",root->data);
PreOrder(root->left);
PreOrder(root->right);
}
📈前序遍历递归图解:
📈前序遍历递归展开图
📚中序和后序遍历的思路和前序类似,就不再展开
//中序遍历
void InOrder(BTNode* root)
{
if(root == NULL)
return;
InOrder(root->left);
printf("%d ",root->data);
InOrder(root->right);
}
//后序遍历
void PostOrder(BTNode* root)
{
if(root == NULL)
return;
PoseOrder(root->left);
PoseOrder(root->right);
printf("%d ",root->data);
}
前序遍历结果:1 2 3 4 5 6
中序遍历结果:3 2 1 5 4 6
后序遍历结果:3 2 5 6 4 1
学习完二叉树的遍历,是否对递归有了那么一丝丝感觉了呢?
递归,就是自己调用自己。将大事化小,小事化了,是一种分而治之的思想。
3.1.2层序遍历
层序遍历:除了先序遍历、中序遍历、后序遍历外,还可以对二叉树进行层序遍历。设二叉树的根节点所在层数为1,层序遍历就是从所在二叉树的根节点出发,首先访问第一层的树根节点,然后从左到右访问第2层上的节点,接着是第三层的节点,以此类推,自上而下,自左至右逐层访问树的结点的过程就是层序遍历。
层序遍历其实就还是想要拿到二叉树里面的每个数据,只是将数据拿出来的顺序不一样罢了。而二叉树只在父节点和子节点之间有链接关系,所以无法直接根据层序提取数据。
那么我们是否可以利用某个容器,放入数据后按照层序拿出吗? ——队列
我们可以将数据放到队列中,然后当要出队列时,顺便将要出队列的节点的左右孩子节点放入队列,由于队列是先进先出,所以如此操作下来便能实现层序遍历了。
✏️代码展示:
void BinaryTreeLevelOrder(BTNode* root)
{
Queue q;
QueueInit(&q);
if (root)
QueuePush(&q, root);
while (!QueueEmpty(&q))
{
BTNode* front = QueueFront(&q);
QueuePop(&q);
printf("%d ", front->_data);
//下一层入队列
if (front->_left)
QueuePush(&q, front->_left);
if (front->_right)
QueuePush(&q, front->_right);
}
}