学习数据结构--第四章:树与二叉树(二叉排序树、二叉平衡树、哈夫曼树)

学习数据结构--第四章:树与二叉树(二叉排序树、二叉平衡树、哈夫曼树)

Scroll Down

第四章:树与二叉树(树与二叉树的应用:二叉排序树、二叉平衡树、哈夫曼树)

1.二叉排序树

二叉排序树:BST,也称二叉查找树

二叉排序树或者为空树,或为非空树,当为非空树时有如下特点:

  • 若左子树非空,则左子树上所有结点关键字值均小于根结点的关键字
  • 若右子树非空,则右子树上所有结点关键字值均大于根结点的关键字
  • 左、右子树本身也分别是一棵二叉排序树。

注意这里是小于和大于而没有等于,就是说二叉排序树中不存在值相同的结点。

二叉排序树

二叉排序树中序遍历:1 2 3 4 5 6 8 10 16

这里可以发现,二叉排序树的中序遍历结果的时递增的,这符合所有的二叉排序树。

二叉排序树的中序遍历序列时一个递增的有序序列

1.1二叉排序树的查找

  • 二叉树非空时,查找根结点,若相等则查找成功;
  • 若不等,则当小于根结点值时,查找左子树;当大于根结点的值时,查找右子树。
  • 当查找到叶节点仍没查找到相应的值,则查找失败。

二叉排序树

练习:

查找5:首先8>5查找左子树,5>4查找右子树,5=5查找成功
查找6:首先8>6查找左子树,6>4查找右子树,6>5查找右子树,6<7查找左子树为空,查找失败。

我们根据这个过程其实很容易发现整个查找过程可以使用递归进行完成,可以自行尝试,这里使用非递归查询,代码编写:

参1 二叉树,参2 关键字 参3 保存查找到的结点的双亲结点,是一个指针引用型的变量,这里函数体内对该指针进行修改时,不仅仅会对形参进行修改,而且会对我们传入的变量指针进行修改。

BSTNode *BST_Search(BiTree T,ElemType key,BSTNode *&p){
    p = NULL; //双亲结点置为空(根结点没有双亲结点)
    while(T != NULL && key != T->data){//树非空且关键字不匹配
       p = T; //p指向改结点
       if(key < T->data){
           T = T->lchild; //循环查找左子树
       }else{
           T = T->rchild; //循环查找右子树
       }
    }
    return T;
}

时间复杂度:O(h) (h为二叉排序树的高度)

1.2二叉排序树的插入

  • 若二叉排序树为空,则直接插入结点;
  • 若二叉排序树非空,当值小于根结点时,插入左子树;当值大于根结点时,插入右子树;当值等于根结点时不进行插入

二叉排序树

练习:

插入6:6<8插入左子树,6>4插入右子树,6>5插入左子树。

二叉排序树

代码编写:

//参1 二叉树(注意是引用),参2 插入值
int BST_Insert(BiTree &T,KeyType k){
	if(T==NULL){ //树为空
		T = (BiTree)malloc(sizeof(BSTNode));
		T->key = k;
		T->lchild = T->rchild = NULL;
		return 1;
	}else if(k == T->key){ //相同不插入
		return 0;
	}else if(k < T->key){//小于插入左子树中,递归调用
		return BST_Insert(T->lchild,k);
	}else{//大于插入右子树中,递归调用
		return BST_Insert(T->rchild,k);
	}
}

1.3构造二叉排序树

读入一个元素并建立结点,若二叉树为空将其作为根结点;若二叉排序树非空,当值小于根结点时,插入左子树;当值大于根结点时,插入右子树;当值等于根结点时不进行插入。

//参3 插入结点的数量
void Create_BST(BiTree &T,KeyType str[],int n){
	T = NULL;
	int i=0;
	while(i<n){
		BST_Insert(T,str[i]);
		i++;
	}
}

二叉排序树的构造过程就算两个数组的值完全相同,但是构造顺序不同,所生成的二叉排序树就是不同的。

1.4二叉排序树的删除

假设我们删除4结点,则剩下的4结点的左子树和右子树该怎么形成结点8的左子树呢?

为了维护二叉树的基本性质,其实删除操作比较复杂的,我们需要分三种情况:

  • 1.若被删除的结点z是叶子结点则可以直接删除,不影响
  • 2.如果被删除的结点z只有一个子树,则让z的子树成为z父结点的子树,带题z结点。

例如我们删除结点5


  • 3.若被删除的结点z有两颗子树,则让z的中序序列直接后继代替z,并删去直接后继结点。

