建堆堆排序TopK问题大合集

Posted 乄北城以北乀

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了建堆堆排序TopK问题大合集相关的知识,希望对你有一定的参考价值。

一、如何建堆

1、向上调整建堆法O(NlogN)

原理:

利用向上调整的方法进行建堆,是通过模仿之前堆的插入操作,从第二个数开始,每次插入一个数,就对这个数进行向上调整,这样子既保证了原有数据为堆(即满足向上调整的条件),又保证了插入一个数据后不会破坏这个堆。这里以建大堆为例

实现逻辑:

传入孩子位置的下标,通过parent = (child-1)/ 2找到parent的下标,比较父亲和孩子的val值的大小,孩子大则交换父子,然后child变成parent,parent继续向上找parent,直到孩子变成下标为0的元素,即最大元素时停止,或者当孩子小于父亲时直接跳出。

图示:

 对于一组无序的数字  2  1  5  7  6  8  0  9  4  3  ,从i=1开始,即下标为1的第二个元素开始,模拟插入的过程。

对于第二个数据,插入前原来只有一个元素,可看作堆,放入后进行向上调整,调整完后是一个有2个元素的堆。

 插入1后不用调整即为大堆。

对于第三个数据,插入前是2个元素的堆,插入后向上调整,变成有3个元素的堆。

 调整完第四个

调整完所有元素为  9   8   7   6   5   2   0   1   4   3

 

如图所示,就得到了一个大堆。

由于每插入一个元素,都要向上调整h=logN,所以时间复杂度为O(NlogN) 

2、向下调整建堆法O(N)

前提:使用向下调整的前提是左右子树必须已经是堆的结构了,因此不能从堆顶下标为0的位置开始调整,否则会导致父子关系错乱,最后得到的结果不是堆。

原理:

对于这样一组数据,我们该怎样使用向下调整法呢?

对于叶子节点,它们没有孩子,因此没有向下调整的必要。

对于第一个非叶子节点6,它有孩子为3,且单纯的一个3可以看作是大堆,因此可以从6开始向下调整,但是6>3因此调整后的结果不变。然后对于7这个结点,左孩子9可以调整,且左右孩子均可看作是左右子树,可以向下调整。

 对于下一个结点5,左右子树8和0均可看作大堆,满足向下调整条件。

然后对于结点1,它的左右子树都是真的大堆,可以向下调整。

最后对于堆顶的结点2,左右子树均为大堆,继续调整。

 

 从第一个非叶子节点开始,位置是(n-1-1)/2,第一个减一找到范围内的第一个孩子,然后-1除2找到其父亲,即第一个非叶子节点。

对于向下调整函数内部,传入要向下调整的位置,先找到其父亲,调整范围为size个数据,child作为下标不能>=size,如果孩子比父亲大就交换,然后parent变为child,child继续找下一个child,直到child>=size,其中不满足就跳出。

同时,为了得到左孩子和右孩子的大小加一步判断,并且要防止右孩子越界,即child+1<size。

向下调整建堆的时间复杂度为O(N)。看似是N个数据,每个都是h=logN趟,但经过计算后为O(N)。对比向上建堆,上层数据个数少,调整的步数多,下层数据多,但调整的步数少,因此总的调整次数就比向上建堆少。(这是一种巧记方法) 

二、堆排序

升序  --  建大堆

降序  --  建小堆

以升序为例。如果建小堆,则只能保证堆顶的元素为最小的,满足升序,如果此时看剩下的n-1个元素,把它们看作新的堆,就会使父子关系错乱,从而不满足堆的结构。

而参照堆中pop函数的实现,我们可以先建一个大堆。

建好后将堆顶元素与最后一个元素交换,然后保证原堆顶的数在数组尾部不动,新堆顶向下调整,此时堆的范围虽然也是n-1,但是包含的是第二大到最小的元素,只有堆顶的元素变化,其它元素没有变化,堆顶的左右子树仍然满足大堆(最后一个数据不在范围内)。

然后经过向下调整,既把最大的元素放到数组尾,又将剩余元素形成了一个新堆,因此可以循环起来,不断将堆顶元素放到数组尾,最终完成排序。

 

先利用向下调整法建堆。

传入要排序的数据个数sz,end=sz-1指向最后一个元素,交换堆顶后最后一个元素,对剩下的sz-1个,即end个元素范围内的数据,对堆顶的元素向下调整。end为1时,为1 -  0的大堆,交换一下变为0-1,对于0这一个元素,向下调整不变。 

堆排序对n个元素分别向下调整,时间复杂度为O(NlogN)。

最下面一层,包含约一半的结点,都是从堆顶向下调整得到的,因此也是N*logN,与向上调整类似(巧记)。

三、堆的应用

TOP-K问题

TOP-K问题:即求数据集合中前K个最大的元素或者最小的元素,一般情况下数据量都比较大。
比如:专业前10名、世界500强、富豪榜、游戏中前100的活跃玩家等。
对于Top-K问题,能想到的最简单直接的方式就是排序,但是:如果数据量非常大,排序就不太可取了(可能数据都不能一下子全部加载到内存中)。

例如:有100亿个整型,占40个GB(实际上37多一点),而电脑的内存可能只有4-8G,无法一下存储这么多数据,后续也就不能进行访问、排序操作。

当内存存不下时,数据就存在磁盘文件中,但磁盘文件速度慢,且不能随机访问,因此不能使用简单的暴力解法。

最佳的方式就是用堆来解决,基本思路如下:
1. 用数据集合中前K个元素来建堆
前k个最大的元素,则建小堆
前k个最小的元素,则建大堆

2. 用剩余的N-K个元素依次与堆顶元素来比较,不满足则替换堆顶元素将剩余N-K个元素依次与堆顶元素比完之后,堆中剩余的K个元素就是所求的前K个最小或者最大的元素。

堆顶的数据一定是50个数据中的最大或最小,也就是Top50(以取最大的k个元素为例),外部的数如果比他大,说明已经有50个元素比堆顶元素大了,堆顶元素最大只能是top51,那么就用此元素替换堆顶元素,然后向下调整。

        向下调整的目的是保证堆顶的元素是50个中最小的,方便后续比较。如果不是最小(建大堆时),假设是所有数据中最大的元素,则其它所有元素都无法进入,最初的50个元素就被认为是top50了,显然,这是错误的。因此TOP-K-MAX--建小堆,反之建大堆。

        实际上,堆的结构有2个好处。

1、堆顶的元素是堆中k个元素的 最大/最小,选出topk时,只需要将堆顶的第50名与其它元素比较,符合则进入前50,反之继续下一个元素。

2、堆的内部结构,保证了父亲大于/小于  左右子树,因此遍历(调整)时可以依靠大小关系,将O(N)简化为O(logN),提高了效率。

 这里我rand了10000以内的10000个值,为了标记我们找到的是前5个最大的值,我将其中5个的值设置为超过10000。

函数内,先建立k个元素的小堆,然后遍历剩下n-k个元素,大于则交换,然后对堆顶元素向下调整,最后得到topk个最大值。

数据结构之堆以及topk问题

前言

博主上一小节动图演示堆讲到了通过堆的特性进行堆排序,今天博主将要提到的就是详细了解堆,以及实现堆操作


堆的结构化

既然我们知道是堆是一种特殊的二叉树,并且是用顺序表进行实现的,那么我们便尝试着用顺序表进行实现堆的高级操作,比如入堆,出堆,初始化等等.


定义堆

既然是用顺序表实现堆,那么我们便需要借助顺序表,代码如下:

typedef int HeapDataType;
typedef struct heap      //堆
{
    HeapDataType* num;     //数组
    int size;
    int capacity;
}heap;

堆的各种操作方法

既然堆是一种数据结构,那么它同样与其他数据结构一样,具有增删改查等功能,所以我们现在先声明各种方法,后面再一一实现各个方法.

堆的初始化声明:

//堆初始化主要负责把数组num中的数字转移到pheap->num中,然后把pheap->num中的数据转换为堆的逻辑结构
void HeapInit(heap* pheap,HeapDataType* num,int n);   //n是数组长度

堆的销毁

//当此堆不再使用以后就要销毁空间
void HeapDestroy(heap* pheap);

数据载入堆

//此函数作用是把新数据载入堆,并且还要保持堆的结构不被破坏
void HeapPush(heap* pheap,int n);

删除堆顶元素

//此函数作用是为了删除堆顶元素并且堆结构不能被破坏,最后还需要返回所删除的元素
HeapDataType HeapPop(heap* pheap);

判断堆中数据是否为空

bool HeapEmpty(heap* pheap);

获取堆顶元素

HeapDataType HeapTop(heap* pheap);

获取堆的大小

int HeapSize(heap* pheap);

此外,没有看博主上一节文章动图演示堆排序的小伙伴先看下堆排序哦,下面博主会直接贴出向下调整算法,不再解释了哦~~

向下调整算法:

void AdjustDown(int num[], int n, int parent)
{
	int child = parent * 2 + 1;
	while (child < n)
	{
		if (child + 1 < n && num[child] < num[child + 1])
		{
			child++;
		}

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

堆操作之初始化

堆初始化主要负责把数组num中的数字转移到pheap->num中,然后把pheap->num中的数据转换为堆的逻辑结构

所以涉及的内容为数组拷贝(挨个赋值也可以),动态空间开辟,向下调整算法进行建堆

void HeapInit(heap* pheap, HeapDataType* num, int n)
{
	assert(pheap);
	//第一步,开辟空间
	HeapDataType* tmp = (HeapDataType*)malloc(sizeof(HeapDataType) * n);
	if (tmp == NULL)
	{
		printf("空间开辟失败,抱歉!\\n");
		exit(-1);
	}
	pheap->num = num;
	pheap->size = pheap->capacity = n;   //初始化数组大小和容量

	//第二步,拷贝数组.
	memcpy(pheap->num, num, sizeof(HeapDataType) * n);

	//第三步,建堆.
	for (int parent = (n - 1 - 1) / 2; parent >= 0; parent--)
	{
		AdjustDown(pheap->num, n, parent);
	}
}

堆操作之销毁空间

堆销毁空间很简单,直接free掉num就行

void HeapDestroy(heap* pheap)
{
	assert(pheap);
	free(pheap->num);
	pheap->num = NULL;
}

堆操作之入堆

该函数的要求是数据必须进入堆,并且不能毁掉堆的特性.大家想想有什么办法可以解决?

答案是进行向上调整,过程如下图(以小堆为例子):

观察上图,我们发现向上调整的步骤是:

  • 数据首先载入最后
  • 与其双亲结点进行比较,如果比双亲结点小,就交换其值,一直不断重叠
  • 如果该数据比双亲结点值大,就结束调整;如果当child等于0,就结束调整

所以代码如下:

void HeapPush(heap* pheap, int n)
{
	assert(pheap);

	//第一步,需要检查堆空间是否足够继续存储新数据,不足时候句增加空间,这一步很多人总是忽略
	if (pheap->size == pheap->capacity)
	{
		HeapDataType* tmp = (HeapDataType*)realloc(pheap->num, pheap->capacity * 2 * sizeof(HeapDataType));
		if (tmp == NULL)
		{
			printf("空间不足,系统尝试增容,但是抱歉,增容失败.\\n");
			exit(-1);
		}
		pheap->capacity *= 2;
	}

	//数据入堆
	pheap->num[pheap->size] = n;
	pheap->size++;

	//开始向上调整
	AdjustUp(pheap->num, pheap->size-1);
}

上面我们可以把向上调整写成一个函数

void AdjustUp(HeapDataType num[], int child)
{
	int parent = (child - 1) / 2;
	while (child > 0)   //主要动的就是child位置,所以child>0
	{
		if (num[child] < num[parent])
		{
			Swap(&num[child], &num[parent]);   //自己写一个交换函数
			child = parent;
			parent = (child - 1) / 2;
		}
		else
		{
			break;
		}
	}
}

堆操作之出堆

该函数的作用是删除堆顶元素,并且不能毁坏堆结构,大家想想有什么办法呢?

答案是,借助堆排序的思想,先把堆顶元素与最后一个元素交换,然后不管最后一个元素,重新进行向下调整.

仍然以小堆为例,看下图演示:

HeapDataType HeapPop(heap* pheap)
{
	assert(pheap);
	assert(!HeapEmpty(pheap));
	//保存需要删除的值
	HeapDataType return_value = pheap->num[0];

	//交换首位
	Swap(&pheap->num[0], &pheap->num[pheap->size - 1]);
	pheap->size--;   //当size减一就代表着已经删除了最后一个值.

	//向下调整
	AdjustDown(pheap->num, pheap->size, 0);

	//返回
	return return_value;
}

堆操作之判空

bool HeapEmpty(heap* pheap)
{
    assert(pheap);
    return pheap->size == 0;
}

堆操作之获取堆顶

HeapDataType HeapTop(heap* pheap)
{
    assert(pheap);
    assert(!HeapEmpty(pheap));
    return pheap->num[0];
}

堆操作之获取大小

int HeapSize(heap* pheap)
{
    assert(pheap);
    return pheap->size;
}

堆结构练习:获取前k个最小或最大元素

题目:

假设有数组num,其内容的定义如下:

#include <time.h>
int main()
{
    int num[10000] = {0};
	srand(time(NULL));
    for(int i = 0;i<10000;i++)
    {
        num[i] = rand() % 10000;  //保证数组中每个元素都是小于10000;
    }
    
    for(int i = 0;i<10;i++)
    {
        num[rand() % 10000] = rand()%10 + 10001;  //随机给数组赋值10个大于10000的数.
    }
}

要求:写一个算法,求出该数组前10个大于10000的数.

而这我们就可以利用堆的特性,因为堆的最值永远在堆顶,所以每次获取删除堆顶的元素就行

void  SetNarry(int num[])
{
    srand(time(NULL));
    for (int i = 0; i < 10000; i++)
    {
        num[i] = rand() % 10000;  //保证数组中每个元素都是小于10000;
    }

    for (int i = 0; i < 10; i++)
    {
        int ret = 0;
        num[ret = rand() % 10000] = rand() % 10 + 10001;  //随机给数组赋值10个大于10000的数.
    }
}

int main()
{
    heap hp = { 0 };
    int  num[10000] = { 0 };

    SetNarry(num); //给数组赋值

    HeapInit(&hp, num,10000); //变成堆,初始化函数里面的向下调整算法注意修改成大堆算法哦

    for (int i = 0; i < 10; i++)
    {
        printf("%d ", HeapPop(&hp));
    }
    return 0;
}

测试:

成功

我们分析下这种算法的时间复杂度是多少?

建堆时间复杂度为O(N) , 删除堆顶时间复杂度复杂为O(k * logN),所以最后时间复杂度为O(N+k * logN).

现在我们对数据升级了,假设有100亿个数据,电脑内存存不下了,请问该怎样利用堆特性解决?

这是一道思考题,博主就不赘述了,大家仔细想想哦~~

以上是关于建堆堆排序TopK问题大合集的主要内容,如果未能解决你的问题,请参考以下文章

排序4-堆排序与海量TopK问题

数据结构之堆以及topk问题

堆排序+TOPK问题

数据结构之堆的应用—TopK问题

PHP利用二叉堆实现TopK-算法的方法详解

那些经典算法:堆排序应用