数据结构(c语言版)-4

树的概念及结构

树是一种非线性的数据结构,他是由n(n>=0)个有限结点组成一个就有层次关系的集合。把它叫做树是因为他看起来像一颗倒挂着的树,也就是说它是根朝上,叶子朝下。树是由结点构成,他同样有空的树和只有一个结点的树。

树这个结构不经常用来存储数据。

 现在我们来看看关于树的一些概念

 1.结点的度:一个结点含有的子树的个数称为该结点的度;如下图所示:A的度为6.

2.叶节点或终端结点:度为0的结点称为叶节点;如下图所示:B  ,  C  ,  H  ,  I...等结点为叶子结点。

3.非终端结点或分支结点:度不为0的结点;如下图:D  E  F  G ...等结点为分支结点。

4.双亲结点或父结点:若一个结点含有子节点,则这个结点称为其子节点的父节点;如下图:

A是B的父节点。

5.孩子结点或子节点:一个结点含有的子树的跟结点为该节点的子节点;图下图:B是A的子节点。

6.兄弟结点:具有相同的父节点的结点互称为兄弟节点;如下图:B.C为兄弟节点,这里知道兄弟是亲兄弟。H,I不是兄弟节点。

7.树的度:一棵树中,最大的节点的度称为树的度:如下图:树的度是6。

8.结点的层次:从根开始定义起,跟为第1层,根的子节点为第2层,以此类推。

注:有些人为了同数组一致,从0开始算,这样方便我们取记忆,但是这里推荐还是从1开始算。

这是因为,树有空树的情况,那么如果A我们记为第0层,那么空树的情况的高度就认为是-1,如果是记为第1层的话,空树我们认为高度是0。

9.树的高度或深度:树中节点的最大层次;如下图中:树的高度为4。

10.结点的祖先:从根到该节点所经分支上的所有结点;如下图:A是所有结点的祖先。Q的祖先是J  E  A。

11.子孙:以某个结点为根的子树中任一结点都称为该结点的子孙。如下图中:所有结点都是A的子孙。E的子孙有  I  J  P  Q。

12.森林:由m(m>0)棵互不相交的多棵树的集合称为森林;(数据结构中想学习并查找集本质就是森林)。

 

 树的表示

 对于树的表示最让头等的是对于每一个结点的定义,我们不清楚每一个结点到底有多少孩子,也就不知道需要多少指针指向它的子节点。如下所示:

struct TreeNode
{
   int data;
   struct TreeNode* child1;
   struct TreeNode* child2;
   .....
}

这时,我们在c++总实现就方便很多,c++中有一个vector。

关于vector的解释:C++_vector操作_会敲代码的地质汪的博客-CSDN博客_vector

 在c++中使用vector来说定义树的结点。

struct TreeNode
{
    int data;
    vector<struct TreeNode*) childs;
}

还有一种方法是:

左孩子右兄弟的方式来实现,具体实现方式是,我们这里定义两个指针,一个指向这个结点的第一个子节点,然后其他的结点用兄弟指针指向这个子节点的兄弟,如下图所示的树:

 这个树用上述方法实现出来,大概就是下面这种情况。我们的结点结构定义里只存储左边第一个子节点的地址,然后用这个子节点的brother指针指向他的兄弟,以此类推,把这个结点的全部子节点都串联起来。当子节点的后面没有兄弟结点的时候,brother指针为NULL。

typedef int DataType;
struct Node
{
     struct Node* _firstChild1;
     struct Node* _pNextBrother;
     DataType _data;
}

 其实除了上述两种实现方式,还有第三种实现方式:双亲表示法。

 由上图我们可以看出,双亲表示法其实就是把这个结点用数字来表示出来,比如:R的父亲是-1也就是没有数据,ABC的父亲是R也就是0,DE的父亲是A也就是1。每一个结点都对应这一个数组,左边是他对应的数字,右边括号里是他的父亲对应结点的数字,这个实现与并查集是差不多的。

 在没有增删查改的情况下创建一个简单的二叉树

