数据结构——二叉树的基本概念及顺序存储(堆)

简介: 数据结构——二叉树的基本概念及顺序存储(堆)



一.前言

友情提醒:本文前面对概念涉及颇深,如果有友友了解二叉树的基本概念,想要看核心代码实现可以直接翻找目录移至四.二叉树顺序结构及实现片段开始阅读。码字不易,希望大家多多支持我呀!(三连+关注,你是我滴神!)

二.树概念及结构

2.1 树的概念

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

  • 有一个特殊的结点,称为根结点,根结点没有前驱结点。
  • 除根结点外,其余结点被分为m(m>0)个互不相交的集合T1、T2、... 、Tm,其中每一个集合Ti(1<=i<=m)又是一棵结构与树类似的子树。每棵子树的根结点有且只有一个前驱,可以有0个或多个后继。
  • 因此,树是递归定义的。

注意:树形结构中,子树之间不能有交集,否则就不是树形结构。

2.2 树的相关概念

下面有一些关于树的术语,大家如有疑惑可以看此解析

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

叶结点或终端结点:度为0的结点称为叶结点;如上图:B、C、H、I...等结点为叶结点

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

双亲结点或父结点:若一个结点含有子结点,则这个结点称为其子结点的父结点;如上图:A是B的父结点

孩子结点或子结点:一个结点含有的子树的根结点称为该结点的子结点;如上图:B是A的孩子结点

兄弟结点:具有相同父结点的结点互称为兄弟结点;如上图:B、C是兄弟结点

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

结点的层次:从根开始定义起,根为第一层,根的子结点为第二层,以此类推

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

堂兄弟结点:双亲在同一层的结点互为堂兄弟;如上图:H、I互为兄弟结点

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

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

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

2.3 树的表现

左孩子右兄弟表示法:当A生了3个孩子:B C D时,永远指向最左边的孩子B),然后让孩子B去带孩子C,孩子C去带孩子D

A只生了3个孩子,所以最后的孩子D指向空,而A也没有兄弟,那么根据左孩子右兄弟的指法,A右边也指向空。

最后再看向E F,B有两个孩子是EF,根据右孩子所以指向最右边的孩子E,而E兄弟F,根据右兄弟E指向F,最后F指向空

那么最后我们如何判断一个结点是不是叶子呢?只需要看(firstchild)是否为空就行了。

树结构相对线性表就比较复杂了,要存储表示起来比较麻烦,既要保存值域,也要保存结点和结点之间的关系,实际中树有很多种表示方法如:双亲表示法,孩子表示法,孩子双亲表示法以及孩子兄弟表示法等。我们这里就简单地了解其中最常用的孩子兄弟表示法。

//树的最优设计
struct  TreeNode
{
  int val;                     //结点中的数据域
  struct TreeNode* firstchild; //第一个孩子结点
  struct TreeNode* nextbrother;//指向其下一个兄弟结点
};

孩子兄弟法演示图:

拓展:双亲表示法演示图

  • 判断有几棵树——(看几个-1)。因为A和B找不到父亲的下标就给数值-1。
  • 判断两个结点在不在同一棵树(看根一不一样)。通过对比所在父亲的下标是否相同来判断。

注:链式结构看指针,下标看数组

2.4 树在实际中的应用(表示文件系统的目录树结构)

三.二叉树的概念及结构

3.1 概念

一棵二叉树是结点的一个有限集合,该集合:

  • 或者为空
  • 由一个根结点加上两棵别称为左子树和右子树的二叉树组成

从上图可以看出:

  • 二叉树不存在度大于2的结点
  • 二叉树的子树有左右之分,次序不能颠倒,因此二叉树是有序树

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

3.2 特殊的二叉树

  • 满二叉树:一个二叉树,如果每一个层的结点数都达到最大值,则这个二叉树就是满二叉树。也就是说,如果一个二叉树的层数为K,且结点总数是2^k-1,则它就是满二叉树。
  • 完全二叉树:完全二叉树是效率很高的数据结构,完全二叉树是由满二叉树而引出来的。对于深度为K的,有n个结点的二叉树,当且仅当其每一个结点都与深度为K的满二叉树中编号从1至n的结点——对应时称之为完全二叉树。要注意的是满二叉树是一种特殊的完全二叉树。

假设它的高度是h,每一层都是满的。

假设它的高度是h,前h-1层是满的,最后一层不一定满,从左到右是连续的。

比如我们移动一个结点后最后一层不再是连续的了,那就不叫做完全二叉树。

下面我们来看看在高度为h中二叉树的结点数是多少~

我们还可以通过结点来反推高度h

