数据结构学习笔记(5)——树与二叉树

树的基本概念

  • 结点:结点不仅包含数据元素,而且包含指向子树的指针
  • 结点的度:结点拥有的子树个数或者分支的个数。
  • 树的度:树中各结点度的最大值。
  • 叶子结点:又叫作终端结点,指度为0的结点
  • 孩子:结点的子树的根结点。
  • 双亲:与孩子的定义相对应。A是B的孩子,那么B就是A的双亲。
  • 兄弟:同一 个双亲的孩子之间互为兄弟。
  • 祖先:从根到某结点的路径上的所有结点,都是这个结点的祖先。
  • 树的高度(或者深度):树中结点的最大层次。
  • 结点的深度:从根结点到该结点的路径上的结点个数。
  • 结点的高度:从某结点往下走可能到达多个叶子结点,对应了多条通往这些叶子结点的路径,其中最长的那条路径上结点的个数。

树的存储结构

  • 顺序存储结构:双亲存储结构

    用一维数组即可实现:用数组下标表示树中的结点,数组元素的内容表示该结点的双亲结点,这样有了结点(下标)以及结点之间的关系(内容),就可以表示一 棵树了。

    image-20210715231435883

  • 链式存储结构:

    1. 孩子存储结构:即将每个结点的所有孩子结点都用单链表链接起来形成一个线性结构,此时n个结点就有n个孩子链表。

    2. 孩子兄弟存储结构:又称二叉树表示法,即以二叉链表作为树的存储结构。

      每个结点都包含了三个内容:结点值、指向结点的第一个孩子结点的指针、指向结点的下一个兄弟结点的指针。

      这种存储方法比较灵活,最大的优点是可以方便实现树转化成二叉树的操作,方便查找孩子,但查找双亲比较麻烦,对此,可以采用三叉链表,多加一个指向父结点的指针。

二叉树的定义

将一般的树加上如下两个限制条件就得到了二叉树:

  1. 每个结点最多只有两棵子树,即二叉树中结点的度只能为0、1、2。
  2. 子树有左右顺序之分,不能颠倒。

请添加图片描述

二叉树的主要性质

  1. 总结点数=总分支数+1(根结点上面没有分支,其余每一个结点上面对应一个分支)

  2. 非空二叉树的叶子结点数=双分支结点数+1,即n0=n2+1(下标表示结点的分支个数)

    证明:总结点数=n0+n1+n2 ,总分支数=n1+2n2 ,又因为树中除了根结点,其余每个结点都对应一个分支,即总结点数=总分支数+1,所以有n0+n1+n2=n1+2n2+1,整理得n0=n2+1。

  3. 二叉树中的空指针数=总结点数+1,空指针数也是线索二叉树的线索数

    证明:假设所有的空指针都是叶子结点,那么树中的所有结点都变成了双分支结点(n个),根据性质1,那么空指针数=叶子结点数=双分支结点数+1=n+1;

  4. 在一个度为m的树中,度为1的结点数为n1,度为2的结点数为n2,……,度为m的结点为nm,则树中的叶子结点数n0=1+n2+2n3+…+(m-1)nm

    证明:总结点数n=n0+n1+…+nm,总分支数=n1+2n2+3n3+…+mnm,总结点数=总分支数+1,则有n0+n1+n2…+nm=1+n1+2n2+3n3+…+mnm,整理得:n0=1+n2+2n3+…+(m-1)nm

  5. 二叉树的第i层上最多有2i-1个结点。(a0=1,q=2的等比数列第i项)

  6. 高度为k二叉树最多有2k-1结点(即高度为h的满二叉树)。(a0=1,q=2的等比数列前h项和)

  7. 高度为k二叉树最少有2k-1结点(=2k-1-1+1)。

  8. 有n个结点的完全二叉树,对各结点从上到下、从左到右依次编号(1~n),对于结点ai来说:

    1. a的父结点的为编号为⌊i/2⌋(向下取整)
    2. 如果2i<=n,那么a的左孩子的编号为2i
    3. 如果2i+1<=n,那么a的右孩子编号为2i+1
  9. 函数Catalan():给定n个结点,能构成h(n)种不同的二叉树: h ( n ) = C 2 2 n n + 1 h(n)=\frac{C_{2}^{2n}}{n+1} h(n)=n+1C22n

  10. 具有n(n>=1)个结点的完全二叉树的高度(或深度)为:

    • ⌊log2n⌋ + 1(⌊⌋表示向下取整)
    • ⌈log2(n+1)⌉(⌈⌉表示向上取整)
  11. 设Nh表示高度为h的平衡二叉树所含有的最少结点数,则有:N1=1,N2=2,N3=4,N5=7,……,Nh=Nh-2+Nh-1+1

二叉树的存储结构

  • 顺序存储结构

    即通过一个数组来存储一个二叉树。适用于完全二叉树,用于存储一般的二叉树会浪费大量的空间。

    假如有n个结点的完全二叉树存储在数组a中,根结点的下标为1,对于结点a[i],它的:

    1. 左孩子:如果2*i<=n,则左孩子为a[2*i],否则没有左孩子
    2. 右孩子:如果2*i+1<=n,则右孩子为a[2*i+1],否则没有右孩子
    3. 双亲:a[j],j=取整{i/2}

    假如有n个结点的完全二叉树存储在数组a中,根结点的下标为0,对于结点a[i],它的:

    1. 左孩子:如果2*i+1<=n,则左孩子为a[2*i+1],否则没有左孩子
    2. 右孩子:如果2*i+2<=n,则右孩子为a[2*i+2],否则没有右孩子
    3. 双亲:a[j-1],j=取整{i/2}

    易错点:注意和树的顺序存储结构区分,在树的顺序存储结构中,数组下标代表结点编号,数组中所存的内容是各结点之间的关系。而在二叉树的顺序存储结构中,数组下标不仅是结点编号,还包含了各结点之间的关系。由于二叉树属于树的一种,所以树的顺序存储结构可以用来存储二叉树,但二叉树的顺序存储结构不能用来存储树。

  • 链式存储结构

    lchilddatarchild
    1. data表示数据域
    2. lchild表示左指针域,存储左孩子的位置
    3. rchild表示右指针域,存储右孩子的位置
    //二叉树链式存储结构
    typedef struct BTNode {
    	char data; 					//默认char,可换
    	struct BTNode *lchild;
    	struct BTNode *rchild;
    }BTNode;
    

二叉树的遍历

二叉树的遍历主要分为先序遍历、中序遍历、后序遍历以及一个层次遍历

  • 先序遍历(DLR)
    1. 访问根结点
    2. 先序遍历左子树
    3. 先序遍历右子树
  • 中序遍历(LDR)
    1. 中序遍历左子树
    2. 访问根结点
    3. 中序遍历右子树
  • 后序遍历(LRD)
    1. 后序遍历左子树
    2. 后序遍历右子树
    3. 访问根结点

这里“序“指的是根结点何时被访问。可以看出三种遍历方式只是访问结点的时机不一样。

  • 层次遍历

    按照从左到右(或从右到左),从上到下逐行遍历结点。

二叉树深度优先遍历算法的实现

  • 三种二叉树深度优先遍历算法的程序模板

    //遍历模板
    void trave(BTNode *p) {
    	if (p != NULL) {
    		//1.
    		trave(p->lchild);
    		//2.
    		trave(p->rchild);
    		//3.
    	}
    }
    

    对于树中的每一个结点,不管是采用先序遍历、中序遍历、后序遍历哪一种,每个结点都会被经过3次。

    如果统一在第一次经过时访问结点,那就是先序遍历;此时把对结点的访问操作写在1处;

    如果统一在第二次经过时访问结点,那就是中序遍历;此时把对结点的访问操作写在2处;

    如果统一在第三次经过时访问结点,那就是后序遍历。此时把对结点的访问操作写在3处;

    //先序遍历
    void preOrder(BTNode *p) {
    	if (p!=NULL) {
    
    		visit(p);				//对结点的访问操作
    
    		preOrder(p->lchild);
    		preOrder(p->rchild);
    	}
    }
    
    //中序遍历
    void inOrder(BTNode *p) {
    	if (p != NULL) {
    		inOrder(p->lchild);
    
    		visit(p);				//对结点的访问操作
    
    		inOrder(p->rchild);
    	}
    }
    
    
    //后序遍历
    void postOrder(BTNode *p) {
    	if (p != NULL) {
    		postOrder(p->lchild);
    		postOrder(p->rchild);
    
    		visit(p);				//对结点的访问操作
    
    	}
    }
    
    //visit()函数是自定义的,根据实际需要,可以用任何针对结点的操作来代替它
    

层次遍历

按照从左到右(或从右到左),从上到下逐行遍历结点。

要进行层次遍历,需要建立一 个队列。先将二叉树头结点入队列,然后出队列,访问该结点,如果它有左子树,则将左子树的根结点入队;如果它有右子树,则将右子树的根结点入队。然后出队列,对出队结点访问。如此反复,直到队列为空为止。

