数据结构学习笔记——第5章 树与二叉树

数据结构学习笔记——第5章 树与二叉树

  • 5 树与二叉树
    • 5.1 树的基本概念
      • 5.1.1 树的定义
      • 5.1.2 基本术语
      • 5.1.3 树的性质
    • 5.2 二叉树的概念
      • 5.2.1 二叉树的定义及其主要特性
        • 二叉树的定义
        • 几个特殊的二叉树
        • 二叉树的性质
      • 5.2.2二叉树的存储结构
        • 顺序存储结构
        • 链式存储结构
    • 5.3 二叉树的遍历和线索二叉树
      • 5.3.1 二叉树的遍历
        • 先序遍历
        • 中序遍历
        • 后序遍历
        • 递归算法和非递归算法的转换
        • 层次遍历
        • 由遍历序列构造二叉树
      • 5.3.2 线索二叉树
        • 线索二叉树的基本概念
        • 中序线索二叉树的构造
        • 中序线索二叉树的遍历
        • 先序线索二叉树和后续线索二叉树
    • 5.4 树、森林
      • 5.4.1 树的存储结构
        • 双亲表示法
        • 孩子表示法
        • 孩子兄弟表示法
      • 5.4.2 树、森林与二叉树的转换
      • 5.4.3 树和森林的遍历
      • 5.4.4 树的应用——并查集
    • 5.5 树与二叉树的应用
      • 5.5.1 二叉排序树(BST)
        • 二叉排序树的定义
        • 二叉排序树的查找
        • 二叉排序树的插入
        • 二叉排序树的构造
        • 二叉排序树的删除
        • 二叉排序树的查找效率分析
      • 5.5.2 平衡二叉树
        • 平衡二叉树的定义
        • 平衡二叉树的判断
        • 平衡二叉树的插入
        • 平衡二叉树的查找
      • 5.5.3 哈夫曼树和哈夫曼编码
        • 哈夫曼树的定义
        • 哈夫曼树的构造
        • 哈夫曼编码

5 树与二叉树

5.1 树的基本概念

5.1.1 树的定义

  • 是n(n>=0)个结点的有限集。当n等于0时,称为空树。在任意一棵非空树应满足:
    • 有且仅有一个特定的称为的结点
    • 当 n>1 时,其余结点可分为m(m>0)个互不相交的有限集T1, T2, …, Tm,其中每个集合本身又是一棵树,并且成为根的子树
  • 树是一种递归的数据结构
  • 树作为一种逻辑结构,同时也是一种分层结构,具有以下两个特点:
    • 树的根结点没有前驱,除根结点外的所有结点有且只有一个前驱
    • 树中的所有节点可以有零个或多个后继
  • 树适用于表示具有层次结构的数据
  • 在 n 个结点的树中有 n-1 条边

5.1.2 基本术语

  • 考虑结点 K 。根 A 到结点 K 的唯一路径上的任意结点,称为结点 K 的祖先。如结点 B 是结点 K 的祖先,而结点 K 时结点 B 的子孙。路径上最接近结点 K 的结点 E 称为 K 的双亲,而 K 为结点 E 的孩子、根 A 是树中唯一没有双亲的结点。有相同双亲的结点称为兄弟,如结点 K 和结点 L。
    数据结构学习笔记——第5章 树与二叉树_第1张图片
  • 树中一个结点的孩子的个数称为该结点的度,树中结点的最大度数称为树的度
  • 度大于0的结点称为分支节点(又称非终端结点);度为0的结点称为叶子结点(又称终端结点)。在分支结点中,每个结点的分支数就是该节点的度。
  • 结点的深度、高度和层次
    • 结点的层次从树根开始定义,根结点为第1层,它的子结点为第2层,以此类推。双亲在同一层的结点互为堂兄弟
    • 结点的深度是从根结点开始自顶向下逐层累加的。
    • 结点的高度是从叶结点开始自底向上逐层累加的。
    • **树的高度(或深度)**是树中结点的最大层数。
  • 有序树和无序树。树中结点的各子树从左到右是有次序的,不能互换,称该树为有序树,否则称为无序树
  • 路径和路径长度。树中两个结点之间的路径是由这两个结点之间所经过的节点序列构成的,而路径长度是路径上所经过的边的个数。
    • 注意:由于树中的分支是有向的,即从双亲指向孩子,所以树中的路径是从上向下的,同一双亲的两个孩子之间不存在路径。
  • 森林。森林是m(m>=0)棵互不相交的树的集合。森林的概念与树的概念十分相近,因为只要把树的根结点删去就成了森林。反之,只要给m棵独立的树加上一个结点,并把这m棵树作为该节点的子树,则森林就变成了树

5.1.3 树的性质

  • 树中的结点数等于所有结点的度数加1
  • 度为m的树中第i层上制度有 m i − 1 m^{i-1} mi1个结点(i>=1)
  • 高度为h的m叉树至多有 ( m h − 1 ) / ( m − 1 ) (m^h - 1)/(m - 1) (mh1)/(m1)个结点
  • 具有n个结点的m叉树的最小高度为 ⌈ l o g m ( n ( m − 1 ) + 1 ) ⌉ \left \lceil log_m(n(m-1)+1) \right \rceil logm(n(m1)+1)

5.2 二叉树的概念

5.2.1 二叉树的定义及其主要特性

