【数据结构与算法篇】深入浅出——二叉树(详解)

简介: 【数据结构与算法篇】深入浅出——二叉树(详解)

👻内容专栏:《数据结构与算法专栏

🐨本文概括: 二叉树是一种常见的数据结构,它在计算机科学中广泛应用。本博客将介绍什么是二叉树、二叉树的顺序与链式结构以及它的基本操作,帮助读者理解和运用这一重要概念。

🐼本文作者: 花 蝶

🐸发布时间:2023.6.5

一、树的概念及结构

1.1 树的概念

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

🌳现实生活中的树:

👇数据结构中的树:将生活中的树倒置,形成一种结构。

📌树的一些相关概念:

节点的度:一个节点含有的子树的个数称为该节点的度; 如上图:A的为6

叶节点或终端节点:度为0的节点称为叶节点; 如上图:BCHI…等节点为叶节点

非终端节点或分支节点:度不为0的节点; 如上图:DEFG…等节点为分支节点

双亲节点或父节点:若一个节点含有子节点,则这个节点称为其子节点的父节点; 如上图:AB的父节点

孩子节点或子节点:一个节点含有的子树的根节点称为该节点的子节点; 如上图:BA的孩子节点

兄弟节点:具有相同父节点的节点互称为兄弟节点; 如上图:BC是兄弟节点

树的度:一棵树中,最大的节点的度称为树的度; 如上图:树的度为6

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

树的高度或深度:树中节点的最大层次; 如上图:树的高度4

堂兄弟节点:双亲在同一层的节点互为堂兄弟;如上图:HI互为兄弟节点

节点的祖先:从根到该节点所经分支上的所有节点;如上图:A是所有节点的祖先

子孙:以某节点为根的子树中任一节点都称为该节点的子孙。如上图:所有节点都是A的子孙

森林:由m(m>0)棵互不相交的树的集合称为森林

1.2 树的结构特点

  • 有一个特殊的节点,称为根节点,根节点没有前驱节点(即树的最顶端的节点)
  • 除根节点外,其余节点被分成M(M>0)个互不相交的集合T1、T2、……、Tm,其中每一个集合Ti(1<= i <= m)又是一棵结构与树类似的子树。每棵子树的根结点有且只有一个前驱节点,可以有0个或多个后继节点。任何一颗树都是由根节点和若干个子树构成。子树又是由一个父节点及若干个子树构成。直到走到叶子节点没有子树为止。
  • 因此,树是递归定义的。
    ⚠️注意:树形结构中,子树之间不能有交集,否则就不是树形结构

1.3 树的表示

树结构相对线性表就比较复杂了,要存储表示起来就比较麻烦了,既要保存数据,也要保存结点和结点之间的关系,实际中树有很多种表示方式如:双亲表示法,孩子表示法、孩子双亲表示法以及孩子兄弟表示法等。

我们这里就简单的了解其中最常用的孩子兄弟表示法。

⌨️代码如下:

struct Node
{
 struct Node* _firstChild1; // 第一个孩子结点
 struct Node* _pNextBrother; // 指向其下一个兄弟结点
 int _data; // 结点中的数据域
};

其下图逻辑就是孩子指针指向下一个孩子结点,进行层次遍历,兄弟指针指向其兄弟结点。

1.4 树在实际中的应用(文件系统的树状目录结构)

二、二叉树概念及结构

2.1 二叉树概念

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

2.2 二叉树结构图

从上图可以看出:

1.== 二叉树不存在度大于2的结点==

2.== 二叉树的子树有左右之分,次序不能颠倒,因此二叉树是有序树==

⚠️ 注意:对于任意的二叉树都是由以下几种情况复合而成的:

2.3 特殊的二叉树

前期数据结构中特殊的二叉树中我们先讲两种:满二叉树和完全二叉树。

1.满二叉树:一个二叉树,如果每一个层的结点数都达到最大值,则这个二叉树就是满二叉树。也就是说,如果一个二叉树的层数为n,且结点总数是2ⁿ - 1,则它就是满二叉树。

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

简单点说,满二叉树的每一层都是满的,而完全二叉树的前h - 1层是满的,最后一层可以不满,但必须保证连续。

PS1假设一颗满二叉树,高度为h,节点数量有多少个呢

其实很简单,第一层,有20个、第二层,有21个,第三层,有22个、…… 第h层,有2h-1个,这里我们把1到h层的个数全部加起来,就有F(h) = 20 + 21 + 22 + ……+ 2h-1 = 2h - 1 ,这本质其实就是一个等比数列的求和。

PS2:高度为h的完全二叉树的节点范围是多少呢