//层次遍历
void levelOorder(BTNode *p) {

	BTNode *que[maxSize];						//定义一个循环队列
	int  front = 0, rear = 0;					//初始化队列,队头与队尾归零

	BTNode *q;									//临时变量,用来临时存储出队元素

	if (p != NULL) {							//非空树
		rear = (rear + 1) % maxSize;
		que[rear] = p;							//这两句是循环队列的入队操作,这里表示根结点入队

		while (front!=rear)	{					//队列非空

			front = (front + 1) % maxSize;
			q = que[front];						//这两句是循环队列的出队操作,这里表示队头元素出队
            
			visit(q);							//访问结点

			if (q->lchild != NULL) {			//如果当前结点有左孩子,左孩子入队
				rear = (rear + 1) % maxSize;
				que[rear] = q->lchild;
			}
			if (q->rchild != NULL) {			//如果当前结点有右孩子,右孩子入队
				rear = (rear + 1) % maxSize;
				que[rear] = q->rchild;
			}
		}
	}
}

这里是借助了循环队列,乍一看好像很麻烦,其实只是循环队列的初始化、入队、出队看着比较麻烦,如果可以把这些操作写成函数放在外面,会看着简洁一些。但是,不管怎么变化,核心思想是不变的,根据借助的队列类型不同、针对结点的操作不同,可以根据此模板来记忆层次遍历:

//层次遍历模板
void levelOorder(BTNode *p) {  
    //1.初始化队列

	BTNode *q;	//临时变量,用来临时存储出队元素
    
	if (p != NULL) {	//树非空	
        //2.根结点入队
        
		while (队列非空){         
			 //3.出队 (出队元素赋值给q)
             //4.visit(q); 针对结点q的操作

			if (q->lchild != NULL) {			
				//5.如果q有左孩子,左孩子入队
			}
			if (q->rchild != NULL) {			
				//6.如果q有右孩子,右孩子入队
			}
		}
	}
}

层次遍历的模板与下面的二叉树深度优先遍历算法的非递归实现中的先序遍历模板很相似,主要区别是:

  1. 层次遍历借助的是队列,而非递归先序遍历借助的是栈
  2. 层次遍历是左孩子优先于右孩子入队,而非递归先序遍历是右孩子优先于左孩子入栈

注意不要记混。

快速写出遍历序列的方法

先看最简单的情况:只有3个结点ABC

image-20210713000928775

  • 先序遍历:按照DLR的规则:ABC(中左右)
  • 中序遍历:按照LDR的规则:BAC(左中右)
  • 后序遍历:按照LRD的规则:BCA(左右中)

