链式结构
链式二叉树的存储结构是指用链表来表示一棵二叉树,即用链表来指示元素的逻辑关系。通常的方法是链表中的每个节点由三个域组成,数据域与左右指针域,左右指针分别用来存储该节点左孩子和右孩子所在链节点的存储位置,这种被称为二叉链。 还有一种实现方法就是三叉链,相比二叉链多了一个指示父节点的指针。
看待非空二叉树一定要把一个节点分成三个部分来开,根、左子树和右子树。每一个叶子结点的左右子树都是空树。
对于链式二叉树,最重要的是学好它的四种结构遍历,其中三种是递归遍历,还有一个层序遍历。
三种递归遍历
1.前序遍历,又称先序遍历——访问根节点的操作发生在遍历其左右子树之前
2.中序遍历——访问根节点的操作发生在遍历其左右子树之间(中)
2.后序遍历——访问根节点的操作发生在遍历其左右子树之后
由于被访问的节点必是某子树的根,所以N(Node)、L(Left subtree)、R(Right subtree)又可解释为根、根的左子树和根的右子树,NLR、LNR和LRN分别又称为先根遍历、中根遍历和后根遍历。
前中后序指的是根节点的访问次序
就拿下面这一棵简单的二叉树来举例
他的前序遍历的节点顺序是:1 2 4 null null null 3 5 null null 6 null null
他的中序遍历的节点顺序是:null 4 null 2 null 1 null 5 null 3 null 6 null
他的后序遍历的节点顺序是: null null 4 null 2 null null 5 null null 6 3 1
学习这里我们要了解函数栈帧的创建和销毁的过程,学习之前的数据结构时,我们都是一上来就手撕一个,但是学习到这些复杂的数据结构时就不能再这么玩了,我们要先理解二叉树的各种操作以及用途意义。 而且对于普通的二叉树,我们是没必要实现他的增删查改的,因为普通二叉树本来就不是用来存储数据的,所以实现他的增删查改是没有意义的。二叉树的一个常见的应用就是搜索二叉树,对于我们来说实现这些复杂的二叉树结构目前来说还不太现实,而且C语言也不太适合这些操作,所以我们在这里就先理解好二叉树的遍历思路。
二叉树的前序遍历该如何实现呢?
对于每一棵二叉树,我们首先访问他的根节点,然后再访问他的左子树,左子树访问完了之后再访问它的右子树。 而访问他的左子树的时候,又是相同的过程,把左子树看成单独的一棵树,先访问他的根节点,再访问他的左右子树。以此类推,这就是一个递归的过程,那么递归的返回条件是什么呢?我们不妨画图来分析一下。以上面的二叉树来举例,
前序遍历(PreOrder(BTNode* root))
我们可以画出递归的栈帧图,当我们调用PreOrder去遍历空树时,这时候就应该返回上一层了,也就是 root==NULL 的时候就返回上一层栈帧。我们每一次调用PreOrder函数时首先要判断root是不是空,如果是空就返回,然后不是空,我们首先要访问根节点的数据,然后遍历左子树和右子树。
我们的代码大概就能写出来了。
//前序遍历
void PreOrder(BTNode* root)
{
if (root == NULL)
return;
//访问根节点
printf("%d ", root->data);
//遍历左右子树
PreOrder(root->left);
PreOrder(root->right);
}
当遍历到 4 的左子树 NULL 时,返回到上一层,并销毁这一层的栈帧 ,然后再接着代码往下执行遍历 4 的右子树 ,而4 的右子树又是空树,返回到上一层,这时候 4 这棵树就访问完了,代码执行完之后销毁栈帧,返回上一层 ,当 4 返回之后也就意味着 2 的左子树访问完返回了,接着代码往下执行就开始遍历 2 的右子树,由于二的右子树为空树,返回之后 2 这棵树就执行完了,在返回上一层(1),这时候 1 的左子树就遍历完了 ,再往下执行去遍历 1 的右子树。以此类推,当 1的右子树遍历完返回之后,整棵树就遍历完了 ,最后销毁这一层栈帧 ,返回 main 函数的栈帧。
二叉树的中序和后序遍历也是一样的思路,把每一个节点分成根节点,左子树和右子树三部分,分而治之,每一层递归只完成他这个节点该完成的事,其他的交给递归左子树和右子树。
我们可以手动创建一棵二叉树来验证上面的前序顺序是否正确
BTNode* CreatTree()
{
BTNode* n1 = (BTNode*)malloc(sizeof(BTNode));
assert(n1);
n1->data = 1;
n1->left = n1->right = NULL;
BTNode * n2 = (BTNode*)malloc(sizeof(BTNode));
assert(n2);
n2->data = 2;
n2->left = n2->right = NULL;
BTNode* n3 = (BTNode*)malloc(sizeof(BTNode));
assert(n3);
n3->data = 3;
n3->left = n3->right = NULL;
BTNode* n4 = (BTNode*)malloc(sizeof(BTNode));
assert(n4);
n4->data = 4;
n4->left = n4->right = NULL;
BTNode* n5 = (BTNode*)malloc(sizeof(BTNode));
assert(n5);
n5->data = 5;
n5->left = n5->right = NULL;
BTNode* n6 = (BTNode*)malloc(sizeof(BTNode));
assert(n6);
n6->data = 6;
n6->left = n6->right = NULL;
n1->left = n2;
n1->right = n3;
n2->left = n4;
n3->left = n5;
n3->right = n6;
return n1;
}
int main()
{
BTNode* root = CreatTree();
PreOrder(root);
return 0;
}
如果想要看到NULL的话也可以在空树返回之前打印一个NULL
前序遍历会了中序和后序就是一样的思路,
中序
//中序遍历
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;
//遍历左右子树
PostOrder(root->left);
PostOrder(root->right);
//访问根节点
prntf("%d ", root->data);
}
但是单纯的遍历一棵树意义是不大的,我们要结合具体的场景来遍历
下面我们就用一些具体的简单场景来练习一下遍历和分治的思维
1. 求二叉树的节点个数
求节点总个数是一个很简单的问题,我们前面遍历的代码是把数据打印了出来,而记录节点个数的话我们就只需要实现一个计数就行了。
//求节点总个数
int TreeSize(BTNode* root)
{
if (!root)//空树结点个数为0
return 0;
//非空树返回左右子树的节点数 加上当前节点
return TreeSize(root->left) + TreeSize(root->right) + 1;
}
思路也很简单,首先就是判断是否为空树,如果是空树,节点个数就是0 ,如果是非空树,就是该节点加上他的左右子树的节点个数。
当然在这个遍历中选择前中后序随便哪一种都行,都不会对结果产生影响
2.求叶子节点的个数
叶子节点怎么计数?无非就是当他的左右子树都为空树的时候这个节点就是叶子节点,而当他是非叶子节点的时候,就递归他的左右子树,返回他的左右子树中的叶子节点之和。 那么这里要不要判断空树呢? 肯定是要的,一来 传参可能传的是空树,空树的节点数为0,二就是当一个节点左子树或右子树有一个为空的时候,我们是要递归他的左右子树的,会递归到空树,也是要有返回的。
//求叶子节点的个数
int TreeLeafSize(BTNode* root)
{
//空树返回0 ,防止传参为NULL
if (!root)
return 0;
//左右子树都为空树,说明是叶子节点,返回
if (!root->left && !root->right)
return 1;
//走到这里说明这个节点是非叶子节点
//返回左右子树的叶子节点之和
return TreeLeafSize(root->left) + TreeLeafSize(root->right);
}
3. 求度为1的节点的个函数
度为1的节点怎么来的呢? 有了上面的经验,我们首先就是要对空树有一个返回值0.对于一个节点,如果他的左右子树有一个为空,他就是度为1 的节点。这时候就返回了吗?还没有,因为这个节点的非空子树上可能还有度为1的节点,所以我们还是要去递归他的左右子树。
//求度为1的节点个数
int TreeNodeof1Size(BTNode* root)
{
if (!root)
return 0;
if(!root->left+!root->right==1)//度为1的节点
return 1+ TreeNodeof1Size(root->left) + TreeNodeof1Size(root->right);
//此节点不是度为1的节点,左右子树都不为空
return TreeNodeof1Size(root->left) + TreeNodeof1Size(root->right);
}
4.求树的高度
求树的高度我们首先要知道空树的高度就是0,一个非空节点作为根节点的树的高度怎么算呢?首先根节点的高度就是1了,然后再加上他的左右子树的高度的较大值。
//求树的高度
int TreeHeight(BTNode*root)
{
//空树返回0,同时也是递归结束条件
if (!root)
return 0;
//求左右子树的高度
int left = TreeHeight(root->left);
int right = TreeHeight(root->right);
return 1 + (left > right ? left : right);
}
5.求第k层的节点个数
第k层是相对于根节点来说的,如果 k 为1的非空树,第1 层就是一个节点。 而相对于根节点的第k层对于他的左右子树的根节点来说就是 k-1 层了,这就是我们递归的思路。
//求第k层的节点个数
int TreeKLevelSize(BTNode* root, int k)
{
if (!root)
return 0;
//非空树的0层节点个数就是1
if (k == 1)
return 1;
return TreeKLevelSize(root->left, k - 1) + TreeKLevelSize(root->right, k - 1);
}
6.在二叉树中查找数据,返回节点
先不管数据是什么,如果是空树肯定就是返回NULL,如果根节点不是要找的数据,这时候返回什么呢?肯定是遍历左右子树去找,但是返回值怎么设置呢?如果左右子树都找不到该数据,那么肯定是返回NULL,但是如果左子树中某的节点的数据就是要找的数据,把这个节点root返回给他的上一层,他的上一层该怎么处理呢?这时候他的上一层还要继续去遍历他的右子树吗?当然不用,所以当他的左子树返回值不为空我们直接就返回了,不要去遍历右子树了。
//查找数据
BTNode* TreeFind(BTNode* root, int data)
{
//空树和递归结束
if (!root)
return NULL;
if (root->data == data)
return root;
//走到这里说明根节点不是要找的,遍历左子树
BTNode* left = TreeFind(root->left, data);
if (left)
return left;
//当左子树找不到时,如果右子树找到了,返回右子树的返回值,如果找不到,右子树的返回值为空,也可以返回
return TreeFind(root->right,data);
}
7.二叉树的销毁
二叉树的销毁与前面有一点不一样,我们不能用前序和中序遍历,要等他的左右子树都销毁了再销毁根节点,也就是后序遍历。
//销毁二叉树
void TreeDestroy(BTNode** proot)
{
if (*proot == NULL)
return;
//先销毁左右子树
TreeDestroy(&(*proot)->left);
TreeDestroy(&(*proot)->right);
(*proot)->left = NULL;
(*proot)->right = NULL;
//最后释放根节点
free(*proot);
*proot = NULL;
return;
}
8.等值二叉树
. - 力扣(LeetCode)
单值二叉树就是所有节点的值都相等的树,首先如果是空树的话返回true,对于一棵非空树,如果他的根节点与左右节点等值,这时候再去遍历他的左右子树。如果左右子树都是等值二叉树,这棵树才是等值二叉树,如果有一棵不是,则返回false
bool isUnivalTree(struct TreeNode* root) {
//空树或递归到空节点都返回true
if(!root)
return true;
//左树不为空
if(root->left)
{
if(root->val!=root->left->val)
return false;
}
//右树不为空
if(root->right)
{
if(root->val!=root->right->val)
return false;
}
//递归子树
return isUnivalTree(root->left)&&isUnivalTree(root->right);
}
9.相同的树
. - 力扣(LeetCode)
这个题和上一个题的思路其实是差不多的。首先如果两棵树都是空树,则返回true,如果其中一棵为空树,就返回false,然后再去遍历他的左右子树。
bool isSameTree(struct TreeNode* p, struct TreeNode* q) {
//空树或者递归到空树返回真
if(!p&&!q)
return true;
//一个为空一个非空
if(!p||!q)
return false;
//值不相等返回假
if(q->val!=p->val)
return false;
//递归子树
return isSameTree(p->left,q->left)&&isSameTree(p->right,q->right);
}
10.另一棵树的子树
. - 力扣(LeetCode)
首先相同的子树就是在另一棵树中有完全相同的一棵子树,这里就能用到我们上面写的相同的树了,我们首先要遍历root二叉树,当遇到子树的根节点与subRoot的根节点相同时,我们就进入是否是相同二叉树的判断,如果为真就直接返回真,如果为假就继续遍历。如果遍历完了都没有返回,就说明找不到相同的子树,返回假。只要左右子树有一个返回真就是真。
bool isSubtree(struct TreeNode* root, struct TreeNode* subRoot){
//subroot是空树时返回真
if(!subRoot)
return true;
//遍历到空节点还没匹配就返回false
if(!root)
return false;
//根节点相同的时候
if(root->val==subRoot->val&&isSameTree(root,subRoot))
{
return true;
}
return isSameTree(root->left,subRoot)||isSameTree(root->right,subRoot);
}
11.对称的树
. - 力扣(LeetCode)
判断是否是对称二叉树,主要就是判断两棵子树,他们的根节点相不相等,然后他们的左右节点是否对称且相等。然后再递归,直到递归到空树。
bool _isSymmetric(struct TreeNode*p,struct TreeNode*q)
{
//递归到空节点
if(!p&&!q)
return true;
if(!p||!q)
return false;
if(q->val!=p->val)
return false;
return _isSymmetric(p->left,q->right)&&_isSymmetric(p->right,q->left);
}
bool isSymmetric(struct TreeNode* root) {
if(!root)
return true;
//两边都为空
if(!root->left&&!root->right)
return true;
//只有一边为空时
if(!root->left||!root->right)
return false;
return _isSymmetric(root->left,root->right);
}
12.前序序列构建二叉树
二叉树遍历_牛客题霸_牛客网
二叉树的前序序列构建二叉树,我们首先要了解二叉树的前序遍历,只有当左子树遍历完了,才会遍历右子树,就拿这个实力来说,我们可以画出他的二叉树结构。首先 a 是整棵二叉树的根节点,而 b 则是他的左子节点,访问b之后会遍历b的左子树,所以 c 时b的左子节点,# 是c 的左子节点,这时会返回去遍历c的右子树,所以 # 的c的右子节点 。c遍历完了就回去遍历b的右子树,所以d是b的右节点,而 e 是d的左子节点,# 是e的左子节点,g是e的右子节点 g的左右子节点都是空,这时就返回到 d ,f是d的右子节点,而f的左右节点都是空,这时候 a的左子树就构建完了,所以#是a的右子节点。
代码的实现构建的思路就是,先构建节点的左子树,遇到 # 的时候就直接返回上一层,然后构建上一层的右子树。在这里传参str的时候,我们可以传str的地址,也可以传str和一个整形的地址,用来修改下标。
#include <stdio.h>
#include<stdlib.h>
#include<assert.h>
typedef struct BTNode
{
char data;
struct BTNode*left;
struct BTNode*right;
}BTNode;
BTNode* NewNode(char ch)
{
BTNode*newnode=(BTNode*)malloc(sizeof(BTNode));
assert(newnode);
newnode->left=NULL;
newnode->right=NULL;
newnode->data=ch;
return newnode;
}
BTNode* CreatTree(char*str,int*pi)
{
if(str[*pi]=='#')
{
(*pi)++;
return NULL;
}
BTNode*root=NewNode(str[*pi]);
(*pi)++;
root->left=CreatTree(str,pi);
root->right=CreatTree(str,pi);
return root;
}
void InOrder(BTNode*root)
{
if(!root)
return;
InOrder(root->left);
printf("%c ",root->data);
InOrder(root->right);
}
int main()
{
char str[101];
scanf("%s",str);
int i=0;
BTNode*root=CreatTree(str,&i);
InOrder(root);
return 0;
}
层序遍历(非递归)
层序遍历就是一层遍历完再遍历下一层,如上图,先访问 1 ,再访问 1 的左右节点 ,再访问 2 的左右节点 ,3的左右节点... ...
层序遍历是利用队列来实现的。
如何实现呢?
第一步:将根节点入队列
注意,这里我们入的是节点的指针。
第二步:访问队头节点,同时将对头节点的左右子节点入队列
然后再访问头节点,然后将对头结点的左右子节点入队列。
直到队列为空,这时候就访问完了。
//层序遍历打印
void TreePrint(BTNode* root)
{
assert(root);
Queue q;
QueueInit(&q);
//首先根节点入队列
QueuePush(&q, root);
while (!QueueEmpty(&q))
{
BTNode* cur = QueueFront(&q);
QueuePop(&q);
printf("%d ", cur->data);
//把非空的节点入队列
if (cur->left)
QueuePush(&q, cur->left);
if (cur->right)
QueuePush(&q, cur->right);
}
QueueDestroy(&q);
}
如果我们只是打印的话不用把NULL也入队列,但是有一些情况是要把NULL也入队列来判断的。比如下面的一个题
判断一棵树是不是完全二叉树
判断一棵树是否为完全二叉树,我们用递归就不好判断了,会很复杂,而如果我们用层序遍历就会很简单。
这里和上面的打印有一点不一样,判断完全二叉树时要把NULL节点也入队列,为什么呢?我们可以拿上面的简单的二叉树来分析一下。
当走到这一步时,再次进入循环,对头的节点就是空节点了,而这时候空节点的后面还有非空节点,这就说明这棵树不是完全二叉树。 因为对于完全二叉树,这样层序遍历的时候,遇到第一个空节点时,他的后面就不会再有非空节点了,完全二叉树在最后一层节点都是连续的,不可能出现最后一层非空节点的左边还有一个空节点。
这时候我们的循环结束条件就变了,变成了队头元素是否为NULL。而跳出循环之后还要判断此时队列内是否都是NULL,如果是,则说明是完全二叉树,如果还有非空节点,则说明不是完全二叉树。
//判断剩余队列是否都是NULL
bool isNULLQueue(Queue* pq)
{
assert(pq);
while (!QueueEmpty(pq))
{
//遇到非空就直接返回假
if (QueueFront(pq) != NULL)
return false;
QueuePop(pq);
}
//都是NULL返回真
return true;
}
//判断是否为完全二叉树
bool _isCompleteTree(BTNode* root)
{
assert(root);
Queue q;
QueueInit(&q);
//根节点入队列
QueuePush(&q, root);
while (QueueFront(&q))
{
BTNode* head = QueueFront(&q);
QueuePush(&q, head->left);
QueuePush(&q, head->right);
QueuePop(&q);
}
//跳出循环之后判断队列是否还有非空节点
if (isNULLQueue(&q))
{
QueueDestroy(&q);
return true;
}
QueueDestroy(&q);
return false;
}
当我们把6节点链接到2的右节点时,他就是一颗完全二叉树了。
这就是二叉树的层序遍历了。
递归遍历和层序遍历都用在什么地方呢?
递归更适合深度优先的遍历,而层序适合广度优先的遍历,但是我们在树里面一般是不谈深度优先遍历(DFS)和广度优先遍历(BFS)的,这两个概念一般用在图和二维数组中。从宽泛的角度来说,树的前中后序遍历只考虑遍历顺序的话都算是深度优先,先往深走到无路可走再退回来走其他的分支,这之中只有前序遍历算是严格意义上的深度优先,遍历顺序和访问顺序都符合深度优先。
层序就是广度优先遍历。
在之前我们再扫雷中用递归来展开一片的功能,其实也可以换成广度优先来遍历,队列中存的数据就是坐标。
扩展思考:
二叉树如果给了前序序列和中序序列能否确定唯一的一棵树?答案是能。
为什么呢?因为前序序列是能够确定树与子树的根节点的,而中序序列则能通过根节点来分出左右子树的区间,就拿下面这个来举例
前序的第一个元素是1 ,说明 1 是整棵树的根节点,而在中序序列中 ,1 的左边的元素就是左子树,1 的右边就是右子树
这时候再看前序的第二个元素,他在左子树中,说明 2 是左子树的根节点,这时在中序序列中又能以 2 划分出他的左右子树了。这时候左右子树都只有一个元素,则1 的左子树就已经确定了
再看前序序列 1 的左子树之后就是1的右子树的根节点,也就是7 的后面,所以右子树的根节点就是4,再看中序序列,以4 划分他的左右子树,这时候他的左子树就是 5 ,右子树就是空了,这就唯一确定一棵二叉树了。
同理,后序序列和中序序列也能唯一确定一棵二叉树,因为后序序列也能表示出根节点,而在中序序列中能够划分出左右子树。
而前序序列和后序序列就不能唯一确定一棵二叉树了,因为前序和后序都只能确定根节点,而无法确定子树。
这就是二叉树的基础知识了,后续在C++中会继续更新更高阶的二叉树以及更多数据结构