我们知道,一个完全二叉树节点最多的情况就是一个满二叉树,即最多的节点就是2h - 1个,那么最少呢,最少就是前一层是满的,外加最后一层最少必须为1个,套用前面的求和结果,最少的节点就是F(h - 1) = 2h-1 - 1,所以节点的范围用区间表示为[ 2h-1 ,2h - 1]

2.4 二叉树的性质

  1. 若规定根节点的层数为1,则一棵非空二叉树的第i层上最多有 2^(i - 1) 个结点.
  2. 若规定根节点的层数为1,则深度为h的二叉树的最大结点数是 2^h - 1
  3. 对任何一棵二叉树, 如果度为0其叶结点个数为 n₀ , 度为2的分支结点个数为 n₂,则有 n₀=n₂ +1
  4. 若规定根节点的层数为1,具有n个结点的满二叉树的深度,h=log₂(n + 1) . (ps:h=log₂(n + 1) 是log以2为底,n+1为对数)
  5. 对于具有n个结点的完全二叉树,如果按照从上至下从左至右的数组顺序对所有节点从0开始编号,则对于序号为i的结点有:
  1. 若 i >0,i位置节点的双亲序号:(i-1)/2;i=0,i为根节点编号,无双亲节点
  2. 若2i+1 < n,左孩子序号:2i+1,2i+1>=n否则无左孩子
  3. 若2i+2 < n,右孩子序号:2i+2,2i+2>=n否则无右孩子

2.5 二叉树的存储结构

二叉树一般可以使用两种结构存储,一种顺序结构,一种链式结构。

  1. 顺序存储
    顺序结构存储就是使用数组来存储,一般使用数组只适合表示完全二叉树,因为不是完全二叉树会有空间的浪费。而现实中使用中只有堆才会使用数组来存储,关于堆我们后面的章节会专门讲解。二叉树顺序存储在物理上是一个数组,在逻辑上是一颗二叉树

🌷顺序存储形态图:

结论:完全二叉树才适合顺序存储,因为非完全二叉树存在大量的空间浪费。

PS:根据上图完全二叉树的顺序存储,我们可以通过下标建立父亲与孩子之间的关系:

通过父亲下标求孩子:

leftChild = parent * 2 + 1
rightChild = parent * 2 + 1

通过孩子小标找父亲:

parent = (child - 1)/ 2

  1. 链式存储
    二叉树的链式存储结构是指,用链表来表示一棵二叉树,即用链来指示元素的逻辑关系。 通常的方法是链表中每个结点由三个域组成,数据域和左右指针域,左右指针分别用来给出该结点左孩子和右孩子所在的链结点的存储地址 。链式结构又分为二叉链和三叉链,当前我们学习中一般都是二叉链,后面课程学到高阶数据结构如红黑树等会用到三叉链。

🌷链式存储形态图:

三、二叉树的顺序结构及实现

3.1 二叉树的顺序结构

普通的二叉树是不适合用数组来存储的,因为可能会存在大量的空间浪费。而完全二叉树更适合使用顺序结构存储。现实中我们通常把堆(一种二叉树)使用顺序结构的数组来存储,需要注意的是这里的堆和操作系统虚拟进程地址空间中的堆是两回事,一个是数据结构,一个是操作系统中管理内存的一块区域分段。

3.2 堆的概念及结构

☃️堆的性质

  • 大根堆(大堆): 树中任意一个父节点都大于等于其子节点;
  • 小根堆(小堆): 树中任意一个父节点都小于等于其子节点。
  • 堆中某个节点的值总是不大于或不小于其父节点的值。
  • 堆总是一颗完全二叉树。

3.3 堆的实现

因为堆是一颗完全二叉树,完全二叉树更适合顺序结构存储,所有我们这里可以用顺序表来实现,实现方法类似于之前使用过的顺序表,代码如下:

typedef int HPdataType;
typedef struct Heap
{
  HPdataType* arr; //动态数组
  int size; //有效元素个数
  int capacity; //数组容量
}Heap;

堆的插入

这里我们拿小堆来实现,大堆的话,大家可以自己操作进行类比。

如果我们要在堆中插入数据,那么其实我们本质上是在数组的最后面进行插入元素,如下图,假设在小堆的基础上继续插入50,它仍旧是一个堆。

如果在小堆的基础上面,继续往后插入一个元素6呢?我们可以发现,如果插入之后,就满足不了小堆的性质了,因为628要小,

势必会影响到根节点到当前节点路径中的所有节点,所以我们需要找到当前插入节点的父亲节点,乃至祖先节点,那么如何根据孩子去找父亲呢?前面我们讲到一个关系公式,parent = (child - 1)/ 2,逻辑中是二叉树,实际中我们就可以这样通过下标在数组中去访问父亲

路径中可能有多个节点需要被调整,所以我们需要不断更新child和parent的下标位置

向上调整算法

以小堆为例,如果孩子节点小于父亲节点就进行两两交换,直到调整到根节点后,确保满足完整堆的性质。