至于高度为h完全二叉树的结点范围,最多结点那就是跟满二叉树一样(2^h-1

最少结点呢?我们可以把它拆成高度为h-1满二叉树,此时结点为(2^(h-1)-1)。最后在h层添上一个节点就是最少结点(2^(h-1))

3.3 二叉树的性质

  • 若规定根结点的层数为1,则一棵非空二叉树的第i层上最多有2^(i-1)个结点
  • 若规定根结点的层数为1,则深度为h的二叉树的最大结点数是2^h-1
  • 对任何一棵二叉树,如果度为0其叶结点个数为n0,度为2的分支结点个数为n2,则有n0=n2+1
  • 若规定根结点的层数为1,具有n个结点的满二叉树的深度,
  • 对于具有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否则无右孩子

下面来几道练习题~

第一问:叶子结点就是度为0的点,通过n0=n2+1公式可以得出为200个,故选A。

第二问:非完全二叉树不适合,会空出数组位置。故选A

第三问:

N1只能是1,因为n只能是整数,故选A。

第四问:

我们可以根据完全二叉树结点数的范围来判断h为多少(把h代入看是否超过范围)。故选B

第五问:

跟第三问一样的特点,只不过767是一个奇数,所以N1只能取0.故选B

3.4 二叉树的存储结构

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

3.4.1 顺序存储

顺序结构存储就是使用数组来存储,一般使用数组只适合表示完全二叉树,因为不是完全二叉树会有空间的浪费。而现实中使用中只有堆才会使用数组来存储,二叉树顺序存储在物理上是一个数组,在逻辑上是一棵二叉树。

左孩子为其父亲所示下标*2+1,右孩子为父亲所示下标*2+2.反过来孩子其父亲的坐标也可以反推——parent = (child-1)/2

注:右孩子都在偶数位上,但6-1=5,5/2还是2公式还是可以用

任意位置通过下标可以找父亲或者孩子

如果是不完全二叉树适不适合用该存储结构呢?——不适合用这样的结构(数组存储)

满二叉树或者完全二叉树适合,不完全二叉树适合用链式结构存储

3.4.2 链式存储

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

四.二叉树顺序结构及实现

4.1 二叉树的顺序结构

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

4.2 堆的概念及结构

如果有一个关键码的集合k={k0,k1,k2,...k(n-1)},把它的所有元素按完全二叉树的顺序存储方式存储在一个一维数组中,并满足:ki<=k()2i+1)且ki<=k(2i+2)   (ki>=k(2i+1)且ki>=k(2i+2)) i=0,1,2...,则称为小堆(或大堆)。将根结点最大的堆叫做最大堆或大根堆,根结点最小的堆叫做最小堆或小根堆。

堆的性质:

  • 堆中某个结点的值总是不大于或不小于其父结点的值;
  • 堆总是一棵完全二叉树。

注:说的是数值而不是下标

我们来用练习题来体会其中的规律~

选项A:我们先按照完全二叉树的定义把堆给表现出来(注意连续),后面发现这是大堆树中任意一个父亲都>=孩子

选项D:我们会发现从上面是一个小堆,而下面又是一个大堆,这是明显矛盾的,所以判定为错误。

底层:

  • 物理结构,数组
  • 逻辑结构,完全二叉树

问:如果是小堆,那底层数组是否升序呢?——不一定~

但可以确定的是小堆的根是整棵树的最小值。