二叉树的定义

  • 二叉树是一种树形结构,其特点是每隔结点至多只有两个子树(即二叉树不存在度大于2的结点),并且二叉树的子树有左右之分,其次序不能任意颠倒(有序树)
  • 二叉树是n(n>=0)个结点的有限集合(递归定义):
    • n=0时,二叉树为空
    • n>0时,由一个根结点和两个互不相交的被称为根的左子树和右子树组成。左子树和右子树又分别是一棵二叉树
  • 五种基本形态
    数据结构学习笔记——第5章 树与二叉树_第2张图片
  • 二叉树与度为2的有序树的区别:
    • 度为2的树至少有3个结点,而二叉树可以为空
    • 度为2的有序树的孩子的左右次序是相对于另一孩子而言的,若某个节点只有一个孩子,则这个孩子就无须区分其左右次序,而二叉树无论其孩子数是否为2,均需确定其左右次序,即二叉树的结点次序不是相对于另一结点而言,而是确实的

几个特殊的二叉树

  • 满二叉树。一棵高度为h,且含有 2 h − 1 2^h-1 2h1个结点的二叉树称为满二叉树,即书中的每层都含有最多的结点。满二叉树的叶子结点都集中在二叉树的最下一层,并且除叶子结点之外的每个结点度数均为2。
    • 可对满二叉树按层序编号:约定编号从根结点(1)起,自上而下,自左向右。这样,每个结点对应一个编号,对于编号为 i i i 的结点,若有双亲,则其双亲为 ⌊ i / 2 ⌋ \left \lfloor i/2 \right \rfloor i/2 ;若有左孩子,则左孩子为 2 i 2i 2i ;若有右孩子,则右孩子为 2 i + 1 2i+1 2i+1
      数据结构学习笔记——第5章 树与二叉树_第3张图片
  • 完全二叉树。高度为 h 、有 n 个结点的二叉树,当且仅当其每个结点都与高度为 h 的满二叉树中编号为 1~n 的结点一一对应时,称为完全二叉树。其特点如下:
    • i ≤ ⌊ n / 2 ⌋ i\leq \left \lfloor n/2 \right \rfloor in/2 ,则结点 i i i 为分支结点,否则为叶子结点。
    • 叶子结点只可能在层次最大的两层上出现。对于最大层次中的叶子结点,都依次排列在该层最左边的位置上。
    • 若有毒为1的结点,则只可能有一个,且该结点只有左孩子,而无右孩子(重要特征)。
    • 按层序编号后,一旦出现某结点(编号为 i i i )为叶子结点或只有左孩子,则编号大于 i i i 的结点均为叶子结点。
    • 若n为奇数,则每个分支结点都有左孩子和右孩子;若n为偶数,则编号最哈的分支结点(编号为 n / 2 n/2 n/2 )只有左孩子,没有右孩子,其余分支结点左、右孩子都有。
  • 二叉排序树。 左子树上所有结点的关键字均小于根结点的关键字;右子树上的所有结点的关键字均大于根结点的关键字;左子树和右子树又各是一棵二叉排序树。
  • 平衡二叉树。树上任一结点的左子树和右子树的深度只差不超过1。

二叉树的性质

  • 非空二叉树上的叶子结点数等于度为2的结点数加1,即 n 0 = n + 2 + 1 n_0 = n+2 + 1 n0=n+2+1
  • 非空二叉树上第 k 层上至多有 2 k − 1 2^{k-1} 2k1 个结点( k ≥ 1 k \geq 1 k1)。
  • 高度为 h 的二叉树至多有 2 h − 1 2^h-1 2h1 个结点( h ≥ 1 h \geq 1 h1)。
  • 对完全二叉树按从上到下、从左到右的顺序依次编号1, 2, …, n,则有以下关系:
    • i > 1 i>1 i>1 时,结点 i 的双亲的编号为 ⌊ i / 2 ⌋ \left \lfloor i/2 \right \rfloor i/2 ,即当 i 为偶数时,其双亲的编号为 i / 2 i/2 i/2 ,它是双亲的左孩子;当 i 为奇数时,其双亲的编号为 ( i − 1 ) / 2 (i-1)/2 (i1)/2 ,它是双亲的右孩子。
    • 2 i ≤ n 2i \leq n 2in 时,结点 i 的左孩子编号为 2 i 2i 2i ,否则无左孩子。
    • 2 i + 1 ≤ n 2i+1 \leq n 2i+1n 时,结点 i 的右孩子编号为 2 i + 1 2i+1 2i+1 ,否则无右孩子。
    • 结点 i 所在层次(深度)为 ⌊ l o g 2 i ⌋ + 1 \left \lfloor log_2i \right \rfloor+1 log2i+1
  • 具有 n 个(n>2)结点的完全二叉树的高度为 ⌈ l o g 2 ( n + 1 ) ⌉ \left \lceil log_2(n+1) \right \rceil log2(n+1) ⌊ l o g 2 n ⌋ + 1 \left \lfloor log_2n \right \rfloor+1 log2n+1

5.2.2二叉树的存储结构

顺序存储结构

  • 用一组连续的存储单元依次自上而下、自左至右存储完全二叉树上的结点元素
  • 依据二叉树的性质,完全二叉树和满二叉树采用顺序存储比较合适,树中结点的序号可以唯一地反映结点之间的逻辑关系,这样既可以最大可能地节省存储空间,又能利用数组元素的下表值确定结点在二叉树中的位置,以及结点之间的关系
  • 但对于一般的二叉树,为了让数组下标能够反映二叉树中交接点之间的逻辑关系,只能添加一些并不存在的空结点,让其每个结点与完全二叉树上的结点相对照,再存储到一维数组的相应分量中。然而,在最坏情况下,一个高度为 h 且只有 h 个结点的单支树却需要占据近 2 h − 1 2^h-1 2h1个存储单元。
  • 这种存储结构建议从数组下标1开始存储树中的结点,比较方便计算
    数据结构学习笔记——第5章 树与二叉树_第4张图片