🤔ps:

  • 向上调整算法一般适合于堆的插入操作
  • 前提是左右子树必须为大堆 or 小堆
  • 一个节点(根节点、叶子节点)可以看作是大堆 or 小堆

🔗代码如下:

//向上调整
void AdjustUp(HPdataType* a,int child)
{
  //根据孩子找父亲
  int parent = (child - 1) / 2;
  //孩子下标小于等于0就结束
  while (child > 0)
  {
    //小堆情况下  孩子小于父亲就交换
    if (a[child] < a[parent])
    {
      HPdataType tmp = a[child];
      a[child] = a[parent];
      a[parent] = tmp;
      //更新节点,继续往上迭代找
      child = parent;
      parent = (child - 1) / 2;
    }
    else
    {
      //孩子大于等于父亲就结束循环
      break;
    }
  }
}
//堆的插入
void HeapPush(Heap* php, HPdataType x)
{
  assert(php);
  if (php->capacity == php->size)
  {
    int newcapacity = php->capacity == 0 ? 4 : php->capacity * 2;
    HPdataType* a = (HPdataType*)realloc(php->arr,sizeof(HPdataType)* newcapacity);
    if (a == NULL)
    {
      perror("malloc fail");
      return;
    }
    php->arr = a;
    php->capacity = newcapacity;
  }
  php->arr[php->size] = x;
  php->size++;
  //为了保证堆的性质,需要进行向上调整
  AdjustUp(php->arr,php->size - 1);
}

堆的删除

对于堆的删除操作,我们不是删除堆的最后一个元素,因为毫无意义,而是删除堆顶元素,我们可以进行先删除堆顶,然后把后面的元素挪动往前覆盖, 挪动后并不能保证它具有堆的性质,因为挪动后父子之间的关系全变了,因此我们需要重新建堆。但是这样的代价太大了,我们接下来寻找最优解法。

我们尽量不改变堆顶元素下面所有节点之间的位置,让堆顶元素与最后一个元素进行交换,然后进行删除,可能交换之后的堆顶元素影响堆的性质,于是我们可以进行向下调整。

向下调整算法

以小堆为例,如果孩子节点小于等于父亲节点就进行两两交换,直到调整到叶子节点,确保满足完整堆的性质。

🤔ps:

  • 前提是左右子树必须为大堆 or 小堆

🔗代码如下:

//向下调整
void AdjustDown(int* a,int n,int parent)
{
   //根据父亲找孩子
  int child = parent * 2 + 1;//假设左孩子最小
  while (child < n)
  {
    //小堆情况下  
    //如果右孩子存在的情况下必须保证小于n
    if (child + 1 < n && a[child + 1] < a[child])
    {
      //如果右孩子小于左孩子
      //那么就把下标位置给到右孩子
      child++;
    }
    if (a[child] < a[parent])
    {
      Swap(&a[child], &a[parent]);
      //继续往下迭代走
      parent = child;
      child = parent * 2 + 1;
    }
    else
    {
      //孩子大于父亲,符合小堆性质,跳出循环
      break;
    }
  }
}
//堆的删除操作
void HeapPop(Heap* php)
{
  assert(php);
  assert(!HeapEmpty(php));
  //首尾元素交换
  Swap(&php->arr[0], &php->arr[php->size - 1]);
  php->size--;
  AdjustDown(php->arr, php->size, 0);
}

那么向上调整算法和向下调整算法,他们的时间复杂度是多少呢?看时间复杂度我们需要看最坏的情况,即需要调整完全二叉树的高度次,前面我们计算过高度为h的完全二叉树的节点范围是[ 2h-1 ,2h - 1],那么根据 N = 2h-1 计算出h = logN + 1, N = 2h - 1 计算出h = log(N+1),所以他们的时间复杂度都为logN

堆的创建

💭前面我们提起的向上调整算法和向下调整算法,前提都是左右子树为大堆或者小堆。如果对于任意的完全二叉树,即根节点的左右子树不满足堆的性质,该怎么调整成堆呢?

下面我们给出一个数组,这个数组逻辑上可以看做一颗完全二叉树,但是还不是一个堆,现在我们通过算法,把它构建成一个堆。根节点左右子树不是堆,我们怎么调整呢?这里我们从倒数的第一个非叶子节点的子树开始调整,一直调整到根节点的树,就可以调整成堆。

3.4 堆的应用

Top-K问题

TOP-K问题:即求数据结合中前K个最大的元素或者最小的元素,一般情况下数据量都比较大。比如:专业前10名、世界500强、富豪榜、游戏中前100的活跃玩家等。

