数据结构——树与二叉树

和自甴很熟 提交于 2020-03-29 19:01:14

导言

轩辕剑是一个经典的中文角色扮演游戏,通过对历史内容的考究,与精彩感人的剧情结合,使得这个系列被公认为华人世界的两大经典角色扮演游戏系列之一。我最为喜欢的两部是《轩辕剑叁:云和山的彼端》和《轩辕剑叁外传:天之痕》,剧情感人精彩、别有深意,2D的场景细致美观、独具特色……当然,我这次仍然不是来给你推荐游戏的,而是想对其中一个场景做点文章。
“建木”是上古先民崇拜的一种圣树,传说建木是沟通天地人神的桥梁,在《轩辕剑叁外传:天之痕》中的仙山岛,利用水墨画的风格进行了描绘,是我最喜欢的游戏场景之一。其中就有对“海中建木”的描绘。“海中建木”无疑是一颗巨大的树,这棵树也肯定是由无数的根、枝、叶组成的,如果我们把“海中建木”抽象成一个数据结构,那么这个结构就有一个根部,还有很多的分支即枝,还有很多的子叶,这就是我们要描述的树结构。

什么是树

树结构定义

树结构(Tree)是 n(n ≥ 0) 个结点的有限集,当 n = 0 时为空树,否则为非空树。对于非空树有如下特点:

  1. 有且仅有一个特定的根结点,不允许存在多个结点;
  2. 除根结点以外的其余结点可分为 m(m > 0) 个互不相交的有限集,其中每一个有限集本身还是一棵树,称为根的子树。子树的个数没有限制,但是一定不能有交集。

如图所示,分别是空树和非空树:

我们单独看一下图示非空树,这个树结构分别有 3 个子树:

树的结点

结点分类

树的结点包含存储数据的数据域和指向的分支,指向的分支可以是多个。我们用度来描述一个结点具有几个分支,结点的度的数值等于其子树的个数。没有分支的结点称为叶结点或终端结点,叶结点的度为 0,除了根结点的度不为 0 的结点称为内部结点,一个树结构的度为根结点和所有内部结点的度的最大值。例如:

结点的联系

结点的分支称为结点的孩子,该结点也被称为其孩子的双亲,同属于同一双亲的子树结点被称为兄弟,结点的祖先是从根结点到该结点的分支上的所有结点,以某一结点为根结点的分支都成为该结点的子孙。

结点的层次

层次表示从根结点开始,根结点的子结点属于第二层,根结点的子结点的子结点属于第三层,以此类推直到到达最底层的叶结点。通过这种方式推导的最大层次为该树结构的层次,其中双亲在同一层的结点互为堂兄弟结点。

有序树

若一个树结构中的结点的子树从左到右是有序的(不能互换),则称之为有序树,否则是无序树。对于有序树而言,最左边子树的根称为第一个孩子,最右边称为最后一个孩子。

森林

是 m(m ≥ 0) 棵互不相交的树的集合,对树中每个结点而言,其子树的集合即为森林,例如上文中的树的各个子树,就可以认为是一个森林,因此可以用森林和树相互递归的定义来描述树。

相比线性结构

线性结构 树结构
第一个元素无前驱 有且仅有一个根结点,无前驱
最后一个元素无后继 叶结点无子结点,不唯一
中间元素有前驱和后继 中间结点可以有多个分支子结点

树的存储结构

对于线性表来说,我们只有两种描述——顺序存储和链式存储,但是对于树结构来说我们显然不能直接生搬硬套,这是因为树结构的数据是多对多的关系,这就说明了我们不能像线性表那样只做到把单个元素的前驱后继说明白,树结构中的结点是有辈分关系的,不能乱了套。因此当我们描述树结构的存储方式时,需要着重描述结点间的亲子关系,这就使得我们有:双亲表示法、孩子表示法和孩子兄弟表示法来描述。

双亲表示法

结构体定义

该表示法着重于描述各个结点与双亲的关系,在使用顺序存储描述时,结构体定义如下。

#define MAXSIZE 100
typedef struct PTNode
{
    ElemType data;    //数据域
    int parent;    //指向双亲的游标域
}PTNode;    //定义结点结构体
typedef struct
{
    PTNode nodes[MAXSIZE];    //结点数组
    int root;    //指向根结点的游标
    int count;    //结点数
}PTree;    //定义树结构体

描述法举例

下标 data parent firstchild rightsib
0 A -1 1 3
1 B 0 4 6
2 C 0 -1 -1
3 D 0 7 7
4 E 1 -1 -1
5 F 1 -1 -1
6 G 1 -1 -1
7 H 3 8 9
8 I 7 -1 -1
9 J 7 -1 -1

