树(二)——二叉树

目录

本章主要讲解内容为:

  1. 树的非递归遍历算法,两种版本

  2. 树的扩展前缀以及前缀中缀构建方法

基础知识

一、定义

二叉树的递归定义:二叉树是每个结点最多含有两棵子树的树结构。

二、性质

二叉树的递归定义标识着它具有很多递归性质。

二叉树的遍历、查找、构建、删除、复制和计数等全部可以用递归来实现,详见代码。

三、构建

二叉树的构建方法有:硬编码生成、扩展前缀、前缀结合中缀等。我实现了后两种方法。

四、遍历

二叉树的递归遍历非常简单,参见代码。

主要分析二叉树的非递归遍历,除了书上的版本外,我自己另写了一个版本,称为通用方法,参照了指令及状态机所设计。不同之处:书上的代码仅用一个栈,而通用方法使用两个栈——指令栈和数据栈。

非递归遍历

一、普通方法

(I)前序遍历

递归遍历方法如下(伪代码):

  1. pre_visit(node)

  2. {
  3. if(!node) return
  4. print node.data
  5. if(node.left) visit(node.left)
  6. if(node.right) visit(node.right)
  7. }

结合(根-左-右)遍历方式,思考递归方法下前序遍历的调用栈情况(步骤):

  1. 从根结点root开始,访问根结点root并打印,接着访问左孩子lchild1,调用栈层数+1,父调用暂停在第5行

  2. 访问lchild1,接着访问lchild1的左孩子lchild2并打印,调用栈层数+1,父调用暂停在第5行
  3. 访问到最左结点lchildn,调用栈层数=n,父调用暂停在第5行始,此时调用栈已经相对较深了
    此时的调用栈:
    n:   visit(lchildn),运行
    n-1:visit(lchildn-1),暂停在第5行

    0:   visit(root),暂停在第5行
  4. 访问完lchildn,没有左孩子了,返回,调用栈出栈
    此时的调用栈:
    n-1:visit(lchildn-1),运行至第6行,访问右孩子rchild1

    0:   visit(root),暂停在第5行
  5. 继续(从lchildn到rchild1的访问变化是以lchildn-1为中间媒介的)
    此时的调用栈:
    n:   visit(rchild1),运行
    n-1:visit(lchildn-1),运行至第6行,访问右孩子rchild1

    0:   visit(root),暂停在第5行
  6. 把rchild1看作root,类比步骤1~4
  7. 运行,直到访问完rchild1
  8. 继续,出栈
    此时的调用栈:
    n-2:visit(lchildn-2),运行至第6行,访问右孩子rchild2

    0:   visit(root),暂停在第5行
  9. 把rchild2看作root,类比步骤1~4
  10. 运行,直到访问完rchild2
  11. 继续,出栈
  12. 直到调用栈为空

由此可以看出调用规律

  1. 对给定结点node,visit(node)即是先运行步骤1~4,依最左路径找到node的最左结点left0,并打印路径(因为路径上的结点依次已被访问过,故先打印根结点)

  2. 访问完最左结点left0,找到其父结点,即是栈顶left1,left1已访问过,故访问left1的右孩子right1
  3. 访问right1的方法参照调用规律1,是递归的

综上可以写出算法:

template <class T, class N>
void BiTree<T, N>::PreOrderNoRecursion2()
{
    stack<N*> s;
    N *p = root;
    while (p != NULL || !s.empty())
    {
        while (p != NULL)//遍历到最左结点,同时记录路径,输出路径(根-左=(根->最左)路径)
        {
            cout << p->data;
            s.push(p);
            p = p->lchild;
        }
        if (!s.empty())
        {
            p = s.top();
            s.pop();//访问后弹出最左结点,当前为最左父结点
            p = p->rchild;//访问最左父结点的右孩子
        }
    }
}

(II)中序遍历

递归遍历方法如下(伪代码):

  1. in_visit(node)

  2. {
  3. if(!node) return   
  4. if(node.left) visit(node.left)

  5. print node.data
  6. if(node.right) visit(node.right)
  7. }

结合(左-根-右)遍历方式,思考递归方法下中序遍历的调用栈情况(步骤):

  1. 从根结点root开始,访问左孩子lchild1(不打印),调用栈层数+1,父调用暂停在第4行

  2. 访问lchild1,接着访问lchild1的左孩子lchild2(不打印),调用栈层数+1,父调用暂停在第4行
  3. 访问到最左结点lchildn并打印,调用栈层数=n,父调用暂停在第4行始,此时调用栈已经相对较深了
    此时的调用栈:
    n:   visit(lchildn),运行
    n-1:visit(lchildn-1),暂停在第4行

    0:   visit(root),暂停在第4行
  4. 访问完lchildn,没有左孩子了,返回,调用栈出栈
    此时的调用栈:
    n-1:visit(lchildn-1),运行至第6行,访问右孩子rchild1

    0:   visit(root),暂停在第4行
  5. 访问lchildn-1(不打印),继续(从lchildn到rchild1的访问变化是以lchildn-1为中间媒介的,即访问lchildn-1的右子树)
    此时的调用栈:
    n:   visit(rchild1),运行
    n-1:visit(lchildn-1),运行至第6行,访问右孩子rchild1

    0:   visit(root),暂停在第4行
  6. 把rchild1看作root,类比步骤1~4
  7. 运行,直到访问完rchild1
  8. 继续,出栈
    此时的调用栈:
    n-2:visit(lchildn-2),运行至第6行,访问右孩子rchild2

    0:   visit(root),暂停在第4行
  9. 把rchild2看作root,类比步骤1~4
  10. 运行,直到访问完rchild2
  11. 继续,出栈
  12. 直到调用栈为空

