NewbieX-二叉树的图形化显示
这个是 NewbieX 的个人代码练习第二篇,之前一篇在这里,本文就用来实现一个二叉树结构的图形化显示,具体就是把一个二叉树打印出来到命令行终端,可以满足平时自己的代码练习,也就没打算再继续升级了,反正够用就行。目前支持的特性/缺点有下面几个:
满二叉树、非满二叉树的打印。
对节点的字符长度要求较为宽松,不必要求等长的节点字符长度。
无法最大限度利用命令行终端的空间,会有浪费。
代码不怎么考虑内存消耗,反正完成任务就行。
一个基本的例子如下所示:
关于 NewbieX
自己建了一个 github 小代码仓库来存放自己用来练习的 C/C++ 代码,名字就随便起了个名字叫做 Newbie-X,X 呢在很多意义上都是神秘的代名词,没有什么具体的意义,很多项目都喜欢用 X 作为一个后缀什么的,这里就效仿一波拿来取了个名字。里面放了一些 C++、数据结构与算法、图形学、Buffer 管理小工具等等东西。作为个人的代码工具库外加代码编写练习,毕竟代码要常写才不会手生。链接: https://github.com/YellowMax2001/Newbie-X:
放一个代码仓库的链接: https://github.com/YellowMax2001/Newbie-X ,阅读原文里面也链接到这里了。
基本思路
最开始的时候肯定是先去网上找一找看有什么新鲜想法,然后大概结合了满二叉树的画法和非满二叉树的画法,最后自己按照自己的想法结合了网上的思路,最终形成下面的设计思路。
输入输出
二叉树必须要有一个可供打印的 INT32 类型的值(value),我们画二叉树的时候只画这个值。
二叉树可以任意类型,但是存进数组里面必须按照满二叉树的方式存放,空的节点就放 NULL 进去数组里面。
输出:图形化的二叉树结构,如文章开头的图所示。
设计
二叉树要向下扩展,就是跟鱼骨形的思维导图一样,生长方向是向下,这样符合我们视觉感受并且降低了输出到命令行的难度。
要确定每一个节点的打印坐标,这里我分了两个坐标,x, y,x 是同一层的节点的值起始坐标,就是命令行的打印坐标,y 是二叉树的层。
动态分配二叉树的暂存数据结构,用于最终的图像化打印显示。
实现方式分为两个过程,第一步就先实现一个满二叉树的打印,也就是说没有空节点,然后再实现任意二叉树的打印,任意二叉树如果有空节点那就是满二叉树的一种特例,后面可以看出来。
我这里定坐标是选择从最后一层开始,因为对于满二叉树来讲,正常情况下最后一层的最左边节点一定是最突出的,先定好这个坐标,上层的坐标就好处理了,现在先看下类似下面的二叉树结构:
第一步先定好 node2 的坐标,假设最左边的第一条红色竖线就是命令行的最左边,那么 node2 的坐标就是 [0,1],因为我们定打印坐标定的是节点值的最左边,节点 2 的值的长度可以看到是 4 个小方格子的宽度,这个是可以通过把 INT32 的值转换为 string 类型来获取的。
然后我们再去找 node3 的坐标,node3 的起始坐标 x 应该等于 x3 = x2 + LengthOfx2 + 2. 也就是节点 2 的 x 坐标加上节点 2 的长度再加上我自己定义的节点间隔 2.
下面两个坐标就找好了,接下来回到最上层的 node1,node1 最重要的是确定其 x 坐标,基本思路是把 node1 的中心点对齐到 node2 和 node3 中心点的中值,也就是图中 3,4 红色竖线的中心,这个公式很简单,也不用多说了。
对于三个节点的我们可以这么搞,那更多的节点呢?就需要进行遍历获取每一个点的坐标,我们从最后一层的左子节点开始,一直往回遍历到第一层,每一个节点都按照上面说的方法来进行坐标定位,不同的节点会有不同的处理方式:
没有叶子节点:
就是上图中的 node2,node3,这种情况就适用于上述的第一步,右边的节点等于左边的节点加上中间偏移值。
有叶子节点:
适用于上述第三步,通过两个子节点的坐标来定位当前节点的坐标。
上面的步骤需要我们怎么去组织数据结构呢?因为我们用到了一种类似于广度优先的遍历方式,上面可以看出,节点的访问顺序是 node2,node3,node1.而这种访问顺序适合用数组来存储,这样我们就可以按照数组顺序来进行顺序访问了,想下满二叉树的数组形式存储,完全二叉树的数组形式存储。所以我们需要一个长度等于二叉树节点数的数组,数组的内容怎么组织呢?我这里在开发的时候想到的,每个成员的含义如注释所述:
struct BTGraphicNode {
INT32 nodeValue; // This is binary tree's node value
Coordinate2D coorOfNodeRoot; // This is a 2D coordinate, use x, y description
INT32 lengthOfNodeRoot; // Node value should be converted into string, this is regarded as string length
INT32 treeLevel; // Which level is this node?
INT32 leafNodeIndex; // The leaf node index who are in same tree level
BOOL bIsValid; // Is this a valid non-null node?
BTGraphicNode *leftBTSubNode; // Left sub-node
BTGraphicNode *rightBTSubNode; // Right sub-node
};
所以我们的每一个节点都是上面的数据结构类型,然后把节点串起来,连好左子树右子树等等。
代码实现
首先需要遍历数组把二叉树的节点值赋值给我们内定的数据结构:
INT32 nodeLevelStartIdx = 2;
INT32 nodeLevelEndIdx = 0;
INT32 nodeLevelIdx = 0;
INT32 nodeTreeLevelIdx = 0;
// Forward traverse to fill main member of binary graphic tree and build binary graphic tree.
for (INT32 nodeIdx = 0; nodeIdx < BTArrayNodeCount; nodeIdx++)
{
INT32 leftSubNodeIdx = nodeIdx * 2 + 1;
INT32 rightSubNodeIdx = leftSubNodeIdx + 1;
// 这里说明已经是最后一层了,所以没有子树
if (leftSubNodeIdx > BTArrayNodeCount - 1)
{
tmpBTGraphicNode[nodeIdx].leftBTSubNode = NULL;
}
else
{
tmpBTGraphicNode[nodeIdx].leftBTSubNode = &tmpBTGraphicNode[leftSubNodeIdx];
}
if (rightSubNodeIdx > BTArrayNodeCount - 1)
{
tmpBTGraphicNode[nodeIdx].rightBTSubNode = NULL;
}
else
{
tmpBTGraphicNode[nodeIdx].rightBTSubNode = &tmpBTGraphicNode[rightSubNodeIdx];
}
// 每次遇到一层的起点,也就是每一层的最左边子树,就重置变量
if ((nodeIdx + 1) == nodeLevelStartIdx)
{
nodeTreeLevelIdx++;
nodeLevelIdx = 0;
nodeLevelStartIdx = nodeLevelStartIdx * 2;
NEWBIE_INFO(GRAPHIC_LOG_GRP, "Got Tree level number %d", nodeTreeLevelIdx);
}
tmpBTGraphicNode[nodeIdx].treeLevel = nodeTreeLevelIdx;
tmpBTGraphicNode[nodeIdx].leafNodeIndex = nodeLevelIdx++;
// 如果节点为非空,就赋值
if (NULL == ppBTArray[nodeIdx])
{
tmpBTGraphicNode[nodeIdx].nodeValue = 0;
tmpBTGraphicNode[nodeIdx].lengthOfNodeRoot = 0;
tmpBTGraphicNode[nodeIdx].bIsValid = 0;
}
else
{
tmpBTGraphicNode[nodeIdx].nodeValue = *ppBTArray[nodeIdx];
tmpBTGraphicNode[nodeIdx].lengthOfNodeRoot = std::to_string(*ppBTArray[nodeIdx]).length();
tmpBTGraphicNode[nodeIdx].bIsValid = 1;
}
}
上面是第一遍遍历进行赋值的过程,做了以下几个事情:
构建一个自定义的二叉树,树的节点保存自己感兴趣的内容。
把节点 value 填充好,并计算出来节点的长度等等。
填充当前节点所在的层。
// Backward traverse to fill the node print coordinates.
// for loop is level by level backward
nodeLevelStartIdx = nodeLevelStartIdx - 1;
nodeLevelEndIdx = nodeLevelStartIdx;
while (nodeLevelStartIdx > 0)
{
nodeLevelStartIdx = nodeLevelStartIdx / 2;
NEWBIE_INFO(GRAPHIC_LOG_GRP, "nodeLevelStartIdx %d, nodeLevelEndIdx %d",
nodeLevelStartIdx, nodeLevelEndIdx);
for (INT32 i = nodeLevelStartIdx; i < nodeLevelEndIdx && i < BTArrayNodeCount; i++)
{
if (HasFullLeafNode(&tmpBTGraphicNode[i]))
{
tmpBTGraphicNode[i].coorOfNodeRoot.xCoor =
tmpBTGraphicNode[i].leftBTSubNode->coorOfNodeRoot.xCoor +
(tmpBTGraphicNode[i].rightBTSubNode->coorOfNodeRoot.xCoor -
tmpBTGraphicNode[i].leftBTSubNode->coorOfNodeRoot.xCoor) / 2;
tmpBTGraphicNode[i].coorOfNodeRoot.xCoor -=
tmpBTGraphicNode[i].lengthOfNodeRoot / 2;
}
else if (NULL != tmpBTGraphicNode[i].leftBTSubNode)
{
tmpBTGraphicNode[i].coorOfNodeRoot.xCoor =
tmpBTGraphicNode[i].leftBTSubNode->coorOfNodeRoot.xCoor +
tmpBTGraphicNode[i].leftBTSubNode->lengthOfNodeRoot + 1;
tmpBTGraphicNode[i].coorOfNodeRoot.xCoor -=
tmpBTGraphicNode[i].lengthOfNodeRoot / 2;
}
else if (NULL != tmpBTGraphicNode[i].rightBTSubNode)
{
tmpBTGraphicNode[i].coorOfNodeRoot.xCoor =
tmpBTGraphicNode[i].rightBTSubNode->coorOfNodeRoot.xCoor - 1 - tmpBTGraphicNode[i].lengthOfNodeRoot;
tmpBTGraphicNode[i].coorOfNodeRoot.xCoor -=
tmpBTGraphicNode[i].lengthOfNodeRoot / 2;
}
else
{
// Completely leaf node, just based on last leaf node's coordinate.
if (i > nodeLevelStartIdx)
{
tmpBTGraphicNode[i].coorOfNodeRoot.xCoor =
tmpBTGraphicNode[i-1].coorOfNodeRoot.xCoor + tmpBTGraphicNode[i-1].lengthOfNodeRoot + 2;
}
}
if (tmpBTGraphicNode[i].coorOfNodeRoot.xCoor < 0)
{
tmpBTGraphicNode[i].coorOfNodeRoot.xCoor = 0;
}
tmpBTGraphicNode[i].coorOfNodeRoot.yCoor = nodeTreeLevelIdx;
}
nodeTreeLevelIdx--;
nodeLevelEndIdx = nodeLevelStartIdx;
}
上面是第一次后向遍历,目的只有一个,那就是为了计算出来每一个 node 的打印起始坐标值,基本的坐标逻辑如下所示:
========______3_______
=======/==============\
====___6_____========_17___
===/=========\======/======\
==15_=========13====1_======6
=/===\=======/=\===/==\====/=\
=12===9_=====1==2==7==10==19==3
其中空格为了显示出来就用了 = 符号进行代替,所以上面的基本逻辑是按照行进行打印的,打印的内容我用了下面几个变量去存储:
struct LinkFillChar {
CHAR LinkFillType; // 连接线的字符,也就是上面的 "_"
CHAR BlankFillType; // 空白处的字符,也就是上面的 "=",实际一般设置为空格字符 " "
CHAR LeftFillType; // 左子节点和父节点连接线的字符,也就是上面的 "/"
CHAR rightFillType; // 右子节点与父节点连接线的字符,也就是上面的 "\"
};
剩下的就是把它打印出来了,这个过程是从前往后的,先打印第一行,然后依次往后面进行,具体的代码就可以在 github 链接里面看到:https://github.com/YellowMax2001/Newbie-X ,阅读原文也可以看到。
End
写这段代码的时候顺便把编译的系统更新了下,添加了下面几个支持:
支持单独编译一个 C、C++ 文件工程:
https://github.com/YellowMax2001/Newbie-X/blob/master/MaxCLib/data_structs/binary_tree/Compile.mk
支持单独编译一个静态库、动态库:
https://github.com/YellowMax2001/Newbie-X/blob/master/MaxCLib/data_structs/fifo/ring_buffer/Compile.mk
支持添加头文件依赖,也就是说改动头文件,相关的 C/CPP 文件也会被重新编译。
具体就是在 Compile.mk 里面添加:
CompileHeaderFiles += $(addprefix $(call my_dir)/, $(wildcard *.h))
,可以选择赋值自己需要添加的头文件依赖。
编译步骤:
根目录下:
source build_tools/newbie_build.src
cd MaxCLib/data_structs/binary_tree/
mMake.sh
目前来看基本上这个编译系统凑合够用,够满足我日常的 coding 需求,后面有需要更新了再去开发新的功能,现在就先这么用着。