堆的运用:

  • topk问题
  • 堆排序(时间复杂度——O(N*logN)

————————————————————————————————以上都是概念。

4.3 堆的实现

4.3.1 堆向下调整算法(略)

4.3.2 堆的创建(略)

4.3.3 建堆时间复杂度(略)

4.3.4 堆的插入(略)

4.3.5 堆的删除(略)

4.3.6 堆代码的实现(详)

4.3.6.1初始化函数
//初始化函数
void HeapInit(HP* php)
{
  assert(php);
  php->a = NULL;
  php->size = 0;
  php->capacity = 0;
}

这是我们的老朋友了,基本每篇文章都写一遍哈哈~

另一种初始化函数

//另一种初始化函数
void HeapInitArray(HP* php, int* a, int n)
{
  assert(php);
  assert(a);
  php->a = (HPDataType*)malloc(sizeof(HPDataType) * n);
  if (php->a == NULL)
  {
    perror("malloc fail");
    exit(-1);
  }
  php->size = 0;
  php->capacity = 0;
  memcpy(php->a, a, sizeof(HPDataType) * n);
  for (int i = 1; i < n; i++)
  {
    AdjustUp(php->a, i);
  }
}

直接给你一个数组,然后把这个数组直接插入堆里面去。原先的初始化是不给值,然后一个一个慢慢插入堆

4.3.6.2 销毁函数
//销毁函数
void HeapDestroy(HP* php)
{
  assert(php);
  free(php->a);
  php->a = NULL;
  php->size = php->capacity = 0;
}
4.3.6.3 插入函数

假设我们插入的数是90,在小堆中刚好可以直接插入,不需要做其他的变动。

那如果我们插入50呢?这样就破坏的小堆的结构了,所以我们需要调整插入的位置。按照小堆的规则我们不可能去向下进行调整,那只好去找上面的结点了~

所以我们现在就要去找该结点的父亲位置了,通过公式(child-1)/2推出父亲为下标2的56

找到后如果该结点比它父亲大,那就不动如果小,那么就得交换位置。相当于让50当父亲,56当儿子,这样才能维持小堆结构。

当然这样还不算结束,我们交换完还得再跟10这个根进行比较,完成上述操作后才可以算是达成小堆的结构。

最坏情况是要调到根才结束,

//插入函数
void HeapPush(HP* php, HPDataType x)
{
  assert(php);
  //扩容
  if (php->size == php->capacity)
  {
    int newCapacity = php->capacity == 0 ? 4 : php->capacity * 2;
    HPDataType* tmp = (HPDataType*)realloc(php->a, sizeof(HPDataType) * newCapacity);
    if (tmp == NULL)
    {
      perror("realloc fail");
      exit(-1);
    }
    php->a = tmp;
    php->capacity = newCapacity;
  }
  php->a[php->size] = x;
  php->size++;
  AdjustUp(php->a, php->size-1);
  //不仅要把数组传过去,我们还得把孩子也传过去,而孩子可以通过下标找到
}

扩容和插入都是很常见的操作了,在成功插入后(尾插),我们还得去做向上的一个调整(毕竟插入后并不能保证可以维持原来小堆或大堆的结构)。

PS: 之所以要传尾部是因为我们插入的数据是在尾部,所以得从该数据插入起进行调整。

4.3.6.4 向上调整函数

当发现插入的孩子小于父亲时,进行交换。然后再让5去当孩子继续和新的父亲10进行对比。

那要在什么时候才算是结束调整呢?在最坏情况下,孩子5父亲10发现交换,child指向5,而parent则指向数组外,说明当parent小于0时就代表无需调整。

但是这里有一点需要注意,如果按照孩子5已经为根(child==0)的计算规则其父亲下标为(child-1)/2结果将会是0.而不是-0.5,那代表调整还未结束,发生矛盾。

所以我们这里调整结束的条件改为(child>0),当child等于0也意味着调整结束了。

//向上调整函数
void AdjustUp(HPDataType* a, int child)
{
  int parent = (child - 1) / 2;
  while (child > 0)
  {
    if (a[child] < a[parent])
    {
      Swap(&a[child], &a[parent]);
      child = parent;
      parent = (parent - 1) / 2;
    }
    else
    {
      break;
    }
  }
}
4.3.6.5 交换函数
//交换函数
void Swap(HPDataType* p1, HPDataType* p2)
{
  HPDataType tmp = *p1;
  *p1 = *p2;
  *p2 = tmp;
}
4.3.6.6打印函数
//打印函数
void HeapPrint(HP* php)
{
  assert(php);
  for (size_t i = 0; i < php->size; i++)
  {
    printf("%d ", php->a[i]);
  }
  printf("\n");
}

做完以上这些函数,我们就来测试一下:

int main()
{
  int a[] = { 65, 100, 70, 32, 50, 60 };
  HP hp;
  HeapInit(&hp);
  for (size_t i = 0; i < sizeof(a) / sizeof(int); i++)
  {
    HeapPush(&hp, a[i]);
  }
  HeapDestroy(&hp);
  return 0;
}

测试完毕,插入后进行调整还是小堆~

PS:我们这里的插入是额外开辟一个空间然后从数组里面取值,在开辟的空间里一个一个插入。

4.3.6.6 删除函数

有一个问题,在堆的删除中,我们删除谁比较有意义呢?

如果只是删除尾,那没有什么意义~最有意义的就是删除根,至于为什么,下面就来演示一番~

注:挪动覆盖数组的方式并不可取,这样无法保证小堆原有的结构。

我们可以这样操作:让根与尾的数据进行交换,再删除,这样的好处是不会破坏原有的左右子树的小堆结构。

然后我们再根据新的根进行向下调整来维持小堆的结构,注:无论是向下调整还是向上调整,它们的前提都是左右子树为小堆或大堆。

向下调整:我们先找出根下面的左右两个孩子,然后和二者中最小的进行对比,这里左孩子50右孩子60更小,让根70左孩子50作对比,发现左孩子比根还小,按照小堆的规则两者需要进行交换。

以此类推,直到最后和叶子65进行对比。向下调整就是把小的往下调,大的往下沉这样一个过程。

其实这里的Pop还有另外一层意义——找出当前二叉树中最小的值。

时间复杂度:O(logN) 具有极高效率的算法排序

//删除函数
void HeapPop(HP* php)
{
  assert(php);
  assert(php->size > 0);
  Swap(&php->a[0], &php->a[php->size - 1]);
  php->size--;//删除数据
  AdjustDown(php->a, php->size, 0);
}
4.3.6.7 向下调整函数

在这里我们不去刻意区分左孩子和右孩子,而是先假设左孩子是最小的,如果实际上是右孩子小,那就让它们进行交换。最后换到叶子就终止。——先假设,错误再纠正。

//向下调整函数
void AdjustDown(HPDataType* a, int n, int parent)
{
  //假设左右孩子中小的为左孩子
  int child = parent * 2 + 1;
  while (child<n)//换到叶子就终止,循环结束后child已经是指向数组外了
  {
    //找出小的孩子
    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;
      //跟向上调整法一样,交换完后把parent和child都移下一层为下一次的循环作准备
    }
    else
    {
      break;
    }
  }
}