我们观察到,除了上述结构体需要描述的父母位置,我还加了一个结点的第一个孩子结点和最后一个孩子结点的游标,分别是长子位和次子位。有些时候我们多设计一些游标会有助于我们实现功能,但是需要具体问题具体分析。

孩子表示法

该描述法着重于描述结点与其孩子结点的关系,通过多重链表来描述,也就是说每个结点都会根据其子结点的个数拥有一定数量的指针域。
我们需要考虑一个问题,就是一个结点要开多少个指针域合适?利用我们前面的度的概念,由于树结构的度是整个树中单个结点拥有的最多的分枝数,如果每个结点的指针域等于树的度当然可以解决问题,但是并不是所有结点都需要这么多指针域的。如果说我们用动态内存分配的想法,一个结点需要多少指针域就开多少空间也可以实现,但是我们就不得不使用类似柔性数组的机制,“杀鸡焉用牛刀”。
综上所述,我们选择的方式是将每个结点的孩子结点用单链表描述起来,每个结点都有一个属于自己的孩子单链表,描述每个结点时可以用顺序表去描述。这样讲还是有点抽象,我们看个例子。

描述法举例


结构体定义

从上面的例子可以看出,我们需要分别设计孩子链表的结点和表头数组的结点。

#define MAXSIZE 100
typedef struct CTNode
{
    int child;    //指向长子的游标域
    struct CTNode *next;    //指向下一个孩子的指针域
}*ChildPtr;    //定义孩子结点结构体
typedef struct
{
    ElemType data;    //数据域
    //同双亲表示法的拓展,这里可以开个指向双亲的指针域
    ChildPtr *firstchild;    //指向长子的指针域
}CTBox;    //定义表头结构体
typedef struct
{
    PTNode nodes[MAXSIZE];    //结点数组
    int root;    //指向根结点的游标
    int count;    //结点数
}CTree;    //定义树结构体

孩子兄弟表示法

该表示法着重于对兄弟结点的描述,由于对于任意一结点而言,若该结点存在子结点,则长子结点和右结点等都是唯一确定的,结构体定义如下:

typedef struct CSNode
{
    ElemType data;    //数据域
    struct CSNode *firstchild;    //指向对应长子结点的指针域
    struct CSNode *rightsib;    //指向对应右兄弟结点的指针域
}CSNode,*CSTree;

描述法举例


二叉树

我们先来看个例子,假设我连续抛一毛、五毛、一块钱的硬币各一个,那么这 3 枚硬币呈现出的状态有多少种可能呢?我们知道抛一枚硬币只有两种可能——证明或反面,也就是说抛硬币这个事件可能会产生两种可能性,所以我们来看:

如果我们把这个过程模拟成一个树,整个树有 8 个叶结点,那么这个事件的 8 种可能性我们就能说明白了。

二叉树的定义

二叉树 (Binary Tree) 是 n(n ≥ 0) 个结点的有限集合,该集合为空集时称为空二叉树,由一个根结点和两棵互不相交的、分别称为根结点的左子树和右子树的二叉树组成。例如上文作为例子的树结构,由于出现了一个结点有 3 个子树的情况,所以不属于二叉树,而如图所示结构就是二叉树。

对于二叉树来说有以下特点:

  1. 二叉树的每个结点至多有两个子树,也就是说二叉树不允许存在度大于 2 的结点;
  2. 二叉树有左右子树之分,次序不允许颠倒,即使是只有一棵子树也要有左右之分。

因此对于一棵有 3 个结点的二叉树来说,由于需要区分左右,会有以下五种情况。

特殊的二叉树

斜树

所有结点都只有左(右)子树的二叉树被称为左(右)斜树,同时这个树结构就是一个线性表,如图所示。

满二叉树

满二叉树要求所有的分支结点都存在左右子树,并且所有的叶结点都在同一层上,若满二叉树的层数为 n,则结点数量为 2n-1 个结点,子叶只能出现在最后一层,内部结点的度都为 2,如图所示。

完全二叉树

从定义上来说,完全二叉树是满足若对一棵具有 n 个结点的二叉树按层序编号,如果编号为 i 的结点 (1 ≤ i ≤ n)于同样深度的满二叉树中编号为 i 的结点在二叉树的位置相同的二叉树。这样讲有些繁琐,可以理解为完全二叉树生成结点的顺序必须严格按照从上到下,从左往右的顺序来生成结点,如图所示。