链式存储结构

  • 二叉树一般都采用链式存储结构,用链表结点来存储二叉树中的每个结点
  • 在二叉树中,结点结构通常包括若干数据域和若干指针域,二叉链表至少包含3个域:数据域 data、左指针域 lchild、右指针域 rchild
    二叉树链式存储的结点结构
  • 在含有 n 个结点的二叉链表中,含有 n+1 空链域
  • 二叉树的链式存储结构描述如下:
typedef struct BiTNode {
	ElemType data;                        //数据域
	struct BiTNode *lchild, *rchild;      //左、右孩子指针
}BiTNode, *BiTree;

数据结构学习笔记——第5章 树与二叉树_第5张图片

5.3 二叉树的遍历和线索二叉树

5.3.1 二叉树的遍历

  • 二叉树的遍历是指按某条搜索路径访问树中的每个结点,使得每个节点均被访问一次,而且仅被访问一次
  • 按照先遍历左子树再遍历右子树的原则,常见的遍历次序有先序(NLR)、中序(LNR)和后续(LRN)三种遍历算法,其中“序”指的是根结点在何时被访问
  • 三种遍历算法,时间复杂度O(n),最坏情况空间复杂度O(n)

先序遍历

  • 先序遍历(PreOrder)的操作过程如下:
    • 若二叉树为空,则什么也不做;否则,
    • 1)访问根结点
    • 2)先序遍历左子树
    • 3)先序遍历右子树
void PreOrder(BiTree T) {
	if(T != NULL) {
		visit(T);                 //访问根结点
		PreOrder(T->lchild);      //递归遍历左子树
		PreOrder(T->rchild);      //递归遍历右子树
	}
}

中序遍历

  • 中序遍历(InOrder)的操作过程如下:
    • 若二叉树为空,则什么也不做;否则,
    • 1)中序遍历左子树
    • 2)访问根结点
    • 3)中序遍历右子树
void InOrder(BiTree T) {
	if(T != NULL) {
		InOrder(T->lchild);      //递归遍历左子树
		visit(T);                //访问根结点
		InOrder(T->rchild);      //递归遍历右子树
	}
}

后序遍历

  • 后序遍历(PostOrder)的操作过程如下:
    • 若二叉树为空,则什么也不做;否则,
    • 1)后序遍历左子树
    • 2)后序遍历右子树
    • 3)访问根结点
void PostOrder(BiTree T) {
	if(T != NULL) {
		PostOrder(T->lchild);      //递归遍历左子树
		PostOrder(T->rchild);      //递归遍历右子树
		visit(T);                  //访问根结点
	}
}

递归算法和非递归算法的转换

  • 借助
  • 中序遍历的非递归算法:
    • 1)沿着根的左孩子,依次入栈,直到左孩子为空
    • 2)栈顶元素出栈并访问;扫描其右孩子
    • 3)若其右孩子为空则继续执行2)
    • 4)若其右孩子不空,将右子树转执行1)
    • 5)反复该过程直到栈空为止
void InOrder(BiTree T) {
	InitStack(S); BiTree p = T;      //初始化栈S;p是遍历指针
	while(p || !IsEmpty(S)) {        //栈不空或p不空时循环
		if(p) {                      //一路向左
			Push(S, p);              //当前结点入栈
			p = p->lchild;           //左孩子不空,一直向左走
		} else {                     //出栈,并转向出栈结点的右子树
			Pop(S, p); visit(p);     //栈顶元素出栈,访问出栈结点
			p = p->rchild;           //向右子树走,p赋值为当前结点的右孩子
		}                            //返回while循环继续进入if-else语句
	}
}
  • 先序遍历的非递归算法:
    • 与中序遍历的基本思想类似,只需把访问结点操作放在入栈操作的前面
void PreOrder2(BiTree T) {
	InitStack(S); BiTree p = T;      //初始化栈S;p是遍历指针
	while(p || !IsEmpty(S)) {        //栈不空或p不空时循环
		if(p) {                      //一路向左
			visit(p); Push(S, p);    //访问当前结点,并入栈
			p = p->lchild;           //左孩子不空,一直向左走
		} else {                     //出栈,并转向出栈结点的右子树
			Pop(S, p);               //栈顶元素出栈
			p = p->rchild;           //向右子树走,p赋值为当前结点的右孩子
		}                            //返回while循环继续进入if-else语句
	}
}
  • 后续遍历的非递归算法:
    • 1)沿着根的左孩子,依次入栈,直到左孩子为空
    • 2)读栈顶元素:若其右孩子不空且未被访问过,将右子树转执行1);否则,栈顶元素出栈并访问
    • 在第2)步中,必须分清返回时是从左子树返回的还是右子树返回的,因此设定一个辅助指针r,指向最近访问过的结点。也可在结点中增加一个标志域,记录是否已被访问过
void PostOrder(BiTree T) {
	InitStack(S);
	p = T;
	r = NULL;
	while(p || !IsEmpty(S)) {
		if(p) {                               //走到最左边
			Push(S, p);
			p = p->lchild;
		} else {                              //向右
			GetTop(S, p);                     //读栈顶指针(非出栈)
			if(p -> rchild && p->rchild != r) {  //若右子树存在且未被访问过
				p = p->rchild;                //转向右
				Push(S, p);                   //压入栈
				p = p->lchild;                //再走到最左
			}
			else {                            //否则,弹出结点并访问
				Pop(S, p);                    //将结点弹出
				visit(p);                     //访问该结点
				r = p;                        //记录最近访问过的结点
				p = NULL;                     //结点访问完后,相当于遍历完以该节点为根的子树,重置p指针
			}
		}
	}
}
  • 按后序遍历的非递归算法,在访问一个结点 p 时,栈中结点恰好是 p 结点的所有祖先,从栈底到栈顶结点在加上 p 结点,刚好构成从根结点到 p 结点的一条路径。在很多算法设计中都可以利用这一思路来求解,如求根到某结点的路径、求两个结点的最近公共祖先等