点需要注意,在我们规定好child<n的条件后还要判定child+1是否在该范围内

就比如我们把在调整第二行80的位置时(尾部的80已经和堆顶的32交换完毕,接着80和它的右孩子40交换完毕),按照我们的代码是会去找到child+1,可是画圈部分根本没有右孩子,所以为了避免它越界随便找一个值替代,我们要再多加一个判定条件——child+1<n。

4.3.6.8 获取根值函数
//获取根值函数
HeapTop(HP* php)
{
  assert(php);
  assert(php->size > 0);
  return php->a[0];
}
4.3.6.9 判空函数
//判空函数
bool HeapEmpty(HP* php)
{
  assert(php);
  return php->size == 0;
}

测试一下:我们去取最小、次小等等的数据

int main()
{
  int a[] = { 65, 100, 70, 32, 50, 60 };
  HP hp;
  HeapInit(&hp);
  for (size_t i = 0; i < sizeof(a) / sizeof(int); i++)
  {
    HeapPush(&hp, a[i]);
  }
  HeapPrint(&hp);
  while (!HeapEmpty(&hp))
  {
    printf("%d ", HeapTop(&hp));
    HeapPop(&hp);
  }
  HeapDestroy(&hp);
  return 0;
}

最后会发现取出来的确实都是最小的,最后形成升序的结果。只要我们去修改成大堆或小堆,就可以起到降序或升序的效果~

4.3.6.10 堆排序

当然现在这样还不算是真正的堆排序,因为我们只是打印出来排序的堆,要想用堆实现数组本身的排序,请看如下解析:

void HeapSort(int* a, int n)
{
  HP hp;
  HeapInit(&hp);
  for (size_t i = 0; i < sizeof(a) / sizeof(int); i++)
  {
    HeapPush(&hp, a[i]);
  }
  int i = 0;
  while (!HeapEmpty(&hp))
  {
    //printf("%d ", HeapTop(&hp));
        //关键代码,真正实现对数组内部的排序
    a[i++] = HeapTop(&hp);
    HeapPop(&hp);
  }
  HeapDestroy(&hp);
}
int main()
{
  int a[] = { 65, 100, 70, 32, 50, 60 };
  HeapSort(a, sizeof(a) / sizeof(int));
  return 0;
}

但这种写法有两个大缺陷:

  • 先有一个堆的数据结构
  • 空间复杂度的消耗(为了排序额外开辟了一块数组空间)

所以我们还需要进行优化:

我们不用先去开辟空间搞堆,直接把数组当成堆去看

目前数组只能说是排成堆的模样,但还不是堆,所以我们需要对它进行调整——建堆

最核心的部分!!!

在我们前面写的向上调整法中一般都是把最后的数据传递进去再进行调整整个堆,因为我们的插入算是尾插,所以传的也只是尾部数据。而在上图中,我们可以想象该数组只有一个数——70自成堆),然后我们插入65进行向上调整(比如我们想要调成大堆),调整完毕后变成70——65两个数组成堆),就这样以此类推地去插入数据,这样就是建堆的关键!——遍历除堆顶以外的数字,把每一次的调整都看作是尾插。

最后来验证一下:

void HeapSort(int* a, int n)
{
  //建堆
  for (int i = 0; i < n; i++)
  {
    AdjustUp(a, i);
  }
}
int main()
{
  //int a[] = { 65, 100, 70, 32, 50, 60 };
  int a[] = { 70, 65, 100, 32, 50, 60 };
  HeapSort(a, sizeof(a) / sizeof(int));
  return 0;
}

最后达成建堆效果,也弥补了前面的两大缺陷~接下来我们来实现排序效果~

如果我们想要升序,那就要去建小堆~我们换个数组来建堆

由于是升序,当我们把根(2)选出去后——选出了最小的数据,要选次小,只能把剩下的数据看作堆。但最后发现已经无法构成小堆的结构了~所以我们只能换个思路了,想要升序建小堆不行的话那就试试大堆怎么样~

在建好大堆后用向下调整法先和尾部60进行交换,交换完不用去删除尾部数据,而是把它从数组中隔离开来(就是无视它在数组和堆中的位置)