对于Top-K问题,能想到的最简单直接的方式就是排序,但是:如果数据量非常大,排序就不太可取了(可能数据都不能一下子全部加载到内存中)。最佳的方式就是用堆来解决,基本思路如下:

  • 1.用数据集合中前K个元素来建堆
    前k个最大的元素,则建小堆
    前k个最小的元素,则建大堆
    用剩余的N-K个元素依次与堆顶元素来比较,不满足则替换堆顶元素(覆盖)
  • 2.将剩余N-K个元素依次与堆顶元素比完之后,堆中剩余的K个元素就是所求的前K个最小或者最大的元素。

⌨️代码如下:

//创造数据
void CreateDate()
{
  int n = 10000;
  srand(time(NULL));
  FILE* fin = fopen("data.txt", "w");
  if (fin == NULL)
  {
    perror("fopen error");
    return;
  }
  int i = 0;
  for (i = 0; i < n; i++)
  {
    fprintf(fin, "%d\n", rand() % 1000);
  }
  fclose(fin);
}
void PrintTopK(int k)
{
  FILE* fout = fopen("data.txt", "r");
  if (fout == NULL)
  {
    perror("fopen error");
    return;
  }
  int* kminheap = (int)malloc(sizeof(int) * k);
  if (kminheap == NULL)
  {
    perror("malloc fail\n");
    return;
  }
  //读取前k个数到数组中
  for (int i = 0; i < k; i++)
  {
    fscanf(fout, "%d", &kminheap[i]);
  }
  //前k个数建立小堆
  for (int i = (k - 1 - 1) / 2; i >= 0; i--)
  {
    AdjustDown(kminheap, k, i);
  }
  //剩余的N-K个元素依次与堆顶元素来比较,不满足则替换堆顶元素(覆盖)
  int val = 0;
  while (!feof(fout))
  {
    fscanf(fout, "%d", &val);
    if (val > kminheap[0])
    {
      kminheap[0] = val;
      //向下调整
      AdjustDown(kminheap, k, 0);
    }
  }
  //打印剩余K个元素就是最大值
  for (int i = 0; i < k; i++)
  {
    printf("%d ", kminheap[i]);
  }
}
int main()
{
  //CreateDate();
  PrintTopK(5);
}
  • 为了方便,可以在写完整个代码之后,F5测试一遍,创造一次数据之后,将CreareDate()执行一次,再到文件当中给随便5个数据增大几位,这样就方便测试,自己写的代码对不对。
  • 打印自己的代码之后,确实找出了剩余K个元素,并且是最大值。

堆排序

堆排序即利用堆的思想来进行排序,总共分为两个步骤:

  1. 建堆
    | 排升序 | 建大堆 |
    | 排降序 | 建小堆 |
    一般利用向下调整算法建堆,思想是倒着调整,不是从根节点开始,而是从第一个非叶子节点开始调整(最后一个节点的父亲),(也可以用向上调整算法建堆,但是效率不如向下调整算法建堆,具体实现见下面的代码和建堆时间复杂度的分析。)
  2. 利用堆删除思想来进行排序
    💌堆删除中用到了向下调整,那么我们这里还是以小堆为例,建出小堆之后,数组中的首尾数据进行交换,最小的数据(堆顶)放到了最后的位置,除去最后一个数据,对堆进行向下调整,再把堆顶(此时是次小的数据),与倒数第二个位置的数据交换……以此类推,整个调整完后,数组中的数据依次排列就是一个降序。
    💌如果是大堆的话,按照以上思想类比,整个调整完后,数组中的数据依次排列就是一个升序。

🍁代码实现如下:

堆排序时间复杂度为:O(N + N*logN)

//向上调整
void AdjustUp(HPdataType* a, int child)
{
  //根据孩子找父亲
  int parent = (child - 1) / 2;
  //孩子下标小于等于0就结束
  while (child > 0)
  {
    //小堆情况下  孩子小于父亲就交换
    if (a[child] < a[parent])
    {
      HPdataType tmp = a[child];
      a[child] = a[parent];
      a[parent] = tmp;
      //更新节点,继续往上迭代找
      child = parent;
      parent = (child - 1) / 2;
    }
    else
    {
      //孩子大于等于父亲就结束循环
      break;
    }
  }
}
//向下调整
void AdjustDown(int* a,int n,int parent)
{
   //根据父亲找孩子
  int child = parent * 2 + 1;//假设左孩子最小
  while (child < n)
  {
    //小堆情况下  
    //如果右孩子存在的情况下必须保证小于n
    if (child + 1 < n && a[child + 1] < a[child])
    {
      //如果右孩子小于左孩子
      //那么就把下标位置给到右孩子
      child++;
    }
    if (a[child] < a[parent])
    {
      Swap(&a[child], &a[parent]);
      //继续往下迭代走
      parent = child;
      child = parent * 2 + 1;
    }
    else
    {
      //孩子大于父亲,符合小堆性质,跳出循环
      break;
    }
  }
}
//交换
void Swap(HPdataType* p1, HPdataType* p2)
{
  HPdataType tmp = *p1;
  *p1 = *p2;
  *p2 = tmp;
}
void heapSort(int* a, int n)
{
  //法一:向上调整建堆(可以想象堆插入的过程,调整前元素前面已经满足堆的性质)
  /*for(int i = 1;i < n;i++)
  {
    AdjustUp(a, i);
  }*/
  //法二:向下调整建堆(从最后一个叶子节点的父亲开始倒着调整,因为叶子节点本来就可以看成堆)
  //O(N)
  for (int i = (n - 1 - 1) / 2; i >= 0; i--)
  {
    AdjustDown(a, n, i);
  }
  //首尾交换再向下调整
  //O(N*logN)
  int end = n - 1;
  while(end > 0)
  {
    Swap(&a[0], &a[end]);
    AdjustDown(a, end, 0);
    end--;
  }
}
int main()
{
  int a[] = {8,40,91,14,39,72,4,81};
  heapSort(a, sizeof(a) / sizeof(int));
}

