手撕基础二叉树:从链式存储到层序遍历,彻底搞懂二叉树所有核心接口
二叉树不仅在数据结构中举足轻重,同时也在算法的学习中至关重要(例如搜索算法 BFS,DFS),今天带大家一起学习二叉树这个硬骨头。
学完本篇文章你能获得什么样的新知识或者新的观点?
- 递归的再一步加深理解
- 链式存储的结构
- 广度优先搜索BFS,深度优先搜索DFS,层序遍历
- 六个统计接口,完全二叉树的判断
- 注:学习本章需要具备 C 语言的基本语法,树与链表,队列的简单概念
二叉树的链式存储
A.二叉树是什么?
二叉树是一种树形结构里的特殊结构,一棵二叉树由左子树➕右子树➕根节点构成,同时它的左子树和右子树,本身又是一棵二叉树,这就奠定了它递归的重要原理。同时树也可以为空树。
同时,二叉树分为普通二叉树,完全二叉树,完全二叉树是指除了最后一层外,所有节点是满的,但最后一层可以不满,但从左到右必须连续,满二叉树,满二叉树是指每一层的节点都为满节点的二叉树,满二叉树是一种特殊的完全二叉树,当一棵树为完全二叉树时,我们就可以用数组来存储,也就是堆 ,详见可以看下面内容。

