【算法】—— Top K问题
目录
一、Top K问题描述
给定一个很大的数组,要求找出最大或最小的前K个数
为了体现出数组的大,和较为直观的观察结果,采用了文件存储大量数据,要取数组版的代码直接跳转到本文末尾
二、解题思路
1. 解题步骤
这里以找出堆最大的前5个值为例子
1.1 建堆
利用向下调整算法将堆的前5个数值依次建成小根堆
2.2 选数
遍历第K个以后的数,若是大于小根堆的堆顶,则进堆并向下调整
- 从k+1个数据开始遍历,遍历到值为6
- 6大于堆顶值,将6写入堆顶并向下调整成堆
- 当所有数组遍历完毕后,则堆中的数据就是最大的前5个数
2. 算法效率
2.1 时间复杂度
- 创建数组并将k个元素复制到数组中需要执行k次
- 遍历k到结束位置的数组需要执行k次,每次遍历向下调整时最多交换 l o g 2 k log_2k log2k次
- 所以一共执行 K + l o g 2 K × ( N − K ) ≈ N K+log_2K\times(N-K)\approx N K+log2K×(N−K)≈N次
时间复杂度为: O ( n ) O(n) O(n)
2.2 空间复杂度
空间只有创建的k个元素的数组,所以空间复杂度: O ( k ) O(k) O(k)
三、代码实现(参数为文件)
注意:向下调整函数的实现在下面单独给出
void PrintTopK(const char* filename, int k)
{
//获取文件
FILE* pf = fopen(filename, "r");
if (pf == NULL)
{
perror("fopen fail");
exit(-1);
}
//创建数组
int* minHeap = (int*)malloc(sizeof(int) * k);
if (minHeap == NULL)
{
perror("malloc fail");
exit(-1);
}
//读取文件前k个数
int i = 0;
for (i = 0; i < k; i++)
{
fscanf(pf, "%d ", &minHeap[i]);
}
//向下调整建堆
for (i = (k - 2) / 2; i >= 0; i--)
{
AdjustDown(minHeap, k, i); //调用向下调整函数
}
//读取k到N个数
int data = 0;
while (fscanf(pf, "%d ", &data) != EOF)
{
if (minHeap[0] < data)
{
minHeap[0] = data;
AdjustDown(minHeap, k, 0);
}
}
//打印最大前k个数
for (i = 0; i < k; i++)
{
printf("%d ", minHeap[i]);
}
free(minHeap);
fclose(pf);
}
向下调整函数:
void AdjustDown(HPDataType* data, int size, int parent)
{
int minchild = parent * 2 + 1;
while (minchild < size)
{
if (minchild + 1 < size && data[minchild] > data[minchild + 1])
{
minchild++;
}
if (data[minchild] < data[parent])
{
Swap(&data[minchild], &data[parent]);
parent = minchild;
minchild = parent * 2 + 1;
}
else
{
break;
}
}
}
四、运行实例
1. 数组文件
创建一个txt文件,并写一个函数在文件写入0到99999共100000个整数,中间使用空格隔开,没有换行符
2. 函数调用
将数组文件传进去,并令k为10,打印最大的前10个数字
int main()
{
char* filename = "array.txt";
PrintTopK(filename, 10);
}
3. 运行结果
打印出了文件中最大的10个数字
五、相关问题
1. 建堆和选数的原则
建堆原则:
- 选出最大的前k个数建小根堆:小根堆堆顶最小,选数时选择比堆顶还小的一定比堆的所有节点小,不入堆
- 选出最小的前k个数建大根堆:大根堆堆顶最大,选数时选择比堆顶还大的一定比堆的所有节点大,不入堆
选数原则:
- 选最大前k个数选择比堆顶大的,小根堆堆顶最小,被比较的数若大于堆顶则入堆,淘汰最小值
- 选最大前k个数选择比堆顶小的,大根堆堆顶最大,被比较的数若小于堆顶则入堆,淘汰最大值
2. 调整方式的选择
将数组构建成堆时,可以使用向上调整或向下调整,但是我们选择的是向下调整,因为向下调整时间效率更高
1. 向上调整
将数组第1个元素作为一个堆,从第2个元素到第n-1个元素依次进行向上调整。
int i = 0;
for (i=1; i<n; i++)
{
AdjustUp(arr, i); //从1到n-1进行调整
}
向上调整的时间复杂度为 O ( l o g 2 n ) O(log_2n) O(log2n)
所以使用向上调整建堆的时间复杂度为 O ( n l o g 2 n ) O(nlog_2n) O(nlog2n)
2. 向下调整
- 向下调整是从后往前调整,先将后面构成堆,再调整上面的节点
- 以叶子节点作为根进行向下调整是完全没有必要的,叶子节点没有子节点,所以对最后一个叶子节点的父节点开始向下调整
- 最后一个节点下标是n-1,它的父节点为 (n-1-1) / 2
int i = 0;
for (i=(n-2)/2; i>=0; i--)
{
AdjustDown(arr, n, i); //从 (n-2)/2 到 0 进行调整
}
时间复杂度计算
则需要移动节点的步数为
T n = 2 0 × ( h − 1 ) + 2 1 × ( h − 2 ) + 2 2 × ( h − 3 ) + . . . + 2 h − 2 × 1 T_n=2^0\times(h-1)+2^1\times(h-2)+2^2\times(h-3)+...+2^{h-2}\times1 Tn=20×(h−1)+21×(h−2)+22×(h−3)+...+2h−2×1
这是一个等差数列乘以一个等比数列,使用裂项相消法进行化简
2 T n = 2 1 × ( h − 1 ) + 2 2 × ( h − 2 ) + 2 3 × ( h − 3 ) + . . . + 2 h − 1 × 1 2T_n=2^1\times(h-1)+2^2\times(h-2)+2^3\times(h-3)+...+2^{h-1}\times1 2Tn=21×(h−1)+22×(h−2)+23×(h−3)+...+2h−1×1
2 T n − T n = − h + 2 0 + 2 1 + 2 2 + . . . + 2 h − 1 + 2 h − 2 × 1 2T_n-T_n=-h+2^0+2^1+2^2+...+2^{h-1}+2^{h-2}\times1 2Tn−Tn=−h+20+21+22+...+2h−1+2h−2×1
T n = − h + 2 h − 1 T_n=-h+2^h-1 Tn=−h+2h−1
因为 n = 2 h − 1 n = 2^h-1 n=2h−1, h = l o g 2 ( n + 1 ) h=log_2{(n+1)} h=log2(n+1)
T n = n − l o g 2 ( n + 1 ) ≈ n T_n=n-log_2(n+1)\approx n Tn=n−log2(n+1)≈n
向下调整的时间复杂度为 O ( n ) O(n) O(n)
因此向下调整建堆的时间效率比向上调整建堆的时间效率更高
六、参数为数组的代码实现
void PrintTopK(const int* arr, int size, int k)
{
//创建堆数组
int* minHeap = (int*)malloc(sizeof(int) * k);
if (minHeap == NULL)
{
perror("malloc fail");
exit(-1);
}
//复制前k个数到堆数组
int i = 0;
for (i = 0; i < k; i++)
{
minHeap[i] = arr[i];
}
//向下调整建堆
for (i = (k - 2) / 2; i >= 0; i--)
{
AdjustDown(minHeap, k, i); //调用向下调整函数
}
//遍历k到N个数
for (i = k; i < size; i++)
{
if (arr[i] > minHeap[0])
{
minHeap[0] = arr[i];
AdjustDown(minHeap, k, 0);
}
}
//打印最大前k个数
for (i = 0; i < k; i++)
{
printf("%d ", minHeap[i]);
}
free(minHeap);
}