zl程序教程

您现在的位置是:首页 >  其他

当前栏目

【初阶数据结构】堆排序和TopK问题

2023-02-25 18:20:12 时间

综述:

  1. 堆排序:排序算法,时间复杂度O(NlogN)
  2. TopK问题:一堆数据前K大或前K小

目录

综述:

1.堆的基本结构

 2.堆的插入删除

2-1用数组下标计算父子关系:

 2-2堆上插入元素-向上调整算法

 2-3删除堆顶元素-向下调整算法

2-4完整代码

3.两种方法建堆:

3-1向上调整法建堆

3-2向下调整法建堆

3-3.完整代码

3-4.两种建堆方式的时间复杂度

4.堆排序

 5.TopK问题


1.堆的基本结构

数据结构的堆和我们在操作系统里的堆不同,我们要讲的堆就是数据结构的堆。

堆的逻辑结构(完全二叉树)和物理结构(数组)

这里的堆是一个小根堆,(堆只分为大根堆和小根堆) ps:小根堆: 堆的逻辑结构(完全二叉树中)的任意一个结点值必须大于他的左孩子和右孩子的结点值,大根堆同理。 值得注意的是这里即使是小根堆但依然不是有序的,通过小根堆我们能直接获取到的是最小值。 PS:大小堆都只是父子之间的大小关系,兄弟之间是没有大小关系的 所以下面让我们看看如何对堆进行排序。

堆只有大根堆和小跟堆,

 2.堆的插入删除

堆的核心就是插入数据和删除数据

2-1用数组下标计算父子关系:

leftchild=2*parent+1; rightchild=2*parent+2; parent=(child-1)/2;  我用下图理解了上面的child不分leftchild和rightchild的原因。(看不懂可以按自己的方式理解)

 2-2堆上插入元素-向上调整算法

如果在小根堆上插入一个数据,由于堆的物理结构是数组,我们采用顺序表实现,同时,如果只是简单的在数组的最后面插入一个数据,这是相当简单的,但是我们为了在插入新数据后能够继续保持堆的形态,我们通常在插入一个新数据后采用向上调整算法来实现。

向上调整法使用前提:树本身就是大堆或者小堆 时间复杂度:LogN

纠正上图:应该是向上调整算法,下图是向上调整法的图解实现

你是否有一个问题就是为什么在将12向上调整的时候,只用关心12的祖先的大小关系 在换的过程中不会打乱除了祖先外的结点和祖先结点的大小关系吗? 答案:不会,因为这本来就是小根堆,如果某结点要下移来交换,移下来的结点换下来之后一定比最原先在换下来的那个位置的结点值还更小,所以一定能够保证换下来之后不会造成父子关系乱掉。

那么向上调整法的代码实现是什么样的呐?如下

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

void AdjustUp(int* a, int child)
{
	assert(a);
	int parent = (child - 1) / 2;
	while (child > 0)//循环里写的是继续的条件while(满足):child==0时跳出循环
	{
		if (a[child] < a[parent])
		{
			Swap(&a[child], &a[parent]);
			child = parent;
			parent = (child - 1) / 2;
		}
		else
		{
			break;
		}
	}
}

//插入X后继续保持堆形态
void HeapPush(HP* php, int x)
{
	assert(php);
	if (php->size == php->capacity)
	{
		int newcapacity = 2 * php->capacity;
		int* temp = (int*)realloc(php->a, sizeof(int) * newcapacity);
		if (temp == NULL)
		{
			perror("realloc fail.\n");
			exit(-1);
		}
		php->a = temp;
		php->capacity = newcapacity;
	}
	php->a[php->size] = x;
	php->size++;

	//向上调整算法,传要调整的数组和从哪个下标child开始调
	AdjustUp(php->a, php->size - 1);
}

HeapPush函数的内容和原来顺序表不同的是在插入新数据X后进行了向上调整,因此我们的关注点只需放在AdjustUp函数。