下面看更一般的情况:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-vAUFRelu-1626800975200)(https://i.loli.net/2021/07/18/c8RfDynuWQqdGYh.png)]

  1. 先序遍历:ABDECFG

    将其按照( A(BDE)(CFG))来划分,有没有观察到,BDE刚好是树A左下角以B为根结点的二叉树的先序遍历排列,CFG刚好是数A右下角以C为根结点的二叉树的先序遍历排列。往下还可以再划分,拿BDE来说,BDE又可以分成:(B(D)(E)),那么D自然就是树D的先序遍历了,E就是树E的先序遍历了。到这里就不能再划分了,因为D没有左子树了。有没有发现规律?

    总结一下对A快速写出先序遍历结果的方法,即从大树化小树的方法:

    1. 沿A最左边的分支一路向左下找,找到第一个没有左孩子的结点,这里就是树D
    2. 先完成对树D的先序遍历,这里树D没有右孩子,那遍历结果就是(D)。注意,如果树D有右孩子,把它当成树,依旧按照本方法
    3. 把对树D的先序遍历结果看成一个整体,再完成对树B的先序遍历,即:B(D)(?)。这里的?表示的B的右子树E的先序遍历结果,E没有子树,那么就是(E)。注意,如果E下面还有很多分支,还是按照本方法。那么树B的先序遍历就是(B(D)(E)),即:BDE
    4. 再把整个树B看成一个整体,再完成对树A的先序遍历即:(A(BDE)(?))。?表示的就是右子树C的先序遍历结果,那么就对树C按照上面的方法,沿C最左边的分支一路向左下找到第一个没有左孩子的结点,就是F,再按照上面的方法一次完成对树F,树C的先序遍历,得到数C的先序遍历结果(C(F)(G)),即:CFG,代入?里,得到树A的先序遍历结果(A(BDE)(CFG)),即:ABDECFG
  2. 中序遍历:DBEAFCG

    方法是类似的,沿A最左边的分支一路向左下找,找到第一个没有左孩子的结点,这里就是树D,依次对树D、树B、树A完成后序遍历即可。即:(((D)B(E))A((F)C(G)))

  3. 后序遍历:DEBFGCA

    方法是类似的,沿A最左边的分支一路向左下找,找到第一个没有左孩子的结点,这里就是树D,依次对树D、树B、树A完成后序遍历列即可。(((D)E(B))((F)G(C))A),即:DEBFGCA

本例中举的是最理想的情况,实际的树可能要比这复杂的多,越复杂的树利用这种方法就越方便。只要按照步骤1找到正确的开始结点,大树化小数的方法都是可以完成的(实际上我们写的遍历的递归程序就是这么做的)。熟悉这个过程这也为我们下面写非递归方法提供了思路。

根据遍历序列确定二叉树

  1. 已知前序遍历序列和中序遍历序列,可以唯一确定一棵二叉树

  2. 已知后序遍历序列和中序遍历序列,可以唯一确定一棵二叉树

  3. 但是已知前序遍历序列和后序遍历序列,是不能确定一棵二叉树

即:没有中序遍历序列的情况下是无法确定一颗二叉树的

why?拿上面的例子来说:

先序遍历:ABDECFG:(A(B(D)(E))(C(F)(G)))

中序遍历:DBEAFCG:(((D)B(E))A((F)C(G)))

后序遍历:DEBFGCA:(((D)E(B))((F)G(C))A)

几个规律:

  1. 先序遍历序列的第一个结点一定是根结点
  2. 后序遍历序列的最后一个结点一定是根结点
  3. 根结点将中序遍历序列分成左右子树两部分

所以,前序和后序在本质上可以将父子结点分离,但并没有指明左子树和右子树的能力,因此得到这两个序列只能明确父子关系,而不能确定一个二叉树。

按照上述的规律可以一步一步的还原二叉树,这里拿先序排列和中序排列举例:

  1. 由先序可以确定根结点是A
  2. 在中序中找到A,左边DBE就是左子树,右边FCG就是右子树
  3. 在先序中找到去掉A,发现D、B、E三个结点的构成的树,B是根结点,同理,C是根结点。
  4. 在中序遍历中确定D是B的左孩子,E是B的右孩子;同理,F是C的左孩子,G是C的右孩子

根据上述信息画图:
在这里插入图片描述

二叉树遍历算法的改进

二叉树深度优先遍历算法的非递归实现

  • Q1:为什么说二叉树的递归算法效率不高?如何解决?

    递归函数所申请的系统栈,是一个所有递归函数都通用的栈。对于二叉树深度优先遍历算法,系统栈除了记录访问过的结点信息之外,还有其他信息需要记录,以实现函数的递归调用。

    如果可以手动建立栈,仅保存遍历所需的结点信息,即对二叉树遍历算法进行针对性的设计,对于遍历算法来说,显然要比递归函数通用的系统栈更高效。

  • 递归算法是把大问题逐渐化成一个越来越小的问题,再从小到大,从内到外逐个解决,这个核心思想在我们把递归转化成循环时是不变的。循环代替递归的关键,就是通过手动维护栈来实现递归,这个时候,结点入栈出栈的时机就显得非常重要。递归算法描述起来非常简洁而且想象,但运行过程并不容易搞透,若想把递归算法转化成非递归算法(循环),就要对他的运行过程非常清楚,手动尝试去模拟各种遍历算法的运行过程有利于理解这一部分内容。

  • 先序遍历的非递归算法

    //先序遍历的非递归算法
    void preOrder2(BTNode *bt) {
        //bt非空
        if (bt != NULL) {
            BTNode *stack[maxSize];					//定义栈
            int top = -1;							//初始化,栈顶指针top为-1时栈空
            stack[++top] = bt;						//根结点入栈
            BTNode *q;								//q是遍历指针,表示当前正在处理的元素
            //开始遍历
            while(top != -1) {						//循环条件:栈非空	
                q = stack[top--];					//出栈,用q保存
                visit(q);							//访问q         
                if (q->rchild != NULL) {			//如果q还有右孩子,右孩子入栈
                    stack[++top] = q->rchild;
                }
                if (q->lchild != NULL) {			//如果q还有左孩子,左孩子入栈
                    stack[++top] = q->lchild;
                }									
            }
        }
    }
    

    先序遍历的非递归过程:从根结点开始,入栈。进入循环,出栈并访问根结点,先判断根结点是否有右孩子,如果有,右孩子入栈,然后判断根结点是否有左孩子,如果有,左孩子入栈。继续循环,根结点的左孩子出栈,如果它有右孩子,右孩子入栈,如果它有左孩子,左孩子入栈…栈空时退出循环。

    关键之处:右孩子优先于左孩子入栈的顺序不能变。在先序遍历中,对左孩子的访问要优先于右孩子,又由于栈的先进后出特性,所以,每次访问完一个结点,它的右孩子要先于它的左孩子入栈,这样做才能保证左孩子先被访问到。

  • 中序遍历的非递归算法

    //中序遍历的递归算法
    void inOrder2(BTNode *bt) {
        if (bt != NULL) {
            BTNode *stack[maxSize];					
            int top = -1;
            BTNode *q = bt;							//q是遍历指针,表示当前正在处理的元素,初始值为根结点bt
            //开始遍历
            while (top != -1 || q != NULL) {		//注意这里的循环条件:栈非空或q非空
                while (q != NULL) {					//这个whiLe的作用是沿着q的左下方走到头,路过的结点依次入栈
                    stack[++top] = q;
                    q = q->lchild;					
                }
                if (top != -1) {			//这个if的作用是将出栈、访问栈顶元素之后,将遍历指针q指向出栈元素的右孩子
                    q = stack[top--];				
                    visit(q);
                    q = q->rchild;					
                }
            }
        }
    }
    

    中序遍历的非递归过程:

    1. 从根结点出发,一路朝着树的左下走到头,找到第一个没有左孩子的结点a,路过的结点依次入栈。a就是中序遍历的第一个结点,它一定在左下角(但并不一定是叶子结点,a可能还有右孩子) 。

    2. 出栈并访问a,然后将遍历指针指向它的右孩子,去判断它的右孩子是否存在:

      如果a的右结点b存在,就把b视为一个新树,回到步骤1,又一路朝着b的左下走…这里也就体现了递归的思想。

      如果a的右结点b不存在(这意味着以a为根结点的数就只有它一个元素,那么树a此时已经遍历),那就去找a的父结点c,去完成对c的遍历(还是递归的思想)。c此时就在栈顶(如果栈非空),那么我们就出栈,完成对c的访问操作后,继续把遍历指针p指向c的右孩子…再接着判断c的右孩子是否存在…

    3. 重复这个过程,当栈空而且p也为空时循环结束。

    关键之处:假设根结点是t,当t出栈并完成访问操作后,这就意味着这个数的左半部分(包括t)遍历完成,此时栈是空的,但树的右半部分还没有遍历,所以不能将栈空作为遍历循环的判断条件。此时遍历指针p指向的是t的右孩子,可以根据p的状态此来判断遍历是否继续,最后一个元素遍历完时p指向的是它的右孩子,此时p为空。这就是外层遍历循环的判断条件top != -1 || q != NULL的原因。

  • 后序遍历的非递归算法

    后序遍历的非递归算法是最困难的,这里提供一种易于理解的版本。

    先序遍历:ABDECFG (A(B(D)(E))(C(F)(G)))

    中序遍历:DBEAFCG

    后序遍历:DEBFGCA

    逆后序遍历:ACGFBED

    有一个规律是:逆后序遍历可以看成是把先序遍历过程中对左右子树遍历顺序交换所得的结果。

    按照此规则实现后序遍历,要做两件事:

    1. 在交换左右子树的遍历顺序的前提下进行先序遍历
    2. 对上述遍历结果逆序输出

    所以我们这里用到两个栈,一个栈是遍历本来就需要的,另一个则是来进行逆序的。

    //非递归后序遍历二叉树
    void postOrder2(BTNode *bt) {
    	if (bt != NULL) {
    		//栈1用来辅助进行交换了左右子树遍历顺序的先序遍历
    		//栈2用来实现上述遍历结果的逆序输出
    		BTNode *stack1[maxSize];	int top1 = -1;
    		BTNode *stack2[maxSize];	int top2 = -1;
    		BTNode *q;							//遍历指针q
    		stack1[++top1] = bt;
    		//进行交换了左右子树遍历顺序的先序遍历
    		while(top1 != NULL) {
    			q = stack1[top1--];
    			stack2[++top2] = q;				//每次从栈1出去的元素,就立即把它放入到栈2中
    			if (q->lchild != NULL) {		//如果q还有左孩子,左孩子入栈,这里左右子树的入栈的先后顺序发生了变化
    				stack1[++top1] = q->lchild;
    			}
    			if (q->rchild != NULL) {		//如果q还有右孩子,右孩子入栈
    				stack1[++top1] = q->rchild;
    			}			
    		}
    		//先序遍历结束后,栈2元素逐个出栈即可实现后序遍历
    		while (top2 != NULL) {
    			q = stack2[top2--];				//栈2元素出栈并访问
    			visit(q);
    		}
    	}
    }
    

线索二叉树

对于先序遍历、中序遍历、后序遍历来说,存在一定的局限性:

  1. 不能从指定结点开始遍历,遍历操作必须从根开始
  2. 无法快速的找到某个结点的前驱与后继,每次查找都要从头开始遍历

对此,解决思路是,能不能通过某种方式把树中结点的前驱和后继的相关信息保存起来,这样后续查找时就非常高效。

n个结点的二叉树共计有n+1个空指针,利于这些空指针来保存前驱与后继信息。

线索二叉树的存储结构

lchildltagdatartagrchild

在二叉树线索化的过程中会把树中的空指针(lchild与rchild)利用起来作为寻找当前结点前驱或后继的线索,这样就出线索和树中原有指向孩子结点的指针无法区分。为解决这个问题,增设两个标识域ltag和rtag,它们的具体意义如下:

  1. 如果ltag=0,则表示lchild为指针,指向结点的左孩子;如果ltag=1, 则表示lchild为线索,指向结点的直接前驱
  2. 如果rtag=0,则表示rchild为指针,指向结点的右孩子;如果rtag=1,则表示rchild为线索,指向结点的直接后继
//线索二叉树数结点结构
typedef struct BTBNode {
	char data;					//默认为char,可替换
	int ltag, rtag;				
	struct BTBNode* lchild;
	struct BTBNode* rchild;
}TBTNode;

先序遍历、中序遍历、后序遍历的线索化方式是不同的,对应的线索二叉树称为先序线索二叉树、中序线索二叉树、后续线索二叉树

中序线索化

对一棵二叉树中所有结点的空指针域按照某种遍历方式加线索的过程叫作线索化,被线索化了的二叉树称为线索二叉树

线索化从某种程度上讲,可以看成是对遍历算法的一种应用

image-20210713203209570

中序线索化的规则是:

  1. 左线索指针指向当前结点在中序遍历序列中的前驱结点,右线索指针指向后继结点;
  2. 需要两个辅助指针p和pre,p表示当前结点,pre表示p的前驱结点;
  3. p 的左线索如果存在则让其指向pre, pre的右线索如果存在则让其指向p,这样就完成了一 对线索的连接;
  4. 按照这样的规则一 直进行下去,当整棵二叉树遍历完成的时候,线索化也就完成了。

按照上述规则可以写出两个结点线索化的过程,这就:

  • 二叉树线索化的代码块(非常重要):
//线索化:p的左线索如果存在则让其指向pre
if (p->lchild == NULL) {
    p->lchild = pre;
    p->ltag = 1;
}
//如果pre非空且pre右线索存在则让其指向p
if (pre != NULL && p->rchild == NULL) {
    pre->rchild = p;
    pre->rtag = 1;
}
//p和pre线索化完成后,将pre指向p(p在之后将指向它的孩子)
pre = p;

二叉树进行中序线索化是在二叉树中序遍历算法的框架中进行的,先回顾下中序遍历递归算法:

//中序遍历
void inOrder(BTNode *p) {
	if (p != NULL) {
		inOrder(p->lchild);

		visit(p);				//线索化写在这里代替visit

		inOrder(p->rchild);
	}
}

把前面的visit()函数替换成线索化的代码块,即可得到中序线索化一个二叉树的代码:

//中序线索化
void inThread(TBTNode *p, TBTNode *&pre) {
	if (p != NULL) {    
		inThread(p->lchild, pre);			//递归,中序遍历并线索化左子树
        
        //线索化:p的左线索如果存在则让其指向pre
        if (p->lchild == NULL) {
            p->lchild = pre;
            p->ltag = 1;
        }
        //如果pre非空且pre右线索存在则让其指向p
        if (pre != NULL && p->rchild == NULL) {
            pre->rchild = p;
            pre->rtag = 1;
        }
        //p和pre线索化完成后,将pre指向p(p在之后将指向它的孩子)
        pre = p;	

		inThread(p->rchild, pre);			//递归,中序遍历并线索化左子树
	}
}

通过中序遍历建立中序线索二叉树的主程序为:

//通过中序遍历建立中序线索二叉树
void creatInThread(TBTNode *tbt) {
	TBTNode *pre = NULL;
	if (tbt != NULL) {	
		inThread(tbt, pre);		 //中序线索化,传入根结点tbt和它的前驱NULL
		pre->rchild = NULL;
		pre->rtag = 1;
	}
}
/*inThread最后一次执行时:
p指向中序遍历的最后一个结点的右孩子(NULL),pre指向最后一个结点,不满足if(p!=NULL){...},函数结束。
此时还差最后一个结点的后继没有线索化,应该手动完成最后一个结点的右线索*/

经过上述操作后,可以理解为已经把二叉树变成了一个中序线索二叉树,可以将其视为一个链表。

中序线索二叉树中隐含了线索二叉树的前驱与后继信息。对其遍历时,只需要先找到序列中的第一个结点,然后依次找到结点的后继,直到后继为空即可。

  • 查找中序线索二叉树的中序序列的第一个结点:

    //求以p为根的中序线索二叉树中,中序序列下的第一个结点:
    TBTNode *getFirst(TBTNode *p) {
    	while(p->ltag == 0) {		//树中最左下的结点(不一定是叶结点)
    		p = p->lchild;
    	}
    	return p;
    }
    
  • 查找中序线索二叉树的中序序列的最后一个结点:

    //求以p为根的中序线索二叉树中,中序序列下的最后一个结点:
    TBTNode* getLast(TBTNode *p) {
    	while (p->rtag == 0) {	//最右下的就是最后一个结点
    		p = p->rchild;
    	}
    	return p;
    }
    
  • 查找中序线索二叉树的中序序列的后继结点:

    1. 若右标志为1,则右链被线索化,直接指向它的后继;
    2. 若右标志为0,就遍历右子树中找到第一个的结点,它就是p的后继,它在右子树的左下角,这里调用getFirst即可
    //结点p在中序线索二叉树的后继结点
    TBTNode* getNext(TBTNode *p) {
    	if (p->rtag == 0)
    		return getFirst(p->rchild);
    	else
    		return p->rchild;
    }
    
    
  • 查找中序线索二叉树的中序序列的前驱结点:

    1. 若左标志为1,则左链被线索化,直接指向它的前驱;
    2. 若左标志为0,就遍历左子树中找到最后一个结点,它就是p的前驱,它在左子树的右下角,这里调用getLast()
    //结点p在中序线索二叉树的前驱结点
    TBTNode* getPrior(TBTNode *p) {
    	if (p->ltag == 0)
    		return getLast(p->lchild);
    	else
    		return p->lchild;
    }
    
  • 中序线索二叉树的中序遍历方法

    //中序线索二叉树的中序遍历方法
    void InOrder(TBTNode *t) {
    	for (TBTNode *p = getFirst(t);p != NULL;p = getNext(p)) {
    		visit(p);
    	}
    }
    

先序线索化

  • 二叉树线索化的代码块

    //线索化:p的左线索如果存在则让其指向pre
    if (p->lchild == NULL) {
        p->lchild = pre;
        p->ltag = 1;
    }
    //如果pre非空且pre右线索存在则让其指向p
    if (pre != NULL && p->rchild == NULL) {
        pre->rchild = p;
        pre->rtag = 1;
    }
    //p和pre线索化完成后,将pre指向p(p在之后将指向它的孩子)
    pre = p;
    
  • 先序遍历的递归算法:

    //先序遍历
    void preOrder(BTNode *p) {
    	if (p!=NULL) {
    
    		visit(p);				//线索化的代码块放这里代替visit
    
    		preOrder(p->lchild);
    		preOrder(p->rchild);
    	}
    }
    

与中序线索二叉树一样,根据二叉树线索化的代码块以及先序遍历和后序遍历算法,只需要变动线索化代码块与递归的位置即可:

  • 二叉树的先序线索化

    //先序线索化
    void preThread(TBTNode *p, TBTNode *&pre) {
    	if (p != NULL) {   
            //线索化:p的左线索如果存在则让其指向pre
            if (p->lchild == NULL) {
                p->lchild = pre;
                p->ltag = 1;
            }
            //如果pre非空且pre右线索存在则让其指向p
            if (pre != NULL && p->rchild == NULL) {
                pre->rchild = p;
                pre->rtag = 1;
            }
            //p和pre线索化完成后,将pre指向p(p在之后将指向它的孩子)
            pre = p;	
            
            //注意,这里在递归入口处设有条件,左右指针不是线索才能继续递归      
    		if(p->ltag==0)
    			inThread(p->lchild, pre);
    		if(p->rtag==0)
    			inThread(p->rchild, pre);
    	}
    }
    

    易错点:注意递归处的条件判断,这是先序线索化独有的,考虑一种特殊情况:加入p此时指向D,pre指向的是B,在对p完成线索化之后,p的lchild已经指向了B,此时按照程序,pre指向D,q指向D的lchild(注意,此时D已经完成线索化),即q又指向了B,又要去完成对B的线索化。可是, 在对D线索化前不是刚对B线索化过了吗?这就产生了死循环。所以,在递归入口前,我们要添加左右指针非线索的条件,就是为了避免这种情况。

    image-20210714014808947

    • 查找先序线索二叉树的先序后继结点

      先序遍历遵循的是“根左右”的原则,

    1. 如果p有左孩子(ltag=0),那么左孩子就是它的后继
    2. 如果p没有左孩子但有右孩子(ltag=1,rtag=0),那么右孩子就是它的后继
    3. 如果p左右孩子都没有(ltag=1,rtag=1),那么必然被线索化,它的右链域就指向它后继

    也就是说,只需要判断p有没有左孩子,如果它有左孩子,左孩子就是它的后继,否则,它的rchild一定指向它的后继。

    所以不难写出先序遍历一个先序线索二叉树的代码:

    //先序遍历先序线索二叉树
    void preOrder(TBTNode *root) {
    	if (root != NULL) {
    		for (TBTNode *p = root;p != NULL;) {
    			visit(p);
    			if (p->ltag == 0)		//ltag == 0说明p的左链域没有被线索化,那么它一定有左孩子,左孩子就是p的后继
    				p = p->lchild;
    			else					//只要p没有左孩子,那么不管右孩子是否被线索化,rchild一定指向p的后继
    				p = p->rchild;
    		}
    	}
    }
    

    天勤书上的版本,个人觉得没有上面这个好理解:

    void preOreder(TBTNode *root) {
    	if (root != NULL) {
    		TBTNode *p = root;
    		while (p != NULL) {
    			while (p->ltag==0){	
    				visit(p);
    				p = p->lchild;
    			}
    			visit(p);
    			p = p->rchild;
    		}
    	}
    }
    
    • 查找先序线索二叉树的先序前驱结点

      1. p结点被线索化,那么p->lchild就是它的前驱
      2. p结点没有被线索化,根据先序二叉树:根左右的顺序,根的左子树和右子树都是他的后继,而未被线索化的结点是没有办法返回查找的,所以这种情况下找不到前驱。此时只能从头遍历一次二叉树,才能确定p结点的前驱。

      改进:改二叉链表结构为三叉链表结构,每个结点再增设一个指向父结点的指针。

后序线索化

  • 后序遍历的递归算法:

    //后序遍历
    void postOrder(BTNode *p) {
    	if (p != NULL) {
    		postOrder(p->lchild);
    		postOrder(p->rchild);
    
    		visit(p);				//线索化的代码块放这里代替visit
    
    	}
    }
    
  • 二叉树的后序线索化

    //后序线索化
    void postThread(TBTNode *p, TBTNode *&pre) {
    	if (p != NULL) {    
    		postThread(p->lchild, pre);			//递归,后序遍历并线索化左子树
            postThread(p->rchild, pre);			//递归,后序遍历并线索化左子树
            
            //线索化:p的左线索如果存在则让其指向pre
            if (p->lchild == NULL) {
                p->lchild = pre;
                p->ltag = 1;
            }
            //如果pre非空且pre右线索存在则让其指向p
            if (pre != NULL && p->rchild == NULL) {
                pre->rchild = p;
                pre->rtag = 1;
            }
            //p和pre线索化完成后,将pre指向p(p在之后将指向它的孩子)
            pre = p;	
    	}
    }
    
  • 查找后序线索二叉树的后序前驱结点

    后序遍历遵循“左右根”的规则:

    1. 如果p有右孩子,那么p的后序前驱为p的右孩子
    2. 如果p没有右孩子,那么p的后序前驱为p的左孩子
    3. 如果p左右孩子都没有,那么p已经被线索化,lchild指向它的前驱

    也就是说,只需要判断p到底有没有右孩子即可,有右孩子,p的前驱就是rchild,否则就是lchild。

  • 查找后序线索二叉树的后序后继结点

    1. 如果p的右链域被线索化,那么p的后继就是rchild;
    2. 如果p没有被线索化,那么根据后序二叉树:左右根的顺序,根的左子树和右子树都是他的前驱,而未被线索化的结点是没有办法返回查找的,所以这种情况下找不到p的后继。此时只能从头遍历一次二叉树,才能确定p结点的后继。

    改进:改二叉链表结构为三叉链表结构,每个结点再增设一个指向父结点的指针。

手动查找线索二叉树中的前驱与后继

首先,上面已经分析了机器查找的过程,总结如下图:先序线索二叉树和后续线索二叉树都存在一定的局限性。为了解决这个问题,可以将二叉链表结构为三叉链表结构,多增设一个指向父结点的指针。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-n3SRU0Ni-1626800975205)(C:/Users/76583/Desktop/%E5%A4%A9%E5%8B%A4%E7%AC%94%E8%AE%B0/image-20210714205603680.png)]