typedef char BTDataType;
typedef struct BinaryTreeNode
{
      struct BinTreeNode* pLeft;    //指向当前结点的左孩子
      struct BinTreeNode* pRight;   //指向当前结点右孩子
      BTDataType data;            //当前结点值域
}BTNode;

int main()
{
    BTData* A = malloc(sizeof(BTDode));
    A->data = 'A';
    A->left = NULL;
    A->right = NULL;

    BTData* A = malloc(sizeof(BTDode));
    B->data = 'B';
    B->left = NULL;
    B->right = NULL;

    BTData* A = malloc(sizeof(BTDode));
    C->data = 'C';
    C->left = NULL;
    C->right = NULL;
  
    BTData* A = malloc(sizeof(BTDode));
    D->data = 'D';
    D->left = NULL;
    D->right = NULL;

    BTData* A = malloc(sizeof(BTDode));
    E->data = 'E';
    E->left = NULL;
    E->right = NULL;

    A->left = B;
    A->right = C;
    B->left = D;
    B->right = E;

}

 树在现实中的应用

表示文件系统的目录树结构

 二叉树概念及结构

二叉树的概念:

一颗二叉树是结点的一个有限集合,该集合或者为空,或者是由一个根结点加上两棵别称为左子树和右子树的二叉树组成。

二叉树的特点:
1.每个结点最多有两棵子树(只有两个儿子),即二叉树不存在度大于2的结点。

2.二叉树的子树有左右之分,其子树的次序不能颠倒。 

实现二叉树也就非常的简单了,因为他只有两个结点。

struct BinaryTreeNode
{
      struct BinTreeNode* pLeft;    //指向当前结点的左孩子
      struct BinTreeNode* pRight;   //指向当前结点右孩子
      BTDataType _data;            //当前结点值域
}

二叉树我们一般分为三个部分来看:

1.根结点

2.左子树

3.右子树

如下图: A为根结点,B以下是左子树,C是右子树,对于子树B也有左子树(D)和右子树(E) 。同样C D E也有左子树和右子树,只不过都为空。

 我们在这里强调这个的意义在于,引出分治算法:所谓分治算法就是分而治之,把大问题分成类似子问题,子问题再分成子问题......直到子问题不可再分割。也就是说分到子树为NULL的时候就不可在分割了。

前序 中序 后序

对于树的遍历是有讲究的,这是因为树不太好进行遍历,比如我们要求树的数据个数,他的深度,他的高度这都是不好进行遍历的,他不像链表等等那些线性结构,我们遍历我弄个循环什么的就可以搞定了。对于树的遍历,我们分为前序 中序 后序。

前序

先根遍历,也就是先访问根,再访问左子树,在访问右子树。对于上图的那个树,我们先访问A,在访问A的左子树(B),在访问A的右子树(C)。那我们访问这个左子树(B)的时候也是先跟遍历,先访问左子树(D),在访问右子树(E),但是不是访问完D之后就访问E,要先访问D的左子树,但是D的左子树为NULL,所以才访问的E。对于访问A的右子树也是一样的。

对于上图的树,我们使用前序的访问顺序是:A->B->D->NULL->NULL->E->NULL->NULL->C->NULL->NULL。

对于前序的访问方式,我们需要注意的是:我们访问完根结点之后,就访问左子树,要把所有的左子树都访问完采访问右子树,什么意思呢?就如上面个例子,顺序是A->B->D然后在访问其他结点,如果D的下面还有一个左子树,那么要把这个D的左子树访问完之后,假设D的左子树也没有子树之后,在去访问其他的数据。

代码实现:

typedef char BTDataType;
typedef struct BinaryTreeNode
{
      struct BinTreeNode* pLeft;    //指向当前结点的左孩子
      struct BinTreeNode* pRight;   //指向当前结点右孩子
      BTDataType data;            //当前结点值域
}BTNode;


void PrevOrder(BTNode* root)
{
     if(root == NULL)          //如果为空就打印NULL并跳出这个函数
      {
         printf("NULL");
        return;
      }

     printf("&d",root->data);  //打印根(访问根)
     PrevOrder(root->left);    //访问左子树,为空就继续往下走
     PrevOrder(root->right);   //访问右子树
}

 把前序的顺序放进数组里

 我们上面的前序排列是每一次都要打印一下,我们直接放入数组里面效果更好,因为我们是函数实现的前序,我们不能直接像"int a[] = ;"这样直接去定义一个数组,因为我们在函数里面这样来定义一个数组之后,在函数返回时,会把这个数组给销毁,达不到储存前序顺序的效果。所以,我们用动态分布的方式来实现这个数组的定义。