由此可以看出调用规律:(其实与前序遍历有相似之处)

  1. 对给定结点node,visit(node)即是先运行步骤1~4,依最左路径找到node的最左结点left0

  2. 访问完最左结点left0并打印,找到其父结点,即是栈顶left1,访问left1的右孩子right1(不打印
  3. 访问right1的方法参照调用规律1,是递归的
  4. 结合规律2和3,可知:遍历完当前结点node后,应该遍历node的父结点(即栈顶)的右子树的最左结点,同样,这也是线索树的遍历顺序

综上可以写出算法:(就输出结果的那一行换了位置,总体逻辑是一样的,说明调用栈的变化规律也是一样的)

template <class T, class N>
void BiTree<T, N>::InOrderNoRecursion2()
{
    stack<N*> s;
    N *p = root;
    while (p != NULL || !s.empty())
    {
        while (p != NULL)//定位到最左结点
        {
            s.push(p);
            p = p->lchild;
        }
        if (!s.empty())
        {
            p = s.top();
            cout << p->data;//从最左结点开始访问
            s.pop();
            p = p->rchild;//访问最左结点(依次)的父结点的右孩子
        }
    }
}

(III)后序遍历

后序遍历较前序、中序复杂,调用栈的变化规律不同于前序、中序。究其原因,是在输出之前有两次递归调用,因此,无法通过取栈顶知晓遍历的上一个结点(遍历的直接前驱),故必须以一变量来记录上一次访问的结点。

递归遍历方法如下(伪代码):

  1. post_visit(node)

  2. {
  3. if(!node) return   
  4. if(node.left) visit(node.left)

  5. if(node.right) visit(node.right)
  6. print node.data
  7. }

结合(左-根-右)遍历方式,思考递归方法下后序遍历的调用栈情况(步骤):

  1. 从根结点root开始,访问左孩子lchild1(不打印),调用栈层数+1,父调用暂停在第4行

  2. 访问lchild1,接着访问lchild1的左孩子lchild2(不打印),调用栈层数+1,父调用暂停在第4行
  3. 访问到最左结点lchildn并打印,调用栈层数=n,父调用暂停在第4行始,此时调用栈已经相对较深了
    此时的调用栈:
    n:   visit(lchildn),运行
    n-1:visit(lchildn-1),暂停在第4行

    0:   visit(root),暂停在第4行
  4. 访问完lchildn,访问lchildn-1的右孩子lchildn-1_rchild(假设有右孩子),lchildn-1_rchild为root最左父结点的右孩子,假如lchildn-1没有右孩子(右子树),那么lchildn-1的子树已访问完,应该打印lchildn-1
    此时的调用栈:
    n:   visit(lchildn-1_rchild),运行
    n-1:visit(lchildn-1),暂停在第5行,访问右孩子

    0:   visit(root),暂停在第4行
  5. 把lchildn-1_rchild看作root,类比步骤1~4
  6. 运行,直到访问完lchildn-1_rchild
  7. 打印lchildn-1_rchild(仅在lchildn-1有右孩子lchildn-1_rchild的情况下)此时的调用栈:
    n:   visit(lchildn-1_rchild),运行至第6行,打印lchildn-1_rchild
    n-1:visit(lchildn-1),暂停在第5行,访问右孩子

    0:   visit(root),暂停在第4行
  8. 继续,出栈
    此时的调用栈:
    n-1:visit(lchildn-1),暂停在第6行,访问并打印最左父结点(在打印lchildn-1_rchild及其子结点之后才打印该结点lchildn-1)

    0:   visit(root),暂停在第4行
  9. 继续,出栈,访问lchildn-2的右结点lchildn-2_rchild此时的调用栈:
    n-1:visit(lchildn-2_rchild),运行
    n-2:visit(lchildn-2),暂停在第5行

    0:   visit(root),暂停在第4行
  10. 将lchildn-2_rchild看作root,运行步骤1~4(看步骤6,会发现很相似,都是遍历左结点的右子树
  11. 直到调用栈为空

设遍历过程中的前驱(上次遍历结点)为pre,由此可以看出调用规律:

  1. 对给定结点node,visit(node)即是先运行步骤1~4,依最左路径找到node的最左结点left0

  2. 访问完最左结点left0并打印,找到其右兄弟,访问右兄弟right0,访问完right0就打印right0。
  3. 访问完right0后,找到并访问right0的父结点left1,访问完后,打印left1,left1访问完后,访问left2。
  4. 上述访问右兄弟right0的方法参照调用规律1,是递归的

算法的实现需要解决几个问题:

  1. 访问完当前结点后,如何找到其右兄弟

  2. 访问完子树后,如何找到父结点

以后序遍历为基础,结合pre这个前驱变量的特征,可以罗列出pre的指向:

  1. 有从子结点向父结点的过渡(父子过渡),此时pre=child,解决问题2

  2. 有从左结点向右结点的过渡(兄弟过渡),此时pre=left,无法解决问题1

要解决问题1,只能通过栈来解决。将要访问的孩子结点索性一次性保存到栈中,由于兄弟结点的遍历顺序是先左再右,故而进栈顺序为先右再左。

那么按照这个方法,处理当前结点时,将其孩子压栈,这是父结点向孩子结点的过渡,是通过栈的,没有借助pre。

因此可以写出基本步骤:

  1. 处理当前结点时,若孩子未访问过(pre<>孩子,兄弟过渡),就将孩子压栈(先右再左),自身不出栈(出栈后就没办法打印了),不使用pre

  2. 处理当前结点时,若孩子已访问过(pre=孩子),则打印自身,然后自身出栈,父子过渡,使用pre

现在,问题简化成:

  1. 什么情况下,将孩子压栈

  2. 什么情况下,访问本结点

以上两种情况之间互斥。

因而有:

  1. 压栈情况:孩子未访问过(pre<>lchild && pre<>rchild),且当前有孩子(lchild<>null || rchild<>null)

  2. 访问情况:除压栈情况以外的情况

综上可以写出算法:

template <class T, class N>
void BiTree<T, N>::PostOrderNoRecursion2()
{
    stack<N*> s;
    N *cur = root;               //当前结点
    N *pre = NULL;               //前一次访问的结点
    s.push(root);
    while (!s.empty() && cur)
    {
        cur = s.top();
        if ((cur->lchild == NULL&&cur->rchild == NULL) ||
            ((pre == cur->lchild || pre == cur->rchild)))
        {
            //当前为叶子结点或上一次访问为孩子结点,即按左-右-根(孩子-根)顺序,孩子全部访问过,接着访问父结点
            cout << cur->data;
            s.pop();
            pre = cur;
        }
        else
        {
            //当前为从上到下访问,孩子没访问过,则孩子入栈
            if (cur->rchild != NULL) s.push(cur->rchild);
            if (cur->lchild != NULL) s.push(cur->lchild);//左-右-根,入栈顺序为(根)-右-左
        }
    }
}

二、通用方法

按指令拆解visit方法:

假设数据栈为s,指令栈为sip

  1. visit(bt)

  2. {
  3. //---- ins #0
  4. if(!bt)return                               //###return=>s.pop+sip.pop
  5. //---- ins #1 pre
  6. if(!bt.left)visit(bt.left)                  //###call visit(bt.left)=>s.push(bt.left)+sip.push(0)
  7. //---- ins #2 in
  8. if(!bt.right)visit(bt.right)             //###call visit(bt.right)=>s.push(bt.right)+sip.push(0)
  9. //---- ins #3 post
  10. }

设一变量为ins,代表指令所在行,按正常的运行顺序,应是0->1->2->3->end。

如果在某处需要返回,则只需将指令出栈即可。

接下来,我们就可以在ins#0 #1 #2 #3这四处地方写上相应的处理程序。若无返回或者调用,则当前ins自增

  1. ins#0,处理空值返回操作,判断数据栈顶是否为空,若为空则数据栈和指令栈都出栈一次。

  2. ins#1,处理前序遍历操作,打印结点数据,递归访问左结点。
  3. ins#2,处理中序遍历操作,打印结点数据,递归访问右结点。
  4. ins#3,处理后序遍历操作,打印结点数据,处理返回操作,数据栈和指令栈都出栈一次。

故算法如下:

template <class T, class N>
void BiTree<T, N>::MainOrderNoRecursion(typename BiTree<T, N>::NoRecursionType type)
{
    if (root == NULL) return;

    //非递归树遍历通用版本,结合状态机指令

    stack<N*> s;//结点栈
    stack<int> sip;//状态机

    s.push(root);
    sip.push(0);

    while (!s.empty() || !sip.empty())
    {
        N* bt = s.top();//取结点栈顶

        switch (sip.top())//取指令栈顶
        {
        case 0: sip.top()++;
            if (bt == NULL)//遍历到NULL,出栈
            {
                s.pop();
                sip.pop();
                continue;
            }

        case 1: sip.top()++;
            if (type == PREORDER) cout << bt->data;
            if (bt->lchild != NULL)
            {
                s.push(bt->lchild);
                sip.push(0);
                continue;
            }

        case 2: sip.top()++;
            if (type == INORDER) cout << bt->data;
            if (bt->rchild != NULL)
            {
                s.push(bt->rchild);
                sip.push(0);
                continue;
            }

        case 3:
            if (type == POSTORDER) cout << bt->data;
            s.pop();
            sip.pop();
            continue;
        }

        throw "非法IP!";
    }
}

树的构建

一、扩展前缀

所谓扩展前缀,顾名思义,必须是前缀编码,扩展就是以“#”代替空结点。

如常见的算术表达式:3+4*5,扩展前缀就是+3##*4##5##。其中?##代表叶子结点。

扩展前缀构建也采用递归调用方式。

将前缀看作[Head] [Left] [Right]三个部分,返回一棵树。[Head]只有一个元素,直接取出来,作为父结点。那两个子结点就从[Left] [Right]这两个前缀中生成,这即是递归调用。

template <class T, class N>
N *BiTree<T, N>::CreateByPre(int& ipre)
{
    if (ipre >= (int)pre.size())
        throw "输入串错误";
    T e = pre[ipre++];
    if (e == ‘\0‘) return NULL;
    if (e == ‘#‘) return NULL;
    N *bt = New();
    bt->data = e;
    bt->lchild = CreateByPre(ipre);  // 建左子树
    bt->rchild = CreateByPre(ipre);  // 建右子树
    return bt;
}

二、前缀与中缀

把扩展前缀的“#”规则拿掉,那普通的前缀字串就无法生成一棵唯一的树了,究其原因,是无法知晓递归调用的出口,而“#”恰恰是递归的出口。

知道一棵树的前缀和中缀,就能够还原这棵树,条件是树的结点值不能有重复,也就是说,前缀和中缀能够完全确定一棵树,如何证明?

假设前缀为pre,中缀为in,pre和in的长度是n。将其作划分:

  • pre=[Head] [Head-Left] [Head-Right]

  • in=[Head-Left] [Head] [Head-Right]

现在,假设in中Head的下标为k,则Head-Left中缀的范围就是[0,k-1],Head-Right中缀的范围就是[k+1,n-1]。

这样,经过一轮划分,生成一棵不完全中缀树——父结点为Head,孩子为Head-Left和Head-Right,且此树是唯一的。

接下来,按照同样方法,只不过这次划分的对象是Head-Left和Head-Right,重复直到Head-Left或Head-Right长度为1(即叶子结点)。

现在思想为什么树的结点值不能重复,关键在于在in中寻找Head——如果Head有多个,就不能保证找到了正确的Head

举例:

  • pre = *+xyz
  • in   = x+y*z

第一次划分后:

  • pre = * [+xy] [z]
  • in   = [x+y] * [z]

第二次划分后:

  • pre = * [+ [x] [y]] [z]
  • in   = [[x] + [y]] * [z]

有优先级(括号)的中缀可以确定一棵二叉树,因此,该方法有效,一般的前缀和中缀可以还原二叉树。

template <class T, class N>
N* BiTree<T, N>::CreateByPreMid(int ipre, int imid, int n)
{
    if (n == 0) return NULL;
    N *p = New();
    p->data = pre[ipre];// 前缀为根-左-右
    int i;
    for (i = 0; i < n; i++)    // 在中序序列中定位根结点
    {
        if (pre[ipre] == mid[imid + i]) break;
    }
    if (i == n) throw "前缀和中缀字符不匹配!";
    p->lchild = CreateByPreMid(ipre + 1, imid, i);// 建左子树
    p->rchild = CreateByPreMid(ipre + i + 1, imid + i + 1, n - i - 1);// 建右子树
    return p;
}

总结

二叉树是计算机数据结构当中的核心内容,它本身有着优美的递归性质。

树结构在查找方面有平衡二叉树AVL、红黑树RBT等,在数据压缩方面有哈夫曼树等,在图形学领域有四叉树、八叉树等等,因而,掌握好树结构对于学习计算机算法而言是不可或缺的。

时间: 2024-10-09 17:06:22

树(二)——二叉树的相关文章

数据结构和算法 (二)数据结构基础之树、二叉树

Java面试宝典之二叉树的实现 我们接着上一篇数据结构继续讲解.本章系数据结构之树与二叉树,从这章开始,我们就要介绍非线性结构了,这些内容理解起来比线性表稍难一些,我尽量写的通俗一些,如果读的过程中有任何问题,请按上述方式联系我! 一.树 树 形结构是一类重要的非线性结构.树形结构是结点之间有分支,并具有层次关系的结构.它非常类似于自然界中的树.树结构在客观世界中是大量存在的,例如家 谱.行政组织机构都可用树形象地表示.树在计算机领域中也有着广泛的应用,例如在编译程序中,用树来表示源程序的语法结

树与二叉树之二--二叉树的性质与存储

二叉树的定义 1)每个节点最多只有两颗子树,即二叉树中结点的度只能为0.1.2: 2)子树有左右之分,不能颠倒. 二叉树的五种基本状态: 1)空二叉树 2)只有根节点 3)只有左子树,右子树为空 4)只有右子树,左子树为空 5)既有左子树,又有右子树 满二叉树: 所有的分支结点都有左孩子和右孩子结点,并且叶子结点都集中在二叉树的最下一层 完全二叉树: 通俗的说,一颗完全二叉树一定是由一颗满二叉树从右至左从下至上,挨个删除结点所得到的. 二叉树的主要性质 性质1: 非空二叉树上叶子结点数等于双分支

