【数据结构】堆——堆排序与海量TopK问题


前言

上一篇我们学习了堆的数据结构,现在我们来看看堆的日常应用和排序


一、堆排序

首先,我们不能因为排个序,而写一个堆的数据结构,过于麻烦
但是正常我们排序的数组都是乱序而且并不是堆结构
而使用堆排序之前,必须用到向下调整算法
向下调整算法:该结点的左右子树都必须是小堆或大堆才能使用

在这里插入图片描述

经过上面的结论,我们得出第一步需要建堆

通过向下调整,从倒数第一个非叶子节点开始调整,因为你从顶开始向下调整是错误的,必须左右子树都是小堆或大堆,默认乱序是不能调整的。

在这里插入图片描述

  • 为什么不从最后一个节点开始调?

    • 最后的都是叶子结点不用调整,因为没有左右子树,默认是堆结构

现在又多了一个问题,排升序到底是建大堆还是建小堆呢?

建大堆,为什么不建小堆呢,小堆堆顶就是最小值,最小值不正好在最前面吗,为什么要建大堆?

  • 如果建小堆的话,会破坏堆的结构,又需要重新选择根,之前堆的关系都会乱掉,导致我们辛辛苦苦的建的堆结构浪费了
    在这里插入图片描述
    建好了大堆,我们改如何进行排序呢?
    堆顶是最大值,我们需要将堆顶的数据和最后一个数据交换,但是同样也破坏了大根堆的结构,但是堆顶数据依旧能使用向下调整算法
    在这里插入图片描述

1.1 整体思路

  1. 建堆,排升序:建大堆,排降序:建小堆
  • 通过向下调整,从倒数第一个非叶子节点开始调整
  1. 堆结构建立完,就排序
  • 堆顶的数据和最后一个数据交换,排好的数据排除掉(缩小区间,end–),在重新向下调整把次大的数据放到堆顶,直到堆顶结束(end>0)

1.2 代码部分

// 交换
void Swap(int* pa, int* pb)
{
	int tmp = *pa;
	*pa = *pb;
	*pb = tmp;
}

// 向下调整
// 大堆
void AdjustDown(int* a, int n, int parent)
{
	int child = parent * 2 + 1;
	
	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 HeapSort(int* a, int n)
{
	// 建大堆,从最后非叶子结点开始到第一个结点
	for (int i = (n - 1 - 1) / 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--;					// 已经排好的数据,给排除
	}
}

1.3 建堆的时间复杂度

时间复杂度是求最坏的情况,我们假设需要建一颗满二叉树的堆,满二叉树也是特殊的完全二叉树,从最后一个非叶子结点开始调整一共需要调整多少次呢?
在这里插入图片描述

通过上图我们等到了一个求全部节点的最大调整等式
在这里插入图片描述
此时该等式是由数列组成,等比数列和等差数列,这里我们运用错位相减法乘以q公比2,得出新等式②减去等式①

在这里插入图片描述

如图得出建堆的时间复杂度是O(N)

1.4 堆排序的总结

建堆的时间复杂度是O(N),排序的时间复杂度是O(n * logn),整体是O(n * logn),具体怎么算出来的大家可以看下面内容。
建堆需要从倒数第一个非叶子结点开始调整,从后往前,直到堆顶的数据

  • 最后一个非叶子结点的计算方式:n - 1是最后一个节点的下标,父亲 = (孩子 - 1) / 2,所以(n - 1 - 1) / 2

排序中向下调整注意事项:

  • end是最后一个数据的下标,我们先交换,在向下调整,就传过去了end(n-1)个要调整的数据,正好排除掉了最后已经排序好的数据
  • 调整的次数:end > 0,即可,不用>=0。end == 0 就是自己和自己交换和调整。

测试
在这里插入图片描述

二、向下调整算法的时间复杂度

堆顶开始调整是最坏的情况,有3层需要调2次,得出h层,最多需要调h-1次, l o g 2 N + 1 log_2N+1 log2N+1,得出时间复杂度为 O ( l o g 2 N ) O(log_2N) O(log2N)
在这里插入图片描述

堆排序时间复杂度解析
一个数调整一次是 O ( l o g 2 N ) O(log_2N) O(log2N),排序的时候除了叶子节点不能调整,其他结点都需要调整,叶子可以忽略不计,所以排序的时间复杂度是 O ( N ∗ l o g 2 N ) O(N*log_2N) O(Nlog2N),建好堆和排完序一共时间复杂度是 O ( N + N ∗ l o g 2 N ) O(N +N*log_2N) O(N+Nlog2N),但是取最大阶,所以整体是 O ( N ∗ l o g 2 N ) O(N*log_2N) O(Nlog2N)

三、向上调整算法的复杂度

最后一个节点开始调整是最坏的情况,有3层需要调2次,得出h层,最多需要调h-1次, l o g 2 N + 1 log_2N+1 log2N+1,得出时间复杂度为 O ( l o g 2 N ) O(log_2N) O(log2N)
在这里插入图片描述

向上调整的主要用途是在插入数据后进行调整,让新数据插入进来一直保持堆结构

四、海量TopK问题

4.1 TopK题目

海量Topk题目
求出最小的k个数,输入n个整数,如:1,3,5,7,2,4,6,8,k = 4,返回1,2,3,4
面试里经常遇到这样的问题:有一亿个浮点数,如何找出其中最大的10000个?

这类问题,我们就称为海量TopK问题:指从大量数据(源数据)中获取最大(或最小)的K个数据。

在这里插入图片描述

思路:

  1. 我们先开辟一个k个数据的数组,应为在函数内不能创建临时变量,要用malloc
  2. 让n(源数据)的前k个拷到topK数组里,为了作建堆的数据来源
  3. 把当前topK数组调整成大堆结构
  4. 正常情况:应该是建小堆,然后用剩下的数据比哪个更小,剩下的数据更小就放到堆顶,然后就调整就能保持堆的结构,同时最小的数就排到堆顶
  • 这里用的我用的逆向,建的大堆,用大堆的堆顶(最大值)和剩下的数比较,堆顶大就交换,最终目的是让剩下的数据都比大堆堆顶(堆的最大值)大。
  • 这样就能满足topK数组是最小的k个数,因为最后大堆的堆顶(堆内的最大值)都是比剩下的数据小了,肯定就是最小的前k个数了

时间复杂度是 O ( n ∗ l o g 2 K ) O(n*log_2K) O(nlog2K),空间复杂度为 O ( K ) O(K) O(K)

// 交换
void Swap(int* pa, int* pb)
{
	int tmp = *pa;
	*pa = *pb;
	*pb = tmp;
}

// 向下调整
// 大堆
void AdjustDown(int* a, int n, int parent)
{
	int child = parent * 2 + 1;
	
	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;
		}
	}
}

int* smallestK(int* arr, int arrSize, int k, int* returnSize){
		
    // 确定返回个数
    *returnSize = k;
    
    // k为0,直接返回空,不判定会越界
    if (k <= 0)
    {
        return NULL;
    }
		
    // 开辟k个数据空间,因为需要返回指针,不能创建临时变量
    int* topK = (int*)malloc(k * sizeof(int));

    // 将数组的前k个放入topk数组
    for (int i = 0; i < k; i++)
    {
        topK[i] = arr[i];
    }
	
    // 当前topK数组调整成大堆结构
    for (int i = (k - 1 - 1) / 2; i >= 0; i--)
    {
        AdjustDown(topK, k, i);
    }

    // 拿剩下arrSize和topK数组比较
    // 正常情况:应该是建小堆,然后用剩下的数据比哪个更小
    // 剩下的数据更小就放到堆顶,然后就调整就能保持堆的结构,同时最小的数就排到堆顶

    // 我用的逆向,建的大堆,用大堆的堆顶(最大值)和剩下的数比较,堆顶大就交换,最终目的是让剩下的数据都比大堆堆顶(堆的最大值)大,这样就能满足topK数组是最小的k个数,因为最后大堆的堆顶(堆内的最大值)都是比剩下的数据小了
    for (int i = k; i < arrSize; i++)
    {
        // 堆顶大,就剩下的小数据进来堆在调整
        if (topK[0] > arr[i])
        {
            topK[0] = arr[i];
            AdjustDown(topK, k, 0);
        }
    }
    return topK;
}

在这里插入图片描述
类似的TopK问题还有:

  • 有10000000个记录,这些查询串的重复度比较高,如果除去重复后,不超过3000000个。一个查询串的重复度越高,说明查询它的用户越多,也就是越热门。请统计最热门的10个查询串,要求使用的内存不能超过1GB。
  • 有10个文件,每个文件1GB,每个文件的每一行存放的都是用户的query,每个文件的query都可能重复。按照query的频度排序。
  • 有一个1GB大小的文件,里面的每一行是一个词,词的大小不超过16个字节,内存限制大小是1MB。返回频数最高的100个词。
  • 提取某日访问网站次数最多的那个IP。
  • 10亿个整数找出重复次数最多的100个整数。
  • 搜索的输入信息是一个字符串,统计300万条输入信息中最热门的前10条,每次输入的一个字符串为不超过255B,内存使用只有1GB。
  • 有1000万个身份证号以及他们对应的数据,身份证号可能重复,找出出现次数最多的身份证号。

总结

通过近期堆方面的两篇文章,想必大家也对堆有了一定了解,堆排序和TopK问题是重中之重,而堆在处理大量数据时候能快速解决问题,大家一定要熟练掌握

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值