【完全二叉树魔法:顺序结构实现堆的奇象】

本章重点

  • 二叉树的顺序结构
  • 堆的概念及结构
  • 堆的实现
  • 堆的调整算法
  • 堆的创建
  • 堆排序
  • TOP-K问题

【完全二叉树魔法:顺序结构实现堆的奇象】_第1张图片

1.二叉树的顺序结构

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

【完全二叉树魔法:顺序结构实现堆的奇象】_第2张图片

访问结点的规律:

//访问孩子节点
leftchild = parent*2+1
rightchild = parent*2+2
 
//访问父亲结点
parent = (child-1)/2

2.堆的概念及结构

【完全二叉树魔法:顺序结构实现堆的奇象】_第3张图片

堆的性质:

  • 堆中某个节点的值总是不大于或不小于其父节点的值;
  • 堆总是一棵完全二叉树。
  • 大堆:任何父亲节点 >= 孩子结点
  • 小堆:任何父亲节点 <= 孩子结点

【完全二叉树魔法:顺序结构实现堆的奇象】_第4张图片

1.下列关键字序列为堆的是:()。

A 100,60,70,50,32,65

B 60,70,65,50,32,100

C 65,100,70,32,50,60

D 70,65,100,32,50,60

E 32,50,100,70,65,60

F 50,100,70,65,60,32

解析:

        堆(Heap)是一种特殊的树形数据结构,它通常有两种类型:小堆(Min Heap)和大堆(Max Heap)。在小堆中,父节点的值小于或等于其子节点的值,而在大堆中,父节点的值大于或等于其子节点的值。

        要判断一个序列是否是堆,需要检查该序列是否满足堆的性质。我们发现A符合大堆的性质父节点的值大于或等于其子节点的值。

【完全二叉树魔法:顺序结构实现堆的奇象】_第5张图片

2.已知小根堆为8,15,10,21,34,16,12,删除关键字 8 之后需重建小堆,在此过程中,关键字之间的比较次数是()。

A 1

B 2

C 3

D 4

解析:

        在一个小根堆中删除根节点后,需要重新构建小根堆。删除根节点后,通常会将堆的最后一个元素移动到根的位置,然后通过与其子节点的比较来逐级下移,以确保小根堆的性质得以恢复。

        给定的小根堆是:8, 15, 10, 21, 34, 16, 12。

        首先删除根节点8后,将最后一个元素12移到根的位置,得到:12, 15, 10, 21, 34, 16。

        然后,我们需要逐级下移12,直到小根堆性质得以恢复。在这个过程中,我们将12与其子节点进行比较,选择较小的子节点来交换位置。

        第一次比较:12与15比较,不需要交换。

        第二次比较:12与10比较,需要交换。

        第三次比较:12与16比较,不需要交换。

        因此,关键字之间的比较次数是3次。

3.一组记录排序码为(5 11 7 2 3 17),则利用堆排序方法建立的初始堆为()。

A(11 5 7 2 3 17)

B(11 5 7 2 17 3)

C(17 11 7 2 3 5)

D(17 11 7 5 3 2)

E(17 7 11 3 5 2)

F(17 7 11 3 2 5)

        堆排序是一种基于堆数据结构的排序算法,通常会建立一个最大堆(Max Heap)或最小堆(Min Heap)来进行排序。在这里,我们需要建立一个最大堆。

        初始堆的建立过程通常是从数组的末尾开始,逐步将元素向上移动,以满足堆的性质。对于给定的排序码数组(5 11 7 2 3 17),初始堆的建立步骤如下:

【完全二叉树魔法:顺序结构实现堆的奇象】_第6张图片

4.最小堆[0,3,2,5,7,4,6,8],在删除堆顶元素0之后,其结果是()。

A[3,2,5,7,4,6,8]

B[2,3,5,7,4,6,8]

C[2,3,4,5,7,8,6]

D[2,3,4,5,6,7,8]

【完全二叉树魔法:顺序结构实现堆的奇象】_第7张图片

3.堆的实现

【完全二叉树魔法:顺序结构实现堆的奇象】_第8张图片        

        这里的堆是使用数组实现的,博主重点介绍堆的删除和插入接口,其他接口同顺序表相同,这里就不过多赘述了。

typedef int HPDataType;

typedef struct Heap
{
	HPDataType* a;
	int size;
	int capacity;
}HP;