然后把剩下的数据看作堆,那选次大要怎么选呢?继续用向下调整法即可。

每一次一个数据的调整是logN,一共有N个数据,那时间复杂度就是O(N*logN)

void HeapSort(int* a, int n)
{
  //建堆
  for (int i = 0; i < n; i++)
  {
    AdjustUp(a, i);
  }
  //建大堆升序
  int end = n - 1;//数组尾部数据下标
  while (end > 0)
  {
    Swap(&a[0], &a[end]);
    AdjustDown(a, end, 0);//选出最大
    end--;//让数组最后数据的前一位和堆顶交换
  }
}
int main()
{
  //int a[] = { 65, 100, 70, 32, 50, 60 };
  //int a[] = { 70, 65, 100, 32, 50, 60 };
      int a[] = { 2, 3, 5, 7, 4, 6, 8 };
  HeapSort(a, sizeof(a) / sizeof(int));
  return 0;
}

调试内容:经过不断调试,最终的结果为升序

4.3.6.11向下调整去建堆

除了可以用向上调整插入的方式去建堆,我们还有另外一种思路:向下调整去建堆

如果我们想要建大堆,能不能直接从2的位置开始向下调呢?

不行~因为向下调整的前提是左右子树都是大堆,这里明显不符合。

那如果3的左子树和右子树都是大堆是不是意味着可以对3进行向下调整,可惜这两棵子树也不是大堆。

那我们不妨逆向思维——从最底层开始向下调整,从最下面一直调整到最上面,这样就能保证每一棵左右子树都是大堆了~不过有一点需要注意,最下面那一行属于叶子的部分没必要调整(叶子自己就可以看成大堆)。

所以我们第一个要找的是倒数第一个非叶子节点(6),然后对它进行向下调整(找出最大的孩子——60并和它交换)。

关于它们的下标也很好找尾部60的下标是n-1,那它的父亲的下标就是(n-1-1)/2,当我们向下调整好6时,按照顺序应该是要调整4这个结点了,而它的下标刚好就在6的前面。以此类推到7这个位置开始调整。

需要找到5的下标跟上面同理下标--就好了,然后进行向下调整。

最后向下调整结束~

关于代码部分的实现也很简单:

void HeapSort(int* a, int n)
{
  //建堆——向上调整建堆
  //for (int i = 0; i < n; i++)
  //{
  //  AdjustUp(a, i);
  //}
  // 
  //建堆——向下调整建堆
  for (int i = (n-1-1)/2; i >=0; i--)
  {
    AdjustDown(a, n, i);
  }
  //建大堆升序
  int end = n - 1;//数组尾部数据下标
  while (end > 0)
  {
    Swap(&a[0], &a[end]);
    AdjustDown(a, end, 0);//选出最大
    end--;//让数组最后数据的前一位和堆顶交换
  }
}
4.3.6.12 建堆复杂度的证明

一般我们更推荐向下调整建堆,因为它效率更高 。

T(h)是一共要调整的次数——倒数第二层调整1次,倒数第三层调整2次.....一直到第一层调整h-1次。

画蓝框的总和是我们之前数二叉树节点的个数。