我们使用动态分布来实现和用数组来实现都有一个问题,就是不知道这个树的大小,我们不能去盲目的去开辟大小,比如开辟1000,10000大小的动态空间,这样容易造成浪费。我们可以先计算一下这个树的大小,也就是这个树的结点的个数,从而是实现动态空间的开辟。

在_prevOrder()这个函数里面的“i”这个参数,我们应该传入的是preorderTraversal函数中i变量的地址,不应该直接传入i的值,然后直接“i++”。

因为如果“i”是在函数里面定义的,我们使用的递归函数的方法实现前序的遍历的。我们知道,函数里面的变量在函数结束的的时候是会销毁里面的变量的,我们是采用传参的方式来保存我们的“i”这个变量的。我们每一次递归函数的时候,总是会有传入参数为空的情况,我们在前序遍历到叶子节点的时候就会返回上一个结点,也就是叶子节点的根结点,加入我们访问到叶子结点的时候,i=3,那么这个叶子结点的根节点所递归的函数还没有return,里面依然保存了“i”的值,这个“i”的值不可能是 3 ,因为每一次函数递归里“i”都要“i++”。

那么我们执行到这个叶子结点的时候,我们期望的是“i=3”,把这个结点传入到数组里面,但是我们返回到根结点的函数里面是“i”就不等于 3  了。

总而言之就是,我们每一层递归函数都有一个“i”,下一层放了值,然后“i++”,不会影响上一层函数里面”i“保存的那个值。

销毁之后,i的值不是2,是1。

 或者不在preorderTraversal函数里面定义i变量,定义一个全局的变量i也是一样的,但是在使用这个方法有一个问题,i的值是需要初始化的,不初始化在之后使用这个“i”的时候,利用“i”下标访问这个数组的时候会越界。

 注:对于报错提示内存访问失败,或者越界等等的一些问题,我们调试的话可能会不太好看,我们可以在代码中加入printf来打印一些值来观察,比如下面这个代码,我把“i”的值和“size”的值打印出来观察就比较好观察出出错点。

int TreeSize(struct TreeNode* root)  //计算树的结点个数
{
    return root == NULL ? 0 : TreeSize(root->left)+TreeSize(root->right)+1;
}


