数据结构:树和森林结构
导言
轩辕剑是一个经典的中文角色扮演游戏,通过对历史内容的考究,与精彩感人的剧情结合,使得这个系列被公认为华人世界的两大经典角色扮演游戏系列之一。我最为喜欢的两部是《轩辕剑叁:云和山的彼端》和《轩辕剑叁外传:天之痕》,剧情感人精彩、别有深意,2D的场景细致美观、独具特色……当然,我这次仍然不是来给你推荐游戏的,而是想对其中一个场景做点文章。
“建木”是上古先民崇拜的一种圣树,传说建木是沟通天地人神的桥梁,在《轩辕剑叁外传:天之痕》中的仙山岛,利用水墨画的风格进行了描绘,是我最喜欢的游戏场景之一。其中就有对“海中建木”的描绘。“海中建木”无疑是一颗巨大的树,这棵树也肯定是由无数的根、枝、叶组成的,如果我们把“海中建木”抽象成一个数据结构,那么这个结构就有一个根部,还有很多的分支即枝,还有很多的子叶,这就是我们要描述的树结构。
什么是树
树结构定义
树结构(Tree)是 n(n ≥ 0) 个结点的有限集,当 n = 0 时为空树,否则为非空树。对于非空树有如下特点:
- 有且仅有一个特定的根结点,不允许存在多个结点;
- 除根结点以外的其余结点可分为 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;
例如当前有一个树结构如下:
使用孩子兄弟表示法的效果如下,不难看出孩子兄弟表示法可以把树转变为二叉树进行存储。
方法应用:目录树
树和二叉树的转换
将树结构转换成二叉树,其实就是用孩子兄弟表示法来表示树。这个过程可以形象地表示为如下两骤,假设有如图所示的树结构:
将属于同一双亲的孩子结点用虚线相连接。
除了第一个结点外,其余结点的分支从左到右都剪掉。
整理一下结构,可以明显地看到树被转换为二叉树。
把二叉树转换为树,其实就是上面的逆过程,首先先将二叉树从左上到右下分为若干层,然后找到每一层结点的父节点进行连接,再将每一层的连接删除即可。
二叉树
森林和二叉树的转换
森林转换成二叉树的过程其实和树转化为二叉树的过程类似,只是在最后把多棵树连接在一起。根据孩子兄弟表示法的特点,由于根节点没有兄弟,所以树转换为二叉树之后根节点没有右子树。
此时可以将这个空出来的右孩子指针利用起来,森林中的二叉树用根节点的右孩子相连接就完成了转换。
二叉树转换回森林就是上述过程的逆过程,首先先将根节点的右子树断开,然后将多棵二叉树转换回树结构就行。
树和森林的遍历
树的遍历
树的遍历方式有两种:
- 先序遍历:先访问根节点,再依次访问根节点的每棵子树;
- 后序遍历:先依次访问根节点的每棵子树,再访问根节点。
例如如下树结构,两种方法得到的遍历序列为:
先序遍历:ABEFCDG
后序遍历:EFBCGDA
树转换为二叉树之后,树的先序遍历对应二叉树的先序遍历,树的后序遍历对应二叉树的中序遍历。
森林的遍历
森林的遍历方式有两种:
- 先序遍历:先访问森林中第一棵树的根结点,再先序遍历第一棵树中根结点的子树,然后依次先序遍历树中的子树;
- 后序遍历:先后序遍历第一棵树中根结点的子树,再访问森林中第一棵树的根结点,然后依次后序遍历树中的子树。
例如如下树结构,两种方法得到的遍历序列为:
先序遍历:ABCDEFGHIJ
后序遍历:BCAEFGDJIH
森林转换为二叉树之后,森林的先序遍历对应二叉树的先序遍历,森林的后序遍历对应二叉树的中序遍历。
堆
并查集
并查集。
AC 自动机
参考资料
《大话数据结构》—— 程杰 著,清华大学出版社
《数据结构教程》—— 李春葆 主编,清华大学出版社
《数据结构(C语言版|第二版)》—— 严蔚敏 李冬梅 吴伟民 编著,人民邮电出版社