所以向下调整建堆的实际时间复杂度其实要比O(N)小一点点。(因为换算成时间复杂度那得跟N有关,但N又不好表达出来,所以就用h来表达,最后再换成N

———————————————————————————————————————————

现在我们来用向上调整建堆~

我们会发现相比向下调整法,向上调整的数都是多乘多最后一个数据是最大的,,换算过来是(N/2)*logN,所以这就是我们为什么会优先选择向下调整法的原因。

开始错位相减~

最终向上调整法实际实际复杂度差不多是O(N*logN-N),总而言之,向上调整最后一层数据太多,要调整到上层的层数也多,所以在效率上反而是不如向下调整法的。

这里的堆排序其实可以看作有N个数据在进行向下调整,那结果不言而喻~O(N*logN)

4.3.6.13 小练习(选看)

注:有C语言内存文件基本可放心食用~

假设10亿个数据,内存存不下,数据在文件中找出最大的前K个。

基本思路:

  • 读取文件的前100个数据,在内存数组中建立一个小堆
  • 再依次读取剩下数据,跟堆顶数据进行比较,大于堆顶,就替换它进堆,向下调整
  • 所以数据读完,堆里面数据就是最大的前100个

N个数要遍历一遍,每一个数最坏情况都要进堆向下调整logK次,当N足够大时,那时间复杂度就是O(N)了而不是O(N*logK)。空间复杂度——O(K)

void CreateNDate()
{
  //造数据
  int n = 1000;
  srand(time(0));
  const char* file = "data.txt";
  FILE* fin = fopen(file, "w");//写操作
  if (fin == NULL)
  {
    perror("fopen error");
    return;
  }
  for (int i = 0; i < n; i++)
  {
    int x = rand() % 1000000;
    fprintf(fin, "%d\n", x);
  }
  fclose(fin);
}
void PrintTopK(const char* filename, int k)
{
  //1. 建堆——用数组a中前k个元素建堆
  FILE* fout = fopen(filename, "r");//r——读操作
  if (fout == NULL)
  {
    perror("fopen fail");
    return;
  }
  int* minheap = (int*)malloc(sizeof(int) * k);//建立所放堆空间大小
  if (minheap == NULL)
  {
    perroe("minheap fail");
    return;
  }
  for (int i = 0; i < k; i++)
  {
    fscanf(fout, "%d", &minheap[i]);//把数组数据放入文件中
  }
  //开始构建前K个数小堆
  for (int i = (k - 2) / 2; i >= 0; i--)
  {
    AdjustDown(minheap, k, i);
  }
  //2.将剩余n-k个元素依次与堆顶元素交换,如果比堆顶还小就换下一个
  //比堆顶大就替换堆顶
  int x = 0;
  while (fscanf(fout, "%d", &x) != EOF)
  {
    if (x > minheap[0])
    {
      minheap[0] = x;
      AdjustDown(minheap, k, 0);
    }
  }
  //上面的操作我们前面写过了,需要注意的一般是文件那部分代码别写错
  for (int i = 0; i < k; i++)
  {
    printf("%d ", minheap[i]);
  }
  printf("\n");
  fclose(fout);
}
int main()
{
  CreateNDate();
  PrintTopK("Data.txt", 10);
  return 0;
}

为了验证是否真的找到了10个最大的数,我们先预先写好10个比一百万还大的数字,到时候看看是否可以找到它们——其他数都比一百万小,直接在文件里手动插入比100万大的数不经历取模看能不能找到。

成功实现~

五.全部代码

//Heap.h
#pragma once
#include <stdio.h>
#include <assert.h>
#include <stdlib.h>
#include <stdbool.h>
#include <string.h>
#include <time.h>
typedef int HPDataType;
typedef struct Heap
{
  HPDataType* a;
  int size;
  int capacity;
}HP;
//初始化函数
void HeapInit(HP* php);
//另一种初始化函数
void HeapInitArray(HP* php, int* a, int n);
//销毁函数
void HeapDestroy(HP* php);
//插入函数
void HeapPush(HP* php, HPDataType x);
//交换函数
void Swap(HPDataType* p1, HPDataType* p2);
//打印函数
void HeapPrint(HP* php);
//向上调整函数
void AdjustUp(HPDataType* a, int child);
//删除函数
void HeapPop(HP* php);
//获取根值函数
HeapTop(HP* php);
//向下调整函数
void AdjustDown(HPDataType* a, int n, int parent);
//判空函数
bool HeapEmpty(HP* php);

————————————————————————————————————————

//Heap.c
#include "Heap.h"
//初始化函数
void HeapInit(HP* php)
{
  assert(php);
  php->a = NULL;
  php->size = 0;
  php->capacity = 0;
}
//另一种初始化函数
void HeapInitArray(HP* php, int* a, int n)
{
  assert(php);
  assert(a);
  php->a = (HPDataType*)malloc(sizeof(HPDataType) * n);
  if (php->a == NULL)
  {
    perror("malloc fail");
    exit(-1);
  }
  php->size = 0;
  php->capacity = 0;
  memcpy(php->a, a, sizeof(HPDataType) * n);
  for (int i = 1; i < n; i++)
  {
    AdjustUp(php->a, i);
  }
}
//销毁函数
void HeapDestroy(HP* php)
{
  assert(php);
  free(php->a);
  php->a = NULL;
  php->size = php->capacity = 0;
}
//交换函数
void Swap(HPDataType* p1, HPDataType* p2)
{
  HPDataType tmp = *p1;
  *p1 = *p2;
  *p2 = tmp;
}
//向上调整函数
void AdjustUp(HPDataType* a, int child)
{
  int parent = (child - 1) / 2;//再用公式去算父亲的下标来找到父亲
  while (child > 0)
  {
    if (a[child] < a[parent])
    {
      Swap(&a[child], &a[parent]);
      child = parent;
      parent = (parent - 1) / 2;
    }
    else
    {
      break;
    }
  }
}
//插入函数
void HeapPush(HP* php, HPDataType x)
{
  assert(php);
  //扩容
  if (php->size == php->capacity)
  {
    int newCapacity = php->capacity == 0 ? 4 : php->capacity * 2;
    HPDataType* tmp = (HPDataType*)realloc(php->a, sizeof(HPDataType) * newCapacity);
    if (tmp == NULL)
    {
      perror("realloc fail");
      exit(-1);
    }
    php->a = tmp;
    php->capacity = newCapacity;
  }
  php->a[php->size] = x;
  php->size++;
  AdjustUp(php->a, php->size - 1);
  //不仅要把数组传过去,我们还得把孩子也传过去,而孩子可以通过下标找到
}
//打印函数
void HeapPrint(HP* php)
{
  assert(php);
  for (size_t i = 0; i < php->size; i++)
  {
    printf("%d ", php->a[i]);
  }
  printf("\n");
}
//向下调整函数
void AdjustDown(HPDataType* a, int n, int parent)
{
  //假设左右孩子中小的为左孩子
  int child = parent * 2 + 1;
  while (child < n)//换到叶子就终止,循环结束后child已经是指向数组外了
  {
    //找出小的孩子
    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;
      //跟向上调整法一样,交换完后把parent和child都移下一层为下一次的循环作准备
    }
    else
    {
      break;
    }
  }
}
//删除函数
void HeapPop(HP* php)
{
  assert(php);
  assert(php->size > 0);
  Swap(&php->a[0], &php->a[php->size - 1]);
  php->size--;//删除数据
  AdjustDown(php->a, php->size, 0);
}
//获取根值函数
HeapTop(HP* php)
{
  assert(php);
  assert(php->size > 0);
  return php->a[0];
}
//判空函数
bool HeapEmpty(HP* php)
{
  assert(php);
  return php->size == 0;
}