层次遍历

  • 按照层次顺序,对二叉树中的各个结点进行访问
  • 借助队列
  • 算法思想:
    • 1)先将根结点入队
    • 2)出队,访问出队结点
    • 3)若它有左子树,则将左子树根结点入队
    • 4)若它有右子树,则将右子树根结点入队
    • 5)反复2)到4)该过程直到队列空为止
void LevelOrder(BiTree T) {
	InitQueue(Q);                       //初始化辅助队列
	BiTree p;
	EnQueue(Q, T);                      //将根结点入队
	while(!IsEmpty(Q)) {                //队列不空则循环
		DeQueue(Q, p);                  //队头结点出队
		visit(p);                       //访问出队结点
		if(p->lchild != NULL)
			EnQueue(Q, p->lchild);      //左子树不空,则将左子树根结点入队
		if(p->rchild != NULL)
			EnQueue(Q, p->rchild);      //右子树不空,则将右子树根结点入队
	}
}

由遍历序列构造二叉树

  • 由二叉树的先序序列和中序序列可以唯一地确定一棵二叉树
    • 在先序序列中,第一个结点一定是二叉树的根结点
    • 根结点将中序序列划分为两部分
    • 在先序序列中确定两部分的结点,并且两部分的第一个结点分别为左子树的根和右子树的根
    • 在子树中递归重复该过程,便能唯一确定一棵二叉树
  • 由二叉树的后序序列和中序序列可以唯一地确定一棵二叉树
    • 后序序列的最后一个结点就如同先序序列的第一个结点,可以将中序序列分割成两个子序列,然后再用类似的方法递归地进行划分,进而确定一棵二叉树
  • 由二叉树的层序序列和中序序列可以唯一地确定一棵二叉树
  • 由二叉树的先序序列和后序序列不可以确定一棵二叉树

5.3.2 线索二叉树

线索二叉树的基本概念

  • 利用二叉树的空指针来存放指向其前驱或后继的指针,使得遍历二叉树向遍历单链表一样方便,加快查找结点前驱和后继的速度
  • 规定:若无左子树,令 lchild 指向其前驱结点;若无右子树,令 rchild 指向其后继结点;还需增加两个标志域标识指针域是指向左(右)孩子还是指向前驱(后继)
    线索二叉树的结点结构
  • 其中,标志域的含义如下:
    l ( r ) t a g { 0 , l ( r ) c h i l d 域 指 示 结 点 的 左 ( 右 ) 孩 子 1 , l ( r ) c h i l d 域 指 示 结 点 的 前 驱 ( 后 继 ) l(r)tag \begin{cases} 0, l(r)child域指示结点的左(右)孩子 \\ 1, l(r)child域指示结点的前驱(后继) \end{cases} l(r)tag{0,l(r)child1,l(r)child
typedef  struct ThreadNode {
	ElemType data;
	struct ThreadNode *lchild, *rchild;
	int ltag, rtag;
}ThreadNode, *ThreadTree;
  • 这种结点结构构成的二叉链表作为二叉树的存储结构,称为线索链表,其中指向结点前驱和后继的指针成文线索。加上线索的二叉树称为线索二叉树

中序线索二叉树的构造

  • 二叉树的线索化是将二叉链表中的空指针改为指向前驱或后继的线索。而前驱或后继的信息只有在遍历的时候才能得到,因此线索化的实质就是遍历一次二叉树
  • 在中序序列中,第一个结点是最左侧的结点,最后一个结点时最右侧的结点
  • 中序线索二叉树
    • 前驱指针
      • 若左指针为线索,则其指向结点为前驱结点
      • 若左指针为左孩子,则其左子树的最右侧结点为前驱结点
    • 后继结点
      • 若右指针为线索,则其指向结点为后继结点
      • 若右指针为右孩子,则其右子树的最左侧结点为后继结点
  • 附设指针 pre 指向刚刚访问过的结点,指针 p 指向正在访问的结点,即 pre 指向 p 的前驱。在中序遍历的过程中检查 p 的左指针是否为空,若为空就将它指向 pre;检查 pre 的右指针是否为空,若为空就将它指向 p
//通过中序遍历对二叉树线索化的递归算法如下:
void InThread(ThreadTree &p, ThreadTree &pre) {
	if(p != NULL) {
		InThread(p->lchild, pre);      //递归,线索化左子树
		if(p->lchild == NULL) {        //左子树为空,建立前驱线索
			p->lchild = pre;
			p->ltag = 1;
		}
		if(pre != NULL && pre->rchild == NULL) {
			pre->rchild = p;           //建立前驱结点的后继线索
			pre->tag = 1;
		}
		pre = p;                       //标记当前结点成为刚刚访问过的结点
		InThread(p->rchild, pre);      //递归,线索化右子树
	}
}

//通过中序遍历建立中序线索二叉树的主过程算法如下:
void CreateInThread(ThreadTree &T) {
	ThreadTree pre = NULL;
	if(T != NULL) {                //非空二叉树,线索化
		InThread(T, pre);          //线索化二叉树
		pre->rchild = NULL;        //处理遍历的最后一个结点
		pre->rtag = 1;
	}
}
  • 为了方便,可以在二叉树的线索链表上也添加一个头结点,令其 lchild 域的指针指向二叉树的根结点,其 rchild 域的指针指向中序遍历时访问的最后一个节点;令二叉树中序序列中的第一个结点的 lchild 域指针和最后一个结点的 rchild 域指针均指向头结点。这好比为二叉树建立了一个双向线索链表,方便从前往后或从后往前对线索二叉树进行遍历
    数据结构学习笔记——第5章 树与二叉树_第6张图片

中序线索二叉树的遍历

  • 对中序线索二叉树遍历时,只要先找到序列中的第一个结点,然后依次找结点的后继,直至其后继为空
  • 在中序线索二叉树中找结点后继的规律是:若右指针为线索,则其指向结点为后继结点; 若右指针为右孩子,则其右子树的最左侧结点为后继结点
  • 不含头结点的线索二叉树的遍历算法如下:
//求中序线索二叉树中中序序列下的第一个结点:
ThreadNode *Firstnode(ThreadNode *p) {
	while(p->ltag == 0)
		p = p->lchild;      //最左下结点(不一定是叶结点)
	return p;
}

//求中序线索二叉树中结点p在中序序列下的后继:
ThreadNode *Nextnode(ThreadNode *p) {
	if(p->rtag == 0)
		return Firstnode(p->rchild);
	else
		return p->rchild;      //rtag==1直接返回后继线索
}

//不含头结点的中序线索二叉树的中序遍历算法:
void Inorder(ThreadNode *T) {
	for(ThreadNode *p = Firstnode(T); p != NULL; p=Nextnode(p))
		visit(p);
}

先序线索二叉树和后续线索二叉树

  • 建立先序线索二叉树和后续线索二叉树的代码与建立中序线索二叉树的代码类似,只需变动线索化改造的代码段与调用线索化左右子树递归函数的位置
  • 在先序线索二叉树中找结点的后继:
    • 1)如果有左孩子,则左孩子就是其后继
    • 2)如果无左孩子但有右孩子,则右孩子就是其后继
    • 3)如果为叶结点,则右链域直接指示了结点的后继
  • 在后序线索二叉树中找结点的后继
    • 1)若结点 x 是二叉树的根,则其后继为空;
    • 2)若结点 x 是其双亲的右孩子,或是其双亲的左孩子且其双亲没有右子树,则其后继即为双亲
    • 3)若结点 x 是其双亲的左孩子,且其双亲有右子树,则其后继为双亲的右子树上按后序遍历列出的第一个结点
    • 在后序线索二叉树上找后继时需知道结点的双亲,即需采用带标志域的三叉链表作为存储结构

