堆与堆排序操作详解

目录

一、什么是堆?

二、堆排序

三、解决TopK问题


一、什么是堆?

        堆(Heap)是计算机科学中一类特殊的数据结构,是最高效的优先级队列。堆通常是一个可以被看作一棵完全二叉树的数组对象。

        堆(heap)是计算机科学中一类特殊的数据结构的统称。堆通常是一个可以被看做一棵树的数组对象。堆总是满足下列性质:

  • 堆中某个结点的值总是不大于或不小于其父结点的值;

  • 堆总是一棵完全二叉树

        将根结点最大的堆叫做最大堆或大根堆,根结点最小的堆叫做最小堆或小根堆。常见的堆有二叉堆、斐波那契堆等。

        堆是线性数据结构,相当于一维数组,有两个直接后继。

        堆的定义如下:n个元素的序列{k1,k2,ki,…,kn}当且仅当满足下关系时,称之为堆。

        若将和此次序列对应的一维数组(即以一维数组作此序列的存储结构)看成是一个完全二叉树,则堆的含义表明,完全二叉树中所有非终端结点的值均不大于(或不小于)其左、右孩子结点的值。由此,若序列{k1,k2,…,kn}是堆,则堆顶元素(或完全二叉树的根)必为序列中n个元素的最小值(或最大值) ————  百度百科

        简单来说,堆结构要么是每个父节点都大于子节点,为大根堆。要么是每个父节点都小于子节点,为小根堆。以下为小根堆与大根堆示意图:堆与堆排序操作详解_第1张图片

        堆这种数据结构是基于完全二叉树来实现的,所以在结构上可以用数组来存储,在二叉树从上到下从左到右进行顺序编号:堆与堆排序操作详解_第2张图片        可能你会有疑问,为什么堆要用数组来操作呢?而不是直接用指针操作?

        这是个好问题,首先,如果直接用指针来操作没有问题,但是在实际操作过程中,每次给指针分配的空间,对指针的访问以及销毁,在时间复杂度和空间复杂度是都要比数组开销大的

        而数组只需要映射关系进行找到子孩子,该映射关系为:

        left = parent * 2 + 1, right = parent * 2 + 2

        按照上面的例子把这种映射带到里面会发现是完全符合这个规律的,这样来寻找子孩子就很方便了。


二、堆排序

        实际上,堆很多时候并不是有序的,它只能保证自己的子孩子要比自己大或者小,但是其实并不是一个有序的数据结构。那么想要有序的数据当然要先排序。

        堆排序可以分成两种排序,一种是向上调整(Adjust Up)建堆排序,一种是向下调整(Adjust Down)建堆排序,无论怎样都要先建立堆的结构在进行排序,那么究竟是用哪种方式建堆更好呢?

        我们不妨两种建堆方式都实现在比较。

1)向上调整和向下调整建堆

        实际上,向上调整和向下调整就是数据的上浮和下降,只不过每次上浮和下降都会对父节点和子节点进行比较,由比较结果选择较大或者较小的数据进行上浮或下降,从而建立大根堆或者小根堆,这其实也变相的说明了堆排序是一种选择排序

这里举例向下调整,向上调整就是相反的过程:

堆与堆排序操作详解_第3张图片

         那么,首先,我们先来看一下向上调整,向上调整是从叶子节点向上“浮动”的过程,每次上浮都需要跟自己的父节点进行比较,那么每次就都需要重新定位父节点,根据堆在二叉树的映射关系:child = parent * 2 + 1, 那么parent = (child - 1)/2。那么每次比较就可以确定父节点位置。

向上调整:

void AdjustUp(int *a, int child)//将需要向上调整的子孩子传入
{
	assert(a);
	if(child == 0)
	return;
	
	int parent = (child-1) / 2;//定位父节点
	while(parent >= 0)//保证父节点不越界
	{
		if(a[parent] < a[child])//当父节点的值小于子孩子的值就一直向上调整建立小根堆
		{
			Swap(&a[parent], &a[child]);//符合条件交换两个值
			child = parent;//将父节点向上浮
			parent = (child-1) / 2;//重新定位父节点
		}
		else//当父节点的值1大于子孩子的值表明前面已经是完好的堆了直接跳出循环
		{
			break;
		}
	}
}

         向下调整又和向上调整有些不太一样,不同的是向下调整要确定向左孩子还是右孩子进行下浮,如果要建立大根堆,那么就需要与较大的那个值进行向下调整了,反之与较小的值向下调整其他的部分与向上调整几乎相同。

向下调整:

void AdjustDown(HPDataType *a, int n, int parent)//n是防止向下调整越界
{
    assert(a);
    int child = parent * 2 + 1;//先定位左孩子
    while (child < n)//防止越界
    {
        if (child + 1 < n && a[child + 1] > a[child])//比较左右子树那个比较大,child+1 a[parent])//孩子大于父亲
        {
            Swap(&a[child], &a[parent]);
            parent = child;
            child = parent * 2 + 1;
        }
        else
        {
            break;
        }
    }
}

        注意我们要进行向上或者向下调整时,必须在进行调整之前就已经是堆的数据结构,这样每次新加入的数据逻辑上才能完成堆的上下调整,所以这个时候需要建堆

        建堆其实可以使用向上调整建堆,也可以向下调整建堆,先来看向上调整建堆:

int len = sizeof(a) / sizeof(int);
    
int i = 0;
for(i = 1 ; i < len ; i++)//向上建堆
{
    AdjustUp(a, i);
}

        其实很简单, 除了第一层节点以外,每个数据都向上调整,那么这样就能保证建立的结构一定是堆。为什么需要第一层的节点?其实第一层以下的节点已经调整完毕了,那么第一层不就自动调整好了?

        向下调整建堆:

int i = 0;
for (i = (n - 2) / 2; i >= 0; i--)//在从最后一个叶子节点的父节点开始向下调整建堆
{
    AdjustDown(a, n, i);
}

         向下建堆和向上建堆相反,最后一层之前的数据全都向下调整,那么最后一层就已经被动调整了,所以只需要向下调整最后一层以上的数据就行,所以只需要最后一个叶子节点的父节点之前的数据向下调整就保证了建堆

child = n - 1, parent = (child - 1) / 2

         所以只需要对i = (n - 1 - 1) / 2以上的数据进行向下调整就可建堆。


        虽然有两种建立堆的方式,但是我们几乎用的都是向下调整建堆,我们可以这样理解: 

        一颗满二叉树的最后一层节点占据整颗二叉树节点的50%,倒数第二层占据整颗二叉树节点的25%,倒数第三层12.5%,以此类推...相比向上建堆和向下建堆,向上建堆第一层不需要进行调整,几乎对建堆过程没有影响,但是向下建堆的最后一层是不需要操作的,这样一来时间复杂度几乎就减少了50%,时间复杂度为O(nlogn)所以除了特殊原因,大部分还是选择向下建堆为好。

2)堆排序

        以下为堆排序,在建堆之后每次都将最后一个元素与第一个元素交换,然后把已将交换过的元素进入已排序区,待排序区不在参与元素的交换,过程如下图:       

void HeapSort(HPDataType *a, int n)//进行排序
{
    assert(a);
    int i = 0;
    for (i = (n - 2) / 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张图片


三、解决TopK问题

        有这么一个问题:我们经常在美团、拼多多等页面进行搜索的时候,出来的总是以某种顺序进行呈现出来的,有些是热度最高,有些是点击量最多,但是总是最高的几个排在前列,那么思考一下,如果特别高的数据量选出来前k个数据该怎么操作?

        而这类问题在计算机上统称topk问题,那么该如何解决topk的问题呢?

        首先,我们知道,在处理数据量大的数据时,我们不能把数据全部放到内存中来读取,这样很可能存不下,例如:这里有10亿个数据,想要找到最大的前100个数据,该如何操作?

其实我们不妨这样想:

        1、先读取文件的前100个数据,在内存数组中建立一个小堆。

        2、在依次读取完剩下的数据,跟堆顶数据进行比较,大于堆顶替换进堆,向下调整。

        3、所有数据读取完,剩下的数据就是最大的前100个。

        其中,应该建立小堆,如果建立大堆,第一个值万一出现在前面,那么就会出现堵塞问题,剩下的数都比这个数要小,就不能继续查找数据了,所以要建小堆,每次进入的数据都是交换这100个元素里面最小的数据。

         在读取数据方面,我们在C语言阶段曾经学习过对文件操控的函数,我们可以用这些函数来完成对大量数据的访问,这样也不会占用大量内存了。详细过程如下:

#define _CRT_SECURE_NO_WARNINGS 1
#include
#include
#include
#include
#include

typedef int HPDataType;

void Swap(int* a, int* b)
{
	assert(a && b);
	int tmp = *a;
	*a = *b;
	*b = tmp;
	return;
}

void AdjustDown(HPDataType* a, int n, int parent)//向下调整建立小堆
{
	assert(a);
	int child = parent * 2 + 1;
	while (child < n)
	{
		if (a[child + 1] < a[child] && child + 1 < n)
		{
			child++;
		}

		if (a[parent] > a[child])
		{
			Swap(&a[parent], &a[child]);
			parent = child;
			child = parent * 2 + 1;
		}
		else
		{
			break;
		}
	}
}

void CreateNDate()//造数据
{
	// 造数据
	int n = 10000;
	srand(time(0));
	const char* file = "data.txt";//建立文件
	FILE* fin = fopen(file, "w");
	if (fin == NULL)
	{
		perror("fopen error");
		return;
	}
	
	int i = 0;
	for (i = 0; i < n; ++i)//在文件内写入1000000个数据
	{
		int x = rand() % 1000000;
		fprintf(fin, "%d\n", x);
	}

	fclose(fin);
}

void PrintTopk(const char* filename, int k)//输出数据内最大的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);
	}

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

	//建小堆,目的是比堆顶大的数入堆,最后留下来的k个数是最大的K个数
	for (i = (k - 2) / 2; i >= 0; i--)
	{
		AdjustDown(minHeap, k, i);
	}

	//topk
	int x = 0;
	while (fscanf(fout, "%d", &x) != EOF)
	{
		if (x > minHeap[0])
		{
			minHeap[0] = x;
		}

		AdjustDown(minHeap, k, 0);
	}

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

int main()
{
	//CreateNDate();
	PrintTopk("data.txt", 10);
	return 0;
}

堆与堆排序操作详解_第5张图片

如果这篇文章对你有所帮助的话,还望多多三连支持支持博主啦~~ 

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