如何人工的查找前驱与后继呢?这里重点看先序线索二叉树找前驱以及后序线索二叉树找后继。

  • 查找中序线索二叉树的中序后继结点:

    1. 若右标志为1,则右链被线索化,直接指向它的后继;
    2. 若右标志为0,就遍历右子树中找到第一个的结点,它就是p的后继,它在右子树的左下角。
  • 查找中序线索二叉树的中序前驱结点:

    1. 若左标志为1,则左链被线索化,直接指向它的前驱;
    2. 若左标志为0,就遍历左子树中找到最后一个结点,它就是p的前驱,它在左子树的右下角。
  • 查找先序线索二叉树的先序后继结点

    1. 如果p有左孩子(ltag=0),那么左孩子就是它的后继
    2. 如果p没有左孩子但有右孩子(ltag=1,rtag=0),那么右孩子就是它的后继
    3. 如果p左右孩子都没有(ltag=1,rtag=1),那么必然被线索化,它的右链域就指向它后继

    也就是说,只需要判断p有没有左孩子,如果它有左孩子,p的后继就是lchild,否则就是rchild。

  • 查找先序线索二叉树的先序前驱结点

    1. p结点被线索化,那么p->lchild就是它的先序前驱
    2. p结点没有被线索化,找到p的父亲结点q,按照“根左右”的规则 :
      1. p是q的左孩子,则p的先序前驱是q;
      2. p是q的右孩子,且p没有左孩子,则p的先序前驱是q;
      3. p是q的右孩子,且p有左孩子,则p的先序前驱是q的左子树最后一个被先序遍历的结点(右下角)
    3. p是根结点,则p没有先序前驱。
  • 查找后序线索二叉树的后序前驱结点

    1. 如果p有右孩子,那么p的后序前驱为p的右孩子
    2. 如果p没有右孩子,那么p的后序前驱为p的左孩子
    3. 如果p左右孩子都没有,那么p已经被线索化,lchild指向它的前驱

    也就是说,只需要判断p到底有没有右孩子即可,有右孩子,p的前驱就是rchild,否则就是lchild。

  • 查找后序线索二叉树的后序后继结点

    1. 如果p的右链域被线索化,那么p的后继就是rchild;
    2. 如果p没有被线索化,找到p的父亲结点q,根据后序二叉树“左右根”的规则:
      1. p是q的右孩子,那么p的后序后继就是q;
      2. p是q的左孩子,且p没有右孩子,则p的后序后继就是q;
      3. p是q的左孩子,且p有右孩子,则p的后序后继就是q的左子树中第一个被后序遍历的结点(左下角)
    3. p是根节点,则p没有后序后继。