5.4 树、森林

5.4.1 树的存储结构

数据结构学习笔记——第5章 树与二叉树_第7张图片

双亲表示法

  • 采用一组连续空间来存储每个结点,同时在每个结点中增设一个伪指针,指示双亲结点在数组中的位置。根结点的下标为0,其伪指针域为-1
    树的双亲表示法
#define MAX_TREE_SIZE 100             //树中最多结点数
typedef struct {                      //树的结点定义
	ElemType data;                    //树的结点定义
	int parent;                       //双亲位置域
}PTNode;
typedef struct {                      //树的类型定义
	PTNode nodes[MAX_TREE_SIZE];      //双亲表示
	int n;                            //结点数
}PTree;
  • 该存储结构利用了每个结点(根结点除外)只有一个唯一双亲的性质,可以很快得到每个结点的双亲结点,但求结点的孩子时需要遍历整个结构

孩子表示法

  • 将每个结点的孩子结点都用单链表连接起来形成一个线性结构,此时n个结点就有n个孩子链表(叶子结点的孩子链表为空表)
    数据结构学习笔记——第5章 树与二叉树_第8张图片
#define MAX_TREE_SIZE 100
typedef struct CNode{               //结点孩子定义
	int child;                      //孩子结点的下标
	sturct CNode *next;             //下一个孩子结点
}CNode;
typedef struct {                    //树的结点定义
	ElemType data;                  //每个结点存放的数据
	sturct CNode *child;            //该结点的孩子的指针
}PNode;
typedef struct {                    //树的类型定义
	PNode nodes[MAX_TREE_SIZE];     //孩子表示
	int n;                          //结点数
}CTree;
  • 这种存储方式寻找子女的操作非常直接,而寻找双亲的操作需要遍历n个结点中孩子链表指针域所指向的n个孩子链表

孩子兄弟表示法

  • 又称二叉树表示法,即以二叉链表作为树的存储结构
  • 孩子兄弟表示法使每个结点包括三部分内容:结点值,指向绩点第一个孩子结点指针,及指向结点下一个兄弟结点的指针(沿此指针可以找到结点的所有兄弟结点)
    孩子兄弟表示法的结点
    数据结构学习笔记——第5章 树与二叉树_第9张图片
typedef struct CSNode {
	ElemType data;                                //数据域
	struct CSNode *firstchild, *nextsibling;      //第一个孩子和右兄弟指针
}CSNode, *CSTree;
  • 这种存储方式比较灵活,其最大的优点是可以方便地实现树转换为二叉树的操作,易于查找结点的孩子等,但缺点是从当前结点查找其双亲结点比较麻烦。若为每个结点增设一个 parent 域指向其父结点,则查找结点的父结点也很方便

