数据结构之二叉树

前言

普通树的存储、运算都存在一定的困难,所以我们想能不能另辟捷径,我们来讨论一棵简单树——二叉树。

在这里插入图片描述


一.二叉树的定义、特点和形态

1.二叉树的定义

(形式化定义,与树一样也是递归定义的)

二叉树是有限的节点集合——递归定义
① 这个集合或者是空。
②或者由一个根节点和两棵互不相交的称为左子树右子树的二叉树组成。(非空)

例如:
在这里插入图片描述

2.二叉树的特点

(1) 结点的度小于等于2
(2)为有序树(子树有序,不能颠倒)

(所以二叉树也不算是普通树的特例,因为二叉树本身的特点是它是一棵有序树)

3.二叉树的五种基本形态

在这里插入图片描述

二.二叉树的性质(五种)

1.性质1:

在二叉树的第i层上至多有 2的i-1次方 个结点
(证明用数学归纳法)

2.性质2:

深度为k的二叉树至多有 2的k次方-1个 结点
①每一层最少要有1个结点,因此,最少结点数为 k。
②最多结点个数借助性质1:用求等比级数前k项和的公式:
2的0次方+2的1次方+2的2次方+…+2的k-1次方=2的k次方-1

(记住结论即可)

3.性质3:

对于任何一棵二叉树,若度为2的结点数有n2个,则叶子结点数n0(因为叶子结点数度为0,所以我们把它写成n0)必定为n2+1 (即n0=n2+1

(二叉树除了度为0、2的结点外还有度为1的结点)

证明:若设度为 1 的结点有 n1 个,总结点数为n,总边数为B


在这里插入图片描述
从图中我们可以看出,对于二叉树来说,除了根结点是没有边伸向他的,其他的结点都有一条边伸向他,所以我们得出:B = n −1。也就是每条边都伸向一个结点。


在这里插入图片描述

接下来,我们从上往下看,我们可以发现:我们算边数的时候,相当于结点是度为2的伸出两条边,度为1的伸出一条边,度为0的结点没有伸出边来,因此,我们得出:B = n2 * 2 + n1 * 1


对于B = n −1 ,B = n2 * 2 + n1 * 1这两个式子,我们划个等式n −1 = n2 * 2 + n1 * 1,得到n = n2 * 2 + n1 * 1 + 1,又因为n = n2 + n1 + n0,所以再将这两个式子划个等式,得到n0 = n2 + 1

满二叉树

(满二叉树的直接意思就是每一层都充满了结点)

(1)定义:
在一棵二叉树中,如果所有分支节点都有左孩子节点和右孩子节点,并且叶节点都集中在二叉树的最下一层,这样的二叉树称为满二叉树

(最后一层都是叶子,越往上的层没有叶子,但也都充满着)

在这里插入图片描述

(2)特点:

①每层都“充满”了结点;
②深度为k 的满二叉树,有2的k次方 -1个结点(假设一共有k层);(最多的)
③叶子节点都在最下一层;
④只有度为0和度为2的节点,没有度为1的结点;
⑤一旦n(这棵树结点的个数)或h(这棵树结点的高度)确定,树型就确定了h=log2(n+1) (2是低数)

完全二叉树

(1)定义:
在一棵二叉树中,深度为k ,有n个结点,除第 k 层外,其它各层 (1~k-1) 的结点数都达到最大个数,第k层从右向左连续缺若干结点,称为完全二叉树

(跟满二叉树比起来,假设深度为k,那么只有第k层是不满的,其他各层都是满的,并且第k层是从右往左缺若干个结点)
在这里插入图片描述

(2)特点:

①叶子只能出现在层次最大的两层
②最大层次上的叶子,都在最左边(因为右缺)
③如果有度为1的节点,只能有个,且该节点只有左孩子,没有右孩子
④按层编号后,一旦出现某结点(编号为i)为叶子或只有左孩子,则编号大于i的结点,均为叶子
⑤完全二叉树一旦n确定,其树形就确定了,可以计算出高度h以及n0、n1和n2,其中n1=0或1,当n为偶数时,n1=1,当n为奇数n1=0。另:h=[log2(n+1)] ([]为上取整)

4.性质4:

具有 n (n≥0) 个结点的完全二叉树的深度为 [log2
(n+1)] ([]为上去整)

(即一棵二叉树的结点个数确定之后,我们就能确定他的深度)

证明:
在这里插入图片描述

(2的k减1次方-1是上面k-1层结点数,n肯定是>他的,而根据兴性质2,二叉树的最大结点个数为2的k次方-1个,所以n是<=2的k次方-1个的,也就是可以右缺,也可以满,把成立的这个式子变形再取对数即可得到)

5.性质5:

将一棵有n个结点的完全二叉树自顶向下,同一层自左向右连续给结点编号1, 2, …, n,则有以下关系:

①若i = 1, 则 i 无双亲 (实际上表示i=1时为根结点)

②若i > 1, 则 i 的双亲为**【i/2】**(在这里【】是下取整)
例如:5的双亲为5/2下取整为2.

③若2*i <= n, 则 i 的左子女2*i, 若2*i+1 <= n, 则 i 的右子女2*i+1
例如:5的25<=10,所以5的左子女为10, 25+1>10,所以5没有右子女
同理,4的左子女为8,右子女为9(先要满足条件)

④若 i 为奇数, 且i != 1, 则其左兄弟为 i-1

⑤若 i 为偶数, 且i != n, 则其右兄弟为 i+1

在这里插入图片描述

三.二叉树的存储

1.二叉树的顺序存储
(1)特殊二叉树:完全二叉树的顺序存储

在这里插入图片描述

①按满二叉树编号次序存储结点,依次存放二叉树中的数据元素。
②编号为i的节点的左孩子编号为2i右孩子编号为(2i+1)除树根节点外,编号为i的结点的双亲节点的编号为[i/2](下取整)

(一棵完全二叉树,我们按照完全二叉树的编号给他编,从上到下,从左往右,依次1,2,3…给每个结点编上号,然后按照编号的位置把它放到内存中去,下标是1的时候放的是A,下标是2的时候放的是B,那么这样放有什么好处呢?
我们会发现用这个方式存储,会使二叉树中每一个结点的位置蕴含着双亲和孩子之间的关系。比如:C它的编号是3,那么他的左孩子就是2i,就是6存的F,右孩子就是2i+1,7存的G)

因此,完全二叉树的顺序存储是通过结点的位置下标来蕴含着结点和结点之间的关系的。

(2)对于一般二叉树的顺序存储

在这里插入图片描述

①先用空节点补全成为完全二叉树
②再对节点编号 (是层序编号)
③最后确定存储

但是会存在着很多空闲的空间没有被利用。

(3)极端情形

只有右单支的二叉树

在这里插入图片描述
这时候如果还要按照顺序存储来存就要补空节点了,需要补很多空的,所以他存在着很大的空间浪费

二叉树的顺序存储特点:
• 结点间关系蕴含在其存储位置中
• 浪费空间,适于存满二叉树和完全二叉树

2.二叉树的链式存储

二叉树的链式存储又有几种方式,二叉链表和三叉链表还有一个静态链表,二叉链表就是有两个指针。

在这里插入图片描述

(1)二叉链表

在这里插入图片描述
(这是一个结点,这个结点的存储方式中间是数据域,两边是一个指向左孩子一个指向右孩子的指针)

✓每个结点有3个成员,
data域存储结点数据,
leftChildrightChild分别存放指向左子女和右子女的指针

(对于这棵树来说,我们要把它表示成二叉链表的形式,首先要写出他的存储结构,存储结构实际上就是他的结点的定义)

代码表示:

typedef struct BiNode
{
	TElemType data;   //定义数据域 
	struct BiNode *lchild,*rchild;  //定义了左孩子右孩子两个指针 
}BiNode,*BiTree;    //定义了这样一个node 这样一个二叉树 

相当于根结点是这个链表的首元结点,然后有一个头指针指向他,或者说中间再加上一个头结点。

在这里插入图片描述
(有左孩子的时候指向左孩子,有右孩子的时候指向右孩子,没有的时候指针域是空的。)

(2)三叉链表

在这里插入图片描述
(三叉链表是每个结点有四个成员,在二叉链表的基础上还增加了一个指针域是指向双亲结点的)

✓每个结点有4个成员,
data域存储结点数据,
leftChildrightChild分别存放指向左子女和右子女的指针。
✓每个结点增加一个指向双亲的指针parent,使得查找双亲也很方便。

代码表示:

typedef struct TriTNode
{ 
	TelemType data;
	struct TriTNode *lchild,*parent,*rchild; //增加了一个parent指针
}TriTNode,*TriTree;

在这里插入图片描述

(有专门的指针域去指向他的双亲)

三叉链表比起二叉链表呢虽然是增加了一些指针域一些空间,但是这种存储方式可以使我们一下子找到某个结点的双亲,而二叉链表要找 双亲的时候必须从链表的首元结点依次往后顺着去找才可以。因此,三叉链表这个存储方式增加了一些存储空间,但对于某些算法提高了效率。

例:对这棵二叉树进行表示
在这里插入图片描述

(3)静态链表

除了上述两种表示之外,还有一种叫静态链表。
实际上就是一个数组顺序存储,但是数组中的每一个结点除了包含这些数据域以外,还包含他的双亲结点的地址,还有左孩子右孩子的地址,其实就是下标。

在这里插入图片描述

四.二叉树的遍历

1.遍历的定义

(1)指按某条搜索路线遍访每个结点且不重复(又称周游)。
(2)是树结构插入、删除、修改、查找和排序运算的前提
是二叉树一切运算的基础和核心

2.二叉树的三种遍历

在这里插入图片描述

(1)先序遍历:根节点–>左子树–>右子树。 ABDC

(2)中序遍历:左子树–>根节点–>右子树。 BDAC

(3)后序遍历:左子树–>右子树–>根节点。 DBCA

(先中后序就是指先根中根和后根)

3.二叉树的遍历算法(递归)

遍历可以用递归,也可以用非递归,在这里呢我们讨论递归的方式
在这里插入图片描述

(1)先序遍历

①算法思路:

若二叉树为空,则空操作
否则
访问根结点 (D)
先序遍历左子树 (L)
先序遍历右子树 (R)

②过程:
在这里插入图片描述
(D是根的意思,L是左子树,R是右子树。对于上面树来说,我们先访问根A,然后再访问左子树L,对于左子树也是先序遍历,先访问左子树的根B,然后遍历他的左子树为空返回,然后再遍历他的右子树根为D,左子树右子树都为空返回,再返回回来,再访问这棵总树的右子树R,右子树也是先序遍历,先遍历他的根C,然后C的左和右都为空,返回,这样就结束了。)

实际上递归算法写起来比较简单,但是执行过程是比较复杂的。这样我们就得到了一个先序遍历序列A B D C。

③回忆递归算法:

例:求n!算法

long Factorial ( long n )      //封装为函数 
{
	if ( n == 0 ) return 1;//基本项(先判断,递归返回项,我们把它叫做基本项) 
	else return n * Factorial (n-1); //归纳项  (否则,返回递归的归纳项) 

}

用这个算法回忆一下递归算法怎么写

代码描述:

Status PreOrderTraverse(BiTree T)   //封装为函数 

{
	if (T==NULL) return OK; //空二叉树
	else
	{ 
		cout<<T->data; //访问根结点
		PreOrderTraverse(T->lchild); //递归遍历左子树
		PreOrderTraverse(T->rchild); //递归遍历右子树
	}
}

⑤代码具体执行过程:

在这里插入图片描述

(2)中序遍历

①算法思路:

若二叉树为空,则空操作
否则:
中序遍历左子树 (L)
访问根结点 (D)
中序遍历右子树 (R)

②过程:

在这里插入图片描述

代码描述

Status InOrderTraverse(BiTree T)   //封装为函数 
{
	if (T==NULL) return OK; //空二叉树
	else
	{ 
		InOrderTraverse(T->lchild); //递归遍历左子树
		cout<<T->data; //访问根结点
		InOrderTraverse(T->rchild); //递归遍历右子树
	} 
}
(3)后序遍历

①算法思路:

若二叉树为空,则空操作
否则
后序遍历左子树 (L)
后序遍历右子树 (R)
访问根结点 (D)

②过程:

在这里插入图片描述

代码描述

Status PostOrderTraverse(BiTree T)  //封装为函数 
{
	if (T==NULL) return OK; //空二叉树
	else
	{ 
		PostOrderTraverse(T->lchild); //递归遍历左子树
		PostOrderTraverse(T->rchild); //递归遍历右子树
		cout<<T->data; //访问根结点
	} 
}

(三种遍历算法前面都一样,就是递归访问根结点的顺序不同而已)

4.遍历表示表达式

对于这棵树实际上给我们展现的是一个表达式,外结点(叶子结点)都是运算数(操作数),这些内结点(也就是度不是零的这些结点分支节点)表示的是运算符,对这么一棵树进行表示:
在这里插入图片描述

先序遍历: - + a * b - c d / e f 前缀表示

中序遍历 :a + b * c - d - e / f 中缀表示

后序遍历 :a b c d - * + e f / - 后缀表示

层序遍历 :- + / a * e f b – c d

(分别对它进行不同的遍历,得到了不同的遍历序列,需要熟悉掌握用这是四种不同的遍历方法写出树不同的遍历序列)

5.总结

在这里插入图片描述
先序:ABDEFGC
中序:DBFEGAC
后序:DFGEBCA

(1)如果去掉输出语句,从递归的角度看,三种算法是完全相同

(2)三种算法的访问路径是相同的,只是访问结点的时机不同

(3)从虚线的出发点到终点的路径上,每个结点经过3次

第1次经过时访问=先序遍历
第2次经过时访问=中序遍历
第3次经过时访问=后序遍历

(4)复杂度:

时间效率:O(n)
//三种遍历都是这样,因为每个结点只访问一次

空间效率:O(n)
//栈占用的最大辅助空间,因为在递归的过程中我们实际上用到的是栈,递归工作栈。

五.二叉树的(遍历)应用

在讨论二叉树的遍历时我们采用的是递归的方法,当然,我们也可以采用非递归的方法,采用递归的方法是在系统中它建立了一个递归工作栈(也就是它的存储),如果我们采用非递归的方式,随着我们二叉树的结点访问的序列不断深入的时候,他访问的路径又有回退,这时候我们是要自己建立一个栈的,通过栈模仿递归的过程,实现非递归。由二叉树遍历递归的方法,我们来看一下二叉树的几个应用。

1.求二叉树的结点个数

可以用逐个访问找到结点,也是用到递归遍历,所以遍历是一个基础。

算法思路:

➢ 如果是空树,则结点个数为0; (递归返回)
➢ 否则,结点个数为左子树的结点个数+右子树的结点个数再+1(根结点)。
(左子树的结点个数也是左子树的左子树的结点个数+他的右子树的结点个数+1,一层一层递归进去,然后再返回)

在这里插入图片描述
代码描述:

int NodeCount(BiTree T)
{ 
	if (T == NULL ) return 0;   //如果是空树,返回0 
	else return NodeCount(T->lchild)+NodeCount(T->rchild)+1;
}
2.求二叉树叶子结点的个数

同样也是这个思路,左子树的叶子结点个数+右子树的叶子结点个数

算法思路:

➢ 如果是空树,则叶子结点个数为0;
➢ 如果是叶子结点,则个数为1;
➢ 否则,结点个数为左子树的叶子结点个数+右子树的叶子结点个数

代码描述:

int LeafCount(BiTree T)
{
	if(T==NULL) //如果是空树返回0
	return 0;
	if (T->lchild == NULL && T->rchild == NULL)
	return 1; //如果是叶子结点返回1 (T的左子树和右子树都是空的时候,证明此时T为叶子) 
	else return LeafCount(T->lchild) + LeafCount(T->rchild);  //否则,返回左子树的叶子+右子树的叶子 
}
3.建立一棵二叉树

(1)按先序遍历序列建立二叉树的二叉链表

例如:A B C # # D E # G # # F # # #

在这里插入图片描述

算法思路:

✓以递归方式建立二叉树。
✓输入结点值的顺序必须对应二叉树结点先序遍历的顺序。
✓约定以输入序列中不可能出现的值作为空结点的值以结
束递归

(先序遍历当然是先建根了,剪完根之后,再建他的左子树,右子树,左子树也是先建他的根,再建他的左子树,右子树,所以,二叉树的建立也是以递归的方式。
注意:这个先序遍历序列也一定要留出叶子结点左右孩子的位置,叶子结点的左右孩子也要给他补全,实际上是作为递归结束的方式,到叶子的时候改结束了,在这我们用#表示)

代码描述:

void CreateBiTree(BiTree &T)
{
	cin>>ch;   //输入一个字符
	if (ch==’#’) T=NULL; //递归结束,建空树
	else
	{
		T=new BiTNode; T->data=ch; //生成根结点
		CreateBiTree(T->lchild); //递归创建左子树
		CreateBiTree(T->rchild); //递归创建右子树
	}
}
4.构造一棵二叉树

(1)同一棵二叉树具有唯一先序序列、中序序列和后序序列。

(3)不同的二叉树可能具有相同的先序序列、中序序列和后序序列。
在这里插入图片描述
在这里插入图片描述
• 仅由一个先序序列(或中序序列、后序序列),无法确定这棵二叉树的树形
• 思考:给定先序、中序和后序遍历序列中任意两个,是否可以唯一确定这棵二叉树的树形?

(4)结论:
在这里插入图片描述

若二叉树中各结点的值均不相同,则:
• 由二叉树的前序序列和中序序列,可唯一确定一棵二叉树(确定后序)
• 由二叉树的后序序列和中序序列,可唯一确定一棵二叉树(确定前序)
• 由前序序列和后序序列不一定能唯一地确定一棵二叉树。

5.思考

在n个结点的二叉链表中,有n+1 个空指针域。

在这里插入图片描述
分析:必有2n个链域。除根结点外,每个结点有且仅有一个双亲,所以只会有n-1个结点的链域存放指针,指向非空子女结点。空指针数目=2n-(n-1)=n+1

结点个数为n的二叉树,他的指针域还有n+1个在空闲着,这些空指针很浪费,而二叉树最常用的操作是遍历。如何利用好空余指针域,解决最关键最常用遍历的效率问题?

六.二叉树的线索化——线索化二叉树

1.什么是二叉树的线索化?

(1)二叉树的遍历可将二叉树中所有结点排列成一个线性序列,由序列可以找到某个结点的前驱和后继

在这里插入图片描述
比如说这棵二叉树,我们进行中序遍历的时候,得到了中序遍历序列
bdaec,拿其中任何一个结点比如a,我们就知道a的前驱是b,a的后继是e。

这样每次通过遍历序列找前驱后继浪费时间,我们可以改进一下。

(2) 事先做预处理,将某种遍历顺序下的前驱、后继关系记在二叉树的存储结构中,以便高效地找出某结点的前驱、后继。

(也就是说我们想要在这个结点的存储过程中就已经存下了这个结点的前驱和后继)

(3)这种对结点前驱、后继关系的记录,称为线索

主要目的:提高遍历效率

2.如何记录线索呢?

(1) 方法一:存储结构增加两个域:前驱指针pred和后继指针succ
在这里插入图片描述
(在二叉树结点存储的时候,在原来的二叉链表的情况下,又增加了两个指针域,一个指向前驱,一个指向后继,这样的话,每个结点的前驱和后继马上就能找到了,在O(1)的时间复杂度下)

缺点:当结点数很大很多时存储消耗较大。

(2)方法二:利用空链域n+1个空链域)改造二叉树结点
在这里插入图片描述
①改造二叉树的结点,将 pred 指针和 succ 指针压缩到 leftChildrightChild 的空闲指针中;
(当一个结点没有左孩子或者右孩子的时候,他的指针域是空着的,那就让他利用起来,指向前驱和后继,那么什么时候指向的是前驱后继,什么时候指向的是子女)

②增设两个标志 ltagrtag,指明指针是指示子女还是前驱/后继;

③若 lTag=0, lchild域指向左孩子;若 lTag=1, lchild域指向其前驱(线索)。
④若 rTag=0, rchild域指向右孩子;若 rTag=1, rchild域指向其后继(线索)。

3.如何进行线索化?

中序遍历序列:bdaec

(要先写出序列,再画二叉树)
在这里插入图片描述
在这里插入图片描述

(首先,我们写出他的存储结构,加了两个整型的标志位,是0的时候,表示指向子女,是1的时候,表示指向线索(前驱和后继)的。先看b,他没有左孩子,又是第一个结点,因此前面是1、空,他有右孩子,因此是0,指针域指向右孩子d;再看d,d的前驱是b,d是一个叶子结点,既没有左孩子也没有右孩子,因此前面是指向前驱,1,、b,他的后继是a,因为他没有右孩子,1、a;再看a,a没有前驱和后继,两个指针分别指向左孩子b和右孩子c,所以标志位为0、0;接下来是e,e是叶子结点,没有左孩子右孩子,所以前面是1、指向前驱a,后面是1、指向后继c;最后一个结点是c,c有左孩子,所以0、指向e,他没有右孩子,而且他是遍历序列当中的最后一个元素了,因此右面是1、指向空)

typedef struct node 
{ 
	ElemType data; 
	int ltag,rtag;   //两个标志位
	struct node *lchild; 
	struct node *rchild; 
} TBTNode;

讨论:在上述线索化二叉树记录的线索中,当访问到b结点时能通过线索找到其后继结点吗?为什么?如何才能找到?

b里面存的是他的右孩子,没有存他的后继结点,右孩子不见得是他的后继,无法直接找到,要想找到后继,我们只能再通过遍历序列来找b后面是谁。
这个讨论告诉我么:线索化二叉树是把空闲的指针利用起来,来存储前驱和后继的线索,但并不是每个结点的前驱和后继都能够一下子找到,因为有的时候他存的是指向左子女和右子女的指针,有的时候是指向前驱和后继的指针。所以他只能说是在一定程度上提高了遍历的效率。


后续

以上就是二叉树的全部基础知识的了,学习了二叉树的定义、性质、存储、遍历以及他的应用和线索化,其中最重要的就是他的递归遍历了,之后我们还会再去学习二叉树的另一种应用——哈夫曼树。
最后,一句调侃语句送给正在肝数据结构的你我
——待我长发及腰,分如二叉树梢,早起满冠枯草,睡前一头蓬毛。

  • 1
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值