3.5 建堆时间复杂度

💡向下调整建堆时间复杂度:O(N)

💡向上调整建堆时间复杂度:O(N*logN)

ps:可以推算一下堆排序的时间复杂度,计算过程与向上调整建堆类似。

按照这样的推理计算,我们很明显观察到向下调整算法建堆比向上调整算法建堆效率要高的多,所以以我们以后选择向下调整算法建堆会更好!

四、二叉树链式结构及实现

4.1 前置说明

在学习二叉树的基本操作前,需先要创建一棵二叉树,然后才能学习其相关的基本操作。由于现在大家对二

叉树结构掌握还不够深入,为了降低大家学习成本,此处手动快速创建一棵简单的二叉树,快速进入二叉树

操作学习,等二叉树结构了解的差不多时,我们反过头再来研究二叉树真正的创建方式。

typedef int BTDataType;
typedef struct BinaryTreeNode
{
  BTDataType data;
  struct BinaryTreeNode* left;
  struct BinaryTreeNode* right;
}BTNode;
BTNode* BuyNode(BTDataType x)
{
  BTNode* node = (BTNode*)malloc(sizeof(BTNode));
  if (node == NULL)
  {
    perror("malloc fail\n");
    return NULL;
  }
  node->data = x;
  node->left = NULL;
  node->right = NULL;
  return node;
}
BTNode* CreatBinaryTree()
{
  BTNode* node1 = BuyNode(1);
  BTNode* node2 = BuyNode(2);
  BTNode* node3 = BuyNode(3);
  BTNode* node4 = BuyNode(4);
  BTNode* node5 = BuyNode(5);
  BTNode* node6 = BuyNode(6);
  node1->left = node2;
  node1->right = node4;
  node2->left = node3;
  node4->left = node5;
  node4->right = node6;
  return node1;
}

⚠️注意:上述代码并不是创建二叉树的方式,真正创建二叉树方式后面详解重点讲解。

再看二叉树基本操作前,再回顾下二叉树的概念,二叉树是:

  1. 空树
  2. 非空:根节点,根节点的左子树、根节点的右子树组成的。

    从概念中可以看出,二叉树定义是递归式的,因此后面基本操作中基本都是按照该概念实现的。

4.2 二叉树的遍历

学习二叉树结构,最简单的方式就是遍历。所谓二叉树遍历(Traversal)是按照某种特定的规则,依次对二叉树中的节点进行相应的操作,并且每个节点只操作一次。访问结点所做的操作依赖于具体的应用问题。 遍历是二叉树上最重要的运算之一,也是二叉树上进行其它运算的基础。

前中后序遍历

按照规则,二叉树的遍历有:前序/中序/后序的递归结构遍历:

  1. 前序遍历(Preorder Traversal 亦称先序遍历)——访问根结点的操作发生在遍历其左右子树之前。【根->左子树->右子树
  2. 中序遍历(Inorder Traversal)——访问根结点的操作发生在遍历其左右子树之中(间)。【左子树->根->右子树
  3. 后序遍历(Postorder Traversal)——访问根结点的操作发生在遍历其左右子树之后。【左子树->右子树->根

⚠️ps:N代表空树访问

前序遍历结果:1 -> 2 -> 3 -> N -> N -> N -> 4 -> 5 -> N -> N -> 6 -> N -> N

中序遍历结果:N -> 3 -> N -> 2 -> N -> 1 -> N -> 5 -> N -> 4 -> N -> 6 -> N

后序遍历结果:N -> N -> 3 -> N -> 2 -> N -> N -> 5 -> N -> N -> 6 -> 4 -> 1

🧐前序遍历代码如下:

//前序遍历
void PrevOrder(BTNode* root)
{
  if (root == NULL)
  {
    printf("N ");
    return;
  }
  printf("%d ", root->data);
  PrevOrder(root->left);
  PrevOrder(root->right);
}
int main()
{
  BTNode* root = CreatBinaryTree();
  PrevOrder(root);
}

打印结果:

🤓递归展开图:

😎中序遍历代码如下:

//中序遍历
void InOrder(BTNode* root)
{
  if (root == NULL)
  {
    printf("N ");
    return;
  }
  InOrder(root->left);
  printf("%d ", root->data);
  InOrder(root->right);
}
int main()
{
  BTNode* root = CreatBinaryTree();
  InOrder(root);
}

打印结果:

🤗后序遍历代码如下:

//后序遍历 
void PostOrder(BTNode* root)
{
  if (root == NULL)
  {
    printf("N ");
    return;
  }
  PostOrder(root->left);
  PostOrder(root->right);
  printf("%d ", root->data);
}
int main()
{
  BTNode* root = CreatBinaryTree();
  PostOrder(root);
}

打印结果:

遍历时间复杂度为O(N),空间复杂度是O(h),h是高度,范围是[logN,N]

🐻ps:中序遍历和后序遍历递归展开图,小伙伴们可以尝试自己画一画,博主这里就不放了。

层序遍历

💡 除了先序遍历、中序遍历、后序遍历外,还可以对二叉树进行层序遍历。设二叉树的根节点所在层数为1,层序遍历就是从所在二叉树的根节点出发,首先访问第一层的树根节点,然后从左到右访问第2层上的节点,接着是第三层的节点,以此类推,自上而下,自左至右逐层访问树的结点的过程就是层序遍历。

队列实现(先进先出):当树的根节点不为空时,让其先进入队列,在队列不为空的情况下,输出队头的元素, 同时且节点孩子存在的情况下入队列。

我们这里拷贝之前队列讲解的代码

Queue.h文件

#include<stdio.h>
#include<stdlib.h>
#include<assert.h>
#include<stdbool.h>
//链式队列
typedef struct BinaryTreeNode* QDataType;
//每个节点
typedef struct QueueNode
{
  struct QueueNode* next;
  QDataType data;
}QueueNode;
//整个队列的结构
typedef struct Queue
{
  QueueNode* phead;//记录链表头
  QueueNode* ptail;//记录链表尾
  int size;//队列的大小
}Queue;
//队列的初始化
void QueueInit(Queue* pqe);
//队列的销毁
void QueueDestroy(Queue* pqe);
//队尾入队列
void QueuePush(Queue* pqe, QDataType x);
//队头出队列
void QueuePop(Queue* pqe);
//获取队头的数据
QDataType QueueFront(Queue* pqe);
//获取队尾的数据
QDataType QueueBack(Queue* pqe);
//获取队列中有效数据个数
int QueueSize(Queue* pqe);
//判断队列是否为空
bool QueueEmpty(Queue* pqe);

Queue.c文件

#include"Queue.h"
//队列的初始化
void QueueInit(Queue* pqe)
{
  assert(pqe);
  pqe->phead = pqe->ptail =  NULL;
  pqe->size = 0;
}
//队列的销毁
void QueueDestroy(Queue* pqe)
{
  assert(pqe);
  QueueNode* cur = pqe->phead;
  while (cur)
  {
    QueueNode* next = cur->next;
    free(cur);
    cur = next;
  }
  pqe->phead = pqe->ptail = NULL;
  pqe->size = 0;
}
//队尾入队列
void QueuePush(Queue* pqe, QDataType x)
{
  assert(pqe);
  QueueNode* newnode = (QueueNode*)malloc(sizeof(QueueNode));
  if (newnode == NULL)
  {
    perror("malloc fail\n");
    return;
  }
  newnode->data = x;
  newnode->next = NULL;
  //分为空节点和非空节点
  if (pqe->phead == NULL)
  {
    //ptial为断言,为了避免phead与ptail同时指向NULL
    assert(pqe->ptail == NULL);
    pqe->phead = pqe->ptail = newnode;
  }
  else
  {
    pqe->ptail->next = newnode;
      pqe->ptail = newnode;
  }
  pqe->size++;
}
//队头出队列
void QueuePop(Queue* pqe)
{
  assert(pqe);
  assert(!QueueEmpty(pqe));
  //分为一个节点和多个节点
  if (pqe->phead->next == NULL)
  {
    free(pqe->phead);
    pqe->phead = pqe->ptail = NULL;
  }
  else
  {
    QueueNode* next = pqe->phead->next;
    free(pqe->phead);
    pqe->phead = next;
  }
  pqe->size--;
}
//获取队头的数据
QDataType QueueFront(Queue* pqe)
{
  assert(pqe);
  assert(!QueueEmpty(pqe));
  return pqe->phead->data;
}
//获取队尾的数据
QDataType QueueBack(Queue* pqe)
{
  assert(pqe);
  assert(!QueueEmpty(pqe));
  return pqe->ptail->data;
}
//获取队列中有效数据个数
int QueueSize(Queue* pqe)
{
  assert(pqe);
  return pqe->size;
}
//判断队列是否为空
bool QueueEmpty(Queue* pqe)
{
  assert(pqe);
  //return pqe->phead == NULL && pqe->ptail == NULL;
  return pqe->size == 0;
}

