目录
- 前言
- 一、堆排序
- 1.1 整体思路
- 1.2 代码部分
- 1.3 建堆的时间复杂度
- 1.4 堆排序的总结
- 二、向下调整算法的时间复杂度
- 三、向上调整算法的复杂度
- 四、海量TopK问题
- 4.1 TopK题目
- 总结
前言
上一篇我们学习了堆的数据结构,现在我们来看看堆的日常应用和排序
一、堆排序
首先,我们不能因为排个序,而写一个堆的数据结构,过于麻烦
但是正常我们排序的数组都是乱序而且并不是堆结构
而使用堆排序之前,必须用到向下调整算法
向下调整算法:该结点的左右子树都必须是小堆或大堆才能使用
经过上面的结论,我们得出第一步需要建堆
通过向下调整,从倒数第一个非叶子节点开始调整,因为你从顶开始向下调整是错误的,必须左右子树都是小堆或大堆,默认乱序是不能调整的。
-
为什么不从最后一个节点开始调?
- 最后的都是叶子结点不用调整,因为没有左右子树,默认是堆结构
现在又多了一个问题,排升序到底是建大堆还是建小堆呢?
建大堆,为什么不建小堆呢,小堆堆顶就是最小值,最小值不正好在最前面吗,为什么要建大堆?
- 如果建小堆的话,会破坏堆的结构,又需要重新选择根,之前堆的关系都会乱掉,导致我们辛辛苦苦的建的堆结构浪费了
建好了大堆,我们改如何进行排序呢?
堆顶是最大值,我们需要将堆顶的数据和最后一个数据交换,但是同样也破坏了大根堆的结构,但是堆顶数据依旧能使用向下调整算法
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(N∗log2N),建好堆和排完序一共时间复杂度是
O
(
N
+
N
∗
l
o
g
2
N
)
O(N +N*log_2N)
O(N+N∗log2N),但是取最大阶,所以整体是
O
(
N
∗
l
o
g
2
N
)
O(N*log_2N)
O(N∗log2N)
三、向上调整算法的复杂度
从最后一个节点开始调整是最坏的情况,有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个数据。
思路:
- 我们先开辟一个k个数据的数组,应为在函数内不能创建临时变量,要用malloc
- 让n(源数据)的前k个拷到topK数组里,为了作建堆的数据来源
- 把当前topK数组调整成大堆结构
- 正常情况:应该是建小堆,然后用剩下的数据比哪个更小,剩下的数据更小就放到堆顶,然后就调整就能保持堆的结构,同时最小的数就排到堆顶
- 这里用的我用的逆向,建的大堆,用大堆的堆顶(最大值)和剩下的数比较,堆顶大就交换,最终目的是让剩下的数据都比大堆堆顶(堆的最大值)大。
- 这样就能满足topK数组是最小的k个数,因为最后大堆的堆顶(堆内的最大值)都是比剩下的数据小了,肯定就是最小的前k个数了
时间复杂度是 O ( n ∗ l o g 2 K ) O(n*log_2K) O(n∗log2K),空间复杂度为 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问题是重中之重,而堆在处理大量数据时候能快速解决问题,大家一定要熟练掌握