_prevOrder(struct TreeNode* root,int* a ,int* pi)
{
    if(root == NULL)
   {
     return;
   }

   a[i] = root->val;
   ++(*pi);

   _prevOrder(root->left,a,pi);
   _prevOrder(root0>right,a,pi);

int* preorderTraversal(struct TreeNode* root,int returnSize)
{
    int size = TreeSize(root);
    int*a = (int*)malloc(sizeof(int));
    int i = 0;
    _prevOrder(root , a ,i);

    return a;
}

 中序

中根遍历,先访问左子树 ,在访问根,在访问右子树。

还是这个例子,我们要先访问最下面的那个左子树,也就是D的左子树,开始访问,这个是需要我们注意的。

中序的访问顺序是:
NULL->D->NULL->B->NULL->E->NULL->A->NULL->C->NULL。

代码实现:

中序的代码实现和前序差不多的思想。

void InOrder(BTNode* root)
{
   if(root == NULL)
    {
       printf("NULL");
       return;
    }
   
   InOrder(root->left);      //访问左子树
   printf("%c",root->data);    //打印根(访问根)
   InOrder(root->right);     //访问右子树

}

后序

后根遍历,先访问左子树,在访问右子树,在访问根。后序也和中序一样先访问最下面的左子树。

后序的访问顺序:

代码实现:

void PostOrder(BTNode* root)
{
   if(root == NULL)
    {
       printf("NULL");
       return;
    }
   
   PostOrder(root->left);      //访问左子树
   PostOrder(root->right);     //访问右子树
   printf("%c",root->data);    //打印根(访问根)

}

算二叉树里结点的个数的函数

 我们可以利用上面的三个遍历的顺序,然后再计数。我们实现的思想和之前 前后中序的遍历实现思想是差不多的,都用的是函数的递归思想,我们在函数里定义一个用来计数的遍历size,然后就函数递归一下根的左子树,当我们左子树递归到NULL的时候就出这个递归函数,然后去在函数递归一下根的右子树。而且每递归一次就让size+1,如此以遍历的方式实现计数。

需要注意的是,这个size不能去函数里定义,函数里定义的变量当函数用完是要销毁的,达不到计数的目的,我们可以用全局变量来定义这个size。

void TreeSize(BTNode* root)
{
     if(root == NULL)       //判断这个根是不是NULL  是NULL就返回   不是就让size++
      {
        return;
      } 
      else
      {
        size++;
      }

     ++size;
     TreeSize(root->left);
     TreeSize(root->right);
}

 虽然使用全局变量来定义这个size可以实现,但是这样实现有一个缺陷--就是当我调用一次这个函数来计算一个树的值之后,当我以后再调用这个函数来计算结点的时候,这个时候因为size是用全局遍历来定义的,size的值是没有初始化的。这就导致第一次之后计算出来的值是错误的,例如这个树:

当我们利用这个函数来计算树A的结点的值,计算为5,size也返回5之后,假设我在计算B的结点的个数,这个是理论上应该输出3,但是实际上输出了8,。这是因为,我们让size加3是在计算A之后,size的初始值为5的情况下计算的,所以值为8。

 这个时候我们当然可以在每一次调用函数之前都把size给置空,但是,这样做不仅麻烦,而且不能从根本上解决问题。当我们在多线程的场景下使用这个函数的时候,这个方法就行不通了,用全局变量实现是不具备线程安全的。(所谓多线程场景就是,有多个场景在同事调用这个函数,用来同时使用这个函数来计算两棵树)

我们这时候使用传参的方式来实现,需要注意的是,传入的参数应该是函数外定义变量的地址,不然没有办法改变这个函数外的变量。

void TreeSize(BTNode* root,int* psize)
{
     if(root == NULL)       //判断这个根是不是NULL  是NULL就返回   不是就让size++
      {
        return;
      } 
      else
      {
        ++(*psize);
      }

     ++size;
     TreeSize(root->left,psize);
     TreeSize(root->right,psize);
}

 还有一个更简单的方法,假设我是校长,我要计算整个学校的学生人数,我就可以让每班班主任算一下每一个班的人数,然后按照年级汇总到各自年级的年级主任那里,然后我在从每个年级的年级主任那里统计出学校的总人数。例如这个树:

我先判断这个树为不为空,不为空就先计算A的左子树的结点个数,再算右子树的结点个数,再加上A自己的(1)就是这个树的结点个数。对于这个左子树B的结点个数,一样的是B的左子树加上B的右子树的结点个数,以此类推,计算出这个树的结点个数。

int TreeSize(BTNode* root)
{
    return root == NULL ? 0 : TreeSize(root->left) + TreeSize(root->right) + 1;
}

 

 计算叶子结点的个数

 叶子结点就是左子树为空,右子树也为空的结点,用上面的方式来实现。

先判断这个树的根是不是空,是空就返回0,不是就判断这个根的左子树和右子树是不是都是空,是空就返回1,如果上述两个条件都不满足,说明不满足叶子结点的条件,就访问左子树和右子树,然后利用递归的方式以此类推。

int TreeLeafSize(BTNode* root)
{
   if(root == NULL )
    {
     return 0;
    }
   if(root->left == NULL && root->right == NULL)
   {
    return 1;
   }
 
   return TreeLeafSize(root->left) + TreeLeafSize(root->right);
}

二叉树的层序遍历

 我们之前都是利用递归遍历树的,现在我们来用一种相对麻烦一点的方式来实现遍历的效果,前中后序遍历也叫做深度优先遍历,我们现在实现的是层序遍历,广度优先遍历。它借助队列的先进先出来实现,上一层带下一层

如图,这种遍历是一层一层来进行遍历的。我们先把a如到队列里面,判断为不为空,不为空就让a出来,然后把a的左子树b-右子树c入列。入了之后同样的,在出列之前先判断这个根为不为空,b不为空就出b然后入d e,接着再出c,入f g。依次类推,就实现了一层一层遍历树的效果。

实现之前我们需要注意的一点是--队列的存储数据的形式,我们在实现树的时候,结点的实现是用结构体来实现的,而我们如果要创建一个新的结点的话,就要以malloc函数或者类似的方法来开辟空间取存储的这个结构体类型的数据。我们在传数据到队列的时候,不能直接传入数据,用一个指针这个结点的指针来存储比较合理。

 利用队列的实现图:

如图所示:利用队列来进行层序遍历。(图只是过程,没有完全遍历完)

 代码实现:

void Leve10eder(BTNode* root)   //传入的参数是树的头指针
{
    Queue q;
    QueueInit(&q);       //队列的初始化
    if(root)
       Queuepush(&q,root);    //在初始化的队列中插入树的第一个根结点

    while(!QueueEmpty(&q))   //这个函数是判断队列是否为空的函数
    {
     BTNode* front = QueueFront(&q);  //找到队列中的第一个结点并用变量保存
     QueuePop(&q);                //在队列中删除一个结点
     printf("%c",front->data);    
    }
    
    if(root->left)            //判断这个根结点有没有左子树
    {
     QueuePush(root->left);      //在队列中插入左子树的根结点
    }
    if(root->right)           //有没有右子树
    {
     QueuePush(root->right);
    }
}

 注:关于上述代码中的以Queue开头的都是关于队列的实现函数,关于队列的实现函数请看这个博客:栈和队列(c语言版)-3_chihiro1122的博客-CSDN博客


我们之前在链表等等线性结构里面实现了增删查改等等操作,但是我们对于这种普通的二叉树的增删查改是没有意义的,因为我们对插入,删除等操作的位置没有严格的定义,而且插入等操作是用来储存数据的结构中的,像这种二叉树这种相对复杂的结构,对于储存数据有些麻烦。但是我们在另一种特殊的二叉树中实现了这些基本操作-----搜索二叉树。

搜索二叉树

 所谓的搜索二叉树,其实是一种特殊的树,就是任何一棵树,左子树的值都比根要小,右子树的值都比根要打。

如这个图,他的左子树都小于它的根,右子树都大于它的根。对于搜索二叉树,他的增删查改才有了意义。比如我现在要插入值为38的结点,那么我应该插入在65这个结点的左边空位,成为它的兄弟结点。

 

 那么我们来看查找功能,假设我们要查找上面二叉树的值为28的结点,我们先看根,根为35比28大,那么我们就找这个的左子树,其值为17比28小,那么就找17的右子树,找到了就返回。

由此可以看出,搜索二叉树用的最多的作用就是搜索数据,在搜索中查找一个树,最多查找高度次,时间复杂度为:O(n)。

为什么时间复杂为O(n)呢?因为搜索二叉树只是对于结点的值的大小进行的规定存放,但是没有对存放的结构进行规定,什么意思呢,就比如下面一种结构:

假设这是一个搜索二叉树,我们要是在前面找到了需要的数据还好,如果需要的数据是在最下面的那个数据,就要查找很多次。

所以由此,我们在值的大小的规定下,增加了规则:左右两边的结点数据比较均匀,由这个规则引出了平衡树的概念,在平衡树中,就有AVL树,红黑树,B树等等的数据结构 。

树与非树?

 其实简单来说就是,带环的,存在回路的这些结构,都不是树,是能说是数据结构里面的图。

特殊的二叉树

 满二叉树:一个二叉树,如果每一个层的结点树都达到最大值,则这个二叉树就是满二叉树。也就是说,如果一个二叉树的层数为K,且结点总数是(2^k)-1,则他就是满二叉树。

如下图:所谓完全二叉树就是每一层的结点个数都是满的,即:第一次为一个,第二层为两个,第三次为四个,第四层为八个.........。总结点的个数:N=2^0+2^1+2^2+2^3+······+2^(h-1)    (h为高度)。

完全二叉树:完全二叉树是效率很高的数据结构,完全二叉树是由满二叉树而引出来的。对于深度为K的,有n个结点的二叉树,当且仅当其每一个结点都与深度为K的满二叉树中编号从1至n的结点一一对应时,称为完全二叉树。要注意的是,满二叉树是一种特殊的完全二叉树。

如下图:我们假设这个完全二叉树的高度为H,那么这个完全二叉树的前(H-1)层都是满的,最后一层不满,但是最后一层从左往右都是连续的。

 比如这个树,他就不是完全二叉树,因为他的最后一层从左往右不是连续的结点。

 二叉树的性质

  1.若规定根结点的层数为1,则一颗非空二叉树的第i层上最多有2^(i-1)个结点。

   2.若规定根结点的层数为1,则深度为h的二叉树的最大结点数为2^(h-1)

   3.对任何一棵二叉树,如果度为0其叶结点个数为n0,度为2的分支结点个数为n2,则有n0=n2+1

   4.若规定根结点的层数为1,具有n个结点的满二叉树的深度,h=log2 N。

 三叉树

 三叉树在二叉树指向两个孩子的指针的基础上,增加了一个指针指向根结点的父亲。

 销毁一个二叉树

 对于一个二叉树的销毁,我们应该用后序的遍历顺序来销毁树,也就是说树的头结点应该是最后销毁的。

void DestoryTree(struct TreeNode* root)
{
    if(root==NULL)
   {
     return;
   }
 
   DestoryTree(root->left);
   DestoryTree(root->right);
  
   free(root);
   root = NULL;
}

浅谈哈夫曼树

 哈夫曼树通常用来做无损压缩数据的这个操作。

在了解哈夫曼树之前,先来了解一下一个概念:

路径:在一棵树中,一个结点到另一个结点之间的通路,称为路径。

路径长度:在一条路径中,每经过一个结点,路径长度都要加 1 。例如一颗树里面有n层,那么从这个树的根结点出发到第N层的最大路径长度就是n-1。例如下面这个树:

这棵树的最大路径长度就是2,从A到D的路径长度为--2。

结点的权:给每一个结点赋予一个新的数值,被称为这个结点的权。

结点的带权路径长度:指的是从根结点到该结点之间的路径长度与该结点的权的乘积。

树的带权路径长度为树中所有叶子结点的带权路径长度之和

构建哈夫曼树的过程:

哈夫曼树就是构建出的树的带权路径长度最小的树。构建这样的一个树,利用到的是贪心算法。

贪心算法: 先把这个树的每一个结点拆开,然后在这些结点里面去寻找两个结点,怎么找呢?就是每一次都去这个树里面选择权值最小的两个结点。选走了之后,就让这两个结点小的做左边,大的做右边,然后构建这两个结点的父亲结点出来,这个父亲结点没有值,但有一个权值,他的权值是这两个结点的权值的和。然后再把这个父亲放入原来的树里面,然后在再这个树里面找两个权值最小的,再小的做左边,大的做右边,构建他们的父亲结点,计算父亲结点的权值,再放回这个树里面。如此反复,知道把这些结点全部串联起来,成为一颗树。

例如这个树:

这是四个结点


选出两个权值最小的结点,然后按小的做左边,大的右边来排列,在构建他们两个父亲结点。

 把这个一个树放回原来的树里面,在按父亲结点和之前的结点里面选出两个权值最小的两个节点。在执行上面的操作。

就像这样,然后再把这个新的父亲结点和原来剩余的结点做选择。在如此操作:

 如此就构建好了一个哈夫曼树。

 这个时候就会生成一个哈夫曼编码,所谓哈夫曼编码就是可以对哈夫曼树中的每一个左右子树进行编码储存。加入我们然后左边编为0,右边编为1。由此这些值可以给出他们的路径的编码:

例如上面这个树:

a7的编码是:0

b5的编码是:10

c2的编码是:110

d4的编码是:111

由此可以发现,权值越小的越在下面,权值越大的越在上面。也可以说是:权值大的它的路径长度较短,反之。

  • 2
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

chihiro1122

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值