数据结构——树

来源:互联网 发布:易顺佳进销存软件 编辑:程序博客网 时间:2024/05/16 10:34

提示:以下内容不适合零基础人员,仅供笔者复习之用。


概要:

是n (n≥0) 个结点的有限集。 n=0 时称为空树。在任意一棵非空树中:

  1. 有旦仅有一个特定的称为根(Root) 的结点;
  2.  当n>1 时,其余结点可分为m(m>0) 个互不相交有限集T1、 T2、 ……、 Tm, 其中每一个集合本身又是一棵树,并且称为根的子树(SubTree)。

一、一些概念:

:结点拥有的子树数称为结点的度。度为0的结点称为叶结点(Leaf) 或终端结点;度不为0 的结点称为非终端结点或分支结点。 除根结点之外,分支结点也称为内部结点。树的度是树内各结点的度的最大值。如图,树的度为3。


孩子:结点的子树的根称为该结点的孩子(Child),相应地,该结点称为孩子的双亲(Parent)。同一个双亲的孩子之间直称兄弟(Sibling)。结点的祖先是从根到该结点所经分支上的所有结点。所以下图对于H来说,D、 B、 A都是它的祖先。 反之, 以某结点为根的子树中的任一结点都称为该结点的子孙。 如图,B的子孙有D、G、H、I。


层次:结点的层次(LeveI) 从根开始定义起,根为第一层, 根的孩子为第二层。若某结点在第l 层,则其子树的根就在第1+1 层。其双亲在同一层的结点直为堂兄弟。显然图中的D、 E、 F是堂兄弟,而G、H、 l、 J 也是。树中结点的最大层次称为树的深度(Dep也)或高度,当前树的深度为4。


有序树:如果将树中结点的各子树看成从左至右是有次序的,不能互换的,则称该树为有序树,否则称为无序树

森林:森林(Forest) 是m(m≥0) 棵互不相交的树的集合。对树中每个结点而言,其子树的集合即为森林。

二叉树:是n(n≥0)个结点的有限集合,该集合或者为空集(称为空二叉树),或者由一个根结点和两棵互不相交的、分别称为根结点的左子树和右子树的二叉树组成。

对比线性结构:


二、树的抽象数据类型

三、树的存储结构

    这里介绍三种不同的表示法:双亲表示法、孩子表示法、孩子兄弟表示法。

3.1 双亲表示法

    假设以一组连续空间存储树的结点,同时在每个结点中,附设一个指示器指示其双亲结点到链表中的位置。也就是说,每个结点除了知道自己是谁以外,还知道它的双亲在哪里。结点结构为表所示:


    其中data是数据域,存储结点的数据信息。而parent是指针域,存储该结点的双亲在数组中的下标

    以下是双亲表示法的结点结构定义代码。

#define MAX_TREE_SIZE 100typedef int TElemType;/*树结点的数据类型,目前暂定为整型*/typedef struct PTNode/*结点结构*/{TElemType data;/*结点数据*/int parent;/*双亲位置*/}PTNode;typedef struct{PTNode nodes[MAX_TREE_SIZE];/*结点数组*/int r,n;/*根的位置和结点数*/}PTree;
    根结点因为没有双亲,我们约定根结点的位置域设置为-1,则每个结点都存有其双亲的位置。

  

    这样的结构容易根据结点的parent指针找到它的双亲结点,时间复杂度为O(1),直到parent为-1时,表示找到了树结点的根。但不易找到结点的孩子结点,除非遍历整个结构,如上图,要找到E的孩子J,必须遍历结点,找到parent为E的下标4,才能找到E的孩子J。

    可以改进一下,增加一个结点最左边孩子的域,不妨叫长子域,这样就容易得到结点的孩子。对于有0个或1个孩子的结点来说,这样的结构是解决了要找结点孩子的问题了。甚至是有2个孩子, 知道了长子是谁,另一个当然就是次子了,如下左图所示。如果我们比较关注各兄弟之间的关系,双亲表示法无法体现这样的关系,可以增加一个右兄弟域来体现兄弟关系,每一个结点如果它存在右兄弟,则记录下右兄弟的下标。同样的,如果右兄弟不存在,则赋值为一1,如下右图所示。

     

    但如果结点的孩子很多,超过了2 个。 我们又关注结点的双亲、 又关注结点的孩子、还关注结点的兄弟,而且对时间遍历要求还比较高,那么我们还可以把此结构扩展为有双亲域、长子域、 再有右兄弟域。存储结构的设计是一个非常灵活的过程。一个存储结构设计得是否合理,取决于基于该存储结构的运算是否适合、是否方便,时间复杂度好不好等。注意也不是越多越好,有需要时再设计相应的结构。