树、森林和二叉树的相互转换

树的孩子兄弟存储结构与二叉树的存储结构本质上都是二叉链表,只是左右结点表达的含义不同:

  • 二叉树
    1. lchild:结点的左孩子
    2. rchild:结点的右孩子
  • 树的孩子兄弟表示法
    1. child:结点的第一个孩子
    2. sibling:结点的下一个兄弟

树转换为二叉树

规则:孩子兄弟表示法:

  1. 每个结点的左指针指向自己的第一个孩子
  2. 每个结点的右指针指向相邻的右兄弟

即“左孩子,右兄弟”,由于根结点没有兄弟,所以转换后的二叉树没有右子树

请添加图片描述

树转换成二叉树的画法:

  1. 在各兄弟结点之间加一条连线

  2. 对每一个结点,只保留它与第一个孩子之间的连线,其余都抹去

  3. 以树根为圆心,顺时针旋转45度

请添加图片描述

二叉树转换为树

把树转换为二叉树的过程逆过来即可:

  1. 以树根为圆心,逆时针旋转45度
  2. 则同一层的为兄弟,顺着兄弟的路径找到上一层父结点,连线
  3. 抹去每一层结点之间的连线

请添加图片描述

森林转换为二叉树

  1. 对森林中的每一颗树都按照上面的方法转换为二叉树
  2. 每个树转换为二叉树后都没有右子树,那么就把森林中的第二棵树当做第一颗树的右子树,以此类推,最终整个森林就转变成了二叉树。

二叉树转换为森林

  1. 不停地将根结点有右孩子的二叉树的右孩子链接断开,直到不存在根结点有右孩子的二叉树为止;
  2. 然后将得到的多棵二叉树按照二叉树转化为树的规则依次转化即可。

树的遍历

树的遍历有两种方式:先序遍历和后序遍历。

  1. 先序遍历是先访问根结点,再依次访问根结点的每棵子树,访问子树时仍然遵循先根再子树的规则;

  2. 后序遍历是先依次访问根结点的每棵子树,再访问根结点,访问子树时仍然遵循先子树再根的规则。
    请添加图片描述

对于如图所示的树:先序遍历的结果为ABEFCGDHIJ,后序遍历的结果为EFBGCHIJDA。

树转换为二叉树后,树的先序遍历对应二叉树的先序遍历,树的后序遍历对应二叉树的中序遍历(注意不是后序遍历)。

所以,可以将树转换为二叉树后,借助遍历二叉树的方法来遍历树。假如一 棵树已经转化为二叉树来存储,要得到其先序遍历序列,只需先序遍历这棵二叉树;要得到其后序遍历序列,只需中序遍历这棵二叉树。

森林的遍历

森林的遍历方式有两种:先序遍历和后序遍历。

  1. 先序遍历的过程:先访问森林中第一 棵树的根结点,然后先序遍历第一 棵树中根结点的子树,最后先序遍历森林中除了第一 棵树以外的其他树。
  2. 后序遍历的过程:后序遍历第一棵树中根结点的子树,然后访问第一棵树的根结点,最后后序遍历森林中除去第一 棵树以外的其他树。

森林转换为二叉树,森林的先序遍历对应二叉树的先序遍历,森林的后序遍历对应二叉树的中序序列(注意不是后序遍历)。

二叉树的应用

赫夫曼树

赫夫曼二叉树

赫夫曼树又叫作最优二叉树,它的特点是带权路径最短。几个相关概念:

  1. 路径:路径是指从树中一个结点到另一个结点的分支所构成的路线。
  2. 路径长度:路径长度是指路径上的分支数目。
  3. 树的路径长度:树的路径长度是指从根到每个结点的路径长度之和。
  4. 带权路径长度:结点具有权值,从该结点到根之间的路径长度乘以结点的权值,就是该结点的带权路径长度。
  5. 树的带权路径长度(WPL):树的带权路径长度是指树中所有叶子结点的带权路径长度之和。
  • 构造赫夫曼二叉树

    给定n个权值,用这n个权值来构造赫夫曼树的算法描述如下:

    1. 将这n个权值分别看作只有根结点的n棵二叉树,这些二叉树构成的集合记为F。
    2. 从F中选出两棵根结点的权值最小的树(假设为a、b)作为左、右子树,构造一 棵新的二叉树c, 新的二叉树的根结点的权值为左、右子树根结点权值之和。
    3. 从F中删除a、b, 加入新构造的树c。
    4. 重复进行2、3两步,直到F中只剩下一 棵树为止,这棵树就是赫夫曼树。
  • 赫夫曼树的特点

    1. 树的带权路径长度最短
    2. 权值越大的结点,距离根结点越近
    3. 树中没有度为1的结点。这类树又叫作正则(严格)二叉树。
    4. 树中两个权值最小的结点一定是兄弟结点;
    5. 树中任一非叶结点的权值一定不小下一层任一结点的权值;
    6. 赫夫曼树的结点总数为2n-1,n为叶子结点个数,也是最开始给定权值的结点个数。
    7. 赫夫曼树不唯一,但WPL相同且最优。

赫夫曼编码

在数据通信中,若对每个字符用相等长度的二进制位表示,称这种编码方式为固定长度编码

若允许对不同字符用不等长的二进制位表示,则这种编码方式称为可变长度编码

可变长度编码比固定长度编码要好得多,其特点是对频率高的字符赋以短编码,而对频率较低的字符则赋以较长一些的编码,从而可以使字符的平均编码长度减短,起到压缩数据的效果。

赫夫曼编码是一种被广泛应用而且非常有效的数据压缩编码。

任意一个字符的编码都不是另一个字符编码的前缀,则称这样的编码为前缀编码。前缀编码可以保证不会出现歧义。

  • 由赫夫曼树得到哈赫夫曼编码

    1. 将每个出现的字符当作一个独立的结点,其权值为它出现的频度(或次数),根据频度大小构造出对应的赫夫曼树。
    2. 对赫夫曼树每个结点的左右分支编号,左0右1,则从根到每个结点的路径上的数字即为每个字符的赫夫曼编码。

    一个例子:假设有字符串s=“AAABBACCCDEEA”,按照上图的方法得到如图所示的赫夫曼树并对分支编号:

    image-20210717233804817

    那么对ABCDE进行赫夫曼编码有:

    1. A=0
    2. B=110
    3. C=10
    4. D=1110
    5. E=1111

    可以看到,显然,所有字符结点都出现在叶结点中,且越靠近根结点的字符频度越高(权值越大),出现次数最多的字符编码长度就越短,而且赫夫曼编码属于前缀编码。根据赫夫曼树WPL最短的特性可知,赫夫曼编码产生的是最短前缀编码

赫夫曼多叉树

赫夫曼二叉树是赫夫曼n叉树的一 种特例。

对于结点数目>= 2 的待处理序列,都可以构造赫夫曼二叉树,但却不一 定能构造赫夫曼 n 叉树。

当发现无法构造时,需要补上权值为0的结点让整个序列凑成可以构造赫夫曼n叉树的序列。

例如:对于序列A(1)、B(3)、C(4)、D(6) (括号内为权值),就不能直接构造赫夫曼三叉树,需要补上一 个权值为0的结点H。

image-20210717234900685

H结点的存在对WPL值没有影响,得到的仍然是最小WPL:(0*2)+(1*2)+(3*2)+(4*1)+(6*1)=18。

但要注意的是,二叉赫夫曼树和三叉赫夫曼树所得到的WPL是不同的,不要混淆最小的概念,这里的最小是说在含有n个带权叶结点的三叉树中,赫夫曼三叉树是WPL最小的。