5.4.2 树、森林与二叉树的转换

  • 由于二叉树和树都可以用二叉链表作为存储结构,因此以二叉链表作为媒介可以导出树与二叉树的一个对应关系,即给定一棵树,可以找到唯一的一棵二叉树与之对应。从物理结构上看,它们的二叉链表是相同的,只是解释不同而已
  • 树转换为二叉树的规则:每个结点左指针指向它的第一个孩子,右指针指向它在树中的相邻右兄弟,这个规则又称“左孩子右兄弟”。由于根结点没有兄弟,所以对用的二叉树没有右子树
  • 树转换成二叉树的画法:① 在兄弟结点之间加一连线;② 对每个结点,只保留它与第一个孩子的连线,而与其他孩子的连线全部抹掉;③ 以树根为轴心,顺时针旋转45°
    数据结构学习笔记——第5章 树与二叉树_第10张图片
  • 森林转换为二叉树的规则与树类似。先将森林中的每棵树转换为二叉树,由于任何一刻和树对应的二叉树的右子树必空,因此将森林中后一棵树当作前一棵树的右兄弟,即将后一棵树对应的二叉树当作前一棵树对应的二叉树的右子树,就可以将森林转换为二叉树
  • 森林转换为二叉树的画法:① 将森林中的每棵树转换成相应的二叉树;② 每棵树的根也可以视为兄弟关系,在每棵树的根之间加一根连线; ③ 以第一棵树的根为轴心顺时针旋转45°
  • 二叉树转换为森林的规则:若二叉树非空,则二叉树的根及其左子树为第一棵树的二叉树形式,故将根的右链。应用同样的方法,直到最后只剩一棵没有右子树的二叉树而知,最后再将每棵二叉树一次转换成树,就得到了原森林。二叉树转换为树或森林是唯一的
    数据结构学习笔记——第5章 树与二叉树_第11张图片

5.4.3 树和森林的遍历

  • 树的遍历是指用某种方式访问树中的每个结点,且仅访问一次。主要有两种方式:
    • 先根遍历。若树非空,先访问根结点,再依次遍历根结点的每棵子树,遍历子树时仍遵循先根后子树的规则。其遍历序列与这棵树相应二叉树的先序序列相同
    • 后跟遍历。若树非空,先依次遍历根结点的每棵子树,再访问根结点,遍历子树时仍遵循先子树后根的规则。其遍历序列与这棵树相应二叉树的中序序列相同
    • 树也有层次遍历,与二叉树的层次遍历思想基本相同,即按层序一次访问各结点
  • 森林的遍历的两种方法:
    • 先序遍历森林。若森林为非空,则按如下规则进行遍历:
      • 访问森林中第一棵树的根结点
      • 先序遍历第一棵树中根结点的子树森林
      • 先序遍历除去第一棵树字后剩余的树构成的森林
    • 中续遍历森林。若森林为非空,则按如下规则进行遍历:
      • 中序遍历森林中第一棵树的根结点的子树森林
      • 访问第一棵树的根结点
      • 中序遍历除去第一棵树字后剩余的树构成的森林
    • 森林的先序和中序遍历即为期对应二叉树的先序和中序遍历

5.4.4 树的应用——并查集

  • 并查集是一种简单的集合表示,它支持一下3种操作:
    • Union(S, Root1, Root2):把集合 S 中的自集合 Root2 并入子集合 Root1。要求 Root1 和 Root2 不相交,否则不执行操作
    • Find(s, x):查找集合 S 中的单元素 x 所在的子集合,并返回该子集合的名字
    • Initial(S):将集合 S 中的每个元素都初始化为只有一个单元素的子集合
  • 通常用树(森林)的双亲表示法作为并查集的存储结构,每个子集合以一棵树表示。所有表示子集合的树,构成表示全集合的森林,存放在双亲表示数组内
  • 通常用数组元素的下标代表元素名,用根结点的下标代表子集合名,根结点的双亲结点为负数(绝对值为该子集的元素的数量)
  • 并查集的结构定义如下:
#define SIZE 100
int UFSets[SIZE];      //集合元素数组(双亲指针数组)
  • 并查集的初始化操作(S 即为并查集):
void Initial(int S[]) {
	for(int i = 0; i < size; i++)      //每个自成单元素集合
		S[i] = -1;
}
  • Find 操作(函数在并查集 S 中查找并返回包含元素 x 的树的根)
int Find(int S[], int x) {
	while(S[x] >= 0)      //循环寻找x的根
		x = S[x];
	return x;             //根的S[]小于0
}
  • Union操作(函数求两个不相交子集合的并集):
void Union(int S[], int Root1, int Root2) {
//要求Root1和Root2是不同的,且表示子集合的名字
	S[Root2] = Root1;      //将根Root2连接到另一根Root1下面
}

5.5 树与二叉树的应用

5.5.1 二叉排序树(BST)

二叉排序树的定义

  • 二叉排序树(又称二叉查找树)或者是一棵空树,或者是具有下列特征的二叉树:
    • 左子树非空,则左子树上所有结点的值均小于根结点的值
    • 右子树非空,则右子树上所有结点的值均大于根结点的值
    • 左、右子树也分别是一棵二叉排序树
    • (默认没有值相同的结点)
  • 对二叉排序树进行中序遍历,可以得到一个递增的有序序列

二叉排序树的查找

  • 二叉排序树非空,先将给定值与根结点的关键字比较,若相等,则查找成果;若不等,如果小于根结点的关键字,则在根结点的左子树上查找,否则在根结点的右子树上查找;当查找到叶结点仍没查到相应的值,则查找失败
//二叉排序树的非递归查找算法,O(h)
BSTNode *BST_Search(BiTree T, ElemType key) { 
	while(T != NULL && key != T->data) {      //若树空或等于根结点的值,则结束循环
		if(key < T->data)                     //小于,则在左子树上查找
			T = T->lchild;
		else                                  //大于,则在右子树上查找
			T = T->rchild;
	}
	return T;
}