3.2 孩子表示法

    由于树中每个结点可能有多棵子树,可以考虑用多重链表,即每个结点有多个指针域,其中每个指针指向一棵子树的根结点,这种方法叫多重链表表示法。不过,树的每个结点的度,也就是它的孩子个数是不同的。 所以可以设计两种方案来解决。

    》方案一

    一种是指针域的个数就等于树的度,复习一下,树的度是树各个结点度的最大值。其结构如图:


    其中data是数据域。child1到childd是指针域,用来指向该结点的孩子结点。上述度为3的树若用此法表示,指针域的个数为3,如图。


    这种方法对于树中各结点的度相差很大时,容易浪费空间,因为有很多的结点,它的指针域都是空的。不过如果树的各结点度相差很小时,意味着开辟的空间被充分利用了,这时存储结构的缺点反而变成了优点

    》方案二

    第二种方案每个结点指针域的个数等于该结点的度,我们专门取一个位置来存储结点指针域的个数,其结构如下图:


    其中data为数据域,degree 为度域,也就是存储该结点的孩子结点的个数,child1到childd为指针域,指向该结点的各个孩子的结点。方案一的表示可更改如下。


    这种方法克服了浪费空间的缺点,对空间利用率是很高了,但是由于各个结点的链表是不相同的结构,加上要维护结点的度的数值,在运算上就会带来时间上的损耗

    能否有更好的方法,既可以减少空指针的浪费又能使结点结构相同。可以用孩子表示法。具体办法是,把每个结点的孩子结点排列起来,以单链表作存储结构,则n个结点有n个孩子链表,如果是叶子结点则此单链表为空。然后n个头指针又组成一个线性表,采用顺序存储结构,存放进一个一维数组中,如图:


    为此,设计两种结点结构,一个是孩子链表的孩子结点,其中child是数据域,用来存储某个结点在表头数组中的下标。next是指针域,用来存储指向某结点的下一个孩子结点的指针。另一个是表头数组的表头结点,其中也data是数据域,存储某结点的数据信息。firstchild是头指针域, 存储该结点的孩子链表的头指针。

    以下是孩子表示法的结构定义代码。

/*树的孩子表示法结构定义*/#define MAX_TREE_SIZE 100typedef struct CTNode/*孩子结点*/{int child;struct CTNode *next;}*ChildPtr;typedef struct/*表头结构*/{TElemType data;ChildPtr firstchild;}CTBox;typedef struct/*树结构*/{CTBox nodes[MAX_TREE_SIZE];/*结点数组*/int r, n;/*根的位置和结点数*/}CTree;
    此结构便于查找某结点的孩子、兄弟,只需查找这个结点的孩子单链表即可遍历整棵树也比较方便,对头结点的数组循环即可。问题是,不易知道某个结点的双亲,为此可以改进成双亲孩子表示法。结构如下图:



3.3 孩子兄弟表示法

    对树观察发现,任意一棵树, 它的结点的第一个孩子如果存在就是唯一的,它的右兄弟如果存在也是唯一的。 因此,我们设置两个指针, 分别指向该结点的第一个孩子和此结点的右兄弟。结构如下图:


    其中data是数据域,firstchild为指针域,存储该结点的第一个孩子结点的存储地址, rightsib是指针域,存储该结点的右兄弟结点的存储地址。结构定义代码如下。