因此我们就不难观察出完全二叉树的特点,完全二叉树的叶结点只能存在于最下两层,其中最下层的叶结点只集中在树结构的左侧,而倒数第二层的叶结点集中于树结构的右侧。当结点的度为 1 时,该结点只能拥有左子树。

二叉树的性质

性质 内容
性质一 在二叉树的 i 层上至多有 2i-1 个结点(i>=1)
性质二 深度为 k 的二叉树至多有 2k-1 个结点(i>=1)
性质三 对任何一棵二叉树 T,如果其终端结点树为 n0,度为 2 的结点为 n2,则 n0 = n2 + n1
性质四 具有 n 个结点的完全二叉树的深度为 [log2n] + 1 向下取整
性质五 如果有一棵有 n 个结点的完全二叉树(其深度为 [log2n] + 1,向下取整)的结点按层次序编号(从第 1 层到第 [log2n] + 1,向下取整层,每层从左到右),则对任一结点 i(1 <= i <= n)有
1.如果 i = 1,则结点 i 是二叉树的根,无双亲;如果 i > 1,则其双亲是结点 [i / 2],向下取整
2.如果 2i > n 则结点 i 无左孩子,否则其左孩子是结点 2i
3.如果 2i + 1 > n 则结点无右孩子,否则其右孩子是结点 2i + 1

二叉树的存储结构

顺序存储

由于二叉树的结点至多为 2,因此这种性质使得二叉树可以使用顺序存储结构来描述,在使用顺序存储结构时我们需要令数组的下标体现结点之间的逻辑关系。我们先来看完全二叉树,如果我们按照从上到下,从左到右的顺序遍历完全二叉树时,顺序是这样的:

那么我们就会发现,设父结点的序号为 k,则子结点的序号会分别为 2k 和 2k + 1,子结点的序号和父结点都是相互对应的,因此我们可以用顺序存储结构来描述,例如如图大顶堆:

用顺序存储结构描述如图所示:

那么对于一般的二叉树呢?我们可以利用完全二叉树的编号来实现,如果在完全二叉树对应的结点是空结点,修改其值为 NULL 即可,例如:

再看个例子,左斜树:

但是我们可以很明显地看到,对于一个斜树,我开辟的空间数远超过实际使用的空间,这样空间就被浪费了,因此顺序存储结构虽然可行,但不合适。

链式存储

由于二叉树的每个结点最多只能有 2 个子树,因此我们就不需要使用上述的 3 种表示法来做,可以直接设置一个结点具有两个指针域和一个数据域,那么这样建好的链表成为二叉链表。例如:


再看个例子,上述我描述孩子兄弟表示法的树结构,稍加改动就可以把图示改成二叉树:

结构体定义

typedef struct BiTNode
{
    ElemType data;    //数据域
    ChildPtr *lchild,*rchild;    //左右孩子的指针域
    //可以开个指针域指向双亲,变为三叉链表
}BiTNode, *BiTree;

二叉树的基本操作

二叉树的遍历

从斐波那契数列说起

我们先不急着开始谈二叉树的遍历,而是先回忆一下我们是怎么利用斐波那契数列实现递归的:

代码实现:

int f(int n)
{
    if (n == 0)
	return 0;
    else
        if (n == 1)
	    return 1;
	else
	    return f(n - 2) + f(n - 1);
}

代码很好读,已经不是什么难题了,但是我们并不是一开始就懂得递归是个什么玩意,我们也是通过模拟来深刻理解的。因此下面我们用图示法进行模拟,假设我需要获取第 4 个斐波那契数:

仔细看,我们模拟递归函数调用的过程,和二叉树长得是一模一样啊,那么对于二叉树的操作,我们能否用递归来作些文章?

遍历算法

由于二叉树的结点使用了递归定义,也就是结点的拥有自己本身作为成员的成员,这就使得遍历算法可以使用递归实现,而且思路很清晰。

void PreOrderTraverse (BiTree T)
{
    if(T == NULL)
        return;
    //cout << T->data << " " ;    //前序遍历
    PreOrderTraverse (T->lchild);
    //cout << T->data << " " ;    //中序遍历
    PreOrderTraverse (T->rchild);
    //cout << T->data << " " ;    //后序遍历
}

可以看到,根据输出语句的位置不同,输出的数据顺序是不一样的,例如如图所示二叉树,3 种顺序的输出顺序为:



· 需要注意的是,无论是什么样的遍历顺序,访问结点都是从根结点开始访问,按照从上到下,从左到右的顺序向下挖掘,分为 3 中顺序主要是有我们输出语句放的位置不同而决定的,所以我这边就将 3 中遍历顺序放在一起谈。

建立二叉树

拓展二叉树

