二叉树的基本操作
1.前言
1.在二叉树的基本操作中,我们使用的算法思想大致分为两种:递归和非递归。由于目前刚刚开始学习,我们以递归思想为主。
2.在对二叉树进行递归是需要注意两个方面(1).分治思想设计我们的代码,将大的问题划分为一个个小问题
(2).注意程序的返回设计3.二叉树在使用递归时,我们可以将其每一个节点分为根、根的左子树、根的右子树
由于我们刚开始学习二叉树,因此建立它我们采用最为原始的方法:简单粗暴的建立几个节点,然后直接将这几个结点按照我们想要的方式的进行连接。
首先定义树的结点,设立数据域和指针域(左、右孩子指针)
//定义树的节点
typedef int BTDataType;
typedef struct BTreeNode
{
BTDataType data;//数据域
struct BTTree* leftchild;//指针域
struct BTTree* rightchild;//指针域
}TreeNode;
这里我们需要申请空间,大小为TreeNode、判断结点是否申请成功、将我们所需要的数字传进函数并给结点的数据域data赋值、将左、右孩子指为NULL。最后返回结点。
//创建节点并初始化
TreeNode* BuyNode(int a)
{
TreeNode* node = (TreeNode*)malloc(sizeof(TreeNode));
if (node == NULL)
{
printf("申请失败\n");
return NULL;
}
node->data = a;
node->leftchild = NULL;
node->rightchild= NULL;
return node;
}
这里我们主要分为两步:第一步是创建节点,调用函数BuyNode,将我们想要的数字赋值进去。
第二步是连接,将每一个节点按照我么需要的方式进行连接,连接时使用左、右孩子指针进行连接。最后返回第一的结点,也就是根结点。
//利用结点创建二叉树
TreeNode* CreateBTree()
{
//创建节点
TreeNode* node1 = BuyNode(1);
TreeNode* node2 = BuyNode(2);
TreeNode* node3 = BuyNode(3);
TreeNode* node4 = BuyNode(4);
TreeNode* node5 = BuyNode(5);
TreeNode* node6 = BuyNode(6);
//链接
node1->leftchild = node2;
node1->rightchild = node4;
node2->leftchild = node3;
node4->leftchild = node5;
node4->rightchild = node6;
return node1;
}
经过上面一系列的操作,我们先将二叉树的建立起来。
2.二叉树使用递归进行先序、中序、后序遍历
学习二叉树结构,最简单的方式就是遍历。所谓二叉树遍历(Traversal)是按照某种特定的规则,依次对二叉树中的节点进行相应的操作,并且每个节点只操作一次。访问结点所做的操作依赖于具体的应用问题。 遍历是二叉树上最重要的运算之一,也是二叉树上进行其它运算的基础。
按照规则,二叉树的遍历有:前序/中序/后序的递归结构遍历。由于被访问的结点必是某子树的根,所以N(Node)、L(Left subtree)和R(Right subtree)又可解释为根、根的左子树和根的右子树。NLR、LNR和LRN分别又称为先根遍历、中根遍历和后根遍历。
- 前序遍历(Preorder Traversal 亦称先序遍历)——访问根结点的操作发生在遍历其左右子树之前。
//先序遍历
void PrevOrder(TreeNode* root)
{
if (root == NULL)
{
printf("# ");
return;
}
printf("%d ", root->data);
PrevOrder(root->leftchild);
PrevOrder(root->rightchild);
}
上述程序我们实现了前序遍历的递归操作。
首先,当递归时,我们要明白,函数返回的是上一层,而不是直接返回给主函数。
其次,递归返回有两种情况:一种是直接被return回去给上一层,一种是本次程序执行完了,返回上一层。
因此,什么时候return在递归程序设计时变得十分重要。
上述程序的返回条件是节点为NULL,当节点为NULL时,我们直接将函数返回。
在递归过程中,我们将节点看成根、根的左子树、根的右子树。先遍历根结点,然后遍历根的左子树、跟的右子树。
- 中序遍历(Inorder Traversal)——访问根结点的操作发生在遍历其左右子树之中(间)。
//中序遍历
void InOrder(TreeNode* root)
{
if (root == NULL)
{
printf("# ");
return;
}
InOrder(root->leftchild);
printf("%d ", root->data);
InOrder(root->rightchild);
}
- 后序遍历(Postorder Traversal)——访问根结点的操作发生在遍历其左右子树之后。
void BackOrder(TreeNode* root)
{
if (root == NULL)
{
printf("# ");
return;
}
BackOrder(root->leftchild);
BackOrder(root->rightchild);
printf("%d ", root->data);
}
3. 计算二叉树的大小(结点个数)
我们在这里演示几个有问题的代码,并分析代码的思路及问题产生的原因。在最后我们会给出正确的代码
代码一:
int TreeSize(TreeNode* root)
{
int size = 0;//定义size为局部变量
if (root == NULL)
{
return 0;
}
size++;
TreeSize(root->leftchild);
TreeSize(root->rightchild);
return size;
}
代码思路:
采取递归的思想,设立一个size变量来计数,通过递归遍历二叉树。每递归一次size+1,最后返回size的值就是结点的个数。
错误原因:
听起来没有啥问题,但是我们要知道,这里的size是一个局部变量,我们每递归一次,递归的函数就会产生一个size,无法实现累加。
举一个例子,要是某一个二叉树不为空,那么无论二叉树的实际大小为多少,这段程序返回值均为1(只返回根结点)。
代码二:
int TreeSize(TreeNode* root)
{
static int size = 0;//定义size为静态局部变量
if (root == NULL)
{
return 0;
}
size++;
TreeSize(root->leftchild);
TreeSize(root->rightchild);
return size;
}
代码思路:
采取递归的思想,设立一个size变量来计数,通过递归遍历二叉树。每递归一次size+1,最后返回size的值就是结点的个数。
错误原因:
这次我们为了解决上端代码中size无法累加的问题,我们将size定义为了静态局部变量。但是随之而来的问题是静态局部变量的生命周期和源程序的生命周期相同,当我们调用一次TreeSize函数,它可以准确的计算出size的值,如果我们多次调用TreeSize函数,size总会在前一次累加的基础上继续进行累加。
举一个例子,假设一个二叉树的节点数为6,我们在第一次调用TreeSize函数时计算的结果为6,第二次调用时size会在原来6的基础上进行累加,得到的结果为12.
代码三:
int size = 0;//定义size为全部变量
int TreeSize(TreeNode * root)
{
if (root == NULL)
{
return 0;
}
size++;
TreeSize(root->leftchild);
TreeSize(root->rightchild);
}
代码思路:
采取递归的思想,设立一个size变量来计数,通过递归遍历二叉树。每递归一次size+1,最后返回size的值就是结点的个数。
错误原因:
这次我们为了解决上端代码中size无法累加的问题,我们将size定义为了全局变量。但是随之而来的问题是全局变量的生命周期和源程序的生命周期相同,当我们调用一次TreeSize函数,它可以准确的计算出size的值,如果我们多次调用TreeSize函数,size总会在前一次累加的基础上继续进行累加。
举一个例子,假设一个二叉树的节点数为6,我们在第一次调用TreeSize函数时计算的结果为6,第二次调用时size会在原来6的基础上进行累加,得到的结果为12.
代码四:
int TreeSize(TreeNode* root)
{
if (root == NULL)
return 0;
return TreeSize(root->leftchild) + TreeSize(root->rightchild) + 1;
}
代码思路:
我们依然采取递归的思路,当结点为NULL时,直接返回0。否则进行递归左、右子树。
这里的+1表示我们给树的具体某个节点加一,然后遍历这个节点的左、右字树
从某个节点不断地往后遍历,一直到它的左子树为NULL,然后开始返回左子树的结点个数
然后继续在它的右子树进行遍历,直到右子树节点为NULL时,然后开始返回右子树的结点数
最后将这个节点的左子树和右子树的节点数以及这个节点一起返回个上一级的节点
不断形成循环,直到返回到根结点。根节点将全部的节点数返回,求解完成。
要是在return哪里感觉代码有点陌生,我们可以将递归的左、右子树的结点先记录下来,最后进行返回,这样写对于我们在求树的高度那里可以大大节省程序的计算时间
int TreeSize(TreeNode* root)
{
if (root == NULL)
return 0;
int left_NodeSize = TreeSize(root->leftchild);
int right_NodeSize = TreeSize(root->rightchild);
return left_NodeSize + right_NodeSize + 1;
}
4.计算叶子结点个数
int TreeLeafSize(TreeNode* root)
{
//空节点
if (root == NULL)
return 0;
//叶子节点
if (root->leftchild == NULL && root->rightchild == NULL)
return 1;
//不是空节点也不是叶子结点(将左、右子树的叶子结点进行相加)
return TreeLeafSize(root->leftchild)
+ TreeLeafSize(root->rightchild);
}
代码思路:
我们知道,所谓叶子节点就是该节点下面不带其他节点,它的左、右孩子指针均指向NULL。
我们依然采取递归思想。
当结点为NULL时返回0、当节点的左、右孩子指针均指向NULL时,表示该节点为叶子节点,返回1,其他情况继续进行递归即可。
5.树的高度
代码一:
int TreeHeight(TreeNode* root)
{
if (root == NULL)
return 0;
return TreeHeight(root->leftchild) > TreeHeight(root->rightchild) ?
TreeHeight(root->leftchild) + 1 : TreeHeight(root->rightchild) + 1;
代码思路:
树的高度是表示数的根节点的左、右子树中最高的子树+1。
首先判断结点是否为空,为空返回0,不为空继续遍历该节点的左右子树。直到遇到空节点时开始返回,返回时先比较左右子树的高度,那个高返回那个并且在最终返回时把该节点也要加上,也就是程序里面的+1。
错误原因:
这段代码执行起来没有什么问题,只是代码的时间复杂度比较高。当我们算出了左、右子树的高度后,直接进行了比较,没有记录。导致在返回高度最大值的时候还进行了递归调用,大大增加了程序的时间复杂度。
代码二:
int TreeHeight(TreeNode* root)
{
if (root == NULL)
return 0;
int left_Height = TreeHeight(root->leftchild);//保存左子树高度
int right_Height = TreeHeight(root->rightchild);//保存右子树高度
return left_Height > right_Height ? //进行比较并返回最大的子树高度+1
left_Height + 1 : right_Height + 1;
}
代码改进思路:我们在算出了左、右子树的高度后,及时将其进行保存。然后进行比较,最终返回最大的子树高度+1。
6.叶子结点的个数(第K层,K>0)
//计算第K层结点的个数
int TreeSize_k(TreeNode* root, int k)
{
//root为NULL(没有节点,返回0)
if (root == NULL)
return 0;
//root不为NULL且k等于1(说明只有这一个结点,直接返回1)
if (root != NULL && k == 1)
return 1;
//root不为空且k>1(我们可以将第k层的节点转换为
//求根节点的左、右子树的第k-1层结点,然后不断递归,直到返回0开始回归)
return TreeSize_k(root->leftchild,k-1)
+ TreeSize_k(root->rightchild,k-1);
}
代码思路:
我们采取递归的思想,如果root为NULL(没有节点,返回0),root不为NULL且k等于1(说明只有这一个结点,直接返回1),root不为空且k>1(我们可以将第k层的节点转换为求根节点的左、右子树的第k-1层结点,然后不断递归,直到返回0开始回归),最后返回左、右子树计算的第K层的结点个数之和。
7.查找具体的值(返回具体的地址)
代码一:
//查找具体的值X
TreeNode* FindNode(TreeNode* root, int x)
{
if (root == NULL)
return NULL;
if (root->data == x)
return root;
FindNode(root->leftchild, x);
FindNode(root->rightchild, x);
return NULL;
}
代码思路:
我们先访问节点,如果节点为NULL,我们就返回NULL
如果结点不为NULL且结点的值等于X,等于找到了,直接返回该节点
如果没有找到,那就进行递归操作,在节点的左、右子树里找。
要是节点还没找到直接返回NULL。
但这样写存在一个问题,如果我们找到了结点,在返回时我们是返回到递归函数的上一层中(递归返回时一层一层返回的)
但上一层函数并没有记录我们返回的结点,导致返回的结点丢失,无法实现最终的返回。
代码二:
TreeNode* FindNode(TreeNode* root, int x)
{
if (root == NULL)
return NULL;
if (root->data == x)
return root;
TreeNode* node1 = FindNode(root->leftchild, x);
if (node1)
return node1;
TreeNode* node2 = FindNode(root->rightchild, x);
if (node2)
return node2;
return NULL;//这里表示我们需要找的数在左右子树里均不存在,直接返回NULL
//通过这样的改进,我们提高的查找的效率和准确性,如果我们在某一个结点的左子树找到了,
//那么会先记录这个值的节点,直接返回节点地址给上一层函数,上一层函数继续返回,不需要在进入右子树进行查找
}
改进思路:
我们创建两个变量来保存找到的节点的地址。找到了直接返回。
通过这样的改进,我们提高的查找的效率和准确性,如果我们在某一个结点的左子树找到了,
那么会先记录这个值的节点,直接返回节点地址给上一层函数,上一层函数继续返回,不需要在进入右子树进行查找
8.构建二叉树(给定前序序列,构建二叉树)
//构建二叉树(给定前序序列的数组,构建二叉树)
TreeNode* CreateTreePrev(char* a, int* p)
{
//如果遇到'#’,将指针后移至下一个元素,并直接返回NULL
if (a[*p] == '#')
{
(* p)++;
return NULL;
}
//如果遇到非“#”元素,开辟一个树的结点空间,将元素放入结点中
TreeNode* root = (TreeNode*)malloc(sizeof(TreeNode));
if (root == NULL)
{
perror("malloc error!");
exit(-1);
}
root->data = a[(* p)++];
root->leftchild = CreateTreePrev(a, p);
root->rightchild = CreateTreePrev(a, p);
return root;
}
代码思路:
使用递归思想,采用先序遍历。首先将数组和指针传入函数中,判断数组的元素是否为“#”,如果是,直接将指针后移,返回NULL。若果不是,我们需要先开辟节点空间,检查空间开辟,并将数值赋给根节点的指针域、指针向后移(注意这里使用的是后置++,先赋值后++)。然后递归进行左、右子树的结点赋值。在最后返回根节点表示二叉树创建完成。
为什么需要单独加一个指针来指向数组进行数组元素的后移呢?
因为我们在递归的过程中一次一次的调用函数,如果我们使用循环来遍历数组元素,实际参数不会改变,在下一次调用时数组仍然会从第一个位置开始;而指针传参数会修改实参,下次数组的会从上一次函数调用的位置开始。可以保持数组往二叉树节点的数据域赋值的连贯性和准确性。(本质上是一个传参问题)
9.删除二叉树(后序遍历删除)
//删除二叉树(后序遍历)
void DeleBTree(TreeNode* root)
{
if (root == NULL)
return;
DeleBTree(root->leftchild);
DeleBTree(root->rightchild);
free(root);
}
代码思路:
先判断节点是否为空,如果为空就什么都不返回。不为空就先遍历左、右子树,最后删除根节点。
10.层序遍历二叉树(非递归思路)
//定义结点
typedef struct BTreeNode* QueueData;
typedef struct QNode
{
QueueData data;//此时队列节点中存放的是二叉树结点的地址
struct QNode* next;
}QNode;
//定义队列(采取链式存储结构)
typedef struct Queue
{
QNode* phead;//指向队列头
QNode* ptail;//指向队列尾
int size;//记录个数
}Queue;
//层序遍历二叉树(非递归思路)
void LeveTreePrint(TreeNode* root)
{
//创建队列变量并初始化队列
Queue q;
InitQueue(&q);
//如果根节点非空就将节点入队列
if (root != NULL)
PushQueue(&q, root);
while (!IsEmpty(&q))//当队列里面不为NULL时表示二叉树的节点没有遍历完,继续循环
{
//设置一个size变量来记录层数,初始层数为1
int size = 1;
while(size--)
{
TreeNode* head = Headqueue(&q);//取对头的元素让head指向
printf("%d ", head->data);//打印数据域
PopQueue(&q);//出队列,并将队列里的队列节点删除
if (head->leftchild != NULL);//如果左子树的结点不为NULL,就直接将其节点入队
PushQueue(&q, root->leftchild);
if(head->rightchild!=NULL)//如果右子树的结点不为NULL,就直接将其节点入队
PushQueue(&q, root->rightchild);
}
printf("\n");
size = SzQueue(&q);//计算二叉树下一层的结点个数。
//因为我们的原理是将二叉树的节点入队列,当这个节点需要出队列时,将它的左、右子树结点也入队列。
//如果第一层结点刚出队,那么第二层的节点也入队。
//如果while(size--)这个循环走完一次,表示这二叉树这一层的结点全部遍历完成。如果while(IsEmpty(&q)这个循环走完,表示二叉树遍历完成
//我们计算size=SzQueue(&q)的目的是计算二叉树下一层的结点个数。
}
DestroyQueue(&q);//最后将队列销毁,防止内存泄漏
}
代码思路:
借用队列思想,首先树的根节点入对,当根节点出队时,它的左、右子节点入队
如果节点为NULL,那就不入队。
当队为NULL时,表示二叉树的节点已经全部出队,即遍历完成
采用size来记录每一层几点的个数,一层一层的出队列。每出完一层的结点,就要结算下一层的节点数。直到队列为空表示二叉树已经遍历完成。
值得注意的是,我们这里创建列两个节点(TreeNode和QueueNode)
QueueNode的数据域里存放的是TreeNode类型的指针,这个指针指向的是TreeNode的结点
因此我们在出队时先使用一个TreeNode*的变量来存放取到的元素(即这个变量)
我们在出队列时将队列的明明已经将队列里的结点删除了,为什么还可以使用head去进行访问它的左子树和右子树呢?head不是野指针吗?
TreeNode* head = Headqueue(&q);//取对头的元素让head指向
printf("%d ", head->data);//打印数据域
PopQueue(&q);//出队列,并将队列里的队列节点删除
if (head->leftchild != NULL);//如果左子树的结点不为NULL,就直接将其节点入队
PushQueue(&q, root->leftchild);
if(head->rightchild!=NULL)//如果右子树的结点不为NULL,就直接将其节点入队
PushQueue(&q, root->rightchild);
1.因为我们在设置队列的节点时,将队列的数据域的类型设置为二叉树结点的类型。(也就是说队列的结点存放的是二叉树的结点的地址),在出队前使用了一个TreeNode*类型的变量head来指向二叉树节点。我们在出队删除时删除的是队列的结点,并没有删除二叉树结点的位置。
2.而head指向了具体的二叉树的结点位置,它不是野指针,访问它的左、右子树节点不会出错。
判断是否是完全二叉树
//判断是否是完全二叉树
bool TreeComplete(TreeNode* root)
{
Queue q;
InitQueue(&q);
if(root!=NULL)
PushQueue(&q, root);
while (!IsEmpty(&q))
{
TreeNode* head = Headqueue(&q);
PopQueue(&q);
if (head == NULL)
break;
PushQueue(&q, head->leftchild);
PushQueue(&q, head->rightchild);
}
while (!IsEmpty(&q))
{
TreeNode* head = HeadQueue(&q);
PopQueue(&q);
if (head != NULL)
{
DestroyQueue(&q);
return false;
}
else
{
DestroyQueue(&q);;
return true;
}
}
}
代码思路:
首先我们需要明白,完全二叉树表示最后一层的结点从左到右是连续的(中间没有空节点),并且其他层的节点是满的。
使用队列将二叉树的结点进行层序遍历,如果遇到空节点时,直接退出循环。
然后进入第二个循环,继续遍历。当队列空时,如果取到的元素全部都是NULL,表示二叉树是完全二叉树。如果有一个结点不为空,表示不是二叉树。
以上两个循环的目的是通过判断节点的连续性来判断是否是完全二叉树,使用到了队列、层序遍历二叉树的知识点。