二叉排序树的插入

  • 若二叉排序树为空,则直接插入结点;否则,若关键字 k 小于根结点值,则插入到左子树,若关键字 k 大于根结点时,则插入到右子树;当等于根结点时不进行插入
int BST_Insert(BiTree &T, KeyType k) {
	if(T == NULL) {                //原树为空,新插入的记录为根结点
		T = (BiTree)malloc(sizeof(BSTNode));
		T->data = k;
		T->lchild = T->rchild = NULL;
		return 1;                  //返回1,插入成功
	} else if(k == T->data) {      //树中存在相同关键字的结点,插入失败
		return 0;
	} else if(k < T->data) {       //插入到T的左子树
		return BST_Insert(T->lchild, k);
	} else {                       //插入到T的右子树
		return BST_Insert(T->rchild, k);
	}
}

二叉排序树的构造

  • 从一棵空树出发,一次输入元素,将它们插入二叉排序树中的合适位置
void Creat_BST(BiTree &T, KeyType str[], int n) {
	T = NULL;                        //初始时T为空树
	for(int i = 0; i < n; i++){      //依次将每个关键字插入到二叉排序树中
		BST_Insert(T,str[i]);
	}
}

二叉排序树的删除

  • 删除操作的实现过程按3中情况来处理:
    • ① 若被删除结点 z 是叶结点,则直接删除,不会破坏二叉排序树的性质
    • ② 若结点 z 只有一棵左子树或右子树,则让 z 的子树称为 z 父结点的子树,替代 z 的位置
    • ③ 若结点 z 有左、右两颗字数,则令 z 的直接后继(或直接前驱)替代 z ,然后从二叉排序树中删去这个直接后继(或直接前驱),这样就转换成了第一或第二种情况
  • 若在二叉排序树中删除并插入某结点,得到的二叉排序树和原来的不同

二叉排序树的查找效率分析

  • 平均查找长度(ASL)取决于树的高度
    • 若二叉排序树的左、右子树的高度之差的绝对值不超过1,则这样的二叉排序树被称为平衡二叉树,它的平均查找长度为 O(log2n)
    • 若二叉排序树是一个只有右(左)孩子的单支树,则其平均查找长度为 O(n)
  • 二叉排序树和二分查找
    • 查找过程:两者相似
    • 平均时间性能:两者差不多
    • 判定树:二分查找判定树唯一,而二叉排序树查找不唯一,相同的关键字其插入顺序不同可能生成不同的二叉排序树
    • 维护表的有序性:二叉排序树无须移动结点,只需修改指针即可完成插入和删除操作,平均执行时间为 O(log2n);二分查找的对象是有序顺序表,若有插入和删除结点的操作,所花的代价是 O(n)
    • 当有序表是静态查找表时,宜用顺序表作为其存储结构,而采用二分查找实现其查找操作;若有序表是动态查找表,则应选择二叉排序树作为其逻辑结构

5.5.2 平衡二叉树

平衡二叉树的定义

  • 平衡二叉树,简称平衡树:或者是一棵空树,或者是具有下列性质的二叉树:它的左子树和右子树都是平衡二叉树,且左子树和右子树的高度差的绝对值不超过1
  • 定义结点左子树与右子树的高度差(左子树高度-右子树高度)为该结点的平衡因子,则平衡二叉树结点的平衡因子的值只可能是-1、0或1
  • 高度为 h 的最小平衡二叉树的结点数 Nh N h = N h − 1 + N h − 2 + 1 , N 0 = 0 , N 1 = 1 N_h = N_{h-1} + N_{h-2} + 1, N_0 = 0, N_1 = 1 Nh=Nh1+Nh2+1,N0=0,N1=1

平衡二叉树的判断

  • 利用递归的后续遍历过程:
    • 1)判断左子树是一棵平衡二叉树
    • 2)判断右子树是一棵平衡二叉树
    • 3)判断以该结点为根的二叉树为平衡二叉树
    • 判断条件:若左子树和右子树均为平衡二叉树,且左子树和右子树的高度差的绝对值不超过1,则平衡
  • 附设两个变量:平衡性 balance(1为平衡,0为不平衡),高度 h
void Judge_AVL(BiTree bt, int &balance, int &h) {
	int bl = 0, br = 0, hl = 0, hr = 0;                      //左右子树的平衡性和高度
	if(bt == NULL){                                          //若树为空
		h = 0;
		balance = 1;
	} else if(bt->lchild == NULL && bt->rchild == NULL) {    //若树只有根结点
		h = 1;
		balance = 1;
	} else {
		Judge_AVL(bt->lchild, bl, hl);                       //递归判断左子树的平衡性
		Judge_AVL(bt->rchild, br, hr);                       //递归判断右子树的平衡性
		if(hl > hr)                                          //计算树的高度
			h = hl + 1;
		else
			h = hr + 1;
		if(abs(hl - hr) <= 1 && bl == 1 && br == 1)          //判断平衡性
			balance = 1;
		else 
			balance = 0;
	}
}