————————————————————————————————————————

//Test.c
#include "Heap.h"
树的最优设计
//struct  TreeNode
//{
//  int val;
//  struct TreeNode* firstchild;
//  struct TreeNode* nextbrother;
//};
//
//void HeapSort(int* a, int n)
//{
//  HP hp;
//  HeapInit(&hp);
//  for (size_t i = 0; i < sizeof(a) / sizeof(int); i++)
//  {
//    HeapPush(&hp, a[i]);
//  }
//  int i = 0;
//
//  while (!HeapEmpty(&hp))
//  {
//    //printf("%d ", HeapTop(&hp));
//    a[i++] = HeapTop(&hp);
//    HeapPop(&hp);
//
//  }
//
//
//  HeapDestroy(&hp);
//}
//void HeapSort(int* a, int n)
//{
//  //建堆
//  for (int i = 0; i < n; i++)
//  {
//    AdjustUp(a, i);
//  }
//  //建大堆升序
//  int end = n - 1;//数组尾部数据下标
//  while (end > 0)
//  {
//    Swap(&a[0], &a[end]);
//    AdjustDown(a, end, 0);//选出最大
//    end--;//让数组最后数据的前一位和堆顶交换
//  }
//}
void HeapSort(int* a, int n)
{
  //建堆——向上调整建堆
  //for (int i = 0; i < n; i++)
  //{
  //  AdjustUp(a, i);
  //}
  // 
  //建堆——向下调整建堆
  for (int i = (n-1-1)/2; i >=0; i--)
  {
    AdjustDown(a, n, i);
  }
  //建大堆升序
  int end = n - 1;//数组尾部数据下标
  while (end > 0)
  {
    Swap(&a[0], &a[end]);
    AdjustDown(a, end, 0);//选出最大
    end--;//让数组最后数据的前一位和堆顶交换
  }
}
//int main()
//{
//  //int a[] = { 65, 100, 70, 32, 50, 60 };
//  int a[] = { 70, 65, 100, 32, 50, 60 };
//  HeapSort(a, sizeof(a) / sizeof(int));
//
//  return 0;
//}
void CreateNDate()
{
  //造数据
  int n = 1000;
  srand(time(0));
  const char* file = "data.txt";
  FILE* fin = fopen(file, "w");//写操作
  if (fin == NULL)
  {
    perror("fopen error");
    return;
  }
  for (int i = 0; i < n; i++)
  {
    int x = rand() % 1000000;
    fprintf(fin, "%d\n", x);
  }
  fclose(fin);
}
void PrintTopK(const char* filename, int k)
{
  //1. 建堆——用数组a中前k个元素建堆
  FILE* fout = fopen(filename, "r");//r——读操作
  if (fout == NULL)
  {
    perror("fopen fail");
    return;
  }
  int* minheap = (int*)malloc(sizeof(int) * k);//建立所放堆空间大小
  if (minheap == NULL)
  {
    perroe("minheap fail");
    return;
  }
  for (int i = 0; i < k; i++)
  {
    fscanf(fout, "%d", &minheap[i]);//把数组数据放入文件中
  }
  //开始构建前K个数小堆
  for (int i = (k - 2) / 2; i >= 0; i--)
  {
    AdjustDown(minheap, k, i);
  }
  //2.将剩余n-k个元素依次与堆顶元素交换,如果比堆顶还小就换下一个
  //比堆顶大就替换堆顶
  int x = 0;
  while (fscanf(fout, "%d", &x) != EOF)
  {
    if (x > minheap[0])
    {
      minheap[0] = x;
      AdjustDown(minheap, k, 0);
    }
  }
  //上面的操作我们前面写过了,需要注意的一般是文件那部分代码别写错
  for (int i = 0; i < k; i++)
  {
    printf("%d ", minheap[i]);
  }
  printf("\n");
  fclose(fout);
}
int main()
{
  CreateNDate();
  PrintTopK("Data.txt", 10);
  return 0;
}