例如我们删除结点4,我们知道结点4的直接后继结点为4的右子树的最左边的那一个结点,这里是5,我们直接将结点4换成结点5,然后直接删除结点5即可,这里为什么能够直接删除?因为这个结点一定是最左侧那个结点,要么没有子树(叶子结点),要么就是只有右子树(如果有左子树他就不是最左侧的那个结点),所以删除的时候直接参考上面的两种情况就可以。


思考:
在二叉排序树中删除并插入某节点,得到的二叉排序树是否与原来相同?

首先我们删除一个结点7:

接着插入结点7

此时发现删除并插入后二叉排序树相同,这是删除叶子结点的情况,出现了删除并插入后二叉排序树相同,有没有不同的情况?

如果我们删除的是一个双亲结点的5

然后插入结点5

此时我们发现删除并插入后二叉排序树不相同了。

故: 在二叉排序树中删除并插入某节点,根据删除并插入的结点类型不同,得到的二叉排序树可能相同,也可能不同。

1.5查找效率

查找长度:查找该节点时所经历的结点的数量。

平均查找长度(ASL):所有结点查找长度求和取平均值,它取决于树的高度。

例如:

查找效率:O(log2n)

查找效率:O(n)

2.平衡二叉树

平衡二叉树:AVL,任意结点的平衡因子绝对值不超过一。

平衡因子:左子树高度 - 右子树高度

如上图二叉树,是否是平衡二叉树?

可以把所有结点的平衡因子计算出来,进行判断,可以看出此二叉树是平衡二叉树。

高度为h的最小(结点数最少)平衡二叉树的结点数Nh?

根据平衡二叉树 右侧那个结点的值可以选择:h、h-1和h-2,如果选择h,加上根节点1层,总层数为h+1,不符合题意。其中h-1和h-2都可以选择但是为什么选择h-2?是因为这里说的是最小平衡二叉树,h-1层结点数肯定比h-2层结点数多。N0=0(0层结点数为0)N1=1(结点数为1)

比如我们现在要计算高度为3的最小平衡二叉树的结点数。则:

N3=N2+N1+1; =》N2=N1+N0+1=2; =》N3=2+1+1=4;

2.1平衡二叉树的判断

利用递归的后序遍历过程:

  • 判断左子树是一棵平衡二叉树
  • 判断右子树是一棵平衡二叉树
  • 判断以该结点为根的二叉树为平衡二叉树
    判断条件
    若左子树和右子树均为平衡二叉树且左子树与右子树高度差的绝对值小于等于1,则平衡。

根据判断条件我们知道,每个结点要保存两个变量:一个是该节点的平衡性(b:1平衡 0不平衡)另一个是该节点的高度(h)。

//参1 该棵树的根节点
void Judge_AVL(BiTree bt,int &balance,int &h){
    //左子树平衡性左子树高度 右子树平衡性右子树高度
	int bl=0,br=0,hl=0,hr=0;
	if(bt==NULL){//如果根节点为空
		h=0; //高度设为0
		balance=1; //并且是平衡的
	}else if(bt->lchild==NULL&&bt->rchild==NULL){
	    //左子树和右子树都为空
		h=1; //高度为1
		balance=1; //平衡的
	}else{
		Judge_AVL(bt->lchild,bl,hl); //判断左子树
		Judge_AVL(bt->rchild,br,hr); //判断右子树
		//下面计算该节点为根二叉树的高度
		//首先判断哪个子树的高度高,然后加1即可
		if(hl>hr){
			h=hl+1;
		}else{
			h=hr+1;
		}
		//判断平衡性
		// abs 是取绝对值
		if(abs(hl-hr)<2&&bl==1&&br==1){
			balance=1;
		}else{
			balance=0;
		}
	}
} 

3.平衡二叉树的插入

平衡二叉树的插入过程其实和二叉排序树的插入过程相比多了一步,如果按照二叉排序树的插入过程,所形成的二叉树不一定是平衡二叉树,所以我们需要先插入之后进行调整。即:先插入后调整

调整原则每次调整最小不平衡子树

例如,如上图插入完成之后我们需要调整,我们调整是从插入结点开始,向上依次调整。首先4结点平衡因子为-1,符合平衡二叉树,然后向上6结点平衡因子为2,不符合平衡二叉树,则需要调整。

3.1LL平衡旋转(右单旋转)

出现不平衡的原因:在结点A的左孩子的左子树上插入了新结点。

调整方法:右旋操作:用A的左孩子B代替A,将A结点称为B的右子树根结点,而B的原右子树则作为A的左子树。

出现了不平衡的情况,下面调整成平衡二叉树:

3.2RR平衡旋转(左单旋转)

出现不平衡的原因:在结点A的右孩子的左子树上插入了新结点。

调整方法:左旋操作:用A的右孩子B代替A,将A结点称为B的左子树根结点,而B的原左子树则作为A的右子树。

3.3LR平衡旋转(先左后右双旋转)