平衡二叉树的插入

  • 每当在二叉树中插入(或删除)一个结点时,首先检查其插入路径上的结点是否因为此次操作而导致了不平衡。若导致了不平衡,则先找到插入路径上离插入结点最近的平衡因子的绝对值大于1的结点 A,再对以 A 为根的子树(最小不平衡子树),在保持二叉排序树特性的前提下,调整各结点的位置关系,使之重新达到平衡
  • 先插入,再调整。可将调整的规律归纳为下列4种情况:
    • LL平衡旋转(右单旋转)
      • 由于在结点 A 的左孩子(L)的左子树(L)上插入了新结点,导致以 A 为根的子树失去平衡,需要一次向右的旋转操作。将 A 的左孩子 B 向右上旋转代替 A 称为根结点,将 A 向右下旋转成为 B 的右子树的根结点,而 B 的原右子树称为 A 结点的左子树
        数据结构学习笔记——第5章 树与二叉树_第12张图片
    • RR平衡旋转(左单旋转)
      • 由于在结点 A 的右孩子(R)的右子树(R)上插入了新结点,导致以 A 为根的子树失去平衡,需要一次向左的旋转操作。将 A 的右孩子 B 向左上旋转代替 A 称为根结点,将 A 向左下旋转成为 B 的右子树的根结点,而 B 的原左子树称为 A 结点的右子树
        数据结构学习笔记——第5章 树与二叉树_第13张图片
    • LR平衡旋转(先左后右双旋转)
      • 由于在结点 A 的左孩子(L)的右子树(R)上插入了新结点,导致以 A 为根的子树失去平衡,需要进行两次旋转操作,先左旋转再右旋转。先将 A 结点的左孩子 B 的右子树的根结点 C 向左上旋转提升到 B 结点的位置,然后再把该 C 结点向右上旋转到 A 结点的位置
        数据结构学习笔记——第5章 树与二叉树_第14张图片
    • RL平衡旋转(先右后左双旋转)
      • 由于在结点 A 的右孩子(R)的左子树(L)上插入了新结点,导致以 A 为根的子树失去平衡,需要进行两次旋转操作,先右旋转再左旋转。先将 A 结点的右孩子 B 的左子树的根结点 C 向右上旋转提升到 B 结点的位置,然后再把该 C 结点向左上旋转到 A 结点的位置
        数据结构学习笔记——第5章 树与二叉树_第15张图片
    • LR 和 RL 旋转时,新结点究竟是插入 C 的左子树还是插入 C 的右子树不影响旋转过程,这里以插入 C 的左子树中为例

平衡二叉树的查找

  • 在平衡二叉树上进行查找的过程与二叉排序树的相同,含有 n 个结点的平衡二叉树的最大深度为 O(log2n),因此 平衡二叉树的平均查找长度为 O(log2n)

5.5.3 哈夫曼树和哈夫曼编码

哈夫曼树的定义

  • 树中的结点被赋予一个表示某种意义的数值,称为该结点的
  • 从树的根到任意结点的路径长度(经过的边数)与该结点上权值的乘积,称为该结点的带权路径长度
  • 树中所有叶结点的带权路径长度之和称为该树的带权路径长度,记为 W P L = ∑ i = 1 n w i l i WPL = \sum_{i=1}^{n}w_il_i WPL=i=1nwili,式中, w i w_i wi 是第 i 个叶结点所带的权值, l i l_i li 是该叶结点到根结点的路径长度
  • 在含有 n 个带权叶结点的二叉树中,其中带权路径长度(WPL)最小的二叉树称为哈夫曼树,也称最优二叉树

哈夫曼树的构造

  • 给定 n 个权值分别为 w 1 , w 2 , . . . , w n w_1, w_2, ..., w_n w1,w2,...,wn 的结点,构造哈夫曼树的算法描述如下:
    • 1)将这n 个结点分别作为 n 棵仅含一个结点的二叉树,构成森林 F
    • 2)构造一个新节点,从 F 中选取两颗根结点权值最小的树作为新结点的左、右子树,并且将新结点的权值置为左、右子树上根结点的权值之和
    • 3)从 F 中删除刚才选出的两棵树,同时将新得到的树加入 F 中
    • 4)重复步骤2)和3),直至 F 中只剩下一棵树为止
  • 哈夫曼树具有如下特点:
    • 每个初始结点最终都会成为叶结点,双支节点都为新生成的结点
    • 权值越小的结点离根结点越远,权值越大的结点离根结点越近
    • 构造过程中共新建了 n-1 个结点(双分支结点,度为2),因此哈夫曼树的结点总数为 2n-1
    • 每次构造都选择 2 棵树作为新结点的孩子,因此哈夫曼树中不存在度为 1 的结点

哈夫曼编码

  • 在数据通信中,若对每个字符用相等长度的二进制表示,成这种编码方式为固定长度编码;若允许对不同字符用不等长的二进制位表示,则这种编码方式称为可变长度编码
  • 可变长度编码比固定长度编码要好得多,其特点是对频率高的字符赋以短编码,而对频率较低的字符则赋以较长一些的编码,从而可以使自负的平均编码长度减短,起到压缩数据的效果。哈夫曼编码是一种被广泛应用而且非常有效的数据压缩编码
  • 若没有一个编码是另一个编码的前缀,则称这样的编码为前缀编码。哈夫曼编码是一种前缀编码
  • 由哈夫曼树得到哈夫曼编码:
    • 将每个出现的字符当作一个独立的结点,其权值为它出现的频度(或次数),构造出对应的哈夫曼树
    • 将字符的编码结实为从根至该字符的路径上边标记的序列,其中边标记为 0 表示“转向左孩子”,标记为 1 表示“转向右孩子”
      数据结构学习笔记——第5章 树与二叉树_第16张图片
  • 此处的 WPL 可视为最终编码得到二进制编码的长度,利用哈夫曼树可以设计出总长度最短的二进制编码
  • 哈夫曼树并不唯一,所以每个字符对应的哈夫曼编码也不唯一,但带权路径长度相同且最优

你可能感兴趣的:(数据结构)