如何用Qt绘制一颗好看的二叉树

原创~~作者码字不易,如需转载请注明出处,谢谢~

最近在学习数据结构二叉树,通过在C++控制台界面已实现了二叉树的前序创建,层次创建和前序加中序确定一颗二叉树三种创建方式。那么问题就来了,一颗已经创建好的二叉树,如何能以图形化的界面显示出来呢?

 

最终,在学习了Qt的绘图事件和坐标系统的相关函数后,发现可以使用Qt绘制出一颗漂亮的二叉树。

效果如下图:

初始化窗口

 

高度为2的树

 

高度为3的树

 

高度为4的树,可以发现高度控制在四层是最好看的

 

高度为5的树,发现节点就已经变得很小了

 

对于6层、7层等以上高度的树,也是可以绘制出来的,但可以想象的到,树节点会变得很小。

所以这里有一个可以改进的地方是,可以给这个QWidget控件分别添加一个水平和竖直的滚动条,设置一个控制条件是当树的高度大于5时,不在缩小其大小,这样的话,画布无法显示所有的节点,但是可以滚动条查看其它的节点。

但是我现在并没有实现这个功能,有时间和精力的朋友可以去尝试一下。

 

接下来的部分我会重点讲解paintEvent()事件,对于创建一个窗口和ui界面的设计等其它部分,我会简单的带过。

OK,让我们开始吧

 

首先创建一个基于QWidget类的窗口应用程序,作为我们的主窗口(即初始界面),我们可以在这个界面输入前序序列、中序序列和层次序列。并添加一个“创建”按钮和“清空”按钮。如下图

 

接下来,我们需要自定义一个新的类myPaint,它继承与QWidget类,这样我们就可以重写paintEvent()事件,达到绘图的效果。

在整个项目上右击—>添加新文件—>选择C++Class,选择base class为QWidget类,点击确定,我们的自定义myPaint类就创建好了,myPaint就相当于我们的画布。

 

当我们点击创建的时候,读取lineEdit控件里面的内容,然后动态创建一个myPaint类的对象,调用自定义函数setInput(QString, QString),将两个QString类型的变量传递给myPaint类,这部分的代码如下

void Widget::on_btnCreat_clicked()
{
    QString input1 = ui->lEdInput1->text();
    QString input2 = ui->lEdInput2->text();

    myPaint *p = new myPaint;
    if (p->setInput(input1, input2))
        p->show();
    else
        QMessageBox::warning(this, "警告", "无效的输入");
}

 

接下来让我们看看myPaint类的内容

myPaint类拥有公有成员函数setInput(),保护成员函数paintEvent(),以及私有成员myTree,代码如下

public:
    explicit myPaint(QWidget *parent = 0);
    bool setInput(QString input1, QString input2);

protected:
    void paintEvent(QPaintEvent *);

private:
    linkedBinaryTree<char>* myTree;

myTree的数据类型就是你自定义的二叉树。

 

首先我们来看一下myPaint构造函数的内容

myPaint::myPaint(QWidget *parent) : QWidget(parent)
{
    resize(600, 400);
    setWindowTitle("怎么样,好看吗");
    setWindowIcon(QIcon("://image/tree.png"));

    myTree = new linkedBinaryTree<char>;
}

主要是为窗口设置一些属性,然后为myTree动态分配空间。

 

然后当myPaint类的对象调用setInput()函数时,myTree就可以根据传过来的参数来创建一颗二叉树,代码如下

bool myPaint::setInput(QString input1, QString input2)
{
    // 将QString转化为C++标准模板库里的String
    std::string s1 = input1.toStdString();
    std::string s2 = input2.toStdString();
    try {
        if (s1 != "" && s2 != "")
        {
            myTree->preAndInCreatBinaryTree(s1, s2);
        }
        else if (s1 != "")
        {
            myTree->preCreatBinaryTree(s1);
        }
        else if (s2 != "")
        {
            myTree->levelCreatBinaryTree(s2);
        }
    }
    catch (invalidSequence) {
        return false;
    }
    return true;
}

当s1和s2都不为空时,会被认为是通过前序和中序序列确定一颗二叉树

当只有s1不为空时,认为是通过前序序列创建二叉树

当只有s2不为空时,认为是通过层次序列创建一颗二叉树

创建成功时返回true,就可以在刚刚“创建”按钮的槽函数里通过show()函数来显示这个窗口,并自动调用paintEvent()函数绘制我们想要的图形。

 

接下来就是讲解的重点部分了,如何绘制一颗好看的二叉树。

在此之前,先给大家补充一个知识,关于Qt的坐标系统,如下图