出现不平衡的原因:在结点A的左孩子的右子树上插入了新结点。

调整方法:先左旋后右旋转操作:将A的左孩子B的右孩子结点C代替B,然后再将C结点向上代替A的位置。

注意:其中Cl和Cr也可能都为空,因为B的右子树Br可能为空。

3.4RL平衡旋转(先右后左双旋转)

出现不平衡的原因:在结点A的左孩子的左子树上插入了新结点。

调整方法:先右旋后左旋转操作:将A的右孩子B的左孩子结点C代替B,然后再将C结点向上代替A的位置。

4.带权路径长度

在学习哈夫曼树之前首先需要了解 带权路径长度

路径长度:路径上所经历 的个数

结点的权:结点被赋予的数值

树的带权路径长度:WPL,树中所有 叶结点 的带权路径长度之和

如上二叉树它的带权路径长度为WPL=7*2+2*2+3*2=24

如上二叉树它的带权路径长度为WPL=7*1+2*2+3*2=17

我们可以看出,虽然两个二叉树的结点的权重相同,但是他们的带权路径长度缺不一样,由此我们引出了哈夫曼树的定义

5.哈夫曼树

哈夫曼树:也称最优二叉树,含有n个带权叶子结点带权路径长度最小的二叉树。

5.1哈夫曼树的构造

哈夫曼树的构造算法

  • 将n个结点作为n棵仅含有一个根结点的二叉树,构成森林F
  • 生成一个新结点,并从F中找出根结点权值最小的两棵树作为它的左右子树,且新结点的权值为两棵子树根结点的权值之和
  • 从F中删除这两个树,并将新生成的树加入到F中
  • 重复2,3步骤,直到F中只有一棵树为止

这里需要注意,哈夫曼树的构造过程并未要求那棵树作为左子树那颗树作为右子树,所以哈夫曼树是不唯一的

例如上面的例子:

首先是三个带有权重的结点A B C

接着挑选两个根节点权重最小的树 B、C,形成一棵二叉树,该二叉树的根结点的权重为两个结点权重之和,然后放回森林中

然后依旧挑选两个根节点权重最小的树......

5.2哈夫曼树的性质

  • 每个初始结点都会成为叶节点,双支结点都为新生成的结点
  • 权值越大离根结点越近,反之权值越小离根结点越远
  • 哈夫曼树中没有结点的度为1
  • n个叶子结点的哈夫曼树的结点总数为2n-1,其中度为2的结点数为n-1

5.3哈夫曼编码

编码:对于一个字符串序列,用二进制来表示字符

固定长度编码

例如:HelloWorld,其中每一个字符使用三位的二进制表示,由此可以得出该字符串对应的二进制序列:000001010010011100011101010110

由上面的编码表示我们可能有两点疑虑:1.为什么要用三位的二进制表示,而不用比较短的二进制表示,比如两位的?2.其中l字符出现的次数比较多,那么这些出现次数比较多的字符是不是可以使用比较短的编码从而得到比较短的二进制序列?由此引出了第二种编码方式:可变长度编码

可变长度编码

例如:HelloWorld,我们用比较短的二进制表示,由此可以得出该字符串对应的二进制序列:0001001101110000

由此可以看到这样的序列比之前的序列短了很多,但是这样的序列是不可以应用的,因为我们之前使用固定长度编码每三位代表一个字符,依次遍历就可以得到对应的字符串,但是现在可变长度的编码比如:00,可以代表字符H或者两个字符l,这样就产生了歧义。其实前缀编码才是可用的。

前缀编码:没有一个编码是另一个编码的前缀

我们修改上面的对应表格为:

我们将 l 修改为 11,它不是任何一个编码的前缀,这样形成的二进制序列就可以逆置成字符串了。

那么这样的前缀编码是怎么得到的呢?其实就是利用了哈夫曼树的特点。举个栗子:五个字母以及出现的次数:A:5 B:3 C:6 D:9 E:13,我们利用哈夫曼树的构造算法构造出一个哈夫曼树,首先每一个字母作为一个结点,并且它的权重就是它的出现次数

然后构造哈夫曼树

接着我们只需要将树中所有左边的边赋值为0,右边的边赋值为1

然后我们利用从根节点到某一个结点所以经历的边,就可以得到结点对应的前缀编码了:D 00 - E 01 - C 10 - A 110 - B 111,而且我们可以看出出现次数越多的结点,它的前缀编码越短,出现次数越少的结点它的编码越长,也达到了缩短二进制序列的目的。

最后我们需要注意:

哈夫曼树并不唯一,所以每个字符对应的哈夫曼编码也不唯一,但带权路径长度相同且最优

关于数据结构的知识公众号 理木客同步更新中,下次将会讲解:数据结构 图,欢迎大家的关注