本章我们将引入树的概念并详细介绍二叉树。我们会在介绍顺序二叉树基础上,进一步介绍堆以及堆的实现,并以此为依据详解topK排序、堆排等问题;然后我们会介绍链式二叉树的实现以及各种操作。最后,我们也会给出几道OJ题来整体回顾,加深印象。
目录
(一)、树的概念和结构
ps:由于本章初入树的章节,所以这里我们简单的介绍一下树,详细的定义大家可以参考教材,这里我们只是大体介绍,重点会放在下面二叉树的地方。
1、树的概念
如图,这就是一个数的图。
2、树的表示
我们可以参考链表的方法表示树,比如我们对于上图中的A可以定义为:
typedef int DataType;
struct Node
{
struct Node* _firstChild1;
struct Node* _secondChild2;
DataType _data; // 结点中的数据域
};
乍一看是没有问题,但是我们这里是树,其实他的孩子数量是不确定的,我们并不能这样表示。又有人想出动态开辟空间的想法,思路是可行的,但实现起来稍微复杂,而且复杂度较高。
下面我们给出一种很妙的方法:孩子兄弟表示法。
比如我们有上面的一棵树,我们不想浪费空间以及易于理解,我们可以选择这样存放:
我们只找左孩子,然后通过左孩子找到他的兄弟们,这样一来,无论一个结点有多少个孩子,我们都可以巧妙地存放下。结构体定义如下:
typedef int DataType;
struct Node
{
struct Node* _firstChild1; // 第一个孩子结点
struct Node* _pNextBrother; // 指向其下一个兄弟结点
DataType _data; // 结点中的数据域
};
树我们就讲这么多,我们的重点还是放在下面的二叉树身上。
(二)、二叉树的概念及结构
1、二叉树基础概念和相关性质
1)概念
2)两类特殊的二叉树
二叉树中有两个特殊的二叉树,分别是满二叉树和完全二叉树。
笔试填空题选择题可能会考这两种特殊的二叉树。
下面我们给出定义:
满二叉树图:
完全二叉树图:
3)二叉树的几个性质
2、二叉树的存储结构
1)顺序结构
顺序结构存储就是使用数组来存储,一般使用数组只适合表示完全二叉树,因为不是完全二叉树会有空间的浪费。而现实中使用中只有堆才会使用数组来存储,关于堆我们后面的章节会专门讲解。二叉树顺序存储在物理上是一个数组,在逻辑上是一颗二叉树。
2)链式结构
typedef int BTDataType;
// 二叉链
struct BinaryTreeNode
{
struct BinTreeNode* _pLeft; // 指向当前节点左孩子
struct BinTreeNode* _pRight; // 指向当前节点右孩子
BTDataType _data; // 当前节点值域
}
(三)、详解二叉树顺序结构以及堆的相关问题
1、顺序结构的存储
顺序结构我们上面介绍过通常用于完全二叉树中
如图,这个我们可以存储在顺序表中:
这样存储,我们查找数据很方便,而且可以利用下标知道它的位置;而且可以节省空间。
但是,不适用普通二叉树里,不然很多空间存放空就浪费掉了。
下面我们讲解一种顺序存储结构实现的数据结构---堆
2、堆的概念和实现
1)概念:
堆(heap)是计算机科学中一类特殊的数据结构的统称。堆通常是一个可以被看做一棵树的数组对象。堆总是满足下列性质:
-
堆中某个结点的值总是不大于或不小于其父结点的值;
-
堆总是一棵完全二叉树。
2) *堆的实现
我们先预想堆的实现有那些过程,然后由整体到个别一一实现:
//初始化
void HeapInit(HP* hp);
//销毁
void HeapDestroy(HP* hp);
//插入
void HeapPush(HP* hp, HPDataType x);
//弹出,删除
void HeapPop(HP* hp);
//堆顶元素
HPDataType HeapTop(HP* hp);
//堆的打印
void HeapPrint(HP* hp);
//判断是否为空
bool HeapEmpty(HP* hp);
//堆的元素个数
int HeapSize(HP* hp);
//向上调整
void AdjustUp(int* a, int child);
//向下调整
void AdjustDown(int* a, int n, int parent);
//交换元素
void Swap(HPDataType* px, HPDataType* py);
堆的初始化:
初始化只需要定义堆的结构体并开辟空间初始化:
typedef int HPDataType;
typedef struct Heap
{
int* a;
int size;
int capacity;
}HP;
void HeapInit(HP* hp)
{
assert(hp);
hp->a = NULL;
hp->size = hp->capacity = 0;
}
堆的销毁:
销毁只需要free堆的数据,然后把堆的空间设为0即可:
void HeapDestroy(HP* hp)
{
assert(hp);
free(hp->a);
hp->size = hp->capacity = 0;
}
插入数据进堆:
目前为止,到这开始有了一定的难度。
我们借上图来分析:
我们假设想插入一个元素21,如何插入呢?
我们可以开辟空间然后先把21插入到堆尾,如图所示:
但是这是一个小堆,21并不符合在小堆中的位置顺序。这时候,我们想到二叉树父亲和孩子结点的关系,下标关系:左孩子下标=父亲下标+1,右孩子下标=父亲下标+2, 父亲下标=孩子下标除2,我们可以利用这一关系让21与他的父亲比较,比他父亲小就交换,以此类推,直到符合小堆条件为之。简称“向上调整”。调整之后如图:
void swap(HPDataType* parent, HPDataType* child)
{
HPDataType tmp = *child;
*child = *parent;
*parent = tmp;
}//交换函数
void AdjustUp(int* a, int child)
{
int parent = (child - 1) / 2;
while (child > 0)
{
if (a[parent] < a[child])
{
swap(&a[parent], &a[child]);
child = parent;
parent = (child - 1) / 2;
}
else
{
break;
}
}
}//向上调整
void HeapPush(HP* hp, HPDataType x)
{
assert(hp);
if (hp->capacity == hp->size)
{
int newcapacity = hp->capacity == 0 ? 4 : hp->capacity * 2;
HPDataType* tmp = (HPDataType*)realloc(hp->a, sizeof(HPDataType) * newcapacity);
if (tmp == NULL)
{
printf("realloc fail\n");
exit(-1);
}
hp->a = tmp;
hp->capacity = newcapacity;
}
hp->a[hp->size] = x;
hp->size++;
AdjustUp(hp->a, hp->size - 1);
}
删除顶数据出堆:
如果直接删除顶元素,那么我们对调整就无从下手。这里我们联想到上面的向上调整,会不会应用向下调整的做法呢?
所以这里我们想到,把首元素和尾元素交换,然后删除尾元素,然后向下调整。这里的数组删除尾元素复杂度小,而且有了上面的经验,向下调整也是简简单单。
void AdjustDown(int* a, int n, int parent)
{
int child = parent * 2 + 1;
while (child < n)
{
if (child + 1 < n && a[child + 1] > a[child])
{
child++;
}
if (a[parent] < a[child])
{
swap(&a[parent], &a[child]);
parent = child;
child = parent * 2 + 1;
}
else
{
break;
}
}
}
void HeapPop(HP* hp)
{
assert(hp);
assert(!HeapEmpty(hp));
swap(&hp->a[0], &hp->a[hp->size - 1]);
hp->size--;
AdjustDown(hp->a, hp->size, 0);
}
其他操作:
返回堆顶元素:
堆顶元素其实就是a[0]:
HPDataType HeapTop(HP* hp)
{
assert(hp);
assert(!HeapEmpty(hp));
return hp->a[0];
}
判是否为空:
判断hp->size==0这一表达式是否正确即可:
bool HeapEmpty(HP* hp)
{
return hp->size == 0;
}
返回堆元素的个数:
返回size即可:
int HeapSize(HP* hp)
{
assert(hp);
return hp->size;
}
3、topK排序和堆排
1)topK排序:
实际操作方法:这里我们以在1000000个数当中找出10个最大的数字为例:
按照上面的思路,我们先建立一个小堆,把前十个元素依次插入堆中,构成小堆。然后剩下的元素依次和小堆堆顶的元素比较大小(堆顶元素为堆中的最小数),如果比堆顶元素大,则进堆然后对堆进行调整,历遍完成一次则堆中的10个元素即为10个最大数。
下面我们给出代码:
void PrintTopK(HPDataType* a, int n, int k)
{
HP hp;
HeapInit(&hp);
int i = 0;
for (i = 0; i < k; i++)
{
HeapPush(&hp, a[i]);//建立小堆,详细见上文
}
for (i = k; i < n; i++)
{
if (a[i] > HeapTop(&hp))
{
hp.a[0] = a[i];
AdjustDown(hp.a, hp.size, 0);//向下调整
}
}
HeapPrint(&hp);
HeapDestroy(&hp);
}
void TestTopk()
{
int n = 1000000;
int* a = (int*)malloc(sizeof(int) * n);
srand(time(0));
for (size_t i = 0; i < n; ++i)
{
a[i] = rand() % 1000000;//随机设置1000000个比1000000小的数字
}
// 再去设置10个比100w大的数
a[5] = 1000000 + 1;
a[1231] = 1000000 + 2;
a[5355] = 1000000 + 3;
a[51] = 1000000 + 4;
a[15] = 1000000 + 5;
a[2335] = 1000000 + 6;
a[9999] = 1000000 + 7;
a[76] = 1000000 + 8;
a[423] = 1000000 + 9;
a[3144] = 1000000 + 10;
PrintTopK(a, n, 10);
}
2)堆排
堆的创建实现是为了提高我们排序的效率,那么我们怎么用堆实现排序呢?
基本思路如下:
1、我们把这一组数放在一个堆中,升序则建立大堆,降序则建立小堆
2、我们每次把堆顶的元素和堆尾的元素交换,然后Pop掉堆尾的元素,然后调整堆,这里得到的就是堆中元素最大或者最小的元素,以此类推,最后就可以得到排序后的元素
代码如下:
//我们取得整个数组,然后从第一个非叶子节点依次往前向下调整成大堆,然后依次交换头尾pop出去,得到了升序
//
void HeapSort(int* a, int n)
{
int i = 0;
//建大堆
for (i = (n - 1 - 1) / 2; i >= 0; i--)
{
AdjustDown(a, n, i);
}
int end = 0;
for (end = n - 1; end > 0; end--)
{
swap(&a[0], &a[end]);
//然后向下调整
AdjustDown(a, end, 0);
}
}
int main()
{
int a[] = { 44,11,23,54,34,65,78,16,84 };
int i = 0;
for (i = 0; i < sizeof(a) / sizeof(a[0]); i++)
{
printf("%d ", a[i]);
}
printf("\n");
HeapSort(a, sizeof(a) / sizeof(a[0]));
for (i = 0; i < sizeof(a) / sizeof(a[0]); i++)
{
printf("%d ", a[i]);
}
printf("\n");
return 0;
}
(四)、详解二叉树的链式结构及相关问题和OJ题
我们下面会经常性的使用到递归操作,初学大家可能有这样一种感觉:我能看懂代码,但是我自己写的时候写不出来或者没有思路,请大家不用着急,一步一个脚印,我们初学不会才要向其他人学习,多学习学习其他人的代码,我们也可以成长,稍安勿躁!
1、链式二叉树的创建
链式二叉树的创建有几大不好操作的地方,我们可能无法准确掌握节点数量和结点位置等,这就导致我们不方便使用循环迭代来写,初学者我们是站在巨人的肩膀上,请大家阅读下面代码,然后我们再来解释:
typedef int TNDataType;
typedef struct TreeNode
{
TNDataType val;
struct TreeNode* left;
struct TreeNode* right;
}TreeNode;
//链式二叉树的结构
TreeNode* CreatTree()
{
int num;
scanf("%d", &num);
TreeNode* root = (TreeNode*)malloc(sizeof(TreeNode));
root->val = num;
if (root->val == -1)//如果输入-1则代表该结点为空
return NULL;
else
{
printf("请输入%d左子树的值",root->val);
root->left = CreatTree();
printf("请输入%d右子树的值", root->val);
root->right = CreatTree();
}
return root;
}
下面我们用一个简单的二叉树为例分析:
这段代码大家很容易读懂,但是他的内涵实质大家可能不太了解,下面我们来慢慢地画递归展开图:
画完递归展开图我们就可以深入了解了。
2、二叉树的遍历
二叉树的遍历方式最基础的有前序遍历,中序遍历和后序遍历,前序顾名思义就是先遍历根然后左子树然后右子树,中序就是先遍历左子树,然后根然后右子树,后序就是先遍历左子树和右子树,最后遍历根。
举个例子:
对于这棵树,我们得到的遍历顺序是:
先序遍历:1 2 3 4 5 6
中序遍历:3 2 1 5 4 6
后序遍历:3 2 5 6 4 1
下面我们应用递归的方法来实现(非递归本章略过,会在后续的文章中讲解)
1)二叉树的前序遍历
利用递归非常好理解,深入理解需要画一画递归展开图,请看下面代码:
// 二叉树前序遍历
void PreOrder(BTNode* root)
{
if (root == NULL)
{
printf("NULL ");
return;
}
printf("%c ", root->data);
PreOrder(root->left);
PreOrder(root->right);
}
2)二叉树的中序遍历和后序遍历
有了前序遍历的基础,相信大部分人都可以写出这两段代码了,废话不多说,请看下面代码:
// 二叉树中序遍历
void InOrder(BTNode* root)
{
if (root == NULL)
{
printf("NULL ");
return;
}
InOrder(root->left);
printf("%c ", root->data);
InOrder(root->right);
}
// 二叉树后序遍历
void PostOrder(BTNode* root)
{
if (root == NULL)
{
printf("NULL ");
return;
}
PostOrder(root->left);
PostOrder(root->right);
printf("%c ", root ->data);
}
3、二叉树的层序遍历
层序遍历和上面三种遍历的方式不一样,他是按顺序一层一层遍历的。
这里我们就无法用递归来实现了,因为层序遍历的结点可能不是在一棵子树中。
这里我们应用到之前讲过的数据结构:队列。
我们先引例子:
层序遍历这棵树:
下面我们给出代码:
思想就是每一层的父亲结点带着他们下一层的孩子入队
//二叉树层序遍历
void LevelOrder(TreeNode* root)
{
Queue q;
QueueInit(&q);
if(root)
QueuePush(&q, root);
while (!(QueueEmpty(&q)))
{
TreeNode* front = QueueFront(&q);
printf("%d ", front->val);
QueuePop(&q);
if (front->left)
QueuePush(&q, front->left);
if (front->right)
QueuePush(&q, front->right);
}
printf("\n");
QueueDestroy(&q);
}
ps:队列的实现见前面的文章。
4、详解几道代表性的链式二叉树OJ题
1)求二叉树结点个数
这道题目我们分而治之的思想是转化为:
求左子树结点个数和右子树结点个数再加上本身。
// 二叉树节点个数
int BinaryTreeSize(BTNode* root)
{
return root == NULL ? 0 :
BinaryTreeSize(root->left)
+ BinaryTreeSize(root->right) + 1;
}
2)求二叉树叶子节点个数
同样是分而治之的思想:我们分别求左子树的叶子结点个数和右子树叶子结点个数,然后加起来。
只是我们需要加上判断叶子结点的条件。
/ 二叉树叶子节点个数
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);
}
3)求二叉树第k层节点个数
这道题目比较困难了,我们可以确定的数量关系只有第一层的结点数目为1,所以我们加以利用这个数量关系。
我们这道题转化为他上一层结点的左孩子和右孩子的和就是该层结点数量,实现递归。
下面给出代码:
// 二叉树第k层节点个数
int BinaryTreeLevelKSize(BTNode* root, int k)
{
if (root == NULL)
return 0;
if (k == 1)
return 1;
return BinaryTreeLevelKSize(root->left, k - 1) + BinaryTreeLevelKSize(root->right, k - 1);
}
4)求二叉树深度/高度
这道题目使用分而治之的思想即为:
根节点的层数1加上左子树和右子树中深度较大的一个即为二叉树的深度。
下面我们给出代码:
int BinaryTreeDepth(BTNode* root)
{
if (root == NULL)
return 0;
int leftdepth = BinaryTreeDepth(root->left);
int rightdepth = BinaryTreeDepth(root->right);
if (leftdepth > rightdepth)
return leftdepth + 1;
else
return rightdepth + 1;
}
5)寻找值为x的结点
我们看到这道题目很明显的就有思路了:
就是分别查找他的左子树和右子树:
BTNode* BinaryTreeFind(BTNode* root, BTDataType x)
{
if (root == NULL)
return NULL;
if (root->data == x)
return root;
BTNode* leftRet = BinaryTreeFind(root->left, x);
if (leftRet)
return leftRet;
BTNode* rightRet = BinaryTreeFind(root->right, x);
if (rightRet)
return rightRet;
return NULL;
}
6)单值二叉树
力扣https://leetcode.cn/problems/univalued-binary-tree/
这道题目对于每一个结点,比较该结点和他左右孩子结点的值,一直向下递归。
bool isUnivalTree(struct TreeNode* root){
if(root==NULL)
return true;
if(root->left&&root->left->val!=root->val)
return false;
if(root->right&&root->right->val!=root->val)
return false;
return isUnivalTree(root->left)&&isUnivalTree(root->right);
}
7)判断是否为相同的树
力扣https://leetcode.cn/problems/same-tree/
这道题目一一比较对应的结点,如果不相等或者一个为空一个不为空,则返回false,否则继续向下递归,终止条件是左右子树对应结点都为空的时候则返回。
bool isSameTree(struct TreeNode* p, struct TreeNode* q){
if(p==NULL&&q==NULL)
return true;
//其中一个为NULL
if(p==NULL||q==NULL)
return false;//注意这两个if语句先后顺序不能调换,在此顺序下,该句意思是一个为空一个不为空
if(p->val!=q->val)
return false;
return isSameTree(p->left,q->left)&&isSameTree(p->right,q->right);
}
8)判断是否是对称二叉树
力扣https://leetcode.cn/problems/symmetric-tree/
借鉴上一道题目的思路,这道题目我们则判断一个结点的左子树和它对应结点的右子树是否相等即可。
ol _isSymmetric(struct TreeNode* root1,struct TreeNode* root2)
{
if(root1==NULL&&root2==NULL)
return true;
if(root1==NULL||root2==NULL)
return false;
if(root1->val!=root2->val)
return false;
return _isSymmetric(root1->left,root2->right)&&_isSymmetric(root1->right,root2->left);
}
bool isSymmetric(struct TreeNode* root){
return _isSymmetric(root->left,root->right);
}
9)判断一棵树是否为另一棵树的子树
力扣https://leetcode.cn/problems/subtree-of-another-tree/
这道题我们递归左子树及右子树的结点,以每个结点为新的“根结点”,然后利用上面是否为相同的数函数来比对。
bool isSameTree(struct TreeNode* p, struct TreeNode* q){
if(p==NULL&&q==NULL)
return true;
//其中一个为NULL
if(p==NULL||q==NULL)
return false;
if(p->val!=q->val)
return false;
return isSameTree(p->left,q->left)&&isSameTree(p->right,q->right);
}
bool isSubtree(struct TreeNode* root, struct TreeNode* subRoot){
if(root==NULL)
return false;
if(isSameTree(root,subRoot))
return true;
return isSubtree(root->left,subRoot)||isSubtree(root->right,subRoot);
}
10)二叉树的前序遍历
力扣https://leetcode.cn/problems/binary-tree-preorder-traversal/
这道题目与之前的有所区别,因为这道题目中的参数个数稍微不一样,但是思路还是一样的,猪样想带着大家一起看看遇到传值给数组 和不知道树结点个数的时候该怎么办。
下面给出代码:
int Treesize(struct TreeNode* root)
{
if(root==NULL)
return 0;
else
return Treesize(root->left)+Treesize(root->right)+1;
}
void preorder(struct TreeNode* root,int*a,int* pi)
{
if(root==NULL)
return ;
a[*pi]=root->val;
(*pi)++;
preorder(root->left,a,pi);
preorder(root->right,a,pi);
}
int* preorderTraversal(struct TreeNode* root, int* returnSize){
*returnSize=Treesize(root);
int* a=(int*)malloc(sizeof(int)*(*returnSize));
int i=0;
preorder(root,a,&i);
return a;
}
11)二叉树的历遍和创建
#include <stdio.h>
#include<stdlib.h>
typedef struct TreeNode
{
char val;
struct TreeNode* left;
struct TreeNode* right;
}TreeNode;
TreeNode* ReBuild(char* arr,int* pi)
{
if(arr[*pi]=='#')
{
(*pi)++;
return NULL;
}
TreeNode* root=(TreeNode*)malloc(sizeof(TreeNode));
root->val=arr[*pi];
(*pi)++;
root->left=ReBuild(arr, pi);
root->right=ReBuild(arr, pi);
return root;
}
void InOrder(TreeNode* root)
{
if(root==NULL)
return ;
InOrder(root->left);
printf("%c ",root->val);
InOrder(root->right);
}
int main() {
char arr[100]={0};
scanf("%s",arr);
int i=0;
TreeNode* root=ReBuild(arr,&i);
InOrder(root);
return 0;
}
12)判断二叉树是否是完全二叉树
这道题目很多人第一反应就是判断节点的数量是不是在理想的范围内,但是这是行不通的。完全二叉树最后一层是连续的,那么我们能不能借鉴上面层序遍历的思想来实现呢?
先层序历遍到队头第一个空为止,跳出循环,然后继续历遍完剩余的结点看是否为空结点,如果不是,则不是完全二叉树。
判断二叉树是否是完全二叉树
int TreeComplete(TreeNode* root)
{
Queue q;
QueueInit(&q);
if (root)
QueuePush(&q, root);
while (!QueueEmpty(&q))
{
TreeNode* front = QueueFront(&q);
QueuePop(&q);
if(!front)
{
break;
}
else
{
QueuePush(&q, front->left);
QueuePush(&q, front->right);
}
}
while (!QueueEmpty(&q))
{
TreeNode* front = QueueFront(&q);
QueuePop(&q);
if (front)
{
QueueDestroy(&q);
return false;
}
}
QueueDestroy(&q);
return true;
}
13)完全二叉树的销毁
为了防止内存泄漏,我们最后实现一个销毁函数。参照后续遍历即可。
void BinaryTreeDestory(TreeNode* root)
{
if (root == NULL)
return;
BinaryTreeDestory(root->left);
BinaryTreeDestory(root->right);
free(root);
}
本篇文章干货满满。码字不易,还望支持!