二叉排序树BST

  • BST(Binary Search Tree)定义

    二叉排序树或者是空树,或者是满足以下条件的树:

    1. 若它的左子树不为空,则左子树上所有关键字的值均不大于(或不小于)根关键字的值
    2. 若它的右子树不为空,则右子树上所有关键字的值均不小于(或不大于)根关键字的值
    3. 左右子树又各是一颗二叉排序数。

    根据定义可知,二叉排序数的中序遍历是非递减有序(或非递增有序)的,没有特殊说明,BST均采取左小右大的分布。

  • 存储结构

    二叉排序树和二叉树的存储结构没有差别,都是有一个值域和两个指针域组成:

    typedef struct BSTNode {
    	int key;		
    	struct BSTNode *lchild;
    	struct BSTNode *rchild;
    }BSTNode;
    
  • 查找关键字

    //查找key
    BSTNode* BSTSearch(BSTNode *bst,int key) {
    	if (bst == NULL)  return NULL;	
    	else {
    		if (key == bst->key) {
    			return bst;
    		}
    		else if (key < bst->key) {
    			return BSTSearch(bst->lchild, key);
    		}
    		else {
    			return BSTSearch(bst->rchild, key);
    		}
    	}
    }
    
  • 插入关键字

    要插入关键字首先要找到插入位置,对于一个不存在于二叉排序树中的关键字,其查找不成功的位置就是该关键字的插入位置。

    1. 查找关键字,当来到空指针的位置就插入,返回1,新插入的结点必是叶结点
    2. 如果找到关键字,则不需要再插入,返回0,插入失败
    //插入
    int BSTInsert(BSTNode *bst, int key) {
    	if (bst == NULL) {
    		bst = (BSTNode*)malloc(sizeof(BSTNode));
    		bst->lchild = bst->rchild = NULL;
    		bst->key = key;
    		return 1;
    	}  
    	else {
    		if (key == bst->key) {
    			return 0;
    		}
    		else if (key < bst->key) {
    			return BSTInsert(bst->lchild, key);
    		}
    		else {
    			return BSTInsert(bst->rchild, key);
    		}
    	}
    }
    
  • 删除关键字

    二叉排序树的删除操作是最麻烦的,因为必须保证删除操作之后继续维持树的“有序性”。假设将要被删除的结点为p,f是它的父结点,那么会出现三种情况:

    1. p是叶子结点。直接删除即可。

    2. p只有右子树而没有左子树,或者p只有左子树没有右子树。此时,只需要删除p,把它的子树接在f上取代p的位置即可。

    3. p既有右子树也有左子树。按照以下操作方法可以将情况3转换为情况1,2:

      找到中序遍历序列中p的直接前驱m,或者,找到中序遍历序列中p的直接后继n,将p的值改为m或者n,之后删除原m或者原n。

      原先的m或者n的删除方式必然是情况1,2中的某一种,这样就完成了情况3向情况1,2的转换。

  • 建立二叉排序树

    void createBST(BSTNode *&bst,int key[],int n){
    	int i;
        bst = NULL;
        for(int i=0;i<n;i++){
            BSTInsert(bst,key[i]);
        }
    }
    

二叉平衡树AVL

  • 定义

    二叉平衡树或者是空树,或者是满足以下条件的树:

    1. 平衡二叉树的左右子树都是平衡二叉树
    2. 左右子树的高度差不超过1。

    即:以树中所有结点为根的树的左右子树高度之差不超过1。

  • 平衡因子

    一个结点的平衡因子为其左子树的高度减去右子树高度的差。

    对于平衡二叉树,树中的所有结点的平衡因子的取值只能是-1、0 、1 三个值。

  • 平衡二叉树的查找

    任意关键字的查找,比较次数不超过AVL树的高度。

    设Nh表示高度为h的平衡二叉树所含有的最少结点数,则有:N0=0,N1=1,N2=2,N3=4,N5=7,……,Nh=Nh-2+Nh-1+1。

    这个结论也可以反过来求给定结点数的AVL树的查找所需要的最多比较次数(或树的最大高度)。

  • 平衡调整

    建立平衡二叉树的过程和建立二叉排序树的过程基本一 样,都是将关键字逐个插入空树中的过程。不同的是,在建立平衡二叉树的过程中,每插入一 个新的关键字都要进行检查,看是否新关键字的插入会使得原平衡二叉树失去平衡,即树中出现平衡因子绝对值>1的结点。如果失去平衡则需要进行平衡调整。平衡二叉树的重点就是平衡调整。

  • 平衡调整的方法

    假定向平衡二叉树中插入一 个新结点后破坏了平衡二叉树的平衡性:

    1. 首先要找出插入新结点后失去平衡的最小子树
    2. 调整这棵子树,使之成为平衡子树。最小不平衡子树调整后,整个树恢复平衡。

    最小不平衡子树是指距离插入结点最近,且以平衡因子绝对值大于1的结点作为根的子树,又称为最小不平衡子树。

二叉平衡树的平衡调整

主要分为四种情况:LL右单旋转,RR左单旋转,LR先左后右双旋转,RL先右后左双旋转。

这里的L与R是对不平衡状态的描述:比如LL,就是指结点A的左孩子左子树上插入了新结点导致A失去平衡。

  1. LL调整:某时刻在a的左孩子b的左子树Y上插入 一 个结点,导致 a 的左子树高度为 h+2 , 右子树高度为h,发生不平衡。

    此时应把b向右旋转代替a成为根结点,这一过程称为右单旋转。

    具体操作为:将a下移一 个结点高度,b 上移一 个结点高度,也就是将 b 从 a 的左子树取下,然后将b的右子树挂在a的左子树上,最后将a挂在b的右子树上以达到平衡。

  2. RR调整:情况与LL调整类似,对称处理请添加图片描述
    即可。

  3. LR调整:某时刻在a的左孩子b的右子树Y上插入一 个结点(不管是插在Y的左孩子还是右孩子,做法一样)导致不平衡。

    需要做两次旋转,先左旋c后右旋b。

    具体操作为:将c作为a和b两棵子树的根,b为左子树,a为右子树,c原来的左子树U作为b的右子树,c原来的右子树V作为a的左子树以达到平衡。这就是LR调整,也叫先左后右双旋转调整,因为调整的过程可以看成是先左旋c后右旋b。

  4. RL调整:如果b在a的右子树上,且插入的结点在b的左子树上,即与图9-8a对称的情况,则此时只需将上述过程做左右对称处理即可。这种调整叫RL调整,也叫先右后左双旋转调整。

请添加图片描述

典型例题

二叉树的性质相关

  1. 一颗完全二叉树有1001个结点,其中叶子结点的个数为(501)个。

    分析:完全二叉树一定是由一颗满二叉树从下到上,从右到左,挨个删除结点所得到的。

    也就是说,一颗完全二叉树,度为1(分支为1)的结点一定是1或者0,如果有度为1的结点,它的孩子一定是二叉树最后一层最后一个结点。

    假设度为1的结点数位1,即n1=1,那么n=n0+n1+n2=n0+1+n0-1=2n0=1001,n0=500.5,显然错误。

    假设度为1的结点数为0,即n1=0,那么此时二叉树中只有度为0和度为2的结点,则n=n0+n2,根据性质2,又有n0=n2+1,则n=n0+n2=n0+n0-1=2n0-1=1001,得n0=501。

  2. 假设高度为h的二叉树中只有度为0和2的结点,那么此类二叉树中所包含的结点数最少为(2h-1)个。

    要求度只有0和2,且结点最少,那么必然是类似二叉赫夫曼数的构造。

    除了第一层只有1个根结点以外,其余每一层都只有2个结点,则结点数为2h-1。

    如果题目要求结点最多,那么自然是满二叉树,此时结点数是2h-1。

  3. 设树的度为4,其中度为1、2、3、4的结点个数分别为4、2、1、1,则树中的叶子结点个数为(8)个。

    根据性质4:n0=1+n2+2n3+…+(m-1)nm=1+2+2*1+3*1=8

  4. 有n个叶子结点的二叉赫夫曼树的结点总数是(2n-1)个。

  5. n个结点的线索二叉树含有的线索数为(n+1)。

    线索二叉树的线索数等于原二叉树中的空指针数=总结点数+1

  6. 一颗具有1025个结点的二叉树的高度h的范围为(11~1025)。

    高度最高:每层只有一个结点,则高度为1025

    高度最低:完全二叉树,根据性质10,h=[log2(1025+1)]向上取整,h=11

  7. 在度为m的赫夫曼树中,叶子结点的个数为n,则非叶子结点的个数为( ⌈ n − 1 m − 1 \frac{n-1}{m-1} m1n1⌉)个。

    在构造度为m的赫夫曼树的过程中,每次把m个叶子结点合并为一个父结点 (第一 次合并可能少于 m 个子结点),每次合并减少 m -1个结点。加入第一次合并了m个结点,为了统一计算给n-1,把第一次合并看成m-1个结点,共需要 ⌈(n-1)/(m-1)⌉次合并,向上取整是因为最后不一定能整除,此时会人为的补上结点形成最后一次合并,每次合并增加一 个非叶子结点。下图展示了度为3,则叶子结点个数为8的赫夫曼三叉树的合并过程,橙色的为人为补上的结点。

    image-20210717221854178

  8. 已知一棵完全二叉树的第6层(设根为第1层)有8个叶子结点,则该完全二叉树的结点个数最多是(111)个。

    需要注意的是,树不一定是6层,不要陷入思维惯性,看到第6层有8个叶子结点,就下定论树只有6层。实际上,结点个数最多的情况下树有7层,第6层的8个叶子结点在第7层均没有孩子,即7层的满二叉树从7层右边往左去掉8*2=16个结点形成的完全二叉树。前6层为满二叉树,共有26-1=63个结点,第7层有27-1-16=48个结点,共计111个结点。

  9. 已知一棵有2011个结点的树,它的叶子结点为116个,则该树转换成的二叉树中没有右孩子的结点个数为(1896)个。

    考虑极端情况:

    image-20210717223732140

    按照“左孩子,右兄弟”的规则构成二叉树,最后一个叶子结点加上上面1895个中间结点必然没有右孩子。

  10. 高度为6的平衡二叉树,所有非叶结点的平衡因子均为1,则该平衡二叉树的结点总数为(N6=20)

    根据性质11可推得,高度为6的平衡二叉树的结点最少为20个,实际上,题目所有非叶结点的平衡因子均为1,暗含的信息也是指结点最少的极端情况,当所有非叶结点的平衡因子均为1时,此时增加一个结点,会使得某个结点的平衡因子变为0,而不影响平衡性。这也就是构成平衡二叉树结点最少的情况。

  11. n和m为一颗二叉树的两个结点,在中序遍历时,n在m前的条件是n在m的左边。(对)

  12. 对于二叉树来说,不管是前序、中序、后序遍历,叶子结点在遍历序列中的先后顺序是相同的。(对)

  13. 一颗二叉树的先序序列和后续序列正好相反,则该二叉树的高度一定等于其结点数。(对)

