在上一篇博客中,已经介绍了顺序二叉树——堆的实现,这次我们接着上一次的成果,继续学习有关于链式二叉树的相关知识。
对于二叉链的树而言,我们以链表的形式组织整棵树结构。因为二叉链要求在携带数据的同时,需要标明其左右孩子,因此我们定义的结构体中,有着两个指针,分别指向左右孩子。
typedef char BTDataType;
typedef struct BinaryTreeNode
{
BTDataType data;
struct BinaryTreeNode* left;
struct BinaryTreeNode* right;
}BTNode;
对于链式二叉树而言,递归是我们解决其问题的重要思想。一个链式二叉树可以看做是由根节点与其左右子树组成的,所以在对树进行处理的时候,我们一般采用递归分治,对其左右子树进行不断分治处理。初学者可以多多注意这一点。
我们按照由简到难的顺序来接触二叉树的接口函数。
这里采用的是很基础的递归方案,即结点个数 = 左树结点个数+右树结点个数 + 1,当遇到了空树时,计0即可。
int BinaryTreeSize(BTNode* root)
{
return root == NULL ? 0 : BinaryTreeSize(root->left) + BinaryTreeSize(root->right) + 1;
}
相似的思路:叶子结点个数 = 左树叶子结点个数+右树叶子结点个数,当遇到叶子结点时计1,遇到空树时计0。
int BinaryTreeLeafSize(BTNode* root)
{
if (root == NULL)
{
return 0;
}
return root->left == NULL && root->right == NULL ? 1 : BinaryTreeLeafSize(root->left) + BinaryTreeLeafSize(root->right);
}
在递归这个函数时,需要注意记录层数问题,当到达第k层时,对非空结点进行计数。同样的,第k层结点个数 = 左树第k-1层结点个数+右树第k-1层结点个数。
int BinaryTreeLevelKSize(BTNode* root, int k)
{
if (root == NULL)
{
return 0;
}
if (k == 1)
{
return 1;
}
else
{
return BinaryTreeLevelKSize(root->left, k - 1) + BinaryTreeLevelKSize(root->right, k - 1);
}
}
二叉树的遍历一般认为前序遍历、中序遍历、后序遍历、层序遍历,其主要描述的是我们访问一棵树的顺序问题。
根 → 左子树 → 右子树
前序遍历首先访问根结点,然后访问左右子树,根据这个思路,递归代码很容易写出来。前序遍历和深度优先遍历的思想一致。
void BinaryTreePrevOrder(BTNode* root)
{
if (root == NULL)
{
printf("# ");
return;
}
printf("%c ", root->data);
BinaryTreePrevOrder(root->left);
BinaryTreePrevOrder(root->right);
}
左子树 → 根 → 右子树
void BinaryTreeInOrder(BTNode* root)
{
if (root == NULL)
{
printf("# ");
return;
}
BinaryTreeInOrder(root->left);
printf("%c ", root->data);
BinaryTreeInOrder(root->right);
}
左子树 → 右子树 → 根
void BinaryTreePostOrder(BTNode* root)
{
if (root == NULL)
{
printf("# ");
return;
}
BinaryTreePostOrder(root->left);
BinaryTreePostOrder(root->right);
printf("%c ", root->data);
}
层序遍历不比之前三个遍历,层序遍历是按照树的结构进行逐层遍历。这与广度优先搜索类似,在访问一个结点后需要将其两个子结点放入队列中,这样才可以完整遍历整棵树,这也就代表了其代码模式与bfs的形式是相似的。
此处使用C语言实现,所以需要自己手搓一个队列出来,我就直接套用了之前写过的队列。
void BinaryTreeLevelOrder(BTNode* root)
{
Queue q;
QueueInit(&q);
if (root != NULL)
{
QueuePush(&q, root);
}
while (!QueueEmpty(&q))
{
BTNode* front = QueueFront(&q);
QueuePop(&q);
printf("%c ", front->data);
if (front->left != NULL)
{
QueuePush(&q, front->left);
}
if (front->right != NULL)
{
QueuePush(&q, front->right);
}
}
QueueDestroy(&q);
}
上文介绍的二叉树的四种遍历在我看来更像是模板,告诉我们需要遍历二叉树时有哪些方案可以选择,在我们处理问题时可以灵活做出选择。在运用以上遍历模式时,需要在适当位置下加入对应的操作代码,这样才能利用遍历来解决我们的问题。
在对树进行查找时,我们需要合理安排策略,由于这个函数具有返回值(需要返回结点地址),所以需要妥善处理不同情况下的返回值。
首先对根结点进行判断,根结点为空直接返回空;根节点找到符合要求的结点直接返回。否则就对左树递归,如果左树没有找到,那么再对右树进行查找。很明显这是前序遍历在对树进行搜索。
BTNode* BinaryTreeFind(BTNode* root, BTDataType x)
{
if (root == NULL)
{
return NULL;
}
if (root->data == x)
{
return root;
}
BTNode* tmp = BinaryTreeFind(root->left, x);
if (tmp != NULL)
{
return tmp;
}
return BinaryTreeFind(root->right, x);
}
我们已经很明确了完全二叉树的形式特征,判断是否为完全二叉树只需要对树进行层序遍历,当遍历到空结点时就是我们做出判断的时候。如果空结点之后没有其他结点,即队列此时为空,则说明是完全二叉树;如若队列不为空,说明空结点之后存在结点,那么就不是完全二叉树。
bool BinaryTreeComplete(BTNode* root)
{
Queue q;
QueueInit(&q);
QueuePush(&q, root);
while (!QueueEmpty(&q))
{
BTNode* front = QueueFront(&q);
QueuePop(&q);
if (front == NULL)
{
break;
}
QueuePush(&q, front->left);
QueuePush(&q, front->right);
}
while (!QueueEmpty(&q))
{
BTNode* first = QueueFront(&q);
QueuePop(&q);
if (first != NULL)
{
QueueDestroy(&q);
return false;
}
}
QueueDestroy(&q);
return true;
}
这里介绍的构建二叉树是以前序遍历的方式给出。实际就是将我们前序遍历输出的字符串反向写成一个二叉树。在通过这种方式构建二叉树时,只要给定字符串正确,那么就会保证所有结点都会被字符覆盖到,同时也不需要在意何时结束,因为字符串结束的时刻二叉树一定被构建完成,所有分支都被处理到了。
当字符为‘#’时为其链接空并返回,因为空结点不存在左右子树。否则,链接对应的字符,并且需要递归链接其左树右树,链接完成后返回根节点。整体与前序遍历相差不大,但是需要注意的是参数pi是标识字符串处理位置的记号。
销毁二叉树时,由于左右子树都需要通过根节点访问,所以需要采用后序遍历,先将左右子树分别销毁后,在释放根节点,避免释放过程中丢失子树。
void BinaryTreeDestory(BTNode** root)
{
if (*root == NULL)
{
return;
}
BinaryTreeDestory(&(*root)->left);
BinaryTreeDestory(&(*root)->right);
free(*root);
*root = NULL;
}
这两篇博客我们一共接触了两种二叉树的实现方式,分别是顺序二叉树和链式二叉树。其在实践中有着不可小觑的作用。对于顺序二叉树,我们需要对堆的数据存储方式加强理解,掌握大堆小堆的特征与控制方式;对于链式二叉树,因为其采用了大量的递归,所以对我们训练递归的思维是个很好的训练,而且从控制二叉树的过程中,能窥见一些更深奥的知识。
树在数据结构中有着举足轻重的地位,未来我相信我们有数不清的机会与它打交道,熟练掌握实现方式,勤加练习,理解思想才是我们学习它的最佳状态。