顺序结构二叉树的实现——堆

前言:

堆的结构:

下图为完全二叉数的结构

下图是满二叉树的结构

满二叉树是一种特殊的完全二叉树,其中堆属于完全二叉树

堆的性质★

堆的底层结构是数组,对于具有 n 个结点的完全⼆叉树,如果按照从上⾄下从左⾄右的数组顺序对所有结点从0 开始编号,则对于序号为 i 的结点有

①:若 i>0 ,i 位置结点的父节点序号: (i-1) / 2 ;若 i=0 ,则 i 为根结点编号,无父结点

②:若 2i+1<n ,左子节点序号: 2i+1 ; 若 2i+1>=n  则 i 节点处 无左子节点

③:若 2i+2<n ,右子节点序号: 2i+2 ; 若 2i+1>=n 则 i 节点处 无右子节点

小堆和大堆:

堆有两种:分别是小堆和大堆

小堆:

子节点的数总是大于其父节点的数。

大堆:

子节点的数总是小于其父节点的数

综上:堆中数据一定遵循上述两种排序方式之一。

堆的实现:

头文件:

#pragma once
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
#include <stdbool.h>

typedef int HDatatype;

typedef struct HeapNode    //堆的底层是数组,这里通过顺序表的方式来实现堆
{                          //因此,堆的初始化和销毁与顺序表的初始化和销毁完全一致
	HDatatype* arr;
	int size;
	int capacity;
}Heap;

//堆的初始化
void HeapInit(Heap* php);

//堆的销毁
void HeapDestroy(Heap* php);

//堆的插入 尾插
void HeapPush(Heap* php,HDatatype x);

//出堆顶数据
void HeapPop(Heap* php);

//堆顶数据的读取
HDatatype HeapTop(Heap* php);

//堆中数据个数
int HeapSize(Heap* php);

//向下调整
void AdjustDown(HDatatype* arr, int parent, int n);

//交换数组中的元素顺序
void swap(HDatatype* x, HDatatype* y);

测试文件:

#define  _CRT_SECURE_NO_WARNINGS
#include "heap.h"
#include <time.h>

void test()
{
	Heap s;            //创建一个堆
	HeapInit(&s);      //初始化堆

	int arr[] = { 15,17,13,20,19,10 };

	for (int  i = 0; i < 6; i++)
	{
		HeapPush(&s, arr[i]);//循环往堆中插入数据
	}

	for (int i = 0; i < 6; i++)
	{
		printf("%d ", s.arr[i]);   //打印插入的数据
	}
	printf("\n");                  

	printf("%d\n",HeapSize(&s));    //堆中数据个数

	while (s.size > 0)              //此循环实现的是 通过堆来实现数组的排序,后续介绍
	{
		printf("%d ", HeapTop(&s));  //取堆顶元素
		HeapPop(&s);                 //删堆顶元素
	}
	HeapDestroy(&s);
}

int main()
{
	test();
	return 0;
}

函数实现:

1、初始化和销毁

指针置为空,数据置为零。

void HeapInit(Heap* php)
{
	assert(php);
	php->arr = NULL;
	php->size = php->capacity = 0;
}

void HeapDestroy(Heap* php)
{
	assert(php);
	if (php->arr)
	{
		free(php->arr);
	}
	php->arr = NULL;
	php->capacity = php->size = 0;
}

2、数据插入

思路:堆数据插入与顺序表数据插入大致类似,可以看到如下代码的前部分与顺序表一致,但是对于堆而言,堆中的数据要遵循小堆结构,或大堆结构,因此单纯的插入是不满足条件的。

void HeapPush(Heap* php, HDatatype x)
{
	assert(php);
	if (php->capacity == php->size)
	{
		int newcapacity = php->capacity == 0 ? 4 : 2 * php->capacity;
		HDatatype* tmp = (HDatatype*)realloc(php->arr, newcapacity * sizeof(HDatatype));
		assert(tmp);
		php->arr = tmp;
		php->capacity = newcapacity;
	}
	php->arr[php->size] = x;
	AdjustUp(php->arr, php->size);
	php->size++;
}

堆的正确处理方法是:在每一次插入数据后,要对每个数据进行调整,因此相较于顺序表尾插而言,堆插入多了一个向上调整函数。代码如下

void AdjustUp(HDatatype* arr, int n)
{
	int child = n;
	int parent = (n - 1) / 2;
	while (child > 0)
	{
		小堆
		//if (arr[child] < arr[parent])
		//{
		//	swap(&arr[child], &arr[parent]);
		//	child = parent;
		//	parent = (child - 1) / 2;
		//}

		//大堆
		if (arr[child] > arr[parent])
		{
			swap(&arr[child], &arr[parent]);
			child = parent;
			parent = (child - 1) / 2;
		}
		else
		{
			break;
		}
	}
}

画图分析上述函数:

现将该数组 arr[] = { 15,17,13,20,19,10 }; 以大堆方式插入进堆中。

初始时,数组中无元素,因此 15 率先插入,堆长这样,注:圆旁边为下标元素

此时堆中的元素个数为 1 ,但为了方便向上调整,(数组下标为:元素个数 - 1) 先将 size 作为实参传递给形参,再后置++。

在形参中定义两个参数,child 、 parent,其中child作为形参接收 size ,而parent的初始大小对应堆的性质中的性质①(已知子节点可以求得父节点的位置);显然开始时,child为0,不进入循环,直接跳过循环,函数返回到堆插入函数后,size++,再返回到主函数,开始插入数组中第二个数据。

注:为什么要传递size呢?因为向上调整函数,是将尾部数据向上调整

主函数第二次循环将数据 17 插入到堆的尾部,此时 size 为 1,进入向上调整函数,此时 child 为 1 ,parent 为 0,结构图如下,正好对应数组下标。

此时不满足大堆排序,因此需要将两个数据进行对调,对调后child变化parent,parent变为新的子节点的父节点。调整后的堆如图:

当数据个数比较少时,child会直接变为 0,从而跳出循环。

而当数据个数表较多时,可以想象,该函数会对队尾的数据循环向上调整,直到不满足条件:

如下图,假设我们现在要在下标为 6 的位置插入 100,

则有 child = 6 ,根据性质①,parent = 2 ,100 > 13 ,下标2的数据与下标6的数据交换,交换后

child = parent = 2,根据性质①,parent = 0 ,又重新得到了新的父子关系,再通过比较发现 100>20,再次交换,最终新插入的数据到了根节点,同理小堆排序,只不过比较条件发生了变化。

通过上述代码,我们就实现了堆(大堆)的数据插入,其结果图如下:

3、出堆顶数据

思路:将堆顶数据与堆尾数据交换,新的堆顶数据不一定满足大堆或者小堆排序,因此需要用到向下调整函数,出堆顶数据函数代码如下:

void HeapPop(Heap* php)
{
	assert(php);
	assert(php->size);
	swap(&php->arr[0], &php->arr[php->size - 1]);
	php->size--;
	AdjustDown(php->arr, 0, php->size);
}

向下调整函数如下:

void AdjustDown(HDatatype* arr, int parent, int n)
{
	int child = parent * 2 + 1;    //已知父节点 可知左子节点
	while (child < n)
	{
//-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
		//小堆
		/*if (child + 1 < n && arr[child] > arr[child + 1])    //小堆排列,找子节点中较小的
		{                                            //数与父节点交换
			child++;
		}
		if (arr[parent] > arr[child])        //如果子节点小于父节点,则交换父子节点
		{
			swap(&arr[parent], &arr[child]);
			parent = child;                //能够让该顶部数据持续向下调整的方式
			child = 2 * parent + 1;        //该节点对应的子节点
		}*/
//-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
	//	大堆
		if (child + 1 < n && arr[child] < arr[child + 1])//与小堆排序类似
		{                                                //需要补充的时,child + 1 不能越界
			child++;
		}
		if (arr[parent] < arr[child])
		{
			swap(&arr[parent], &arr[child]);
			parent = child;
			child = 2 * parent + 1;
		}
//-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
		else
		{
			break;
		}
	}
}

思考一个问题:为什么我传这几个参数?

传数组名:数组名即为地址,我们要对数组元素进行修改,因此一定是传址。

传0:交换后,顶部数据的下标为0,我们要把顶部数据向下调整。

传数组内数据的个数:我们要对剩下的数重新进行大堆或者小堆的排列,而这个排列是不断向下进行的,因此在排列的过程中,参数child会不断变大,我们需要对child进行一定的限制,那么如何限制呢?即 child 不能 ≥ size,超了,数组就越界访问了。

分析过程:

假设有以下大堆,我们要出顶部数据:

首先  0   5 数据交换,size-- ,显然不满足大堆排序,如图所示

进入向下调整函数,此时 size 为 5,初始child = 1 , parent = 0,进入循环。

因为下标为 child 的数据 大于 下标为 child+1的数据,因此child 不变,child中数据又满足 child > parent 因此 child 和parent 中的数据交换,交换后,此时 parent = child,而child根据性质②,找到其对应的左子节点,以此循环往复,直至 child 不满足 循环条件( child < n)。

4、读取堆顶数据

读取堆顶数据时很简单的,代码如下

HDatatype HeapTop(Heap* php)
{
	assert(php);
	return php->arr[0];
}

而取堆顶数据+出堆顶数据,能够实现堆的排序

代码如下:

while (s.size > 0)
{
	printf("%d ", HeapTop(&s));
	HeapPop(&s);

}

结果如图所示:

用堆来实现排序(不通过结构体地址):

该代码是通过两次向下调整函数来实现的。

代码如下:

void Heap_paixu_test()
{
	int arr[] = { 15,17,13,20,19,10 };
	int n = 6;
	int i = (n - 2) / 2;
	for ( i; i >= 0; i--)        //让数组中的元素为大堆或者小堆排列
	{
		AdjustDown(arr, i, n);    
	}

	for ( i = 0; i < n; i++)
	{
		printf("%d ", arr[i]);
	}
	printf("\n");

	while (n--)//堆中元素为0时,循环结束
	{
		swap(&arr[0], &arr[n]);//
		AdjustDown(arr, 0, n);
	}
	
	for (i = 0; i < 6; i++)
	{
		printf("%d ", arr[i]);
	}
}

重点分析一下以下这段代码:  

	int arr[] = { 15,17,13,20,19,10 };
	int n = 6;//数组元素个数
	int i = (n - 2) / 2;	最后一个父节点
    for ( i; i >= 0; i--)        
    {
		AdjustDown(arr, i, n);
	}

将数组内的元素排列成小堆或者大堆有两种方式,一种是向上,一种是向下,至于为什么选用向下排列,是因为通过数学分析后,发现向下调整函数的时间复杂度为O(N),而向上调整函数的时间复杂度为O(N*log(以2为底的N)),因此选用向下调整函数来建堆。

先前使用向下调整函数时,我们传递的参数分别为:

arr(数组名即地址),0(堆顶),n(堆中元素个数)

此函数中:

arr(数组名即地址),i(最后一个父节点),n(堆中元素个数)

因此我们可以得知,通过向下调整函数的方式来建堆,需要从倒数第一个父节点开始向下调整,每调整完一次,父节点--,移至倒数第二个父节点,以此循环,直至根节点。

其中,倒数第二层的父节点只会向下调整一次,还记得先前我们说的,每次父与子节点的数据交换后,下标会重新赋值吗?对于这一层而言,parent = child;child = parent * 2 +1 后会直接超过 n(堆中元素个数) ,当我们把这一层的父节点全部调整后,来到倒数第三层父节点时,重新开始调整。假设此时有一个节点满足判定条件,父节点跟子节点交换后, parent = child;child = parent * 2 +1 ,child 不一定会超过 n ,因此循环继续,此时 parent 下标对应原父节点数据,而 child 对应该节点处左子节点的位置,如满足判定条件则继续交换,这样就会把原先父节点的数据一直交换到底层。接下来就是如此循环往复,最终实现建堆。

分析下段代码:

while (n--)//堆中元素为0时,循环结束
	{
		swap(&arr[0], &arr[n]);//
		AdjustDown(arr, 0, n);
	}

为什么对大堆进行向下调整能够排升序,而对小堆进行向下调整能够排降序?

答:以大堆为例,当堆顶元素和堆尾元素交换时,此时堆尾元素一定是最大值,而堆顶不满足大堆排列,因此需要向下调整,直到满足大堆排列,此时新的堆顶又是最大值,同时n要--,确保是堆顶和新的堆尾进行交换,原先的最大值能够保留,如此循环下去,就能够得到升序排序。如图所示

TopK的实现:

从众多数据(几百万甚至几亿)中找到最大的或者最小的几个数,这类问题被称之为TopK问题。

此类问题的核心思想是:建堆,再用后续数据与堆顶比较,满足条件入堆,不满足则跳过该数据。

思路:

假设从20个数据中找到6个最大的数,我们先取这20个数据中的前6个数据进行建小堆

此时堆顶的数据最小,我们再从第7个数据与堆顶数据进行比较,若该数据大于堆顶数据,则交换,此时堆顶元素已被舍弃,将新的堆顶元素再进行小堆排列,得到新的小堆,此时堆顶又是最小值,而较大的值排排列在堆尾左右的位置,以此循环遍历,经过20次后,堆中一定是这20个数据中最大的五个。

找最小的6个与此类似,就不再过多分析。

通过上述思想,我们就能够解决TopK的问题。

代码如下:

//通过文本的形式来创建大量数据
void createtext()
{
	const char* file = "data.txt";
	srand(time(0) != EOF);
	FILE* p = fopen(file, "w");
	if (p == NULL)
	{
		perror("fopen error");
		exit(1);
	}
	int n = 10000;
	for (int i = 0; i < n; i++)
	{
		int x = (rand() + i) % 100000;
		fprintf(p, "%d\n", x);
	}
	fclose(p);
	p = NULL;
}



//TopK的实现
void TopK_test()
{
	const char* file = "data.txt";
	FILE* p = fopen(file, "r");
	int k = 0;
	scanf("%d", &k);
	int* maxk = (int*)malloc(sizeof(int) * k);
	for (int i = 0; i < k; i++)
	{
		fscanf(p, "%d", &maxk[i]);
	}
	for (int i = (k-2)/2; i >= 0; i--)
	{
		AdjustDown(maxk, i, k);
	}
	
    printf("建堆后的数据\n");
	for (int i = 0; i < k; i++)
	{
		printf("%d ", maxk[i]);
	}

	printf("\n");
	int x = 0;
	while (fscanf(p, "%d", &x) != EOF)
	{
		//最大
	/*	if (x > maxk[0])
		{
			swap(&x, &maxk[0]);
			AdjustDown(maxk, 0, k);
		}*/

		//最小
		if (x < maxk[0])
		{
			swap(&x, &maxk[0]);
			AdjustDown(maxk, 0, k);
		}
	}
    printf("最小的几个数\n");
	for (int i = 0; i < k; i++)
	{
		printf("%d ", maxk[i]);
	}
}

int main()
{
	
	createtext();
	TopK_test();
	return 0;
}

结果如下:

源文件中的部分数据:

注:1 2 3 4 5 9 为修改后的数据

最终结果:

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值