例如要确定一个二叉树,我们肯定不能只是把结点说明白,还需要把每个结点是否有左右孩子说明白。例如如图所示树结构,我们可以向其中填充结点,使其的所有结点填充完后均具有左右结点,为了表示该结点其实是不存在的,我们需要设置一个标志来表示,例如是“#”,那么这种描述就是拓展二叉树如图所示。

按照前序遍历,输出的结果为“ABD#GE##C#F##”。

建树算法

对于树来说,遍历是各种操作的基础,我们刚刚是通过递归的方式实现了二叉树的遍历读取,现在我们可以再次搬出递归,使其按照前序遍历的顺序建立二叉树。假设树结构的每一个结点的数据域都是一个字符,先序遍历的顺序已知,算法要求将一个字符序列的元素依次读入建立二叉树。
由于对一个树结构来说,每个结点的左右分支都可以被理解为是一个树结构,例如根结点就拥有左右子树,叶结点可以理解为左右子树都是空树的根结点。因此我们可以通过分治思想,每一次只构建一棵子树的根结点,然后递归建立左右子树,直至读取到“#”终止递归。

void CreatBiTree(BiTree &T)
{
    char ch;

    cin >> ch;
    if(ch == '#')    //读取到 NULL 结点
        T = NULL;    //建立空树,结束递归
    else
    {
        T = new BiTree;    //生成树的根结点
        T->data = ch;
        CreatBiTree(T->lchild);    //创建根结点的左子树
        CreatBiTree(T->rchild);    //创建根结点的右子树
    }
}

复制二叉树

还是用递归,与创建二叉树类似,先申请一个新结点用于拷贝根结点,然后通过递归依次复制每一个子树的根结点即可实现。

void CopyBiTree(BiTree &T,BiTree &NewT)
{
    if(T == NULL)    //根结点是空树,结束复制
        NewT = NULL; 
        return;
    else
    {
        NewT = new BiTNode;
        NewT->data = T->data;    //拷贝根结点
        CopyBiTree(T->lchild,NewT->lchild);    //拷贝左子树根结点
        CopyBiTree(T->rchild,NewT->rchild);    //拷贝右子树根结点
    }
}

获取二叉树的深度

还是用递归,与创建二叉树类似,利用分治的思想,对于倒数第二层的子树来说深度为 1 或 2,即左右子树是否存在的问题,那么当我从最底层分治回根结点时,二叉树的深度即为左右子树深度较大的数值加 1,最后函数需要返回树的深度。

int DepthBiTree(BiTree T)
{
    int l_depth,r_depth;

    if(T == NULL)    //若树为空树则表示子树的深度为 0
        return 0;
    else
    {
        l_depth = DepthBiTree(T->lchild);    //向左子树挖掘深度
        r_depth = DepthBiTree(T->rchild);    //向右子树挖掘深度
        if(l_depth > r_depth)    //返回左右子树中的较大层数
            return l_depth + 1;
        else
            return r_depth + 1;
    }
}

统计二叉树的结点数

还是用递归,每一个子树的结点数为其左子树和右子树的结点树之和再加上它本身,也就是加 1。

int NodeCount(BiTree T)
{
    if(T == Tree)
        return 0;    //若为空树,则结点数为 0
    else
        return NodeCount(T->lchild) + NodeCount(T->rchild) + 1;    //挖掘左右结点的节点个数
}

线索二叉树

描述前驱与后继

回顾一下双向链表,为了能够准确描述某个结点的前驱,我们给结点结构体引入了前驱指针域,通过前驱指针域我们就能够清楚地知道一个结点的前驱,而不需要再次遍历。那么再看一下二叉树,虽然在树结构中,结点间的关系是一对多的关系,但是当我们遍历二叉树时,无论是前序遍历、中序遍历还是后序遍历,我们使用了某些规则使得我们能够按照一定的顺序来描述二叉树的结点。也就是说,例如我们使用中序遍历的时候,我们是可以按照中序遍历的规则明白一个结点的前驱和后继的,但是如果我们需要知晓这一点,就不得不进行一次遍历操作。那么我们能不能像双向链表那样开辟一些指针域来描述前驱与后继的关系呢?