// 堆的初始化
void HeapInit(HP* php);
// 堆的打印
void HeapPrint(HP* php);
// 堆的销毁
void HeapDestroy(HP* php);
//堆的创建
void HeapInitArray(HP*php, int* a, int n);
// 堆的插入
void HeapPush(HP* php, HPDataType x);
// 堆的删除
void HeapPop(HP* php);
// 取堆顶的数据
HPDataType HeapTop(HP* php);
// 堆的数据个数
int HeapSize(HP* php);
// 堆的判空
bool HeapEmpty(HP* php);

3.1堆的插入:void HeapPush(HP* php, HPDataType x)

  1. 先将元素插入到堆的末尾,即最后一个孩子之后。
  2. 插入之后如果堆的性质遭到破坏,将信新插入节点顺着其双亲往上调整到合适位置即可,即向上调整
  3. 向上调整结束的条件是child等于0,parent等于-1,但是我们写的循环结束条件是child大于0,因为parent的值不会是-1,而是0,这里可以去看我的另外一篇文章,里面介绍了c语言取整规则:链接

【完全二叉树魔法:顺序结构实现堆的奇象】_第9张图片

void Swap(HPDataType* p1, HPDataType* p2)
{
	HPDataType temp = *p1;
	*p1 = *p2;
	*p2 = temp;
}

//向上调整
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* temp = (HPDataType*)realloc(php->a, sizeof(HPDataType) * newCapacity);
		if (temp == NULL)
		{
			perror("realloc fail");
			exit(-1);
		}
		php->a = temp;
		php->capacity = newCapacity;
	}

	php->a[php->size] = x;
	php->size++;

	AdjustUP(php->a, php->size - 1);
}

【完全二叉树魔法:顺序结构实现堆的奇象】_第10张图片

3.2堆的删除:void HeapPop(HP* php)

  1. 将堆顶元素与堆中最后一个元素进行交换。
  2. 删除堆中最后一个元素。
  3. 将堆顶元素向下调整到满足堆特性为止。
  4. 向下调整的结束条件是child等于叶子结点。
void Swap(HPDataType* p1, HPDataType* p2)
{
	HPDataType temp = *p1;
	*p1 = *p2;
	*p2 = temp;
}

//向下调整
void AdjustDown(HPDataType* a, int n, int parent)
{
	int child = parent * 2 + 1;
	//parent到叶子结点就结束
	while (child < n)
	{
		//可能不存在右孩子
		if (child + 1 < n && a[child] > a[child + 1])
		{
			child++;
		}
		if (a[child] < a[parent])
		{
			Swap(&a[child], &a[parent]);
			parent = child;
			child = parent * 2 + 1;
		}
		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);
}

【完全二叉树魔法:顺序结构实现堆的奇象】_第11张图片

4.堆的调整算法

【完全二叉树魔法:顺序结构实现堆的奇象】_第12张图片

4.1堆向下调整算法

        现在我们给出一个数组,逻辑上看做一颗完全二叉树。我们通过从根节点开始的向下调整算法可以把它调整成一个小堆。向下调整算法有一个前提:左右子树必须是一个堆,才能调整。

int array[] = {27,15,19,18,28,34,65,49,25,37};

【完全二叉树魔法:顺序结构实现堆的奇象】_第13张图片

//向下调整
void AdjustDown(HPDataType* a, int n, int parent)
{
	int child = parent * 2 + 1;
	//parent到叶子结点就结束
	while (child < n)
	{
		//可能不存在右孩子
		if (child + 1 < n && a[child] > a[child + 1])
		{
			child++;
		}
		if (a[child] < a[parent])
		{
			Swap(&a[child], &a[parent]);
			parent = child;
			child = parent * 2 + 1;
		}
		else
		{
			break;
		}
	}
	
}

4.2堆向上调整算法

        现在我们给出一个数组,前n-1个数已经是堆了,现在再添加一个数要让其满足堆的性质。我们通过从最后一个叶子结点向上调整算法可以把它调整成一个小堆。向上调整算法有一个前提:前面的数据必须是一个堆,才能调整。

【完全二叉树魔法:顺序结构实现堆的奇象】_第14张图片

//向上调整
void AdjustUp(int* a, int child)
{
	int parent = (child - 1) / 2;
	while (child > 0)
	{
		if (a[child] < a[parent])
		{
			Swap(&a[child], &a[parent]);
			child = parent;
			parent = (child - 1) / 2;
		}
		else
		{
			break;
		}
	}
}

5.堆的创建

方法一:向上调整插入的思想

        下面我们给出一个数组,利用上面push函数的思路,将数组a中的元素依次插入向上调整,把第一个数当成堆,满足堆向上调整的前提,可以调整成堆。