test.c文件

#include "Queue.h"
// 层序遍历
void LevelOrder(BTNode* root)
{
  Queue q;
  QueueInit(&q);
  //根节点入队列
  if (root != NULL)
    QueuePush(&q, root);
  //队列不为空
  while (!QueueEmpty(&q))
  {
    //输出队头的元素
    BTNode* front = QueueFront(&q);
    printf("%d ", front->data);
    QueuePop(&q);
    //左孩子和右孩子都存在,就入队列
    if (front->left != NULL)
      QueuePush(&q, front->left);
    if (front->right != NULL)
      QueuePush(&q, front->right);
  }
  QueueDestroy(&q);
}

判断二叉树是否是完全二叉树

// 判断二叉树是否是完全二叉树(层序遍历思想)
bool BinaryTreeComplete(BTNode* root)
{
  Queue q;
  QueueInit(&q);
  if (root == NULL)
    QueuePush(&q, root);
  //队列不为空
  while (!QueueEmpty(&q))
  {
    //节点入队列
    BTNode* front = QueueFront(&q);
    QueuePop(&q);
    //遇到空树就跳出循环
    if (front == NULL)
      break;
    //左右子树入队列
    QueuePush(&q, front->left);
    QueuePush(&q, front->right);
  }
  //继续往后寻找,只要遇到非空说明不是完全二叉树 返回false
  //空树后面还是空说明是完全二叉树返回true
  while (!QueueEmpty(&q))
  {
    BTNode* front = QueueFront(&q);
    QueuePop(&q);
    if (front != NULL)
    {
      QueueDestroy(&q);
      return false;
    }
    QueuePush(&q, front->left);
    QueuePush(&q, front->right);
  }
  QueueDestroy(&q);
  return true;
}

4.3 二叉树的基本操作

求二叉树的节点个数

// 二叉树节点个数
//法一:遍历计数(使用完后需要将size置为0)
//int size = 0;
//void BinaryTreeSize(BTNode* root)
//{
//  if (root == NULL)
//    return;
//  size++;
//
//  BinaryTreeSize(root->left);
//  BinaryTreeSize(root->right);
//}
//法二:分治算法
int  BinaryTreeSize(BTNode* root)
{
  if (root == NULL)
    return 0;
  return BinaryTreeSize(root->left) + BinaryTreeSize(root->right) + 1;
}

求二叉树的叶子节点个数

// 二叉树叶子节点个数
int BinaryTreeLeafSize(BTNode* root)
{
  if (root == NULL)
    return 0;
  if (root->left == NULL && root->right == NULL)
    return 1;
  return BinaryTreeLeafSize(root->left) + BinaryTreeLeafSize(root->right);
}

求二叉树的高度

//二叉树的高度
int BinaryTreeHeight(BTNode* root)
{
  if (root == NULL)
    return 0;
  int leftHeight = BinaryTreeHeight(root->left);
  int RightHeight = BinaryTreeHeight(root->right);
  return leftHeight > RightHeight ? leftHeight + 1 : RightHeight + 1;
}

二叉树第k层节点个数

// 二叉树第k层节点个数
int BinaryTreeLevelKSize(BTNode* root, int k)
{
  //层数都是从1开始
  assert(k > 0);
  //root 为空返回0
  if (root == NULL)
    return 0;
  //root为空且k = 1时,返回1
  if (k == 1)
    return 1;
  //分治:左子树和右子树
  return BinaryTreeLevelKSize(root->left, k - 1)
    + BinaryTreeLevelKSize(root->right, k - 1);
}

二叉树查找值为x的节点

// 二叉树查找值为x的节点
BTNode* BinaryTreeFind(BTNode* root, BTDataType x)
{ 
  if (root == NULL)
    return NULL;
  if (root->data == x)
    return root;
  //分治
  BTNode* ret1 = BinaryTreeFind(root->left, x);
  if (ret1)
    return ret1;
  BTNode* ret2 = BinaryTreeFind(root->right, x);
  if (ret2)
    return ret2;
  //子树都没找到返回空 
  return NULL;
}

4.4 经典题型:二叉树的构建及遍历

👉 牛客网链接

