树与线性表同是逻辑结构的一种,不同于线性表,树是一种非线性结构,线性表的中的数据很明显是一种一对一的关系,树中的数据是一对多的形式。所以树也是一种重要的数据结构。
树是n(n>0)个节点的有限集,当n=0时,称为空树,在任意一个非空树应该满足:
- 有且仅有一个特定的点称为根节点
- 当n > 1的时候,其余节点可分为m(m>0)个互不相交的有限集 T 1 , T 2 , . . . , T m T_1,T_2,...,T_m T1,T2,...,Tm,其中每个集合本身又是一棵树,并且称为根的子树
很显然,树的结构是递归的的,即在树的定义中又即树中又有子树,定义中又有自己。同时树也是一种分层结构,具有以下两个特点:
以如上图为例
- 祖先,子孙,双亲,兄弟:从A-G的唯一路径上,除了K以外都是K节点的祖先,同理K为该路径任意节点的子孙,距离G节点最近的D节点是G的双亲,具有相同双亲节点的H、I与节点K互为兄弟。
- 度:树中节点孩子的个数为该节点的度,树的度为该树中最大节点的度。
- 分支节点:度大于零的节点或有孩子的节点
- 叶子节点:度为零的节点或没有子没有孩子节点的节点
- 节点的深度、高度、层次:
- 节点的层次从树根开始定义,根节点为第一层,在同一层的节点互为堂兄弟
- 节点的深度是从根节点开始自顶向下逐层累加的
- 节点的高度(深度)是从叶子节点自下向上逐层累加的
- 树的高度是树中节点的最大层数- 有序树和无序树:树中节点的各子树从左到右是有次序的不能互换,称为有序树,否则称为无序树。
- 路径和路径长度:树中两个节点之间的路径是由这两个节点之间所经过的节点序列构成的,而路径长度是路径上经过的边的个数
- 森林:森林是m(m>=0) 棵互不相交的树的集合。去掉树的根节点就成了森林
二叉树是 另一种树形结构,每个节点之多只有两个子树(所以二叉树中不存在度大于2的节点),并且二叉树的子树也有左右之分不可随意颠倒。
与树相似,二叉树也是以递归的形式定义,二叉树是n(n>=0)个节点的有限集合
- 或者为空二叉树,即n = 0
- 或者由一个根节点和两个互不相交的被称为根的左子树和右子树组成,左子树和右子树又分别是一个二叉树
因为二叉树是有序的所i有左子树和右子树不可颠倒,颠倒后的树与原来的树是两棵树,也正因此,二叉树区别于度为2的有序树树,例如,当根节点只有一个度为二的节点,有序树则不予区分左右,而对于二叉树来讲左右便是不同的。除了该区别外,度为二的树至少要有3个节点,而二叉树可以为空树。
满二叉树: 一棵高h的树,且含有 2 h − 1 2^h-1 2h−1个节点的二叉树为满二叉树,即每层都含有最多的节点,只存在度为二和度为零 的节点,可按照约定编号,根为一自上而下自左至右依次递增,左节点为2n,右节点为2n+1
完全二叉树:从满二叉树编号最大的位置依次删除任意个节点的树便是完全二叉树。
所以完全有以下几个特点:
- 当 i < 1 时,节点i的双亲编号为[i/2](小数时向下取整),当i为偶数的时候它是左孩子,当是奇数时候是右孩子
- 当2i < n 时,节点i的右孩子编号为2i,否则无左孩子
- 2i + 1 <= n时,节点i 的左孩子为2i否则无左孩子
- 节点i所在层次(深度)为 [ l o g 2 i ] + 1 [log_2 i] + 1 [log2i]+1【】是取整符号
二叉树的顺序存储是指用一组地址连续的存储单元依次自上而下,自左至右存储完全二叉树上的节点。
依据二叉树的性质,完全二叉树和满二叉树更是后采用顺序存储,树中节点的序号可以唯一的反应节点之间的逻辑关系,这样既能最大程度节约空间,又能利用数组元素的下标确定节点在二叉树中的位置,以及节点之间的关系。
对于一般的二叉树,为了让数组下标能反映二叉树节点之间的逻辑关系,数组中下标为0的位置不存放数据,根节点下标为一依次排列。
尽管顺序存储比较适合完全二叉树,但链式的存储结构更适合二叉树的形式,有更高的空间利用率,用链表节点来存储二叉树中的每个节点也更符合树的逻辑结构,
typedef struct BitNode{
int data; //数据域
node *lnode,*rnode; // 左右指针
}node;
这就是基本的节点的的形式,也可以根据实际要求增加某些指针域。
在含有有n个节点的二叉链表中含有n+1个空链域
二叉树遍历是指按照某条搜索路径访问树中每个节点,使得每个节点均被访问一次,而且仅仅是被访问一次,由于二叉树是一种非线性结构,每个节点都可能有两棵子树,所以需要寻找一种规律以便于二叉树上的节点可以排列在一个线性队列上,进而便于遍历。
由二叉树的递归定义可知,遍历一棵二叉树便要决定对根节点N,左子树L和右子树R的访问顺序,按照先遍历左子树再遍历右子树的原则,常见的遍历次序有先序(NLR),中序(LNR)和后序(LRN)遍历三种算法。
以下图为例,三种遍历方式的输出结果为:
先序遍历:1 => 2 => 4 => 5 => 3 =>6
中序遍历:4 => 2 => 5 => 1 => 6 => 3
后序遍历:4 => 5 => 2 => 6 => 3 => 1
实现过程如下。
以先序遍历为例:
因为树本身就是一种递归结构,所以我们采用递归方式进行访问。
当节点不为空时进行当前节点访问以及左右子树的递归访问,而前序中序和后序遍历的区别就是这三个访问的顺序变化
void preOrder( Bitree *t){ //先序遍历
if(t != NULL){
printf("%d ",t->data); // 访问打印根节点
preOrder(t->lnode); // 递归访问左子树
preOrde(t->left); // 递归访问右子树
}
}
void InOrder( Bitree *t){ //中序遍历
if(t != NULL){
INOrder(t->lnode); // 递归访问左子树
printf("%d ",t->data); // 访问打印根节点
INOrde(t->left); // 递归访问右子树
}
}
void PostOrder( Bitree *t){ //后序遍历
if(t != NULL){
PostOrder(t->lnode); // 递归访问左子树
PostOrde(t->left); // 递归访问右子树
printf("%d ",t->data); // 访问打印根节点
}
}
对于这三种遍历算法,递归遍历左右子树都是固定的只是访问根节点的顺序发生了改变,不管是采用哪一种算法,每一个节点都只是访问了一次且仅访问一次,所以时间复杂度都是O(n),在递归工作栈的深度恰好为树的深度,所以在最坏的情况下,有n个节点的二叉树深度为n,则空间复杂度为O(n).
递归的本质实际上是把一个大的问题依次划分为小问题依次堆叠,等小问题解决之后,依次向下解决大问题,其实这就是和栈的思路是一致的。当该函数运算无法得出结果,我们把它入栈,等待下一个小问题函数结果,若还是解不出来,继续压栈,反反复复,直到得出结果,依次出栈。
我们看到在上面代码部分如果我们拿去访问打印节点的部分,可以看到三种方式递归执行的过程是完全相同。
借助栈我们先简单分析一下中序遍历:
执行过程 栈情况(左为栈顶) 输出 1. 首先传入根节点1 空 无 2. 把1入栈,有左子树,递归遍历左子树 =>1 无 3. 把2入栈,有左子树,递归遍历左子树 =>2=>1 无 4. 把4入栈,无左子树 4=>2=>1 无 5. 把4出栈,访问节点4无右子树,函数执行完毕 2=>1 4 6. 节点2出栈,访问节点2,递归遍历右子树 =>1 4 2 7. 节点5入栈,无左子树 5=>1 4 2 8. 节点5出栈,访问节点5,无右子树,函数执行结束 =>4 4 2 5 9. 节点2左右子树访问完成函数结束,节点1出栈,访问节点1,递归遍历右子树 空 4 2 5 1 10. 节点3入栈 有左子树,递归遍历左子树 =>3 4 2 5 1 11. 节点6入栈,无左子树 6=>3 4 2 5 1 12. 访问节点6,无右子树,函数结束 =>3 4 2 5 1 6 10. 节点3出栈,访问节点3,无右子树,函数结束 空 4 2 5 1 6 3 11. 节点1为根节点的树函数执行结束 空 4 2 5 1 6 3
那么前序和后序遍历啊也是一样的道理,下面用代码实现利用栈的非递归算法遍历:
//后序遍历
void inOrder(Bitree *T){
stack *s = initStack(); //初始化一个栈
Bitree *t = T;
while(t || isEmpty(s)){
if(t){
push(s, t); // 把当前节点入栈
t = t->lnode; //遍历左子树
}
else(){
t = pop(s); //出栈;
printf("%d ",t->data); //访问出栈节点
t = t->rnode; //访问右子树
}
}
}
//先序遍历
void PreOrder(Bitree *T){
stack *s = initStack(); //初始化一个栈
Bitree *t = T;
while(t || isEmpty(s)){
if(t){
printf("%d ",t->data); //访问出栈节点
push(s, t); // 把当前节点入栈
t = t->lnode; //遍历左子树
}
else(){
t = pop(s); //出栈;
t = t->rnode; //访问右子树
}
}
}
但是在后序遍历的实现中要比这两个方法要麻烦,因为访问根节点的前提是,根节点的左右孩子均已经被访问,这就为流程控制带来了问题。所以思路应该是,先从左树一直入栈,知道当前节点没有左子树,这时候访问栈顶元素,是否有右子树,若有继续重复上述过程。若无出栈访问节点。
//后序遍历
void PostOrder(Bitree *T){
stack *s = initStack(); //初始化一个栈
Bitree *t = T;
r = NULL;
while(t || isEmpty(s)){
if(t){
push(s, t); // 把当前节点入栈
t = t->lnode; //遍历左子树
}
else(){
t = gettop(s); //获取栈顶元素;
if(t->rnode && t->rnode != r){ //判断是否有无未被访问的右子树
t = t->rnode;
}
else{
t = top(s); //出栈
printf("%d ",t->data); //访问节点
r = p; // 记录被访问节点
p = NULL; // 访问后重置,避免死循环
}
}
}
}
除了先序遍历,中序遍历和层序遍历之外,还有一种遍历是层序遍历,它不同于其他三种按照节点的关系进行遍历,而是按照所在层遍历例如上图层序遍历顺序为: 1 2 3 4 5 6
既然我们不再按照节点关系去遍历树的话,那自然就不需要利用到栈,但是我们按照遍历顺序可以看到,层序遍历从左至右,从上向下依次输出就像是队列一样,根节点入队,出队时把左右孩子入队。如此按照顺序入队出队便完成了层序遍历
void levelOrder(Bitree *t){
queue *q = initQueue();
push(q, t);
while(!isEmpty(q)){ //队列非空则循环
int a = top(q); //出队
printf("%d ", a); //访问节点
if(t->lnode != NULL){ //如果有左节点,加入队列
push(q, t->lnode);
}
if(t->rnode != NULL){ //如果有右节点,加入队列
push(q, t->rnode);
}
}
}
之前讲了四种遍历二叉树的方式,可通一棵树确定节点的先后序列,我们也可以用序列确定一棵树,但是只可以用前序+中序或中序+后序或中序+层序
前序和中序是不可以唯一确定一棵树的
1.线索二叉树概念
遍历二叉树是以一定的规则将二叉树中的节点排列成一个线性序列。使得该序列中的每个节点除了(第一个和最后一个),都有一个前驱和后继。
传统的二叉链表存储仅能体现一种父子关系,不能直接得到节点在遍历中的前驱或后继,前面提到,在含n个节点的二叉树中,有n+1个空指针,由此设想能否使用这些指针来放入其前驱和后续指针,使其遍历就像链表一样。
(引入线索二叉树的目的就是为了加快查找节点的前驱和后继的速度)
typedef struct Node{
int data, ltag, rtag;
node *left, *right;
}node;
可以看到线索二叉树的定义多了ltag和rtag两个标志域。
当ltag其值为1的时候,left域指示节点为前驱,为0时指示左孩子
当rtag其值为1的时候,right域指示节点为后继,为0时指示右孩子
以这种节点结构构成的二叉链表作为二叉树的存储结构,称为线索链表,其中指向节点前驱和后继的指针为线索,加上线索的二叉树为线索二叉树
线索二叉树是一种物理结构
前序线索树:按照前序遍历的序列,把节点空指针指向相应的前驱后继
中序线索树:按照中序遍历的序列,把节点空指针指向相应的前驱后继
后序线索树:按照后序遍历的序列,把节点空指针指向相应的前驱后继
但是在后续线索树的遍历中必须要有栈的的支持,而前序和中序则不需要。且即使在线索化之后,后续线索二叉树依然不能对树中求后序后继的问题有效解决。因为并不是每个节点都可以通过线索找到他的前驱后继
树的存储方式我们已经知道可以采用顺序式存储,也可采用链式存储结构,但无论采用何种形式都要求可以唯一反映出树中各节点之间的关系。所以针对树还有其他的存储结构。
这种存储方式采用一组连续的空间来存储每个节点,同时在每个节点中增设一个伪指针,指示其双亲所在位置。根节点下标为0,其伪指针域为-1。
typedef struct node{
int data;
int parent; //双亲下标
}node;
typedef struct tree{
node *array; //节点数组
int n; //节点数
}
该存储结构存储了除根节点外每个节点只有唯一双亲的性质,可以很快得到每个节点双亲的节点,但是求节点的孩子时需要遍历整个结构。
孩子表示法,就是将每个节点的孩子节点用单链表连接起来形成一个线性结构,此时n个节点就有n个孩子链表(叶子节点的孩子链表为空表)
这种方式寻找子女的方式非常直接但是,但是寻找双亲的操作需要遍历n个节点中孩子链表指针域所指向的n个孩子的链表。
孩子兄弟表示法又称二叉树表示法,即以二叉链表作为树的存储结构,孩子兄弟表示法使每个节点包括三部分内容:节点值,指向节点第一个孩子节点的指针,及指向节点下一个兄弟节点的指针。(左孩子右兄弟)
typedef struct node{
int data;
node *child, *bro;
}node;
这种存储方式比较灵活,其最大优点是可以方便地实现树转换二叉树的操作,易于查找孩子节点。但从当前节点查找双亲节点就比较麻烦,若为每一个节点增设一个parent指针域指向其父节点,则可改善。
由于二叉树和树都可以用二叉链表作为存储结构,因此以二叉链表作为媒介可以导出树与二叉树的一个对应关系,即给定一棵树,可以找到唯一的一棵二叉树与之对应,从物理结构上看,他们的二叉链表是相同的只是解释不同。
树转换二叉树原则,左孩子右兄弟。
森林转换二叉树方法类似:先把森林中每棵树转换成二叉树,再按照右兄弟的原则把树连接。
将森林转换成二叉树时,森林中叶节点的个数等于二叉树左孩子指针为空的个数
与之前介绍类似,这里只放一个对应关系
树 | 森林 | 二叉树 |
---|---|---|
先根遍历 | 先序遍历 | 先序遍历 |
后根遍历 | 中序遍历 | 中序遍历 |
给定N个权值作为N个叶子结点,构造一棵二叉树带权路径长度最小的二叉树称为哈夫曼树
构造方法为:每次选出两个权值最小的结点进行合并将合并后的点放回后,重复上述过程直到最终剩余一个结点作为树根(以以下节点为例)
生成的哈夫曼树为
哈夫曼树不唯一,因为我们可以把两个兄弟节点相互调换位置
带权路径长度 = 叶子节点乘以层数之和 = 分支节点之和。
哈夫曼树的带权路径长度一定比其他节点组成的树的长度小。
哈夫曼树应用
哈夫曼编码:对于哈夫曼树中的叶子结点,从根节点出发向左子树移动编码为0,向右子树移动编码为1,到达自身的所有路径连接起来,即为该叶子结点的编码
根据哈夫曼编码可以得出一个唯一的对应序列,因为哈夫曼编码中不存在一个编码是另一个编码的前缀的情况。若出现一个最长的编码,他一定是叶子节点。
并查集是很多个集合,一个集合代表一棵树,他解决的问题是连通性的问题,就好比朋友的朋友是朋友。那么他主要有两个操作,一个是合并,一个是查询。我们采用一维数组进行存取。而数组存储的是父节点的编号。
如图所示,代表六个节点的父节点分别为自己,下面是简单的合并操作
把1合并到2,先访问1的父节点,发现1就是自己父节点,同样2是自己的父节点,所以把1的父节点改为2,在树中操作如图中所示。
树与并查集对应关系。
同样查询操作与查询操作相似都是找自己的父节点,若最后找到的根节点相同则两个点是在一个集合当中的,若不相同则不在同一树中。