int a[] = {1,5,3,2,8};

【完全二叉树魔法:顺序结构实现堆的奇象】_第15张图片

//建堆
//向上调整:前提是前面的数据是堆
// 思路:第一个数据当作堆,后面数据依次插入,向上调整
//时间复杂度O(N*logN)
for (int i = 1; i < n; i++)
{
	AdjustUp(a, i);
}

        所以这里我们就可以给堆结合实现一个创建堆的接口:使用向上调整的思路。

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 = php->capacity = n;

	memcpy(php->a, a, sizeof(HPDataType) * n);

	for (int i = 0; i < n; i++)
	{
		AdjustUp(php->a, i);
	}
}

        因为堆是完全二叉树,而满二叉树也是完全二叉树,此处为了简化使用满二叉树来证明(时间复杂度本来看的就是近似值,多几个节点不影响最终结果):

【完全二叉树魔法:顺序结构实现堆的奇象】_第16张图片

因此:建堆的时间复杂度为O(N*logN)。

【完全二叉树魔法:顺序结构实现堆的奇象】_第17张图片

方法二:倒数第一个非叶子结点向下调整的思想

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

int a[] = {1,5,3,8,7,6}; 

//倒数第一个非叶子结点:(最后一个叶子结点-1)/2 

【完全二叉树魔法:顺序结构实现堆的奇象】_第18张图片

//建堆
//向下调整建堆
//找到倒数第一个非叶子结点
//时间复杂度O(N)
for (int i = (n - 1 - 1)/2; i >= 0; i--)
{
	AdjustDown(a, n, i);
}

        因为堆是完全二叉树,而满二叉树也是完全二叉树,此处为了简化使用满二叉树来证明(时间复杂度本来看的就是近似值,多几个节点不影响最终结果):

【完全二叉树魔法:顺序结构实现堆的奇象】_第19张图片

因此:建堆的时间复杂度为O(N)。

6.堆排序

【完全二叉树魔法:顺序结构实现堆的奇象】_第20张图片

1. 排序如何建堆

  • 升序:建大堆
  • 降序:建小堆

        为什么升序是建大堆呢?按照我们的常理,我们先建小堆,然后再取出堆顶的数据,这样就取得了最小的数据,这样数据不就有序了,为什么要去建大堆呢???

        取出堆顶的数据,这样就取得了最小的数据,然后再选次小的数,此时我们只能将剩下的数看做堆,但是剩下的数据还是堆嘛?

【完全二叉树魔法:顺序结构实现堆的奇象】_第21张图片

        此时就要重新建堆,然后再取堆顶数据,再建堆...每次建堆的时间复杂度N*logN,一共有N个数据,所以总的排序时间复杂度就是N * logN * N,那还不如直接遍历一遍排序来的快呢!!!

2. 利用堆删除思想来进行排序

        所以此时我们可以建大堆,将堆顶的数据和最后一个叶子结点交换,由于此时的堆结构没有破坏,左子树和右子树仍然是堆,使用堆的向下调整去调整堆,然后在缩小下次向下调整的范围,也就是把最大的那个数不算做堆的范围了,这样最大的数据就保存在了下标最大的位置处,满足了升序的要求。每次向下调整的时间复杂度是logN,一共有N个数据,所以总的排序时间复杂度就是N * logN。

#define _CRT_SECURE_NO_WARNINGS 1

#include 

void Swap(int* p1, int* p2)
{
	int temp = *p1;
	*p1 = *p2;
	*p2 = temp;
}
//向上调整
void AdjustUp(int* a, int child)
{
	int parent = (child - 1) / 2;
	while (child > 0)
	{
		if (a[child] > a[parent])
		{
			Swap(&a[child], &a[parent]);
			child = parent;
			parent = (child - 1) / 2;
		}
		else
		{
			break;
		}
	}
}
//向下调整
void AdjustDown(int* a, int n, int parent)
{
	int child = parent * 2 + 1;
	//parent到叶子结点就结束
	while (child < n)
	{
		//可能不存在右孩子
		if (child + 1 < n && a[child] < a[child + 1])
		{
			child++;
		}
		if (a[child] > a[parent])
		{
			Swap(&a[child], &a[parent]);
			parent = child;
			child = parent * 2 + 1;
		}
		else
		{
			break;
		}
	}
	
}
void HeapSort1(int a[],int n)
{
	//建堆
	//向上调整:前提是前面的数据是堆
	// 思路:第一个数据当作堆,后面数据依次插入,向上调整
	//O(N*logN)
    for (int i = 1; i < n; i++)
	{
		AdjustUp(a, i);
	}

	//升序建大堆
    //O(N*logN)
	//向下调整:前提是左右子树是堆
	int end = n - 1;
	while (end > 0)
	{

		Swap(&a[0], &a[end]);
		AdjustDown(a, end, 0);
		end--;
	}
}
void HeapSort2(int a[],int n)
{
	//建堆
	//向下调整:前提是左右子树是堆
	// 思路:找到倒数第一个非叶子结点,与最后一个叶子结点进行向下调整,直至根节点
	//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[] = { 3,17,4,20,16,5 };
	//HeapSort1(a,sizeof(a)/sizeof(a[0]));
    HeapSort2(a,sizeof(a)/sizeof(a[0]));
	int i = 0;
	for (i = 0; i < sizeof(a) / sizeof(a[0]); i++)
	{
		printf("%d ", a[i]);
	}
	return 0;
}