别急,我们先来观察一下,如图所示的二叉树使用二叉链表来组织结点,中序遍历的结点顺序为 GDBEACF,但是我们发现并不是所有的结点的指针域都得到了充分的应用。该二叉树有 7 个结点,也就是说有 14 个指针域,可是我们只使用了 6 个指针域来描述逻辑关系。再接着看,当我们需要描述后继关系时,也就是 G->D、D->B E->A、F->NULL 这四个关系,描述清楚之后就能够吧中序遍历所得的后继关系说明白;描述前驱关系时,需要把 G->NULL、E->B、C->A、F->C 这四个关系说明白。观察一下,如图二叉树有 6 个分支,这些分支分别需要有 1 个指针域来存储信息,总共有 14 个指针域,那也就是还有 8 个指针域是空闲的,然后我们就能发现,这个数字与我们要描述清前驱后继所需要的指针域是一样的,也就是说我们无需对结构体的定义进行操作,只需要将这些空闲的空间充分利用即可。
如图所示,描述后继关系:

描述前驱关系:

对于这类用于描述前驱和后继的指针,我们称之为线索,而将空闲的指针域利用起来的二叉链表,也就是引入线索的二叉链表成为线索链表,描述的二叉树成为线索二叉树。通过对线索的使用,我们把一棵二叉树描述为一个双向链表,我们很清楚双线链表的插入、删除和查找结点的操作都是很方便的,而我们以某种遍历顺序设置线索的过程成称为线索化。线索二叉树的结构即充分利用了二叉树的空指针域,又使得一次遍历就能获取结点的前驱和后继信息,既节省了空间也节省了时间。

线索二叉树结点结构体定义

我们明白了可以利用空闲的指针域来描述前驱后继,但是我们要如何确定这些指针域是用来描述左右子结点还是前驱后继的关系的呢?也就是说,我们不仅需要一些机制来进行判断,更要留下一些标志来为我们后续的访问提供便利。我们的做法是,引入两个 bool 性成员变量 ltag、rtag,当 ltag 的值为 0 时表示指针域指向该结点的左结点,值为 1 时指向该结点的前驱,rtag 的用法同理。

typedef enum {Link,Thread} PointerTag;    //Link 表示指向子结点,Thread 表示指向前驱或后继
typedef struct BiThrNode
{
    ElemType data;    //数据域
    BiThrNode *lchild,*rchild;    //左右孩子的指针域
    PointerTag LTag;    //判断左指针域作用的 flag
    PointerTag RTag;    //判断右指针域作用的 flag
}BiThrNode, *BiThrTree;

线索化

所谓线索化就是将二叉树中没有使用的空闲指针域进行修改,使其能够描述前驱和后继的过程,而前驱和后继的信息我们在遍历的时候比较关心,因此线索化本质上就是在中序遍历的时候添加描述的过程,算法的实现也是基于遍历算法的实现。

BiThrTree pre;    //当前访问结点的前驱指针
void InThreading(BiThrTree ptr)
{
    if(ptr != NULL)
    {
        InThreading(ptr->lchild);    //左子树线索化
        if(!ptr->lchild)    //结点无左子树
        {
            ptr->LTag = Thread;    //修改 flag
            ptr->lchild = pre;    //左指针域指向前驱
        }
        if(!pre->rchild)    //结点的前驱无右子树
        {
            pre->RTag = Thread;    //修改 flag
            pre->rchild = ptr;    //右指针域指向后继
        }
        pre = ptr;    //移动 pre,使其始终指向当前操作结点的前驱
        InThreading(ptr->rchild);    //左子树线索化
    }
}

遍历线索二叉树

由于线索二叉树实现了近似于双向链表的结构,因此我们可以添加一个头结点,使其左指针域指向线索二叉树的根结点,右指针域指向中序遍历访问的最后一个结点。同时我们可以运用一下循环链表的思想,使中序遍历的第一个结点的左指针域和最后一个结点的右指针域指向头结点,就能够实现从任何结点出发都能够完整遍历线索二叉树的功能了。该算法时间复杂度 O(n)。

bool InOederTraverse_Thr(BiThrTree T)    //指针 T 指向头结点
{
    BiThrTree ptr = T->lchild;    //ptr 初始化为根结点
    
    while(ptr != T)    //遇到空树或遍历结束时,ptr 会指向头结点
    {
        while(ptr->LTag == Link)    //结点指向左子树时循环到中序序列的第一个结点
            ptr = ptr->lchild;
        cout << ptr->data;
        while(ptr->RTag == Thread && ptr->rchild != T)    //中序遍历,并找到下一个右子树
        {
            ptr = ptr->rchild;
            cout << ptr->data;
        }
        ptr = ptr->rchild;    //ptr 进入右子树
    }
    return true;
}

参考资料

《大话数据结构》—— 程杰 著,清华大学出版社
《数据结构教程》—— 李春葆 主编,清华大学出版社
《数据结构(C语言版|第二版)》—— 严蔚敏 李冬梅 吴伟民 编著,人民邮电出版社

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!