#include <stdio.h>
typedef int BTDataType;
typedef struct BinaryTreeNode
{
  BTDataType data;
  struct BinaryTreeNode* left;
  struct BinaryTreeNode* right;
}BTNode;
BTNode* BuyNode(BTDataType x)
{
  BTNode* node = (BTNode*)malloc(sizeof(BTNode));
  if (node == NULL)
  {
    perror("malloc fail\n");
    return NULL;
  }
  node->data = x;
  node->left = NULL;
  node->right = NULL;
  return node;
}
//构建树(前序遍历)
BTNode* CreateTree(char* a,int* pi)
{
    if(a[*pi] == '#')
    {
        (*pi)++;
        return NULL;
    }
    BTNode* root = BuyNode(a[*pi]);
        (*pi)++;
    root->left =  CreateTree(a,pi);
    root->right =  CreateTree(a,pi);
    return root;
}
//中序遍历输出
void InOrder(BTNode* root)
{
    if(root == NULL)
        return;
    InOrder(root->left);
    printf("%c ",root->data);
    InOrder(root->right);
}
int main() {
    char str[100];
    scanf("%s",str);
    int i = 0;
    BTNode* root = CreateTree(str,&i);
    InOrder(root);
    printf("\n");
    return 0;
}

🥰🥰本章节完,后续会补充二叉树进阶内容知识,小伙伴们可以持续关注,若本篇文章对你有帮助的话,可以三连支持博主哦~,另外本篇内容有编写有误的话,可以私聊博主进行纠正!

目录
相关文章
|
7天前
|
算法
分享一些提高二叉树遍历算法效率的代码示例
这只是简单的示例代码,实际应用中可能还需要根据具体需求进行更多的优化和处理。你可以根据自己的需求对代码进行修改和扩展。
|
10天前
|
存储 缓存 算法
如何提高二叉树遍历算法的效率?
选择合适的遍历算法,如按层次遍历树时使用广度优先搜索(BFS),中序遍历二叉搜索树以获得有序序列。优化数据结构,如使用线索二叉树减少空指针判断,自定义节点类增加辅助信息。利用递归与非递归的特点,避免栈溢出问题。多线程并行遍历提高速度,注意线程安全。缓存中间结果,避免重复计算。预先计算并存储信息,提高遍历效率。综合运用这些方法,提高二叉树遍历算法的效率。
29 5
|
14天前
|
C语言
【数据结构】二叉树(c语言)(附源码)
本文介绍了如何使用链式结构实现二叉树的基本功能,包括前序、中序、后序和层序遍历,统计节点个数和树的高度,查找节点,判断是否为完全二叉树,以及销毁二叉树。通过手动创建一棵二叉树,详细讲解了每个功能的实现方法和代码示例,帮助读者深入理解递归和数据结构的应用。
63 8
|
13天前
|
机器学习/深度学习 JSON 算法
二叉树遍历算法的应用场景有哪些?
【10月更文挑战第29天】二叉树遍历算法作为一种基础而重要的算法,在许多领域都有着不可或缺的应用,它为解决各种复杂的问题提供了有效的手段和思路。随着计算机科学的不断发展,二叉树遍历算法也在不断地被优化和扩展,以适应新的应用场景和需求。
24 0
|
1月前
|
存储 算法 关系型数据库
数据结构与算法学习二一:多路查找树、二叉树与B树、2-3树、B+树、B*树。(本章为了解基本知识即可,不做代码学习)
这篇文章主要介绍了多路查找树的基本概念,包括二叉树的局限性、多叉树的优化、B树及其变体(如2-3树、B+树、B*树)的特点和应用,旨在帮助读者理解这些数据结构在文件系统和数据库系统中的重要性和效率。
19 0
数据结构与算法学习二一:多路查找树、二叉树与B树、2-3树、B+树、B*树。(本章为了解基本知识即可,不做代码学习)
|
1月前
|
存储 算法 搜索推荐
数据结构与算法学习十七:顺序储存二叉树、线索化二叉树
这篇文章主要介绍了顺序存储二叉树和线索化二叉树的概念、特点、实现方式以及应用场景。
20 0
数据结构与算法学习十七:顺序储存二叉树、线索化二叉树
|
1月前
|
Java
【用Java学习数据结构系列】震惊,二叉树原来是要这么学习的(二)
【用Java学习数据结构系列】震惊,二叉树原来是要这么学习的(二)
27 1
|
1月前
|
算法 Java C语言
【用Java学习数据结构系列】震惊,二叉树原来是要这么学习的(一)
【用Java学习数据结构系列】震惊,二叉树原来是要这么学习的(一)
24 1
|
1月前
|
存储
【数据结构】二叉树链式结构——感受递归的暴力美学
【数据结构】二叉树链式结构——感受递归的暴力美学
|
1月前
|
存储 算法
【二叉树】—— 算法题
【二叉树】—— 算法题
【二叉树】—— 算法题