/*树的孩子兄弟表示法结构定义*/typedef struct CSNode{TElemType data;struct CSNode *firstchild, *rightsib;}CSNode,*CSTree;
    示意图如下:


    此法方便找某个结点的孩子,只需要通过fistchild 找到此结点的长子,然后再通过长子结点的rightsib 找到它的二弟,接着一直下去,直到找到具体的孩子。如果想找到某个结点的双亲,这个表示法就有缺陷了,可以考虑加个parent指针域来解决。其实此法最大的好处就是,把一棵复杂的树变成了二叉树。上图可以变形如下:



四、二叉树

4.1 二叉树的特点

  • 每个结点最多有两棵子树
  • 左子树和右子树是有顺序的,次序不能任意颠倒。
  • 即使树中某结点只有一棵子树,也要区分它是左子树还是右子树.


4.2 特殊二叉树

  1. 斜树:所有的结点都只有左子树的二叉树叫左斜树。所有结点都是只有右子树的二叉树叫右斜树。这两者统称为斜树。
  2. 满二叉树:在一棵二叉树中,如果所有分支结点都存在左子树和右子树,并且所有叶子都在同一层上,这样的二叉树称为满二叉树。其特点有,
    • 叶子只能出现在最下一层;
    • 非叶子结点的度一定是2;
    • 在同样深度的二叉树中,满二叉树的结点个数最多,叶子数最多
  3. 完全二叉树:对一棵具有n个结点的二叉树按层序编号,如果编号为i (l≤i≤n) 的结点与同样深度的满二叉树中编号为i 的结点在二叉树中位置完全相同,则这棵二叉树称为完全二叉树。其特点有,
    • 叶子结点只能出现在最下两层;
    • 最下层的叶子一定集中在左部连续位置;
    • 倒数二层,若有叶子结点,一定都在右部连续位置;
    • 如果结点度为1,则该结点只有左孩子,即不存在只有右子树的情况;
    • 同样结点数的二叉树,完全二叉树的深度最小


4.3 二叉树的性质

  • 性质1:在二叉树的第i层上至多有2^(i-1)个结点(i≥1)。
  • 性质2:深度为k的二叉树至多有(2^k)-1个结点(k≥l)。
  • 性质3:对任何一棵二叉树T,如果其终端结点数为n0,度为2的结点数为n2,则n0=n2+1。终端结点数其实就是叶子结点数,而一棵二叉树, 除了叶子结点外,剩下的就是度为1 或2 的结点数了,我们设n1 为度是1 的结点数。 则树T 结点总数n=n0+n1+n2。如下图:结点总数为10,由A、B、C、D等度为2 结点,F、G、 H、l、J等度为0的叶子结点和E这个度为1的结点组成。 总和为4+1+5=10。我们换个角度,数一数它的连接线数,由于根结点只有分支出去,没有分支进入,所以分支线总数为结点总数减去1。下图就是9 个分支。对于A、 B、 C、 D结点来说,都有两个分支线出去,而E 结点只有一个分支线出去。 所以总分支线为4x2+1x1=9。用代数表达就是分支线总数=n - 1=n1 + 2n2. 因为刚才我们有等式n=n0+n1+n2,所以可推导出n0+n1+n2- 1= n1+2n2. 结论就是n0 = n2 + 1。


  • 性质4: 具有n个结点的完全二叉树的深度为,其中,前者表示不大于被包括数的最大整数。
  • 性质5:如果对一棵有n个结点的完全二叉树(其深度为) 的结点按层序编号(从第1 层到第层,每层从左到右),对任一结点i (1≤i≤n) 有以下特点,可结合图示理解。




4.4 二叉树的存储结构