运行结果:

【完全二叉树魔法:顺序结构实现堆的奇象】_第22张图片

【完全二叉树魔法:顺序结构实现堆的奇象】_第23张图片

7.TOP-K问题

        TOP-K问题:即求数据结合中前K个最大的元素或者最小的元素,一般情况下数据量都比较大。

【完全二叉树魔法:顺序结构实现堆的奇象】_第24张图片

        比如:专业前10名、世界500强、富豪榜、游戏中前100的活跃玩家等。

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

1. 用数据集合中前K个元素来建堆

  • 前k个最大的元素,则建小堆

这里不能用大堆,如果第一个数据就是最大的,放在堆顶,其余数据就无法入堆,所以要用小堆,最大的前k个数肯定比堆顶大,此时该数替换堆顶的数入堆,入完k个后就找到前k个最大的元素。

  • 前k个最小的元素,则建大堆

2. 用剩余的N-K个元素依次与堆顶元素来比较,不满足则替换堆顶元素

  • 将剩余N-K个元素依次与堆顶元素比完之后,堆中剩余的K个元素就是所求的前K个最小或者最大的元素。

3.复杂度

  • 时间复杂度:O(N*logK)
  • 空间复杂度:O(K)
#include 
#include 
#include 
#include 
void Swap(int* p1, int* p2)
{
	int temp = *p1;
	*p1 = *p2;
	*p2 = temp;
}
//向下调整
void AdjustDown(int* a, int n, int parent)
{
	int child = parent * 2 + 1;
	//parent到叶子结点就结束
	while (child < n)
	{
		//可能不存在右孩子
		if (child + 1 < n && a[child] > a[child + 1])
		{
			child++;
		}
		if (a[child] < a[parent])
		{
			Swap(&a[child], &a[parent]);
			parent = child;
			child = parent * 2 + 1;
		}
		else
		{
			break;
		}
	}
}
void PrintTopK(const char *filename, int k)
{
	// 1. 建堆--用a中前k个元素建堆
	FILE* fout = fopen(filename, "r");
	if (fout == NULL)
	{
		perror("fopen fail");
		exit(-1);
	}
	int *Minheap = (int*)malloc(sizeof(int) * k);
	if (Minheap == NULL)
	{
		perror("malloc fail");
		exit(-1);
	}
	//读文件
	for (int i = 0; i < k; i++)
	{
		fscanf(fout, "%d", &Minheap[i]);
	}
	//向下调整建小堆
	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);
}
void CreatNData()
{
	//造数据
	int n = 10000;
	srand((unsigned int)time(0));
	const char* file = "data.txt";
	FILE* fin = fopen(file, "w");
	if (fin == NULL)
	{
		perror("fopen fail");
		exit(-1);
	}
	for (int i = 0; i < n; ++i)
	{
		int x = rand() % 1000000;
		fprintf(fin, "%d\n", x);
	}
	fclose(fin);
}
int main()
{
	//CreatNData();
	PrintTopK("data.txt",10);
	return 0;
}

运行结果:

【完全二叉树魔法:顺序结构实现堆的奇象】_第25张图片

但是我们怎么知道这几个数据就是前k个最大的呢?我们可以在文件中手动创造10个最大的值,看看输出是不是我们刚刚手动创造10个最大的值。

1000001;1000002;1000003;10000041000005;

1000006;1000007;1000008;1000009;1000009。

【完全二叉树魔法:顺序结构实现堆的奇象】_第26张图片

这样就完成了我们的TOP-K问题!!!

【完全二叉树魔法:顺序结构实现堆的奇象】_第27张图片

你可能感兴趣的:(数据结构,数据结构)