作为电子专业,对树的认识比较少,因为平时基本用不到,最多也就是链表,不过了解了树之后,才发现树的用处比链表的还多,所以这一次有必要好好补充一些树的知识。
5.1 树
5.1.1 树的概念
树(Tree)是n(n>=0)个结点的有限集。n=0时称为空树。在任意一颗非空树中:
(1)有且仅有一个特定的称为根(Root)的结点;
(2)当n>1时,其余结点可分为m(m>0)个互不相交的有限集T1、T2、…Tm,其中每一个集合本身又是一颗树,并且称为根的子树(subTree),如图
图片来自网络
概念说了那么多,还不如来图的实在。实话说的好有图有真相。
5.1.2 其他重要概念
结点:树的结点包含数据元素和若干个指向其子树的分支。
度(Degree):结点拥有的子树数称为结点的度。最大结点的度称为树的度
叶结点(leaf):度为0的结点称为叶结点或终端结点。
树的深度(Depth):结点的层次从根开始定义,根为第一层,根的孩子为第二层一次类推,树中结点的最大层次称为树的深度或高度。
上面的概念都来自《大话数据结构》,这些概念还是需要了解了解的,后面需要用到的。
5.2 二叉树
5.2.1 二叉树的定义
二叉树(Binary Tree)是(n>=0)个结点的有限集合,该集合或者为空集(称为空二叉树),或者由一个根结点和两颗互不相交的,分别称为根结点的左子树和右子树的二叉树组成。《大话数据结构》
概念这东西,看着就是难受,下面抽取一些特点再简化描述一下:
(1)每个结点最多有两颗子树,所以二叉树中不存在度大于2的结点,注意二叉树是可以没有子树或者有一颗子树的存在。
(2)左子树和右子树是有顺序的,次序不能任意颠倒。
(3)即使树种某结点只有一颗子树,也要区分它是左子树还是右子树。
二叉树图:
图片来源网络
-
斜树
树的所有结点都只有左子树或者右子树,看着图就斜在一边的。这样就退化到线性结构了。图就不画了,只要是偷懒。 -
满二叉树
在一颗二叉树中,如果所有分支结点都有左子树和右子树,并且所有叶子都在同一层上,这样的二叉树称为满二叉树。
看图,这就是满二叉树,每个结点都有左右子树,所有叶子节点都在同一层。 -
完全二叉树
完全二叉树这个就有点难受了,好好的搞这么多概念干啥,按我的话说,完全二叉树就是按0-9这中顺序排的,中间不能有缺失,就可以看着是完全二叉树,如果要看具体的文字描述,可以博客也可以看《大话数据结构》
5.2.2 二叉树的性质
这部分来自《大话数据结构》,个人感觉大话数据结构讲的还不错,这里借鉴借鉴
-
在二叉树的第i层上至多有2i-1个结点(i>=1)
第i层包括第一层,根结点那层,因为每层都是2的分散出去的,就是2的几次方,所以这个公式就是2i-1,可以通过归纳法证明,我已经忘记归纳法了。 -
深度为k的二叉树至多有2k-1个结点(k>=1)
注意这是2k之后再减1,跟上面的不一样,深度就是树的高。 -
对任何一颗二叉树T,如果其终端结点树为n0,度为2的结点树为n2,则n0=n2+1.
这个具体怎么推到我也不是很清楚
4. 具有n个结点的完全二叉树深度为|log2n|+1
这一条比较重要,因为这是算深度的公式,由满二叉树的定义我们知道,深度为k的满二叉树的结点树为2k-1,通过n=2k-1到推出满二叉树的深度为k=log2n。完全二叉树层次序号跟满二叉树是一样的,只是再最后几个位置缺了几个,所以完全二叉树的结点数一点大于2k-1-1个,所以2k-1-1<n≤2k-1,因为n是整数,所以2k-1≤n<2k,两边取对数k-1≤log2n<k,而k作为深度也是整数,所以k-1=log2n,然后k=|log2n|+1.
- 如果对一颗有n个结点的完全二叉树(其深度为|log2n+1|)的结点按层序编号,(从第1层到第|log2n|层,每层从左到右),对任一结点i(1≤i≤n)有:
(1)如果i=1,则结点i是二叉树根,无双亲;如果i>1,则其双亲是结点i/2.
(2)如果2i>n,则结点i无左孩子(结点i为叶子结点);否则其左孩子是结点2i
(3)如过2i+1>n,则结点i无右孩子;否则其右孩子是结点2i+1
后面三条加粗的话比较适合用在数组构建的二叉树上,因为用数组查找左右孩子和双亲就是利用下标,但是上面的情况适合根结点在数组下标为1的情况。就是把数组的首元素空出来,如果要用到数组下标为0的话:
(1)子结点为i,则双亲为(i-1)/2
(2)结点为i的,其左孩子为2i+1
(3)结点为i的,其右孩子为2i+2
二叉树的性质就讲到这里,这些理论终于讲完了,下面就可以详细研究二叉树。
5.2.3 二叉树的创建
大话数据结构里面有创建二叉树的例子,不过我就不写那种了, 直接写二叉排序树的创建。这个还比较实用一点。
二叉排序树的定义:二叉排序树,又称二叉查找树。它或者是一颗空树,或者是具有下列性质的二叉树。
- 若它的左子树不空,则左子树上所有结点的值均小于它的根结点的值;
- 若它的右子树不空,则右子树上所有结点的值均大于它的根结点的值;
- 它的左、右子树也分别为二叉排序树。
讲的这么多,简单一点理解就是左子树比根结点小,右子树比根结点大,所以创建的要按照这个方式插入。
原始数据:12, 45, 89, 127, 7, 4, 56, 57, 789, 9
- 第一步插入头结点
只要一个头结点的树是不完整的,接下来插入第二个结点 - 插入45
因为45比12大,所以往右子树插入 - 接下来的结点89,127都比根结点大,所以都是插入到右子树上
- 7对根结点12小,所以在左子树上,4又比7小,所以在7为结点的左子树上
- 56比12大往右边走,也比45大,往右边走,比89小,所以插入89的左子树
- 剩下的57,789,9 就一起画了,应该也知道二叉树排序树怎么插入了
插入完成之后就是这样的一颗树,明显这个数是不平衡的,右重左轻,如果是这样查找的话,效率也会下降很多,不过怎么说,这颗树是符合二叉排序树的性质的。
下面来看插入的代码:
//先看看树的数据结构
typedef int Elemtype;
#define BITREE_ENTRY(name, type) \
struct name \
{ \
struct type *left; \
struct type *right; \
}
typedef struct BiTree_node
{
Elemtype data; //结点数据
BITREE_ENTRY(, BiTree_node) bst; //左右孩子的结点
}_BiTree_node;
typedef struct BiTree
{
struct BiTree_node *root;
}_BiTree;
这次利用结构和数据分离,树的结点单独定义成一个结构体,然后再用一个大的结构体包含树的结点数据,其实链表也是可以这样实现的,只不过当初觉得麻烦,就没这样实现。
插入的操作代码:
/**
* @brief 创建新结点
* @param
* @retval
*/
struct BiTree_node *biTree_creat_node(Elemtype data)
{
struct BiTree_node *node = (struct BiTree_node*)malloc(sizeof(struct BiTree_node));
assert(node);
node->data = data;
node->bst.left = NULL;
node->bst.right = NULL;
return node;
}
/**
* @brief 插入二叉树对象(包括根结点)
* @param
* @retval
*/
int biTree_insert(struct BiTree *T, Elemtype data)
{
assert(T);
//如果头节点为空,就创建头结点
if(T->root == NULL){
T->root = biTree_creat_node(data);
return 0;
}
//1.判断插入点
struct BiTree_node *node = T->root;
struct BiTree_node *temp = T->root;
while(node != NULL){
//记录上一个结点指针,这个跟单链表的插入有点像
temp = node;
//data比根结点小,往左子树走
if(node->data > data){
node = node->bst.left;
}else { //否则往右子树走
node = node->bst.right;
}
}
//判断要插入的是左子树还是右子树
if(temp->data > data){
temp->bst.left = biTree_creat_node(data);
}else {
temp->bst.right = biTree_creat_node(data);
}
return 0;
}
插入的思想,就是循环判断当前要插入的元素是在哪一个位置,是哪个结点的左子树还是右子树,当找到这个结点的时候,就可以适当的插入到对应的位置。
5.2.4 二叉树的遍历
二叉树都创建了,但是不知道创建的对不对是吧,总要遍历出来看看,像链表那样,一遍历就知道插入的对不对了,所以二叉树也是需要遍历的,但是二叉树的遍历跟其他的线性表不同,线性表就是以前说的数组,链表,因为计算机是顺序执行的,所以这个线性表比较好遍历,一个挨着一个遍历就行了,但二叉树因为有两个节点,所以要有策略去选择先遍历那一个子树,然后在遍历另一个子树,如果我们限制了从左到右的习惯方式,那么二叉树遍历只要有4种方式:
- 前序遍历
规则是若二叉树为空,则空操作返回,否则先访问根结点,然后前序遍历左子树,再前序遍历右子树。还是看图好懂。
从图看出先从根结点出发,然后往左子树方向走,7是12的左子树,也是4,9的根结点,所以继续往7的左子树4,然后4没有了子树就,就切回到7的右子树9,然后9也没有子树,至此12的左子树遍历完成,接下来切到右子树45,然后45又根据上面所说的遍历。
看着这么复杂,一度以为程序会很复杂,其实程序是很简单,利用了递归调用,这个就有点像我们当初写的二叉堆的递归了,先看看代码吧
/**
* @brief 前序遍历,先遍历根结点,再左子树,然后右子树
* @param
* @retval
*/
void bstree_preOrderTraversal(struct BiTree_node* node)
{
if(node == NULL)
return ;
printf("%d ", node->data);
bstree_preOrderTraversal(node->bst.left);
bstree_preOrderTraversal(node->bst.right);
}
函数参数是树的根结点,前序遍历是从根结点出发,所以先打印,然后左子树的优化,所以下次传承是根结点的左子树,函数第二次调用,然后打印这个结点,然后以这个结点又为根结点继续左子树打印,直到遇到了结点为空,开始回退函数,回退的第一个之后,就接着调用右子树的遍历,如果右子树有子树继续遍历,没有就退出,一直退出就可完成遍历。
这样我就不细写了,排序的时候因为不熟递归,所以写了两个详细的,现在熟了很多了,就不细写了。
- 中序遍历
规则是若树为空,则空操作返回,否则从根结点开始(注意并不是先访问根结点),中序遍历根结点的左子树,然后访问根结点,最后中序遍历右子树。看图:
看图就好懂了,先从最左边的左子树开始(4),然后遍历这个左子树的根结点(7),再遍历这个左子树的根结点的右子树(9),然后往上7作为左子树,又开始遍历7的根结点(12),12的右子树(45)又作为根结点,所以要寻找45的左子树,但是我这个45是没有左子树的,所以直接遍历根结点(45),然后45的右子树(89)作为根结点,寻找左子树(56),然后56又作为根结点寻找左子树,这个56也没有左子树,然后遍历56根结点和右子树(57),接下来就遍历根结点89,在遍历89的右子树127、789。
知道为什么右边遍历这么奇怪么,是因为我用了程序的里面的思路讲的,从程序的思想讲左边其实跟右边是一样的,先看程序
/**
* @brief 中序遍历,先左子树,再根结点,然后右子树
* @param
* @retval
*/
void bstree_iNOrderTraversal(struct BiTree_node* node)
{
if(node == NULL)
return ;
bstree_iNOrderTraversal(node->bst.left);
printf("%d ", node->data);
bstree_iNOrderTraversal(node->bst.right);
}
从函数来看,传入的参数也是树的结点,但是我们要从最左边的开始遍历,所以我们首先得目的的寻找最左边的结点,所以首先就去的就是遍历左子树,一直没有左子树的情况才返回,接着打印,这也是像我们刚刚遍历12的右子树的时候,因为45这个结点没有左子树,所以只能遍历45这个结点,正因为89有左子树,所以从56开始打印(89的左子树),我这里就不多说了,能体会到代码的自然能理解这种遍历方式
- 后序遍历
规则是若树为空,则空操作返回,否则从左到右先叶子后结点的方式遍历访问左右子树,最后根结点。
有前面两个铺垫,这个后续遍历就不讲这么多了。
刚刚在画的时候,老是画错,这个逻辑根我们平时思考的不太一样,谨记一点就是从叶子结点开始,如果没有左节点,就遍历右节点。
/**
* @brief 后序遍历,先左子树,再右子树,然后根结点
* @param
* @retval
*/
void bstree_postOrderTraversal(struct BiTree_node* node)
{
if(node == NULL)
return ;
bstree_postOrderTraversal(node->bst.left);
bstree_postOrderTraversal(node->bst.right);
printf("%d ", node->data);
}
这三种遍历可以不用递归方式使用,可以用栈来实现,栈的原理根递归的也差不多,有兴趣可以多了解了解。
栈实现前序遍历的基本思想:(入栈打印,有左子树进栈,没有出栈,右子树进栈)
根结点12入栈
栈:12
然后12左子树7入栈
栈:12 7
然后7左子树4入栈
栈:12 7 4
然后4没有左子树,所以4出栈,7也出栈,7的右子树9进栈
栈:12 9
然后9没有子树出栈,12出栈,12的右子树45进栈
栈:45
然后45没有左子树,45出栈,右子树89出栈
栈:89
然后89的左子树56进栈
栈:89 56
然后56没有左子树出栈,56的右子树57进栈
栈:89 57
然后57没有子树 出栈
栈:89
然后89出栈,89右子树127进栈
栈:127
然后127没有左子树出栈,789进栈
栈:789
然后789没有子树,出栈
(现在先这么写,以后有时间完善完善)
- 层序遍历
这个层序遍历我只画图,和说一下方法,具体的就不实现了,c语言没有泛型就是有点难受。
这个层序遍历是一层一层的遍历的,因为用链表实现的二叉树,一层一层遍历有点难受,所以需要借助队列。
基本思想是:(出队输出打印)
根结点12入队
队列:12
然后12出队,左右子树7,45入队
对列:7 45
然后7出队,7的左右子树4,9入队
对列:45 4 9
然后45出队,45的左右子树89入队
对列:4 9 89
然后4出队,4没有子树,不入队
队列:9 89
然后9出队,9没有子树,不入队
对列:89
然后89出队,左右子树56,127入队
对列56 127
然后65出队,右子树57入队
对列:127 57
然后127出队,右子树789入队
对列:57 789
然后57 789都出队
5.2.5 二叉排序树
前面的二叉树的创建,就是二叉排序树的插入操作,所以这里插入操作就不说了,看前面就可以了,二叉排序树的目的不是为了排序的,是为了提高查找和插入删除关键字的速度,不管怎么说,在一个有序数据集上查找,速度总是要快于无序的数据集的,而二叉排序树这种非线性的结构,也有利于插入和删除的实现。
- 查找操作
其实查找操作也比较简单,可以用循环判断的方式,也可以用递归,用循环判断的方式可以看创建的时候,就是用了循环判断应该插入那个结点,查找我用递归,递归看着比较简单:
/**
* @brief 查找二叉树数据
* @param
* @retval
*/
int biTree_search(struct BiTree_node* node, Elemtype data)
{
if(node == NULL) //说明找不到结点
return -1;
if(node->data == data) { //递归的返回条件
printf("biTree_search %d\n", data);
return 0;
}
else if(node->data > data){ //往左子树
biTree_search(node->bst.left, data);
}
else{ //往右子树
biTree_search(node->bst.right, data);
}
return 0;
}
-
删除操作
二叉树的删除,有几种情况: -
只删除了叶子节点
如果只删除了叶子节点的话,其他结点是不受影响的,所以直接删除 -
删除结点只有左子树或者只有右子树的情况下
结点删除后,将它的左子树或者右子树整个移动到删除结点的位置即可,它的左子树本来就比被删除的结点的父结点小,所以补上也符合二叉排序树的要求。
删除127,它的右子树789就补上它的位置,成为89的右子树
-
删除的结点有左右子树的情况
这个比较复杂了,我们这里做的是要找到要删除结点的直接前驱(或者直接后驱),把这个直接前驱(或者直接后驱)直接替换要删除的结点。
删除89结点,这个结点刚好左右子树都存在,按照要找的89结点的左子树中的最右边的结点,就是57,然后把57直接替换89结点,如图:
这样看不是很完美,这个就是找到直接的前继,这个直接前继就是比原来的左子树的所有书都打,比右子树的所有都小,所以这样直接替换才很完美,但是还有一种情况,就是89的左子树56没有右子树,这时候89的直接前继就是56,所以这时候也可以直接把65替换到89,但是在程序中就要分开处理了。
代码:
/**
* @brief 删除二叉树结点
* @param
* @retval
*/
static int biTree_deleteNode(struct BiTree_node *node, struct BiTree_node *prev_node)
{
struct BiTree_node *q, *s;
//只有右子树,需要接左子树
if(node->bst.left == NULL) {
prev_node->bst.right = node->bst.right;
free(node);
}else if(node->bst.right == NULL) { //只有右子树,只接右子树
prev_node->bst.left = node->bst.left;
free(node);
}else {
//左右子树都存在,直接寻找要删除结点的直接前继
//直接前继就是node结点的左子树的最后边的数据
s = node->bst.left;
q = node;
while(s->bst.right)
{
q = s;s = s->bst.right; //寻找最右边的结点,还要考虑这个结点是否有左子树
}
//q要保存,这是s的父结点,s的左子树要挂在到q这个结点上
if(q == node) //相等的话,就是s=node左子树,已经是直接后继了
{
//不改变
}
else //不想等的话,说明node的左子树是有右子树的,q是s的父节点,s是q的右子树,s的左子树要挂在q的右子树上
{
q->bst.right = s->bst.left;
s->bst.left = node->bst.left;
}
//把s挂在prev_node的上,不过需要判断是左子树还是右子树
if(prev_node->data > s->data) //左子树
{
prev_node->bst.left = s;
}
else
{
prev_node->bst.right = s;
}
s->bst.right = node->bst.right;
free(node);
}
return 0;
}
/**
* @brief 查找二叉树数据
* @param
* @retval
*/
int biTree_delete(struct BiTree *T, Elemtype data)
{
struct BiTree_node *node = T->root;
struct BiTree_node *temp = T->root;
while(node)
{
if(node->data == data){
//要删除的结点
biTree_deleteNode(node, temp);
}
else if(node->data > data) { //往左子树走
temp = node;
node = node->bst.left;
}
else {
temp = node;
node = node->bst.right;
}
}
return 0;
}
删除操作,使用了两个函数来实现,biTree_delete()这个函数是循环查找对应的结点和父节点,这次是利用循环不用递归操作,两个方式都行,biTree_deleteNode()这个函数是删除结点的操作,删除结点中,分别判断了对应的情况,并做对应的处理,特别是有左右子树的情况下,处理比较麻烦,基本思想就是上面说的,仔细推敲就明白了。
二叉排序树总结:
对于二叉排序树的查找,走的就是从根结点到要查找的结点的路径,其比较次数等于给定值的结点在二叉排序树的层数。极端情况,最少为1次,即根结点就是要找的结点,最多也不会超过树的深度。
也就是说,我们希望二叉排序树是比较平衡的,即其深度与完全二叉树相同,均为|log2n+1|,那么查找时间复杂度为O(logn),近似折半查找,我举的例子的树也是不平衡,右重左轻。不平衡的最坏情况就是斜树,查找时间负责度为O(n),这等与顺序查找。
所以我们需要把二叉排序树转化成平衡二叉树。