int main()
{
	int a[] = { 10,2,20,4,12,67,56,1 };
	int size = sizeof(a) / sizeof(a[0]);
	HP heap;
	HeapInit(&heap);
	for (int i = 0; i < size; i++)
	{
		HeapPush(&heap, a[i]);
    }
	HeapPrint(&heap);
	HeapDestory(&heap);
	return 0;
 }

测试用例:10,2,20,4,12,67,56,1

写成完全二叉树的形式:(预期结果:小根堆)

结果:小根堆,代码无误~~

 要是我想得到大根堆改如何改呐? 小根堆就是要把小的换上去 大根堆就是要把大的换上去 因此同样顺序表插入代码,只需在调整部分稍作修改 也就是只需改一下调整部分代码的判断条件

 2-3删除堆顶元素-向下调整算法

错误的顺序表式删除头:

正确的删除堆顶元素方式:向下调整算法 前提:堆顶的把左子树和右子树都是大堆或者小堆。 向下调整算法:将要删除的堆顶元素和数组的最后一个元素先做一个交换,交换后覆盖删除数组的最后一个元素,,将堆顶元素做一次向下调整。

void HeapAdjustDown(int* a, int n, int parent)
{
	int minchild = 2 * parent + 1;
	while (minchild < n)
	{
		if (minchild + 1 < n && a[minchild] > a[minchild + 1])
		{
			minchild++;
		}
		if (a[minchild] < a[parent])
		{
			Swap(&a[minchild], &a[parent]);
			parent = minchild;
			minchild = 2 * parent + 1;
		}
		else
		{
			break;
		}
	}
}

//删除堆顶元素,找到次小或次大的元素
//删除之后仍要尽量保持堆的形态
void HeapPop(HP* php)
{
	assert(php);
	Swap(&php->a[0], &php->a[php->size - 1]);
	php->size--;
	HeapAdjustDown(php->a, php->size-1,0);
}

2-4完整代码

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

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

void HeapInit(HP* php)
{
	assert(php);
	php->a = (int*)malloc(sizeof(HP) * 4);
	php->size = 0;
	php->capacity = 4;
}

void HeapDestory(HP* php)
{
	assert(php);
	php->a = NULL;
	php->size = php->capacity = 0;
}

void Swap(int* a, int* b)
{
	int temp = *a;
	*a = *b;
	*b = temp;
}

void AdjustUp(int* a, int child)
{ 
	assert(a);
	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;
		}
	}
}

//插入X后继续保持堆形态
void HeapPush(HP* php, int x)
{
	assert(php);
	if (php->size == php->capacity)
	{
		int newcapacity = 2 * php->capacity;
		int* temp = (int*)realloc(php->a, sizeof(int) * newcapacity);
		if (temp == NULL)
		{
			perror("realloc fail.\n");
			exit(-1);
		}
		php->a = temp;
		php->capacity = newcapacity;
	}
	php->a[php->size] = x;
	php->size++;

	//向上调整算法,传要调整的数组和从哪个下标child开始调
	AdjustUp(php->a, php->size - 1);
}

void HeapAdjustDown(int* a, int n, int parent)
{
	int minchild = 2 * parent + 1;
	while (minchild < n)
	{
		if (minchild + 1 < n && a[minchild] > a[minchild + 1])
		{
			minchild++;
		}
		if (a[minchild] < a[parent])
		{
			Swap(&a[minchild], &a[parent]);
			parent = minchild;
			minchild = 2 * parent + 1;
		}
		else
		{
			break;
		}
	}
}

//删除堆顶元素,找到次小或次大的元素
//删除之后仍要尽量保持堆的形态
void HeapPop(HP* php)
{
	assert(php);
	Swap(&php->a[0], &php->a[php->size - 1]);
	php->size--;
	HeapAdjustDown(php->a, php->size-1,0);
}