算法题

求二叉树的总结点数

  1. 采用中序、先序、后序任意一种方式遍历树来计算;

    int nums = 0;	//全局变量,一个计数器
    int getNodeNums(BTNode *bt) {
    	if (bt != NULL) {
    		nums++;	//每次经过一个结点,计数器+1
    		getNodeNums(bt->lchild);
    		getNodeNums(bt->rchild);
    	}
    }
    
  2. 采用另外一种递归思路:如果树空,返回0;如果树非空,求其左子树的的结点数n1,右子树的结点数n2,返回n1+n2+1。

    int  getNodeNums(BTNode *bt) {
    	int n1, n2;
    	if (bt == NULL) {
    		return 0;
    	}else{
    		n1 = getNodeNums(bt->lchild);
    		n2 = getNodeNums(bt->rchild);
    		return n1 + n2 + 1;
    	}
    }
    

求二叉树的叶子结点数

给上一题加上限制条件:左右孩子非空即可。

  • 方法一:

    int nums = 0;
    int getNodeNums(BTNode *bt) {
    	if (bt != NULL) {
            if(bt->lchild == NULL && bt->rchild == NULL){
                nums++;
            }
    		getNodeNums(bt->lchild);
    		getNodeNums(bt->rchild);
    	}
    }
    
  • 方法二:

    int  getNodeNums(BTNode *bt) {
    	int n1, n2;
    	if (bt == NULL) {	//空树返回0
    		return 0;
        }else if(bt->lchild == NULL && bt->rchild == NULL){	//如果是叶子结点,返回1
            return 1;
        }else{
    		n1 = getNodeNums(bt->lchild);	//既不是空树也不是叶子结点,求它的左子树的叶子结点数
    		n2 = getNodeNums(bt->rchild);	//求它的右子树的叶子结点数
    		return n1 + n2 ;
    	}
    }
    
    

把二叉树的叶子结点从左到右串成链表

具体要求就是修改叶子结点的rchild指针,指向它右边的叶子结点。用head和tail分别指向链表的表头和表尾。

分析:这里要用到一个非常重要的性质:不管是先序遍历、中序遍历还是后序遍历,在它们的遍历过程中叶子结点被访问的先后顺序都是不变的,都是从左往右。任选一种,修改visit函数即可,这里采用先序遍历的模板:

void linkNodes(BTNode *bt, BTNode *&head, BTNode *&tail) {
	if (bt != NULL) {
        //判断是否是叶子结点
		if (bt->lchild == NULL && bt->rchild == NULL) {
            //开始串接链表
            //head为空,说明bt是表头,表尾指针和表头指针同时指向它
			if (!head) {
				head = bt;
				tail = bt;
			}
			else {
                //bt不是表头,就把它接在表尾,同时挪动表尾指针
				tail->rchild = bt;
				tail = bt;
			}	
		}
        
		linkNodes(bt->lchild, head, tail);
		linkNodes(bt->rchild, head, tail);
	}
}

带有指向父结点指针的二叉树

  1. 修改数据结构

    typedef struct BTNode_p {
    	char data;
    	struct BTNode_p *lchild;
    	struct BTNode_p *rchild;
    	struct BTNode_p *parent;	//增加父亲指针
    }BTNode_p;
    
  2. 遍历二叉树,给每个结点都设立父结点,我们需要两个参数,当前结点和它的父结点:

    //遍历二叉树设立父结点
    //传入根结点和它的父结点NULL即可
    void setParentTree(BTNode_p *btp, BTNode_p *par) {
    	if (btp != NULL) {
    		btp->parent = par;
    		btp = par;
    		setParentTree(btp->lchild, btp);
    		setParentTree(btp->rchild, btp);
    	}
    }
    

将一颗满二叉树的先序序列转换为后序序列

假设先序序列存储在数组pre[L1,…,R1]中,请把后序序列存储到post[L2,…,R2]数组中。

分析:根据满二叉树的特性可知,满二叉树具有一个重要的特性就是左右子树的结点数目是相等的,利用这一特性,我们可以很自然的根据先序序列唯一的满二叉树:序列的第一个元素就是根结点,然后将剩余的元素等分为两份,则分别为左子树序列和右子树序列。那如何把先序序列转换为后序序列呢?根据先序遍历和后序遍历的特性,它们的叶子结点的相对位置都是相同的,我们只需要递归地把先序序列中根结点的位置放到序列的末尾即可。但是,这种方法只适合于满二叉树,正是因为是满二叉树,我们才能每次正确的找到子树的根结点。

这里的主要难点就在于左右子树的下标问题,假设先序序列为pre[L1,…,R1],后序序列将存储在post[L2,…,R2]中,那么:

左子树的下标:

  • L1+1:在pre数组中除去第一个元素(根结点),剩下的第一个就是左子树下标的开始位置

  • (L1 + 1 + R1) / 2:在pre数组中L1+1到R2的中间位置就是左子树的末端下标

  • L2——0:在post数组中左子树的序列的开始位置始终在数组开始位置

  • R2——(L2 + R2 - 1) / 2:在post数组中L2到R2-1的中间位置就是左子树的末端下标

左子树的下标:

  • (L1 + 1 + R1) / 2 + 1:在pre数组中左子树的末端下标+1就是右子树的开始位置
  • R1:在pre数组中从右子树的开始位置一直到pre数组的末端都是右子树的结点
  • (L2 + R2 - 1) / 2 + 1:在post数组中左子树的末端下标+1就是右子树的序列的开始位置
  • R2 - 1:在post数组中从右子树的开始位置一直到post数组的倒数第二个位置(倒数第一是根结点)都是右子树的结点
//把先序序列pre数组中下标L1到R1的元素转换为后序序列存到post数组中的L2到R2位置上
void preToPost(char pre[], int L1, int R1, char post[], int L2, int R2) {
    //L1>R1为递归结束的条件
	if (L1 <= R1) {	
        //将根结点放到后序序列的最后一位
		post[R2] = pre[L1];
        //递归转换左子树
		preToPost(pre, L1 + 1, (L1 + 1 + R1) / 2, post, L2, (L2 + R2 - 1) / 2);
        //递归转换右子树
		preToPost(pre, (L1 + 1 + R1) / 2 + 1, R1, post, (L2 + R2 - 1) / 2 + 1, R2 - 1);
	}
}

求二叉树的深度

写一个算法求二叉树的深度,二叉树以二叉链表的形式存储。

分析:假设这棵树的左子树的深度为ld,右子树的深度为rd,那这棵二叉树的深度等于ld和rd中的较大者再加一(根结点本身),采用递归的思想,先求左子树的深度,再求右子树的深度,最后返回二者中的较大者+1,按照"左右中"的遍历顺序,这不就正好是后序遍历吗?

//求二叉树的深度
int getDepth(BTNode *bt) {
	int ld, rd;
	if (bt == NULL) {						//作为递归结束标志,空树的深度自然为0
		return 0; 
	}
	else {
		ld = getDepth(bt->lchild);			//递归求得左子树的深度
		rd = getDepth(bt->rchild);			//递归求得右子树的深度
		return (ld > rd ? ld : rd) + 1;		//返回二者中的较大者+1
	}
}

求二叉树的宽度

写一个算法,求出二叉树的宽度(结点数最多的那一层上的结点个数),二叉树以二叉链表的形式存储。

分析:直接在二叉树中求最大宽度显然不是一件容易的事情,如果我们能把树中的元素都存到线性表(队列)里,并记录下每一个结点所在的层数,直接遍历线性表就可以得到最大宽度了。那么我们要做的事情就变成了:

  1. 遍历二叉树,求得树中每个结点所在的层数,并把信息存到队列里。
  2. 遍历队列,找出层数相同的最大结点个数

根结点的层数显然为1,根结点的孩子的层数就为1+1。这就说明,只要我们当前结点的层数,就能知道它的孩子所在的层数。那该采取哪种二叉树遍历方式呢?显然是层次遍历,我们当然希望把结点一层一层的从左往右逐个放到线性表里,那么在同一层的结点就是相邻的,这样只需要一次遍历就能求得最大宽度。

//根据我们的需求,定义队列中元素的结构体:结点+层数
typedef struct {
	BTNode *node;	//结点指针
	int lno;		//结点所在层数
}St;		
//求二叉树的宽度
int getWidth(BTNode *bt){
	//初始化队列
	St que[maxSize];				//队列尽可能的大,能放下树中的所有结点
	int front = 0, rear = 0;
	//定义两个临时变量用于接收每次出队元素保存的信息
	BTNode *q;
	int Lno = 0;										
	if (bt != NULL) {
		//根结点入队,它所在的层数是1
		que[++rear].node = bt;					
		que[++rear].lno = 1;
		//队列非空时循环
		while (front != rear) {						
			//出队
			q = que[++front].node;					
			Lno = que[++front].lno;	
			//左右孩子入队
			if (q->lchild != NULL) {				//如果出队结点q有左孩子,则左孩子入队,它所在的层数是q所在的层数+1
				que[++rear].node = q->lchild;
				que[++rear].lno = Lno + 1;
			}
			if (q->rchild != NULL) {				//如果出队结点q有右孩子,则右孩子入队,它所在的层数是q所在的层数+1
				que[++rear].node = q->rchild;
				que[++rear].lno = Lno + 1;
			}
		}
	}
	/*
        最后一个结点出队后,Lno就保存的是树中的最大层数;
        上面所说的出队,并没有将元素从队列中删除,只是挪动了队头指针;
        遍历队列,求得最大宽度:
	*/
	int maxWidth = 0;								//宽度
	int num;										//计数器
	int last=0;										//last用来保存每次查找下一层结点时的开始位置
	for (int i = 1; i <= Lno; i++)					//分别查找第一层、第二层...第Lno层的结点个数
	{
		num = 0;	
		for (int  j = last; j < rear; j++)			//从last位置开始统计第i层结点的个数
		{
			if (que[j].lno == i) {					//每发现一个第i层的结点,计数器+1
				num++;
				if (num > maxWidth) maxWidth = num;	//刷新最大宽度值
			}
			else if (que[j+1].lno > i) { //下一个结点不是第i层(必然是i+1层),就记录下次开始的位置并跳出循环找下一层
				last = j + 1;
				break;
			}
		}
	}
	return maxWidth;
}
/*
由于树中结点时按照一层一层从左往右的顺序存放的,那么层数相同的结点在队列中必然是相互挨着的,所以我们可以设置一个结束标志last,当发现下一个元素的层数不是i时,就直接跳出循环,下一次,找i+1层的元素时就直接从last开始找。所以,只需要遍历一次队列就够了。
*/

求二叉树指定结点所在的层数

方法一:利用层次遍历,把结点和它所在的层数信息保存在一个新的数据结构中(跟上一题求最大宽度是的做法一样),然后保存在一个队列中,遍历队列即可解决问题。

方法二:利用递归遍历,定义一个全局变量层数L,初始值为1,每次遍历左孩子的时候就L+1,每次遍历完右孩子的时候将要返回根结点时就给L-1:

int L = 1;	//全局变量L表示层数
void leno(BTNode* p, char x) {
	if (p != NULL) {
		if (p->data == x) {		//如果p->data==x,就输出层数L
			cout << L << endl;
		}
		++L;					//每次遍历左孩子前就给层数+1
		leno(p->lchild,x);
		leno(p->rchild,x);
		--L;					//每次遍历完右孩子放回根结点前,就给层数-1
	}
}

根据先序序列和中序序列构造二叉树

二叉树的先序序列存储在一维数组pre[L1,…,R1]中,中序序列存储在一维数组in[L2,…,R2]中,(L1,L2,R1,R2均表示了数组中元素的下标范围,元素为char型),假设二叉树中各结点中数据值不相同,请给出由pre[L1,…,R1]和in[L2,…,R2]构造二叉树的算法。

分析:根据先序序列和中序序列构建二叉树:

  1. 根据先序序列的第一个结点找到根结点

  2. 在中序序列中找到根结点的位置i,i左边就是左子树,i右边就是右子树:

    在in中,从L2到i-1就是左子树,i+1到R2就是右子树

    与之对应的左子树在pre的位置是L1+1到L1+(i-L2),右子树的位置是L1+(i-L2)+1到R1

  3. 重复1,2两步,递归地构建左右子树,当L1-R1<0(表示待处理序列的长度<0)时递归结束

这个算法的关键是确定左右子树的序列在pre和in数组中的下标。给出实例结合代码分析。

先序遍历pre:ABDECFG:(A(B(D)(E))(C(F)(G)))

中序遍历in:DBEAFCG:(((D)B(E))A((F)C(G)))

// 由pre[L1,...,R1]和in[L2,...,R2]构造二叉树
BTNode *createBT(char pre[], char in[], int L1, int R1, int L2, int R2) {
    //L1>R1说明处理的序列长度小于0,返回NULL,是递归结束的条件
    if (L1 > R1) 	return NULL;
    //构造根节点
	BTNode *bt;				
	bt = (BTNode *)malloc(sizeof(BTNode));
	bt->lchild = bt->rchild = NULL;
    //查找pre[L1]在in数组中的位置,用i记录下来
	int i;
	for (i = L2;i <= R2;i++) {
		if (in[i] == pre[L1])
			break;
	}
    //给bt的各参数赋值
	bt->data = in[i];	
    //递归构建bt的左右子树
    //pre[L1 + 1,...,L1 + i - L2]是左子树先序序列,pre[L1 + i - L2 + 1,..., R1]是右子树的先序序列
    //in[L2,..., i - 1]是左子树的中序序列,in[i + 1,..., R2]是右子树的中序序列
	bt->lchild = createBT(pre, in, L1 + 1, L1 + i - L2, L2, i - 1);
	bt->rchild = createBT(pre, in, L1 + i - L2 + 1, R1, i + 1, R2);
    
    //构建完成后返回根节点
	return bt;
}

1,每次遍历完右孩子的时候将要返回根结点时就给L-1:

int L = 1;	//全局变量L表示层数
void leno(BTNode* p, char x) {
	if (p != NULL) {
		if (p->data == x) {		//如果p->data==x,就输出层数L
			cout << L << endl;
		}
		++L;					//每次遍历左孩子前就给层数+1
		leno(p->lchild,x);
		leno(p->rchild,x);
		--L;					//每次遍历完右孩子放回根结点前,就给层数-1
	}
}

根据先序序列和中序序列构造二叉树

二叉树的先序序列存储在一维数组pre[L1,…,R1]中,中序序列存储在一维数组in[L2,…,R2]中,(L1,L2,R1,R2均表示了数组中元素的下标范围,元素为char型),假设二叉树中各结点中数据值不相同,请给出由pre[L1,…,R1]和in[L2,…,R2]构造二叉树的算法。

分析:根据先序序列和中序序列构建二叉树:

  1. 根据先序序列的第一个结点找到根结点

  2. 在中序序列中找到根结点的位置i,i左边就是左子树,i右边就是右子树:

    在in中,从L2到i-1就是左子树,i+1到R2就是右子树

    与之对应的左子树在pre的位置是L1+1到L1+(i-L2),右子树的位置是L1+(i-L2)+1到R1

  3. 重复1,2两步,递归地构建左右子树,当L1-R1<0(表示待处理序列的长度<0)时递归结束

这个算法的关键是确定左右子树的序列在pre和in数组中的下标。给出实例结合代码分析。

先序遍历pre:ABDECFG:(A(B(D)(E))(C(F)(G)))

中序遍历in:DBEAFCG:(((D)B(E))A((F)C(G)))

// 由pre[L1,...,R1]和in[L2,...,R2]构造二叉树
BTNode *createBT(char pre[], char in[], int L1, int R1, int L2, int R2) {
    //L1>R1说明处理的序列长度小于0,返回NULL,是递归结束的条件
    if (L1 > R1) 	return NULL;
    //构造根节点
	BTNode *bt;				
	bt = (BTNode *)malloc(sizeof(BTNode));
	bt->lchild = bt->rchild = NULL;
    //查找pre[L1]在in数组中的位置,用i记录下来
	int i;
	for (i = L2;i <= R2;i++) {
		if (in[i] == pre[L1])
			break;
	}
    //给bt的各参数赋值
	bt->data = in[i];	
    //递归构建bt的左右子树
    //pre[L1 + 1,...,L1 + i - L2]是左子树先序序列,pre[L1 + i - L2 + 1,..., R1]是右子树的先序序列
    //in[L2,..., i - 1]是左子树的中序序列,in[i + 1,..., R2]是右子树的中序序列
	bt->lchild = createBT(pre, in, L1 + 1, L1 + i - L2, L2, i - 1);
	bt->rchild = createBT(pre, in, L1 + i - L2 + 1, R1, i + 1, R2);
    
    //构建完成后返回根节点
	return bt;
}
  • 1
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值