树、二叉树、遍历二叉树的总结

首先介绍树: 如上图所示就是一棵树,先介绍树的几个关键名词: 节点:A.B.C.D等都叫节点 节点的度:节点有几个分支,就叫节点的度,比如节点B有2个分支,那B的度为2 终端节点(叶子):没有分支的节点,如E.F.G.H 非终端节点:有分支的节点,如A.B.D.C 节点的层次:自上而下排列层次,A为1层,B为2层,D为3层 树的度:哪个节点的度最大,这个最大的度就是树的度,如图树的度为2 树的深度:简而言之,就是树有几层,如图的树的深度为4 我们接触最多的树是二叉树 二叉树:在计算机科学中,二叉

树和二叉树

以下的内容做为学习笔记,复制别人的,感觉总结的比较好: 第5章 树和二叉树 本章中主要介绍下列内容:  1.树的定义和存储结构  2.二叉树的定义.性质.存储结构  3.二叉树的遍历.线索算法  4.树和二叉树的转换  5.哈夫曼树及其应用课时分配:     1.2两个学时,3四个学时,4两个学时, 5两个学时,上机两个学时重点.难点:     二叉树的遍历.线索算法.哈夫曼树及其应用 第一节 树 1.树的定义和基本运算1.1 定义    树是一种常用的非线性结构.我们可以这样定义:树是n(n≥

数据结构学习笔记(树、二叉树)

树(一对多的数据结构) 树(Tree)是n(n>=0)个结点的有限集.n=0时称为空树.在任意一颗非空树种: (1)有且仅有一个特定的称为根(Root)的结点: (2)当n>1时,其余结点可分为m(m>0)个互不相交的有限集T1.T2........Tn,其中每一个集合本身又是一棵树,并且称为根的子树. 对于树的定义还需要强调两点:1.n>0时根结点是唯一的,不可能存在多个根结点,数据结构中的树只能有一个根结点.2.m>0时,子树的个数没有限制,但它们一定是互不相交的. 结点

6-5-树的双亲表示法-树和二叉树-第6章-《数据结构》课本源码-严蔚敏吴伟民版

课本源码部分 第6章  树和二叉树 - 树的双亲表示法 ——<数据结构>-严蔚敏.吴伟民版        源码使用说明  链接??? <数据结构-C语言版>(严蔚敏,吴伟民版)课本源码+习题集解析使用说明        课本源码合辑  链接??? <数据结构>课本源码合辑        习题集全解析  链接??? <数据结构题集>习题解析合辑        本源码引入的文件  链接? Status.h.Scanf.c.SequenceStack.c    

树和二叉树-第6章-《数据结构题集》习题解析-严蔚敏吴伟民版

习题集解析部分 第6章 树和二叉树 ——<数据结构题集>-严蔚敏.吴伟民版        源码使用说明  链接??? <数据结构-C语言版>(严蔚敏,吴伟民版)课本源码+习题集解析使用说明        课本源码合辑  链接??? <数据结构>课本源码合辑        习题集全解析  链接??? <数据结构题集>习题解析合辑       相关测试数据下载  链接? 数据包       本习题文档的存放目录:数据结构\▼配套习题解析\▼06 树和二叉树  

基本数据结构学习笔记——树与二叉树

1.树的形式化定义: 树(Tree)是由一个或多个结点组成的有限集合T,其中有一个特定的称为根的结点:其余结点可分为m(m≥0)个互不相交的有限集T1,T2,T3 ,…,Tm,每一个集合本身又是一棵树,且称为根的子树. 2.有关树的基本术语: 1.结点(Node):树中的元素,包含数据项及若干指向其子树的分支. 2.结点的度(Degree):结点拥有的子树数. 3.结点的层次:从根结点开始算起,根为第一层. 4.叶子(Leaf):度为零的结点,也称端结点. 5.孩子(Child):结点子树的根称

树、二叉树基础

刚看到堆排序,顺便记录一下关于树的一些基本概念: 前言 前面介绍的栈.队列都是线性结构(linear structure).而树是非线性结构(non-linear structure).因此,树中的元素之间一般不存在类似于线性结构的一对一的关系,更多地表现为多对多的关系.直观地看,它是数据元素(在树中称为节点)按分支关系组织起来的结构.显然,树形结构是比线性结构更复杂的一种数据结构类型. 一.树 树的定义:树是含有n个节点的有穷集合,其中有一个节点比较特殊称为根节点.在图示树时,用一条边连接两个

数据结构与算法系列研究五——树、二叉树、三叉树、平衡排序二叉树AVL

树.二叉树.三叉树.平衡排序二叉树AVL 一.树的定义 树是计算机算法最重要的非线性结构.树中每个数据元素至多有一个直接前驱,但可以有多个直接后继.树是一种以分支关系定义的层次结构.    a.树是n(≥0)结点组成的有限集合.{N.沃恩}     (树是n(n≥1)个结点组成的有限集合.{D.E.Knuth})      在任意一棵非空树中:        ⑴有且仅有一个没有前驱的结点----根(root).        ⑵当n>1时,其余结点有且仅有一个直接前驱.         ⑶所有结