4.4.1 二叉树顺序存储结构

    以完全二叉树为标准,将不存在的元素位置值置空,这样可以体现完全二叉树顺序存储的优越性,如下图,D、F、H、I(淡色)位置不存在,则对应位置置空。但对于右斜树,这样的结构会造成大量的空间浪费。



4.4.2 二叉链表

    既然顺序存储适用性不强,我们就要考虑链式存储结构。二叉树每个结点最多有两个孩子,所以设计一个数据域和两个指针域,我们称这样的链表叫做二叉链表。如图,


    结构示意图如下,


    二叉链表的结构定义代码:

/*二叉树的二叉链表结点结构定义*/typedef struct BiTNode{TElemType data;/*结点数据*/struct BiTNode *lchild, *rchild;/*左右孩子指针*/}BiTNode, *BiTree;

4.5 二叉树的遍历

  • 前序遍历:规则是若二叉树为空,则空操作返回,否则先访问根结点,然后前序遍历左子树, 再前序遍历右子树。如下最左图,遍历的顺序为:ABDGHCEIF。
  • 中序遍历:规则是若树为空,则空操作返回,否则从根结点开始(注意并不是先访问根结点),中序遍历根结点的左子树,然后是访问根结点,最后中序遍历右子树。 如下中左图,遍历的顺序为: GDHBAEICF。
  • 后续遍历:规则是若树为空,则空操作返回,否则从左到右先叶子后结点的方式遍历访问左右子树,最后是访问根结点。 如下中右图,遍历的顺序为: GHDBIEFCA。
  • 层序遍历:规则是若树为空, 则空操作返回,否则从树的第一层,也就是根结点开始访问,从上而下逐层遍历,在同一层中, 按从左到右的颇用才结点逐个访问。如下最右图所示,遍历的顺序为:ABCDEFGHI。


    代码实现:

/* 初始条件: 二叉树T存在 *//* 操作结果: 前序递归遍历T */void PreOrderTraverse(BiTree T){ if(T==NULL)return;printf("%c",T->data);/* 显示结点数据,可以更改为其它对结点操作 */PreOrderTraverse(T->lchild); /* 再先序遍历左子树 */PreOrderTraverse(T->rchild); /* 最后先序遍历右子树 */}

   

    调用过程:调用PreOrderTraverse(T),T根结点不为null,所以执行printf,打印A;调用PreOrderTraverse(T->lchild);访问A的左孩子,不为null,执行printf,打印B;同理依次打印DH;(注意这里有个函数嵌套调用的过程,从第二次嵌套开始,每调用一次PreOrderTraverse都表明要新嵌套一个函数执行,或者返回到上一个函数,明白这点很重要!如下图所示),打印完H后再次递归调用PreOrderTraverse(T->lchild);访问H的左孩子,此时T==null,返回,递归调用PreOrderTraverse(T->rchild);访问H的右孩子,打印K;(按程序)打印后再次递归调用PreOrderTraverse(T->lchild);访问K的左孩子,K无左孩子则返回,再调用PreOrderTraverse(T->rchild);访问K的右孩子,无右孩子则返回。于是此函数执行完毕,返回到上一级递归的函数(即打印H的函数,程序从这里来的,就要return到这里去),执行完毕了,再return到打印D时的函数(按上述黑体字打印顺序回溯,从打印D的printf函数下一行PreOrderTraverse(T->lchild);出去,递归回来后调用下一行函数),再调用PreOrderTraverse(T->rchild);访问D的右孩子,不存在,则返回到B结点,同样调用PreOrderTraverse(T->rchild);找到E并打印。由于E没有左右孩子,返回打印B 时的递归函数,递归执行完毕,返回到最初的PreOrderTraverse(),至此,最开始从PreOrderTraverse(T->lchild);嵌套调用出去并一层层return回来,按下图最左端程序执行路径,再调用PreOrderTraverse(T->rchild);访问A的右孩子,打印C,依次分析下去,打印结果为:ABDHKECFIGJ。


    同理,中序遍历和后续遍历的算法其实很简单,只需更改递归函数的执行顺序即可,中序遍历这棵二叉树的结点顺序是:HKDBEAIFCGJ。后序遍历的结点的顺序就是:KHDEBIFJGCA。代码如下示意,执行过程不再具体分析。

