上一篇文章我们结束了二叉树的顺序存储,本届内容我们来到二叉树的链式存储!
首先我们定义二叉树节点内容:
typedef int BTDataType;
typedef struct BinaryTreeNode
{
BTDataType data;
struct BinartTreeNode* left;
struct BinartTreeNode* right;
}TreeNode;
每个节点包含两个指针,指向左子树和右子树
左右子树的节点又可以细分为:根,左子树,右子树
前序遍历、中序遍历和后序遍历。这些遍历方式指的是节点访问的顺序。
我们以一颗树为例来展开讨论
首先讨论前序遍历:
在前序遍历中,我们首先访问根节点,然后是左子树,最后是右子树。 对于上述树的前序遍历,遍历顺序将是:
如果用N来代表空,我们可以表示出访问顺序:
1 2 3 N N N 4 5 N N 6 N N
接下来讨论中序遍历
在中序遍历中,我们首先遍历左子树,然后访问根节点,最后遍历右子树
遍历顺序:
N 3 N
N 3 N 2 N
N 3 N 2 N 1 N 5 N
N 3 N 2 N 1 N 5 N 4 N 6 N
最后我们来看后序遍历:
N N 3
N N 3 N 2
N N 3 N 2 N N 5
N N 3 N 2 N N 5 N N 6
N N 3 N 2 N N 5 N N 6 4 1
接下来我们硬代码构造一个上图的树,来完成我们三种遍历方式的代码
TreeNode* CreatTreeNode(int x)
{
TreeNode* node = (TreeNode*)malloc(sizeof(TreeNode));
node->data = x;
node->left = NULL;
node->right = NULL;
return node;
}
TreeNode* FCreatTree()
{
TreeNode* node1 = CreatTreeNode(1);
TreeNode* node2 = CreatTreeNode(2);
TreeNode* node3 = CreatTreeNode(3);
TreeNode* node4 = CreatTreeNode(4);
TreeNode* node5 = CreatTreeNode(5);
TreeNode* node6 = CreatTreeNode(6);
node1->left = node2;
node1->right = node4;
node2->left = node3;
node4->left = node5;
node4->right = node6;
return node1;
}
在上述三种方法的讨论中我们可以知道,三种遍历是用递归方法来完成的
前序遍历,首先访问当前节点(根节点),然后递归地进行左子树的前序遍历,最后递归地进行右子树的前序遍历
代码如下:
void PrevOrder(TreeNode* root)
{
if (root == NULL)
{
printf("N ");
return;
}
printf("%d ", root->data);
PrevOrder(root->left);
PrevOrder(root->right);
}
printf("%d ", root->data);
首先访问根节点PrevOrder(root->left);
访问左子树,进入左子树里面又分为先访问根节点,再访问左子树PrevOrder(root->right);
访问完左子树后,访问右子树我们可以画出递归展开图来加深理解
由1先访问根节点1,再到左子树2,2访问完根节点,到左子树3
3访问左子树为空,返回到上一层函数,接着访问3的右子树
3函数结束,返回到上一层函数,来对2的右子树进行访问,以此类推
递归展开图可以更好的帮我们理解递归的过程
有了上述的讲解,中序遍历和后续遍历则变得很简单了
中序遍历代码:
void InOrder(TreeNode* root)
{
if (root == NULL)
{
printf("N ");
return;
}
InOrder(root->left);
printf("%d ", root->data);
InOrder(root->right);
}
InOrder(root->left);
先访问左子树printf("%d ", root->data);
左子树访问完后访问根节点InOrder(root->right);
最后访问右子树后序遍历代码:
void PostOrder(TreeNode* root)
{
if (root == NULL)
{
printf("N ");
return;
}
PostOrder(root->left);
PostOrder(root->right);
printf("%d ", root->data);
}
代码测试如下:
与刚刚讨论结果一致!
获取二叉树节点个数的核心思想是基于递归遍历整个树并计数。基于二叉树的性质,我们可以采用分治法:二叉树的节点总数是其左子树的节点数加上右子树的节点数,再加上根节点本身
基本步骤:
int TreeSize(TreeNode* root)
{
if (root == NULL)
{
return 0;
}
return 1 + TreeSize(root->left) + TreeSize(root->right);
}
在上面的代码中,TreeSize函数会递归地遍历整棵树,每遇到一个非空节点,就通过递归计算它的左右子树的节点数,然后将这两个数加起来,并加上1(当前节点)。通过这种方式,当递归遍历完整棵树后,我们就可以得到二叉树的节点总数。
我们还可以用三目运算符简化上述代码:
int TreeSize(TreeNode* root)
{
return root==NULL?0:TreeSize(root->left)+1+TreeSize(root->right);
}
测试代码,以上树为例:
1
/ \
2 4
/ / \
3 5 6
获取二叉树中叶节点(叶子节点)个数的思路与获取节点总数相似,但关注的是那些没有子节点的节点。叶子节点定义为没有左右子树的节点。基于递归的方法,我们可以遍历整棵树,并在遇到叶子节点时对计数器进行增加。
int TreeleafSize(TreeNode* root)
{
if (root == NULL)
{
return 0;
}
if (root->left == NULL && root->right == NULL)
{
return 1;
}
else
{
return TreeleafSize(root->left) + TreeleafSize(root->right);
}
}
对于每一个节点,算法检查是否它是一个叶子节点:如果是,返回1;否则,继续在它的左右子树中寻找叶子节点并计数。最终,通过对所有找到的叶子节点进行计数,我们可以得到整个二叉树的叶子节点数。
测试:
获取二叉树的高度(或深度)的基本思路是遵循分治法原则,即递归地计算二叉树每个节点的左右子树的高度,并从中选择最大的一个,然后加1(当前节点在路径上增加的高度)来得到以该节点为根的子树的总高度。树的总高度即是从根节点到最远叶子节点的最长路径上的节点数。
首先我们来看这串代码
int TreeHeight(TreeNode* root) {
if (root == NULL)
{
return 0;
}
TreeHeight(root->left);
TreeHeight(root->right);
return (TreeHeight(root->left) > TreeHeight(root->right) ? TreeHeight(root->left) : TreeHeight(root->right)) + 1;
}
这串代码有很大的缺陷
三目运算符对 TreeHeight(root->left)
和 TreeHeight(root->right)
各调用了两次。对于每一个非叶子节点,它的左右子树高度都被计算了两遍。这种重复计算对于简单的树结构可能不明显,但对于较大的二叉树来说,就会导致大量不必要的计算,进而降低程序的运行效率。
改进:
int TreeHeight(TreeNode* root) {
if (root == NULL)
{
return 0;
}
int leftHeight = TreeHeight(root->left);
int rightHeight = TreeHeight(root->right);
return (leftHeight > rightHeight ? leftHeight : rightHeight) + 1;
}
计算二叉树第 (k) 层的节点个数同样可以采用递归的方法来解决。这个问题可以拆分为:计算某节点左子树的第 (k-1) 层节点个数和右子树的第 (k-1) 层节点个数的和,因为从当前节点的视角看,它的子树的层数都少了一层。
int TreeKcount(TreeNode* root, int k)
{
if (root == NULL||k<1)
{
return 0;
}
if (k == 1)
{
return 1;
}
return TreeKcount(root->left, k - 1) + TreeKcount(root->right, k - 1);
}
当达到第 1 层时,若二叉树非空,则第1层的节点数肯定是1(即当前节点),因为每次递归减少1层,直到递归到目标层级
测试代码:
1
/ \
2 4
/ / \
3 5 6
在二叉树中寻找值为 x 的节点可以通过递归方法来实现。本质上,这是一种深度优先搜索(DFS)策略。思路是从根节点开始,先检查根节点的值是否等于 x,如果是,就返回当前节点。如果不是,就先在左子树中递归查找,如果左子树中找到了,就直接返回结果;如果左子树中没有找到,再在右子树中查找。如果整棵树都没找到,最终返回 NULL 表示不存在值为 x 的节点。
TreeNode* TreeFind(TreeNode* root, BTDataType x)
{
if (root == NULL)
return NULL;
if (root->data == x)
return root;
TreeNode* tmp1 = TreeFind(root->left, x);
TreeNode* tmp2 = TreeFind(root->right, x);
if (tmp1)
{
return tmp1;
}
if (tmp2)
{
return tmp2;
}
return NULL;
}
假设我们寻找3
1
/ \
2 4
/ / \
3 5 6
这里简单解释一下这段代码的含义:
if (tmp1) {
return tmp1;
}
if (tmp2) {
return tmp2;
}
return NULL;
tmp1
和tmp2
分别是对当前节点的左子树和右子树递归执行TreeFind函数后得到的结果。tmp1是左子树中查找的结果,而tmp2是右子树中查找的结果。
if (tmp1)
检查左子树递归查找的结果。如果tmp1不为NULL,这意味着在左子树中找到了值为x的节点,因此直接返回该节点。这里的if (tmp1)、
在上述铺垫后,我们来做一个题:
通过前序遍历的数组"ABD##E#H##CF##G##"构建二叉树
这里的#
可以当做前面讲的NULL
首先我们定义函数;
TreeNode* CreatTree(BTDataType* a, int *pi);
'A'
的左子节点是'B'
,'B'
的左子节点是'D'
。'D'
的左右子节点都是空("##"
), 这表示’D’是叶子节点。'B
’,'B'
的右子节点是'E'
。'E'
的左子节点为空(由"#"表示),右子节点是’H’。'H’的左右子节点也都是空。'A'
,它的右子节点是'C'
。'C'
的左子节点为空,右子节点是'F'
。'F’的左右子节点都是空。'C'
的右子节点是'G'
,同样,'G’的左右子节点都是空得到的树结构如下:
A
/ \
B C
/ \ \
D E F
\ \
H G
代码如下:
TreeNode* CreatTree(char* a, int* pi)
{
if (a[*pi] == '#')
{
*pi++;
return NULL;
}
TreeNode* root = (TreeNode*)malloc(sizeof(TreeNode));
if (root == NULL)
{
perror("malloc fail");
exit(-1);
}
root->data = a[(*pi)++];
root->left = CreatTree(a, pi);
root->right = CreatTree(a, pi);
return root;
}
这里TreeNode的数据类型需要从int 改为char,我们将typedef后面的int修改即可
typedef char BTDataType;
typedef struct BinaryTreeNode
{
BTDataType data;
struct BinartTreeNode* left;
struct BinartTreeNode* right;
}TreeNode;
void DestroyTree(TreeNode* root) {
if (root == NULL) {
return;
}
DestroyTree(root->left); // 销毁左子树
DestroyTree(root->right); // 销毁右子树
free(root); // 释放当前节点
}
当你调用DestroyTree并传入树的根节点时,函数首先检查是否给定的节点(在最初的调用中,这是根节点)为NULL。如果不是,它会递归地调用自身来销毁左子树和右子树。完成这些递归调用之后,返回到最初的调用层次时,它会释放根节点本身占用的内存。
free(root)
;释放掉该节点占用的内存。尽管根节点被销毁,但原始root变量本身在函数返回后仍然存在,只是它现在成为了一个悬空指针。为了安全起见,调用完DestroyTree之后,应该手动将root设置为NULL
例如:
int main() {
TreeNode* root = FCreatTree()
DestroyTree(root);
root = NULL;
return 0;
}
如果不想手动设置为NULL,只用一个DestroyTree函数来解决,这里意味着我们需要在函数里面实现置空过程,意味着我们要修改指针,则需要传参二级指针:
void DestroyTree(TreeNode** root) {
if (*root == NULL) {
return;
}
DestroyTree(&((*root)->left));
DestroyTree(&((*root)->right));
free(*root);
*root = NULL; // 将调用方的根节点指针置为NULL
}
层序遍历(Level Order Traversal)是指按照从上到下、从左到右的顺序访问二叉树的每个节点。这种遍历方式可以保证二叉树中每个节点的访问顺序正好对应其在树中的层次和位置。层序遍历通常借助于队列这一数据结构来实现。
所以要想实现层序遍历,首先得有队列的函数,在前几节内容中我们已经对其讲解,这里直接引用其代码:
void QueueInit(Queue* pq)
{
assert(pq);
pq->phead = pq->ptail = NULL;
pq->size=0;
}
void QueuePush(Queue* pq, QDataType x)
{
assert(pq);
QNode* newnode = (QNode*)malloc(sizeof(QNode));
if (newnode == NULL)
{
perror("malloc fail");
return;
}
newnode->val = x;
newnode->next = NULL;
if (pq->ptail == NULL)
{
pq->ptail = pq->phead = newnode;
}
else
{
pq->ptail->next = newnode;
pq->ptail = newnode;
}
pq->size++;
}
void QueuePop(Queue* pq) {
assert(pq); // 确保 pq 不是 NULL
if (pq->phead == NULL) return; // 如果队列为空,则没有元素可以弹出
QNode* temp = pq->phead; // 临时保存队列头部节点的地址,以便后续释放内存
pq->phead = pq->phead->next; // 更新头指针指向下一个节点
free(temp); // 释放原头节点占据的内存
temp = NULL;
if (pq->phead == NULL) { // 如果弹出之后队列变为空,则尾指针也要更新为 NULL
pq->ptail = NULL;
}
pq->size--; // 更新队列大小
}
QDataType QueueFront(Queue* pq)
{
assert(pq); // 确保 pq 不是 NULL
assert(pq->phead != NULL);
return pq->phead->val;
}
QDataType QueueBack(Queue* pq) {
assert(pq != NULL); // 确保队列指针不为NULL
assert(pq->ptail != NULL); // 确保队列不为空,即队尾指针不为NULL
// 返回队列尾部元素的值
return pq->ptail->val;
}
bool QueueEmpty(Queue* pq)
{
assert(pq);
return pq->phead == NULL;
}
int QueueSize(Queue* pq)
{
assert(pq);
return pq->size;
}
void QueueDestroy(Queue* pq)
{
assert(pq);
QNode* cur = pq->phead;
while (cur)
{
QNode* next = cur->next;
free(cur);
cur = next;
}
pq->phead = pq->ptail = NULL;
pq->size = 0;
}
队列在层序遍历中的使用是由于其先进先出(First In First Out, FIFO)的特性,这保证了树的节点能够按照从上到下、从左到右的顺序进行访问,这里解释为什么要用队列进行层序遍历:
实现步骤:
我们思考一下,这里队列里面放的是什么,节点还是节点的值?
这里放入的是节点,如果只放入节点的值,则找不到下一个节点,所以这里我们需要修改队列的typedef类型!
typedef struct BinaryTreeNode* QDataType;
typedef struct QueueNode
{
QDataType val;
struct QueueNode* next;
}QNode;
typedef struct Queue
{
QNode* phead;
QNode* ptail;
int size;
}Queue;
接下来实现代码:
void LevelOrder(TreeNode* root)
{
Queue q;
QueueInit(&q);
if (root != NULL)
{
QueuePush(&q, root);
}
while (!QueueEmpty(&q))
{
TreeNode* front = QueueFront(&q);
QueuePop(&q);
printf("%d ", front->data);
if (front->left != NULL)
QueuePush(&q, front->left);
if (front->right != NULL)
QueuePush(&q, front->right);
}
QueueDestroy(&q);
}
我们来进行分析: 以最开始的那棵树为例子:
1
/ \
2 4
/ / \
3 5 6
让我们展开遍历的过程:
打印顺序:1, 2, 4, 3, 5, 6。
判断一棵树是否为完全二叉树,可以利用层序遍历的思想。完全二叉树的特点是,除了最后一层外,每一层都是完全填满的,且最后一层的所有节点都集中在左侧。在层序遍历过程中,如果遇到了一个节点,其有右子节点而无左子节点,那么这棵树肯定不是完全二叉树;另外,如果遇到了一个节点并非左右子节点都有,那么所有接下来遍历到的节点都必须是叶子节点。
我们再原有层序遍历代码上修改即可:
bool BinaryTreeComplete(TreeNode* root)
{
Queue q;
QueueInit(&q);
if (root != NULL)
{
QueuePush(&q, root);
}
while (!QueueEmpty(&q))
{
TreeNode* front = QueueFront(&q);
QueuePop(&q);
if (front == NULL)
break;
QueuePush(&q, front->left);
QueuePush(&q, front->right);
}
while (!QueueEmpty(&q))
{
TreeNode* front = QueueFront(&q);
QueuePop(&q);
if (front)
{
QueuePop(&q);
return false;
}
}
QueueDestroy(&q);
return true;
}
第一部分:将所有节点(包括空节点)按层序添加到队列中
第二部分:检查队列中剩余的所有节点是否都是空的
退出第一部分的循环后,理论上队列中剩下的所有节点应该都是空节点(如果是完全二叉树的话)。因此,接下来的循环会遍历队列中剩余的所有节点:
从队列中弹出一个节点(记为 front)。 如果 front 是非空的,说明在遇到第一个空节点之后还有非空节点,这违反了完全二叉树的性质。因此,函数返回 false。 如果循环顺利结束,没有遇到任何非空节点,说明这棵树是一棵完全二叉树,函数返回 true。
本节内容到此结束!!感谢大家阅读!!!