bool HeapEmpty(HP* php);
int HeapTop(HP* php)
{
	assert(php);
	assert(!HeapEmpty(php));
	return php->a[0];
}

bool HeapEmpty(HP* php)
{
	assert(php);
	return php->size == 0;
}

int HeapSize(HP* php)
{
	assert(php);
	return php->size;
}

void HeapPrint(HP* php)
{
	assert(php);
	for (int i = 0; i < php->size; i++)
	{
		printf("%d  ", php->a[i]);
	}
}

int main()
{
	int a[] = { 10,2,20,4,12,67,56,1 };
	int size = sizeof(a) / sizeof(a[0]);
	HP heap;
	HeapInit(&heap);
	for (int i = 0; i < size; i++)
	{
		HeapPush(&heap, a[i]);
    }
	HeapPop(&heap);
	HeapPrint(&heap);
	HeapDestory(&heap);
	return 0;
 }

3.两种方法建堆:

从上面我们可以知道:我们已经学会了建堆以及堆的插入删除数据。 但是我们知道我们建好的堆并不是有序的,而且堆中的数组和待的数组还不是同一个数组,这就意味着如果要使待排序的数组有序的话,还得将堆中的数据通过heapTop函数和HeapPop函数不断先取出堆顶元素插入到待排序数组,后删除堆顶元素(向下调整法)....

最重要的话这样的话还会导致我们使用额外的空间来拷贝待排序的数组来建堆

因此问题来了:怎么将数组本身建立成一个堆,从而减少额外空间的开辟 如果随便给你一个数组,元素向后顺序随机,要你把这个数组建成一个小根堆.(比如 14, 12, 4, 3, 6, 68, 21, 2 )

3-1向上调整法建堆

向上调整法的使用前提:每插入一个元素前,原数组的逻辑二叉树必须是一个小根堆(大根堆).

那么我们可以把14默认为是一个符合前提的堆,然后从12往后不断向数组中插入元素,并不断向上调整,直至把整个数组元素全部插完,即完成堆的建立.

	//向上调整法建堆
	for (int i = 0; i < n; i++)
	{
		AdjustUp(a, i);
	}

3-2向下调整法建堆

向下调整法使用的前提:左子树和右子树必须是小根堆(大根堆)

由于排序的数组是随机给的,所以对于堆顶元素来说,其左子树和右子树大大部分都不是小根堆(大根堆),所以不能从第一个数组元素(堆顶)开始向下调整;同时,叶子节点不需要向下调整,所以我们采用从倒数第一个非叶子节点开始向下调整(当然如果代码中写的是从叶子节点开始向下调整,结果也没有问题,但是就是多次一举而已);

	向下调整法建堆
	//for (int i = (n - 1 - 1) / 2; i >= 0; i--)
	//{
	//	HeapAdjustDown(a, n, i);
	//}

3-3.完整代码

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--)
	//{
	//	HeapAdjustDown(a, n, i);
	//}
}

void HeapPrint2(int* a, int n)
{
	assert(a);
	for (int i = 0; i < n; i++)
	{
		printf("%d  ", a[i]);
	}
}



int main()
{
	int a[]={ 14, 12, 4, 3, 6, 68, 21, 2 };
	int sz = sizeof(a) / sizeof(a[0]);
	HeapSort(a, sz);
	HeapPrint2(a, sz);

	return 0;
}

3-4.两种建堆方式的时间复杂度

时间复杂度=总调整次数=每一层节点个数*该层需要调整的次数

 向下调整法建堆:LogN

 向上调整法建立堆:NLogN

 分析向上调整法和向下调整法建堆时间复杂度相差这么大的原因:

因为向下调整法的节点数量多的时候,需要调整的次数就少;

而向上调整法的节点数量多的时候,需要调整的次数也越多;

4.堆排序

前面我们学会了如何去高效的建立堆,其中我们优先采用时间复杂度更小的向下调整法建堆