/* 初始条件: 二叉树T存在 *//* 操作结果: 中序递归遍历T */void InOrderTraverse(BiTree T){ if(T==NULL)return;InOrderTraverse(T->lchild); /* 中序遍历左子树 */printf("%c",T->data);/* 显示结点数据,可以更改为其它对结点操作 */InOrderTraverse(T->rchild); /* 最后中序遍历右子树 */}/* 初始条件: 二叉树T存在 *//* 操作结果: 后序递归遍历T */void PostOrderTraverse(BiTree T){if(T==NULL)return;PostOrderTraverse(T->lchild); /* 先后序遍历左子树  */PostOrderTraverse(T->rchild); /* 再后序遍历右子树  */printf("%c",T->data);/* 显示结点数据,可以更改为其它对结点操作 */}
    具体代码参考文末源码——二叉树链式结构实现_BiTreeLink.c。

    另外需要说明的是,己知前序遍历序列和中序遍历序列,可以唯一确定一棵二叉树;已知后序遍历序列和中序遍历序列,可以唯一确定一棵二叉树;但已知前序和后序遍历,是不能确定一棵二叉树的。比如前序序列是ABC,后序序列是CBA。 我们可以确定A一定是根结点,但接下来,我们无法知道,哪个结点是左子树,哪个是右子树。这棵树可能有如下图所示的四种可能。



4.6 二叉树的建立

    如果我们要在内存中建立一个如下左图这样的树,为了能让每个结点确认是否有左右孩子,我们对它进行了扩展,变成右图的样子,也就是将二叉树中每个结点的空指针引出一个虚结点,其值为一特定值,比如“#”。我们称这种处理后的二叉树为原二叉树的扩展二叉树扩展二叉树就可以做到一个遍历序列确定一棵二叉树了。比如下图的前序遍历序列就为AB#D##C##。


    实现算法:

/* 按前序输入二叉树中结点的值(一个字符) *//* #表示空树,构造二叉链表表示二叉树T。 */void CreateBiTree(BiTree *T){ TElemType ch;/* scanf("%c",&ch); */ch=str[index++];if(ch=='#') *T=NULL;else{*T=(BiTree)malloc(sizeof(BiTNode));if(!*T)exit(OVERFLOW);(*T)->data=ch; /* 生成根结点 */CreateBiTree(&(*T)->lchild); /* 构造左子树 */CreateBiTree(&(*T)->rchild); /* 构造右子树 */} }
    其实建立二叉树,也是利用了递归的原理。只不过在原来应该是打印结点的地方,改成了生成结点、给结点赋值的操作而已。

4.7 线索二叉树

    对于一个有n 个结点的二叉链表,每个结点有指向左右孩子的两个指针域,所以一共是2n个指针域。而n个结点的二叉树一共有n-1 条分支线数,也就是说,其实是存在2n-(n-1)=n+1个空指针域。可以考虑利用那些空地址,存放指向结点在某种遍历次序下的前驱和后继结点的地址。这种指向前驱和后继的指针称为线索,加上线索的二叉链表称为线索链表,相应的二叉树就称为线索二叉树。线索二叉树等于是把一棵二叉树转变成一个双向链表。这样对我们插入删除结点、查找某个结点带来了方便。所以我们对二叉树以某种次序遍历使其变为线索二叉树的过程称作是线索化。在实际问题当中,如果二叉树需经常遍历或者查找结点时需要某种遍历序列中的前驱和后继,采用线索二叉链表的存储结构是非常不错的选择。



附:源码参考

http://download.csdn.net/detail/daijin888888/9828642


参考:《大话数据结构》









2 1
原创粉丝点击