在一个空白窗口中,Qt默认的坐标系统的原点就是窗口的左上角,水平向右是X轴的正方向,竖直向下是Y轴正方向,如位置1

但是Qt同样也提供了一组函数使我们可以操作这个坐标系统,如平移,旋转,缩放等操作。

translate()函数提供了坐标系统平移的功能。假使我们用W表示当前窗口的宽度,用H表示当前窗口的高度,那么

translate(W/2, H/2)就可以将坐标系统的原点平移至上图的位置2。这时(0, 0)坐标点就表示窗口的中点,(-W/2, -H/2)就表示窗口的左上点,是不是很好理解?

rotate()函数可以旋转坐标系统。如rotate(45)可以将坐标系统顺时针旋转45度;rotate(-45)可以将坐标系统逆时针旋转45度。

接下来还有两个函数,这两个函数一般是成对使用的,它们就是save()函数和restore()函数,功能如同其字面意思,保存和恢复。含义就是当调用save()函数时,可以将当前坐标系统的状态压入堆栈;restore()函数可以从堆栈中弹出栈顶的坐标系统的状态。那么有什么用呢?

我们可以设想这样一个场景,最初,坐标系统的原点(下文简称原点)位于窗口的左上角,我们调用save()函数。然后通过translate(W/2, H/2)函数将原点平移至窗口的中间位置,我们再调用一次save()函数。接下来你可能会对坐标系统进行各种各样的操作,平移,旋转,缩放啊等等。这时,你可以突然调用restore()函数,你会发现原点突然出现在窗口的中间位置了,即恢复上一次调用save()函数时坐标的状态了。再调用一个restore(),原点出现在窗口的左上角。对save()和restore()函数理解了吗?

 

OK,接下来我们分析一下一颗二叉树在窗口中的位置,如下图

这是一颗四层二叉树。首先来看第四层的叶子节点,有8个叶子节点,9个空位。我们以节点的半径R为单位长度,窗口的宽用W表示,高用H来表示。易计算得

W = (8 + 9) * 2 * R,很好理解吧。而8个节点又和二叉树的高度有关,如果我们用treeHeight表示二叉树的高度,则

W = (2 ^ (treeHeight - 1) + (2 ^ (treeHeight - 1) + 1)) * 2 * R。蓝色部分是节点个数8,红色部分是空位9

化简一下可得 W = (2 * 2 ^ treeHeight + 2) * R

其中窗口的宽W可以通过widgt()函数获得,而treeHeight也有对应的二叉树方法可以获得,所以我们就可以求出二叉树节点的半径了。这部分代码如下

qreal W = this->width();                            // 画布的宽
qreal H = this->height();                           // 画布的高
int treeHeight = myTree->getHeight();               // 树的高度
qreal R = W / (2 * std::pow(2, treeHeight) + 2);    // 节点的半径

 

那么上图中二叉树的层高h是如何确定的呢?

其实也很简单,用窗口的高度H减去4 * R,在除以(treeHeight - 1)就可以了。4个R就是最上层和最下层节点中心距窗口的上边缘和下边缘的距离之和。代码如下

const int layerHeight = (H-4*R) / (treeHeight-1);     // 层高,即垂直偏移量

 

我们再来看上面那幅图,可以发现,从最底层的叶子节点开始,每向上一层,所形成的直角三角形的底边长就会增加二倍,不信你数数它多少个单位长度R就知道了。

好了,现在思路就很明显了,根据所画节点的层数,可以确定它距父节点的水平偏移量,垂直偏移量就是我们刚刚确定的层高。然后我们就可以根据边长计算出角度,从而计算出两个节点连线的长度。

 

在遍历节点的过程中,我们采用前序非递归遍历的方法,这时就需要一个栈来存放当前节点的右孩子节点了。除此之外还不够,我们还必须存放右孩子节点的所在的层数,因为必须通过层数来确定它距父节点的水平偏移量。

我们可以通过一个自定义栈节点来实现我们所说的,代码如下

struct stackNode
{
    binaryTreeNode<char>* treeNode;
    int layer;      // 标记该节点属于第几层
};

 

在遍历之前,进行一些初始化工作,代码如下

// 初始化
// 节点的定义
QRectF node(QPointF(-R, -R), QPointF(R, R));
arrayStack<stackNode> stack;    // 存放右孩子节点
stackNode qNode;

arrayStack<QPointF> points;     // 存放右孩子节点相对于当前坐标系统原点的位置
QPointF point;

qreal Hoffset;                  // 水平偏移量
binaryTreeNode<char>* t = myTree->getRoot();
const qreal Pi = 3.14159;
int curLayer = 1;
int curAngle;                   // 当前角度
qreal deg;                      // 当前弧度

// 将坐标系统的原点(下文简称原点)移动到初始位置
painter.translate(W/2, 2*R);

注意到,我们在绘制节点之前,将坐标系统的原点移动到窗口水平居中,高度距上边缘2R的位置,这就是根节点的位置

 

接下来开始我们的while循环,代码如下

while (1)
    {
        painter.drawEllipse(node);
        painter.drawText(node, Qt::AlignCenter, QString(t->element));

        // 设置孩子节点相对于父节点的水平偏移量
        Hoffset = std::pow(2, (treeHeight - curLayer)) * R;
        deg = std::atan(Hoffset / layerHeight);             // 返回的是弧度
        curAngle = 180 / Pi * deg;                          // 将弧度转化为角度

        if (t->rightChild != NULL)
        {
            // 坐标轴逆时针旋转
            painter.rotate(-curAngle);

            //绘制图形路径
            painter.drawLine(0, R, 0, layerHeight / std::cos(deg) - R);

            // 旋转复原
            painter.rotate(curAngle);

            // 右孩子节点压栈
            qNode.treeNode = t->rightChild;
            qNode.layer = curLayer + 1;
            stack.push(qNode);
            
            // 右孩子相对于当前坐标系统原点的位置压栈
            points.push(QPointF(QPointF(0, 0) + QPointF(Hoffset, layerHeight)));
            painter.save();
        }
        if (t->leftChild != NULL)
        {
            // 坐标轴顺时针旋转
            painter.rotate(curAngle);
            // 绘制边
            painter.drawLine(0, R, 0, layerHeight / std::cos(deg) - R);
            // 旋转复原
            painter.rotate(-curAngle);
            // 原点移动到左孩子节点的位置
            painter.translate(QPointF(QPointF(0, 0) + QPointF(-Hoffset, layerHeight)));
            t = t->leftChild;
            // 层次加1
            curLayer++;
        }
        else {
            try {
                // 获取到右节点的层次状态
                stack.pop(qNode);
                t = qNode.treeNode;
                curLayer = qNode.layer;

                // 原点移动到右孩子节点的位置
                painter.restore();
                points.pop(point);
                painter.translate(point);
            }
            catch (stackEmpty) { painter.resetTransform(); return; }
        }
    }

将当前节点绘制出来后,我们需要计算出它孩子节点相对于它的水平偏移量。根据上文的分析,对于一个高度为4的树,第一层和第二层的水平偏移量为8R,第二层和第三层的水平偏移量为4R,第三层和第四层的水平偏移量为2R,以curLayer表示当前绘制节点的层数,Hoffset表示当前的水平偏移量时,有如下公式

Hoffset = 2 ^ (treeHeight - curLayer) * R

在结合层高,可以计算出连线的角度。

如果当前节点的右孩子节点存在的话,将坐标系统逆时针旋转,旋转的角度即为刚刚计算出来的角度,然后画线

painter.drawLine(0, R, 0, layerHeight / std::cos(deg) - R);

前两个参数表示起点坐标(0,R),后两个参数表示线终点坐标,注意,因为这时候坐标系统已经旋转了,所以起点和终点的X轴的值都为0。那么为什么终点的纵坐标还要减R呢?因为连线不能之间连到节点的中心呀,所以终点纵坐标需要减R。

连线绘制完之后,将右孩子节点压栈,右孩子节点的位置压栈,注意,这里必须要调用save()函数保存当前的坐标系统。

这是为什么呢?因为右孩子节点的位置是相对于当前父节点的位置来说的,所以当弹栈时,也同样调用restore()函数来恢复右孩子节点所在的坐标系统状态(即恢复后的原点就是该右孩子的父节点的位置)。

最后,catch语句调用的resetTransform()函数是将坐标系统重置为最初的状态。

 

好了,这就是我们绘制一颗二叉树的全部过程了~~~

下面我会附上myPaint.cpp的全部代码,供大家参考。

作者码字不易,如需转载请注明出处,谢谢~

#include "mypaint.h"
#include <QPainter>
#include <QIcon>

struct stackNode
{
    binaryTreeNode<char>* treeNode;
    int layer;      // 标记该节点属于第几层
};

myPaint::myPaint(QWidget *parent) : QWidget(parent)
{
    resize(600, 400);
    setWindowTitle("怎么样,好看吗");
    setWindowIcon(QIcon("://image/tree.png"));

    myTree = new linkedBinaryTree<char>;
}

bool myPaint::setInput(QString input1, QString input2)
{
    // 将QString转化为C++标准模板库里的String
    std::string s1 = input1.toStdString();
    std::string s2 = input2.toStdString();
    try {
        if (s1 != "" && s2 != "")
        {
            myTree->preAndInCreatBinaryTree(s1, s2);
        }
        else if (s1 != "")
        {
            myTree->preCreatBinaryTree(s1);
        }
        else if (s2 != "")
        {
            myTree->levelCreatBinaryTree(s2);
        }
    }
    catch (invalidSequence) {
        return false;
    }
    return true;
}

void myPaint::paintEvent(QPaintEvent *)
{
    //创建QPainter对象
    QPainter painter(this);

    // 反锯齿
    painter.setRenderHint(QPainter::Antialiasing);
    painter.setRenderHint(QPainter::TextAntialiasing);

    //背景图
    painter.drawPixmap(rect(), QPixmap("://image/600_400.jpg"));

    //设置字体
    QFont font;
    font.setPointSize(12);
    font.setBold(true);
    painter.setFont(font);

    //设置画笔
    QPen penLine;
    penLine.setWidth(2); //线宽
    penLine.setColor(Qt::blue); //划线颜色
    penLine.setStyle(Qt::SolidLine);//线的类型,实线、虚线等
    penLine.setCapStyle(Qt::FlatCap);//线端点样式
    penLine.setJoinStyle(Qt::BevelJoin);//线的连接点样式
    painter.setPen(penLine);

    qreal W = this->width();                            // 画布的宽
    qreal H = this->height();                           // 画布的高
    int treeHeight = myTree->getHeight();               // 树的高度
    qreal R = W / (2 * std::pow(2, treeHeight) + 2);    // 节点的半径

    const int layerHeight = (H-4*R) / (treeHeight-1);     // 层高,即垂直偏移量

    // 初始化
    // 节点的定义
    QRectF node(QPointF(-R, -R), QPointF(R, R));
    arrayStack<stackNode> stack;    // 存放右孩子节点
    stackNode qNode;

    arrayStack<QPointF> points;     // 存放右孩子节点相对于当前坐标系统原点的位置
    QPointF point;

    qreal Hoffset;                  // 水平偏移量
    binaryTreeNode<char>* t = myTree->getRoot();
    const qreal Pi = 3.14159;
    int curLayer = 1;
    int curAngle;                   // 当前角度
    qreal deg;                      // 当前弧度

    // 将坐标系统的原点(下文简称原点)移动到初始位置
    painter.translate(W/2, 2*R);

    while (1)
    {
        painter.drawEllipse(node);
        painter.drawText(node, Qt::AlignCenter, QString(t->element));

        // 设置孩子节点相对于父节点的水平偏移量
        Hoffset = std::pow(2, (treeHeight - curLayer)) * R;
        deg = std::atan(Hoffset / layerHeight);             // 返回的是弧度
        curAngle = 180 / Pi * deg;                          // 将弧度转化为角度

        if (t->rightChild != NULL)
        {
            // 坐标轴逆时针旋转
            painter.rotate(-curAngle);

            //绘制图形路径
            painter.drawLine(0, R, 0, layerHeight / std::cos(deg) - R);

            // 旋转复原
            painter.rotate(curAngle);

            // 右孩子节点压栈
            qNode.treeNode = t->rightChild;
            qNode.layer = curLayer + 1;
            stack.push(qNode);

            // 右孩子相对于当前坐标系统原点的位置压栈
            points.push(QPointF(QPointF(0, 0) + QPointF(Hoffset, layerHeight)));
            painter.save();
        }
        if (t->leftChild != NULL)
        {
            // 坐标轴顺时针旋转
            painter.rotate(curAngle);
            // 绘制边
            painter.drawLine(0, R, 0, layerHeight / std::cos(deg) - R);
            // 旋转复原
            painter.rotate(-curAngle);
            // 原点移动到左孩子节点的位置
            painter.translate(QPointF(QPointF(0, 0) + QPointF(-Hoffset, layerHeight)));
            t = t->leftChild;
            // 层次加1
            curLayer++;
        }
        else {
            try {
                // 获取到右节点的层次状态
                stack.pop(qNode);
                t = qNode.treeNode;
                curLayer = qNode.layer;

                // 原点移动到右孩子节点的位置
                painter.restore();
                points.pop(point);
                painter.translate(point);
            }
            catch (stackEmpty) { painter.resetTransform(); return; }
        }
    }
}

 

©️2020 CSDN 皮肤主题: 大白 设计师:CSDN官方博客 返回首页