我们直接在数组上建立了堆,那我们就可以接着通过选数,把数组进行排序,从而完成堆排序

那么问题又来了:如果我要排升序,我们应该建大堆还是小堆呐? 让我们想一想,如果要排升序,如果我们建立的是小堆的话,我们的确可以轻松的选出最小的数,但是如果我们在选次小的数的时候,就不得不破坏整个堆的结构,父子关系全乱了(和堆的插入和删除那里一样),这样下来重新建堆的话就是O(N)的时间复杂度;要选N个数,选一次数就要重新建一次堆的话,时间复杂度总体上就是O(N*N),那还不如直接遍历数组n次,也是N方,这样简直是拿着一手好牌,却打的稀烂! 所以我们升序的话采用建大堆的方式,那又有一个问题,建大堆后又是如何选出次小的呐?请往下看~~

 代码如下:(在原来的基础上增加的选数的代码完成堆排序)

void HeapSort(int* a, int n)
{

	//向下调整法建堆,时间复杂度:O(N)
	for (int i = (n - 1 - 1) / 2; i >= 0; i--)
	{
		HeapAdjustDown(a, n, i);
	}

	//升序-建小堆
	//降序-建大堆

	//选数:堆排序
	int i = 1;
	//时间复杂度:O(NLogN)
	while (i < n)
	{
		Swap(&a[0], &a[n - i]);
		HeapAdjustDown(a, n - i, 0);
		++i;
	}
}

 5.TopK问题

在一堆数中,我们如果要找前K个最大的数,该怎么做? 或许你脑海里最先想到的是用快排先排序,然后直接选择前K个数据,那代价有点大. 这里鉴于选择排序中的堆排序的选数的经验,我们考虑采用堆的选数的思想解决这个问题. (这里因为数据量过大,担心内存空间不够大,我们选择在磁盘上存储这些数据)

 这时我们优先选择建小堆,我们建一个K个数的小堆,然后将后N-K个数和堆顶元素比较,如果堆顶元素小于后N-K个树数,就交换,然后向下调整,以此类推。

#include<time.h>

void CreateFileName(const char* filename, int N)
{
	FILE* pf = fopen(filename, "w");
	if (pf == NULL)
	{
		perror("fopen\n");
		exit(-1);
	}

	//生成随机数
	srand(time(0));
	for (int i = 0; i < N; i++)
	{
		fprintf(pf, "%d ", rand() % 10000 + 1);
	}

	fclose(pf);
	pf = NULL;
}

void PrintTopK(const char* filename, int k)
{
	FILE* pf = fopen(filename, "r");
	if (pf == NULL)
	{
		perror("fopen\n");
		exit(-1);
	}

	//文件读取前K个数
	int* minHeap = (int*)malloc(sizeof(int) * k);
	if (minHeap == NULL)
	{
		perror("malloc");
		exit(-1);
	}

	for (int i = 0; i < k; i++)
	{
		fscanf(pf,"%d", &minHeap[i]);
	}

	//建小堆
	for (int i = (k-1-1)/2; i>=0;--i)
	{
		HeapAdjustDown(minHeap, k, i);
	}
	
	//后N-k个数和堆顶元素比较
	int val = 0;
	while (fscanf(pf, "%d", &val)!=EOF)
	{
		if (val > minHeap[0])
		{
			minHeap[0] = val;
			HeapAdjustDown(minHeap, k, 0);
		}
	}

	//打印出前k大的数
	for (int i = 0; i < k; i++)
	{
		printf("%d ", minHeap[i]);
	}

	fclose(pf);
	pf = NULL;
}


int main()
{
	const char* filename = "test.txt";
	int N = 100;
	int k = 10;
	//给文件内随机生成N个数
	CreateFileName(filename, N);
	//从文件中选出N个数中前K大的几个数字,并且打印
	PrintTopK(filename, k);
	return 0;
}

 TopK问题的时间复杂度分析: