堆的应用
上一篇文章介绍了堆是什么,并且说过他在排序方面和TopK问题有着不俗的表现,现在我来详细说明堆在这两个部分中的应用。
1.堆排序
a.引入
我们先来看看堆是如何在拿出堆中的数据是有序拿出(这里的拿出是拿到数组的结尾了)的。以下图中的堆为例:
当我们拿出堆顶的数据后会发生下面一系列过程:
向下调整算法会将根节点与根节点的孩子节点中较大的节点进行迭代轮换(假设节点为10在顺序表中是末尾数据)。我们可以发现当调整完之后会将堆中最大的数给置到堆顶。这就是排序的体现。
b.分析
上面展示了堆可以将一组数据排序拿出,但是如果在进行解决排序问题的时候,我们难道将堆的整个实现搬到题中,还得开辟额外的空间来维护堆然后再取出放到原数组吗?显然这并不能突出他在排序上的突出之处,这样会浪费空间。解决方法是我们可以将原数组就当成一个待处理的堆来将他进行堆的相关操作后将他变成堆,然后在使用堆的相关操作实现排序。
这里就只需要用到向上调整和向下调整算法来对整个数组进行操作,上篇文章用向上调整来维护堆的数据插入,向下调整来维护堆的数据删除,其实向下调整也可以用来实现堆的数据插入
这两个调整的区别是:向上调整需要原来的数据构成堆,向下调整需要子树构成堆。需要注意的是:只有一个节点时默认成堆。
c.实现
下面我们以一组数据来实现不同调整方法建堆
向上和向下调整
代码如下:
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
#include <stdbool.h>
#include <time.h>
typedef int HeapData;
typedef struct Heap
{
HeapData* data;
int size;
int capcity;
}Hp;
void Swap(HeapData* x, HeapData* y)
{
HeapData tmp = *x;
*x = *y;
*y = tmp;
}
void AdjustUp(HeapData* a, int child)
{
int parent = (child - 1) / 2;
while (child > 0)
{
if (a[child] < a[parent])
{
Swap(&a[child], &a[parent]);
child = parent;
parent = (parent - 1) / 2;
}
else
{
break;
}
}
}
void AdjustDown(HeapData* 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[parent] > a[child])
{
Swap(&a[parent], &a[child]);
parent = child;
child = child * 2 + 1;
}
else
{
break;
}
}
}
void HeapSort(int* a, int sz)
{
//构建堆
//1.向上调整构建
int i = 0;
for (i = 1; i < sz; i++)
{
AdjustUp(a, i);
}
//2.向下调整构建
//for (i = (sz - 1 - 1) / 2; i >= 0; i--)
//{
// AdjustDown(a, sz, i);
//}
for (i = sz - 1; i >= 0; i--)//堆排序
{
Swap(&a[0], &a[i]);
AdjustDown(a, i, 0);
}
}
int main()
{
int a[] = { -96,-91,-90,-87,54,77,-85,-84,72,85};
HeapSort(a, sizeof(a) / sizeof(a[0]));
return 0;
}
可以发现,在使用向上调整时是从数组的第二位开始的,是因为只有一个数据时就是堆,所以当我们开始从第二个数据开始向上调整时可以保证调整前就是堆。向下调整时是从最后一个开始的,这样调整时可以保证调整当前操作的根节点的两个子树是堆。
排序的工作是让向下调整来实现,在对的数据结构中我们是从堆顶来拿出那个极值,上面代码建的是小根堆,假如我们要对那组数据升序排序就需要将最小的放在数组的开头然后依次,但是小堆取出的数据就是最小的并且在数组的开头,那我们要找出第二小的呢?我们会发现我们会覆盖前面那个最小的数据,所以我们可以得出结论:
升序建大堆,降序建小堆
堆排序时间复杂度最快的方式是O(nlogn)。
TopK问题
TopK问题就是在一堆数据中找出前k大/小的数据,如果我们用暴力的方法,假设数据量为N,需要的时间大约是10N,但是如果用堆来处理只需要N。处理方法就是建一个大小为k的堆然后让堆顶遍历数组填充,若是前k小个,就用大根堆。相反前k大个建小根堆,因为如果反着来的话,以前k小个举例,建小堆的话堆顶就是整个数据中最小的,这样会导致前k小的数据无法都进入到堆中。
TopK问题的代码解决
下面我们来解决这个问题,在此之前我们需要大量的数据,这时候需要使用srand函数获取时间戳来制造伪随机树,来生成数据。生成数据的代码如下:
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;
}
for (size_t i = 0; i < n; ++i)
{
int x = rand() % 1000000;
fprintf(fin, "%d\n", x);
}
fclose(fin);
}
这个代码是将生成的随机数生成到当前代码目录下名为data.txt的文本中(如果没有会自动创建文件)。
然后我们来实现解决问题的代码,代码如下:
void PrintTopK(int k)
{
int* a = (int*)malloc(sizeof(int) * k);
if (a == NULL)
{
perror("malloc fail");
return;
}
FILE* pf = fopen("data.txt", "r");
int i = 0;
for (i = 0; i < k; i++)
{
fscanf(pf, "%d", &a[i]);
}
for (i = (k - 1 - 1) / 2; i >= 0; i--)
{
AdjustDown(a, k, i);
}
while (!feof(pf))
{
int val;
fscanf(pf, "%d", &val);
if (val > a[0])
{
a[0] = val;
AdjustDown(a, k, 0);
}
}
for (i = 0; i < k; i++)
{
printf("%d\n", a[i]);
}
free(a);
a = NULL;
fclose(pf);
pf = NULL;
}
int main()
{
//CreateNDate();//这个在执行TopK函数时不需要再执行,要不然会让文件里的数据重新生成
PrintTopK(5);
return 0;
}
为了验证结果的正确性,我们在创建数据的函数中限制了数据的大小,这样我们可以创建好数据后,手动将文件里的某k个数据修改成整形可以存储的范围内的比限制数大的数,这样在我们打印结果数据时,要是打印的这几个数据,就说明我们的代码是正确的。这样我们就可以成功的解决这个问题了。
堆排序的时间复杂度分析
我在上面的文章里加亮了两个字:“最快”,下面我们来分析一下,在堆排序过程中的时间复杂度。在下列过程中我们以一颗满二叉树(高度为h)来进行举例:
分析
在堆排序中,排序时用的向下调整,我们先来分析这一部分,我们知道满二叉树的节点总数是等比为二的前n项和,是2^h-1个节点,向下调整时底层的每个数据都要和堆顶的数据交换后再调整堆顶的数据,调整次数是最大h,当倒数第二层的数据开始和堆顶交换数据时,堆顶数据调整最大需要调整h-1次…(这里的交换就完成了排序,每次把极值运输到数组的相对的末位)。那么需要时间消耗:
F(h) = h * 2^(h-1) + (h-1) * 2^(h-2) + … + 2 * 2^(2-1) + 1 * 2(1-1) = (H-2) * 2^h。
转换成数据量为N,那么需要的时间消耗是:log(N+1) * (N+1),时间复杂度:O(nlogn)。
然后再分析建堆时的两种调整方法,首先是向上调整:
向上调整建堆从树的第二层开始,第二层最多调整1次,第三层最多调整2次…第h-1层最多调整h-2次,第h层调整h-1次,就可以得到时间消耗:
F(h) = 1 * 2^(2-1) + 2 * 2^(3-1) + … + (h-2) * 2^(h-2) + (h-1) * 2^(h-1) = (h-2) * 2^h + 2。同样再替换得到:l(og(N+1) + 2) * (N + 3),得到时间复杂度:O(nlogn).
向下调整:从最后一层开始调整最多需要调整0次,倒数第二层最多需要调整1次…第二层最多需要调整h-2次,第一层最多需要h-1次,得出时间消耗:
F(h) = 1 * 2^(h-1) + 2 * 2^(h-2) + … + (h-2) * 2^(2-1) + (h-1) * 2^(1-1) = 2^(h+1) - h - 1;
换成N个数据:N+1 - log(N+1) - 1,时间复杂度:O(n)。
可以看到,在计算时间复杂度的过程中向下调整建堆的时间消耗要优于向上调整建堆,所以,我们在面对排序问题时,只需要使用一个向下调整函数就可以实现对数组的排序了 。
这就是我对堆的相关应用的认识,如果有不对的地方,望指正。