由此,我们就可以用代码来定义一棵二叉树与创建一棵二叉树(注:二叉树有链式存储与数组存储,展现二叉树的特殊递归性质,接下来用链式存储)
typedef int DataType;
typedef struct Node
{
struct Node* left; // 左孩子孩子结点
struct Node* right; // 右孩子节点
DataType data; // 结点中的数据
}Treenode;
Treenode * BuyNode(BTDataType x)
{
Treenode * NODE = (Treenode*)malloc(sizeof(Treenode));
if (NODE == NULL)
{
perror("malloc fail");
exit(1);
}
NODE->data = x;
NODE->left = NODE->right = NULL;
return NODE;
}
Treenode * CreatBinaryTree()
{
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->left = node2;
node1->right = node4;
node2->left = node3;
node4->left = node5;
node4->right = node6;
return node1;
}
当我们创建好之后,这棵树的样子就是这样
1
/ \
2 4
/ / \
3 5 6
B.二叉树的深度优先遍历
二叉树与顺序表,链表,队列,栈等线性数据结构不同,那我们该怎样遍历一棵二叉树呢?上面的介绍就埋下了这个伏笔,二叉树的左子树与右子树都是一棵新的二叉树这个性质至关重要。
a.前序遍历
在二叉树中,通常有三种遍历方式,分别为前序遍历,中序遍历,后序遍历,这里的前中后都是指的根节点,例如前序为—根节点,左孩子,右孩子,中序就是左根右,后序则是左右根。我们重点讲前序遍历
void * PreOrder(Treenode * root)
{
if (root == NULL)
{
printf("N ");
return;
}
printf("%d ",root->data);
PreOrder(root->left);
PreOrder(root->right);
}
- 此代码核心逻辑为:二叉树也可以是一棵空树,而当遍历到一棵空树时,说明我们走到了叶子节点,此时我们就返回到它的父亲,如果它不是空树,我们需要访问它自己,也就是根节点,此时再先以左孩子为根节点去递归,后再用右孩子为根节点去递归遍历
PreOrder(1) → 打印1
PreOrder(2) → 打印2
PreOrder(3) → 打印3
PreOrder(NULL) → 打印N,返回
PreOrder(NULL) → 打印N,返回
PreOrder(NULL) → 打印N,返回
PreOrder(4) → 打印4
PreOrder(5) → 打印5
...
以上就是代码的递归过程,需要特别提示的是
- 焚决:当我们看着递归的代码时,需要看到函数的所有代码,然后我们试想最特殊的情况,例如空树,发现它也能完成工作,再从特殊到了一般,所以,递归不需要在脑子里模拟全过程,只需要相信每一个子调用都能正确完成它的工作。这句话对使用和理解递归非常的关建,它关系到我们后面的所有代码
b.中序遍历与后序遍历
中序遍历与后序遍历的本质依旧是利用递归,只不过访问根节点的时机不同,所以遍历的结构也会不同,这里就不做代码演示了,直接给出不同遍历的不同结果
前序:1 2 3 N N N 4 5 N N 6 N N
中序:N 3 N 2 N 1 N 5 N 4 N 6 N
后序:N N 3 N 2 N N 5 N N 6 4 1
C.二叉树的六个统计接口
Treesize() // 节点总数
TreeleafSize() // 叶子节点数
TreeHeight() // 树的高度
TreeLevelKsize() // 第k层节点数
TreeFind() // 查找节点
TreeDestroy() // 销毁
处理所有的二叉树问题,分治的思想至关重要,始终想清楚二叉树的性质与结构,左子树➕右子树➕根节点
a.Treesize()
节点总数 = 左子树 + 右子树 + 根节点
int Treesize(Treenode * root)
{
return root == NULL ? 0 : Treesize(root->left)
+Treesize(root->right)+1;
}
b.Treeleafsize()
叶子节点,左右都为 NULL 返回 1,否则递归求和
int Treeleafsize(Treenode * root)
{
if(root == NULL) return 0;
if(root->left == NULL && root->right == NULL) return 1;
return Treeleafsize(root->left) + Treeleafsize(root->right);
}
c.TreeHeight()
树的高度,是左右子树高的那一棵再加 1
int TreeHeight(Treenode * root)
{
if(root == NULL) return 0;
int left = TreeHeight(root->left);
int right = TreeHeight(root->right);
return left > right ? left +1 : right+1;
}
d.TreeLevelKsize()
第k层 = 左子树第k-1层 + 右子树第k-1层
int TreeLevelKsize(Treenode * root,int k)
{
if(root == NULL) return 0;
//遇到了该第 k 层,递归到了这一
if(k == 1) return 1;
//第一层的第 k 层,是子树的k-1 层
return TreeLevelKsize(root->left,k-1) + TreeLevelKsize(root->right,k-1);
}
e.TreeFind()
先在左子树里找,找到之后就返回,找不到就去右子树
Treenode * TreeFind(Treenode * root,BTDataType x)
{
if(root == NULL) return NULL;
if(root->data == x) return root;
Treenode * left = TreeFind(root->left,x);
//判断优化性能
if(left != NULL)
return left;
return TreeFind(root->right,x);
}
f.TreeDestroy()
先删孩子再删节点,后序遍历
void TreeDestroy(Treenode * root)
{
if(root == NULL) return ;
TreeDestroy(root->left);
TreeDestroy(root->right);
free(root);
}
C.二叉树的层序遍历
二叉树的层序遍历,与前中后序遍历不同的是,它并不依靠递归遍历,因为我们需要一层一层的去访问二叉树的元素,而递归遍历天然是一种深度遍历,不能达到我们的目的。
a.那我们该怎样实现一个层序遍历呢?
依靠队列这个数据结构,我们能很好的实现层序遍历,想想一下,一个根节点它如果有左孩子和右孩子,我们入队列,而当它出队列的时候,我们将它的左孩子和右孩子再入队列,如此循环,我们便能很方便的实现层序遍历。
但由于 C 语言没有队列的容器,没有像 C++那样的 queue,所以我们需要自己手动实现 i 一个队列
//将队列的元素类型调整为二叉树节点的指针
typedef struct BinaryTreeNode * QDatatype;
typedef struct QueueNode
{
QDatatype data;
struct QueueNode * next;
}QNode;
//将整个队列用头节点和尾节点管理
typedef struct Queue
{
QNode * phead;
QNode * ptail;
int size;
}Que;
//队列的初始化
void QueInit(Que * pq)
{
assert(pq);
pq->size = 0;
pq->phead = pq->ptail = NULL;
}
//尾插
void QuePush(Que * pq,QDatatype x)
{
//pq是装了头尾节点的结构体,nwenode 是一个节点
assert(pq);
QNode * newnode = (QNode *)malloc(sizeof(QNode));
if (newnode == NULL)
{
perror("malloc fail");
return;
}
newnode->next = NULL;
newnode->data = x;
if (pq->ptail == NULL)
{
pq->phead = pq->ptail = newnode;
}
else
{
pq->ptail->next = newnode;
pq->ptail = newnode;
}
pq->size++;
}
//头删
void QuePop(Que * pq)
{
assert(pq);
assert(pq->size != 0);
if (pq->phead->next == NULL)
{
free(pq->phead);
pq->phead = pq->ptail = NULL;
}
else
{
QNode * next = pq->phead->next;
free(pq->phead);
pq->phead = next;
}
pq->size--;
}
//取队头
QDatatype QueFront(Que * pq)
{
assert(pq);
assert(pq->phead);
return pq->phead->data;
}
//取队尾
QDatatype QueBack(Que *pq)
{
assert(pq);
assert(pq->ptail);
return pq->ptail->data;
}
//判空
bool QueEmpty(Que* pq)
{
assert(pq);
return pq->size == 0;
}
//销毁
void QueDestroy(Que * 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;
}
我们模拟整个层序遍历的过程
初始入队:[1]
出1,打印1,左孩子2和右孩子4入队:[2, 4]
出2,打印2,左孩子3入队(右为NULL不入):[4, 3]
出4,打印4,左孩子5和右孩子6入队:[3, 5, 6]
出3,打印3,无孩子:[5, 6]
出5,打印5:[6]
出6,打印6:[]
再根据整个过程很容易的将代码写出
//层序遍历
void TreeLevelOrder(Treenode * root)
{
Que q;
QueInit(&q);
if (root)
QuePush(&q,root);
while (!QueEmpty(&q))
{
Treenode * front = QueFront(&q);
QuePop(&q);
printf("%d ",front->data);
if (front->left)
QuePush(&q,front->left);
if (front->right)
QuePush(&q,front->right);
}
QueDestroy(&q);
}
b.层序遍历的重要作用
通过层序遍历,我们可以实现一个重要作用,用来判断一棵树是否为完全二叉树,完全二叉树的定义是前 k-1 层是满层结构,而最后一层可以不满,但必须连续,所以我们需要解决的问题是,怎么用代码检测中间有没有空洞?
我们的核心思路就是:把空也入队列,让我们检测第一次层序遍历二叉树,遇到空,那就 break,说明后面不应该有节点了,如果后面还有节点,那么这中间就有个空洞,不是完全二叉树,而在第二阶段,检查队列里是否还有节点

接下来,我们顺着思路,将代码轻松写出
bool CompleteTree(Treenode * root)
{
Que q;
QueInit(&q);
if (root)
QuePush(&q,root);
while (!QueEmpty(&q))
{
Treenode * front = QueFront(&q);
QuePop(&q);
if (front == NULL)
break;
QuePush(&q,front->left);
QuePush(&q,front->right);
}
while (!QueEmpty(&q))
{
Treenode * front = QueFront(&q);
QuePop(&q);
if (front)
{
QueDestroy(&q);
return false;
}
}
QueDestroy(&q);
return true;
}
二叉树的链式存储各接口的总结

为什么空间复杂度都是O(N)?
递归调用会压栈,最坏情况下树退化成链表,递归深度为N,所以栈空间是O(N)。层序遍历的空间消耗在队列上,最坏情况队列存满一层,也是O(N)。
有没有O(1)空间的?
严格来说没有,递归本身就有栈开销。如果面试问到,可以说"不计递归栈空间的话是O(1)"。
层序遍历和完全二叉树判断有什么不同?
时间复杂度一样都是O(N),但完全二叉树判断的队列会把NULL也压进去,常数项略大。
以上就是关于基础二叉树的所有重要知识,如果你能看到这里,我相信你一定有很深的体会和长足的进步!
流水不争先,争滔滔不绝
1603



被折叠的 条评论
为什么被折叠?