六.结语

本文除了给友友们普及二叉树与堆的概念外,更重要的是关于堆功能的代码实现,其中的方法会帮助提高我们的算法效率。最后感谢大家的观看,友友们能够学习到新的知识是额滴荣幸,期待我们下次相见~

相关文章
|
13天前
|
存储 算法 Java
散列表的数据结构以及对象在JVM堆中的存储过程
本文介绍了散列表的基本概念及其在JVM中的应用,详细讲解了散列表的结构、对象存储过程、Hashtable的扩容机制及与HashMap的区别。通过实例和图解,帮助读者理解散列表的工作原理和优化策略。
29 1
散列表的数据结构以及对象在JVM堆中的存储过程
|
1月前
|
存储 算法
数据结构与算法学习二二:图的学习、图的概念、图的深度和广度优先遍历
这篇文章详细介绍了图的概念、表示方式以及深度优先遍历和广度优先遍历的算法实现。
52 1
数据结构与算法学习二二:图的学习、图的概念、图的深度和广度优先遍历
|
15天前
|
存储 搜索推荐 算法
【数据结构】树型结构详解 + 堆的实现(c语言)(附源码)
本文介绍了树和二叉树的基本概念及结构,重点讲解了堆这一重要的数据结构。堆是一种特殊的完全二叉树,常用于实现优先队列和高效的排序算法(如堆排序)。文章详细描述了堆的性质、存储方式及其实现方法,包括插入、删除和取堆顶数据等操作的具体实现。通过这些内容,读者可以全面了解堆的原理和应用。
58 16
|
15天前
|
C语言
【数据结构】二叉树(c语言)(附源码)
本文介绍了如何使用链式结构实现二叉树的基本功能,包括前序、中序、后序和层序遍历,统计节点个数和树的高度,查找节点,判断是否为完全二叉树,以及销毁二叉树。通过手动创建一棵二叉树,详细讲解了每个功能的实现方法和代码示例,帮助读者深入理解递归和数据结构的应用。
65 8
|
1月前
|
存储 安全 数据库
除了 HashMap,还有哪些数据结构可以实现键值对存储?
【10月更文挑战第11天】 除了`HashMap`,其他常见支持键值对存储的数据结构包括:`TreeMap`(基于红黑树,键有序)、`LinkedHashMap`(保留插入顺序)、`HashTable`(线程安全)、`B-Tree`和`B+Tree`(高效存储大量数据)、`SkipList`(通过跳跃指针提高查找效率)及`UnorderedMap`(类似`HashMap`)。选择合适的数据结构需根据排序、并发、存储和查找性能等需求。
|
1月前
|
存储 JavaScript 前端开发
为什么基础数据类型存放在栈中,而引用数据类型存放在堆中?
为什么基础数据类型存放在栈中,而引用数据类型存放在堆中?
68 1
|
1月前
|
存储 算法 关系型数据库
数据结构与算法学习二一:多路查找树、二叉树与B树、2-3树、B+树、B*树。(本章为了解基本知识即可,不做代码学习)
这篇文章主要介绍了多路查找树的基本概念,包括二叉树的局限性、多叉树的优化、B树及其变体(如2-3树、B+树、B*树)的特点和应用,旨在帮助读者理解这些数据结构在文件系统和数据库系统中的重要性和效率。
20 0
数据结构与算法学习二一:多路查找树、二叉树与B树、2-3树、B+树、B*树。(本章为了解基本知识即可,不做代码学习)
|
1月前
|
存储 算法
探索数据结构:分支的世界之二叉树与堆
探索数据结构:分支的世界之二叉树与堆
|
16天前
|
C语言
【数据结构】栈和队列(c语言实现)(附源码)
本文介绍了栈和队列两种数据结构。栈是一种只能在一端进行插入和删除操作的线性表,遵循“先进后出”原则;队列则在一端插入、另一端删除,遵循“先进先出”原则。文章详细讲解了栈和队列的结构定义、方法声明及实现,并提供了完整的代码示例。栈和队列在实际应用中非常广泛,如二叉树的层序遍历和快速排序的非递归实现等。
91 9
|
7天前
|
存储 算法
非递归实现后序遍历时,如何避免栈溢出?
后序遍历的递归实现和非递归实现各有优缺点,在实际应用中需要根据具体的问题需求、二叉树的特点以及性能和空间的限制等因素来选择合适的实现方式。
16 1