携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第22天,点击查看活动详情
前言
在讲完线性表后,接下来要介绍树结构中的二叉树,本文是基于C语言实现的。
本文就来分享一波作者对数据结构二叉树的学习心得与见解。本篇属于第九篇,继续介绍二叉树的顺序结构——堆相关内容,建议阅读本文之前先把前面的文章看看。
笔者水平有限,难免存在纰漏,欢迎指正交流。
原地建堆
下面我们给出一个数组,这个数组逻辑上可以看做一颗完全二叉树,但是还不是一个堆,现在我们通过算法,把它构建成一个堆。根节点左右子树不是堆,我们怎么调整呢?
向下调整算法建堆
向下调整算法有一个前提:左右子树必须是一个堆,才能调整。 如果是从上向下进行的话,不能保证每一个结点的左右子树都是堆,不过最下面一层不需要调整,因为它们是都是叶结点,本身就是堆,那不妨就从下向上进行调整。
这里我们从倒数的第一个非叶结点的子树开始调整,使用向下调整算法,每次调整完后目标下标减1,然后再调整......一直循环过程到根节点的树,就可以调整成堆。
比如下图建大根堆:
代码实现
void Swap(HPDataType* x, HPDataType* y)
{
HPDataType tmp = *x;
*x = *y;
*y = tmp;
}
void AdjustDown(HPDataType* arr, int sz, int parent)
{
int minChild = parent * 2 + 1;
while (minChild < sz)
{
if (minChild + 1 < sz && arr[minChild + 1] < arr[minChild])
++minChild;
if (arr[minChild] < arr[parent])
{
Swap(&arr[minChild], &arr[parent]);
parent = minChild;
minChild = parent * 2 + 1;
}
else
break;
}
}
void HeapCreateDown(HPDataType* arr, size_t n)
{
for(int i = (n - 1 - 1) / 2; i >= 0; --i)
{
AdjustDown(arr, n, i);
}
}
时间复杂度计算
用满二叉树来看最坏情况:
计算得到时间复杂度仅为:O(n)。
层数越深,结点越多,单个结点需要调整次数越少;层数越浅,结点越少,单个结点需要调整次数越多。
向上调整算法建堆
从上向下进行向上调整算法,初始状态下数组第一个元素可以看成堆,根据要建成大根堆还是小根堆确定向上调整函数的比较逻辑,然后从第二个元素开始进行向上调整,接着到第三个......依次类推,每次向上调整都是一次元素插入,并且满足大根堆或小根堆的要求。
代码实现
void Swap(HPDataType* x, HPDataType* y)
{
HPDataType tmp = *x;
*x = *y;
*y = tmp;
}
void AdjustUp(HPDataType* arr, int child)
{
int parent = (child - 1) / 2;
while (child > 0)
{
if (arr[child] < arr[parent])
Swap(&arr[child], &arr[parent]);
else
break;
child = parent;
parent = (child - 1) / 2;
}
}
void HeapCreateUp(HPDataType* arr, size_t n)
{
for(int i = 1; i < n; i++)
{
AdjustUp(arr, i);
}
}
时间复杂度计算
用满二叉树来看最坏情况:
计算得到时间复杂度为O(n*logn)。
层数越深,结点越多,单个结点需要调整次数越多;层数越浅,结点越少,单个结点需要调整次数越少。
堆的应用
堆排序
本质上是一种选择排序,依次选数,从后往前排。
不建议新建一个堆来实现堆排序,没有必要再手搓一个堆出来,一是麻烦,二是空间复杂度会变成O(n)。
堆排序即利用堆的思想来进行排序,总共分为两个步骤:
-
原地建堆
升序:建大堆
降序:建小堆
-
利用堆删除思想来进行排序
建堆和堆删除中都用到了向下调整,因此掌握了向下调整,就可以完成堆排序。
比如:要求升序的话,每次都把堆顶元素和最后的元素互换,然后最后一个元素不看作堆里的,堆顶向下调整,选出次大的,后续依次类似处理(要求降序的话思路类同,就是每次把最小的换到后面去顺便选出次小的)。实际上就是从后向前排序。
如图所示:
代码实现
前面写的AdjustDown( )用的是针对小根堆的逻辑,适合降序使用,如果想要升序,就在函数里把arr[minChild + 1] < arr[minChild]改成arr[minChild + 1] > arr[minChild],并且要把if (arr[minChild] < arr[parent])改成if (arr[minChild] > arr[parent])。
void Swap(HPDataType* x, HPDataType* y)
{
HPDataType tmp = *x;
*x = *y;
*y = tmp;
}
void AdjustDown(HPDataType* arr, int sz, int parent)
{
int minChild = parent * 2 + 1;
while (minChild < sz)
{
if (minChild + 1 < sz && arr[minChild + 1] < arr[minChild])
++minChild;
if (arr[minChild] < arr[parent])
{
Swap(&arr[minChild], &arr[parent]);
parent = minChild;
minChild = parent * 2 + 1;
}
else
break;
}
}
void HeapSort(int* arr, int n)
{
//建堆
for (int i = (n - 2) / 2; i >= 0; --i)
{
AdjustDown(arr, n, i);
}
//排序
for (int i = 1; i < n; ++i)
{
Swap(&arr[0], & arr[n - i]);
AdjustDown(arr, n - i, 0);
}
}
时间复杂度计算
向下调整算法的时间复杂度为O(logn),外面套个循环,那就是O(n*logn)。
Top-K问题
即求数据结合中前K个最大的元素或者最小的元素,一般情况下数据量都比较大。比如:专业前10名、世界500强、富豪榜、游戏中前100的活跃玩家等。 对于Top-K问题,能想到的最简单直接的方式就是排序,但是,如果数据量非常大,排序就不太可取了(可能数据都不能一下子全部加载到内存中,而存在磁盘文件中)。
最佳的方式就是用堆来解决,基本思路如下:
- 用数据集合中前K个元素来新建堆 前k个最大的元素,则建小堆(建大堆popK次遇到大量数据就不行了,因为数据很可能不在内存而在磁盘文件中,而建堆必须要在内存中搞) 前k个最小的元素,则建大堆(建大堆popK次遇到大量数据就不行了,因为数据很可能不在内存而在磁盘文件中,而建堆必须要在内存中搞)
- 用剩余的N-K个元素依次与堆顶元素来比较,不满足条件则替换堆顶元素并调整堆。将剩余N-K个元素依次与堆顶元素比完之后,堆中剩余的K个元素就是所求的前K个最小或者最大的元素。
思路分析
在分析之前,我们先约定一下,将所有元素分为已访问的元素和未访问的元素,一开始把前K个元素建堆(称为K堆),此时已访问元素就是这K个,那为什么要找前K大元素要建小堆而找前K小元素要建大堆呢?我们是从第K+1个元素开始向后访问的吧,是在不断地扩大访问范围,每次都把访问的元素和K堆堆顶元素比较,要是访问的元素更大就让它入堆(找前K大元素),实际上就是不断把当前访问范围内前K大的元素集中到K堆中,再不断扩大访问范围直到访问完了所有元素,最终就能找出所有元素前K大元素了。
比如说我要找前K个最大元素:
void Swap(HPDataType* x, HPDataType* y)
{
HPDataType tmp = *x;
*x = *y;
*y = tmp;
}
void AdjustDown(HPDataType* arr, int sz, int parent)
{
int minChild = parent * 2 + 1;
while (minChild < sz)
{
if (minChild + 1 < sz && arr[minChild + 1] < arr[minChild])
++minChild;
if (arr[minChild] < arr[parent])
{
Swap(&arr[minChild], &arr[parent]);
parent = minChild;
minChild = parent * 2 + 1;
}
else
break;
}
}
HPDataType* GetTopK(HPDataType* arr, int n, int k)
{
HPDataType* heap_k = (HPDataType*)malloc(k * sizeof(HPDataType));
if (heap_k == NULL)
{
perror("malloc fail");
exit(-1);
}
for (int j = 0; j < k; j++)
{
heap_k[j] = arr[j];
}
//建小根堆
for (int i = (n - 1 - 1) / 2; i >= 0; --i)
{
AdjustDown(heap_k, k, i);
}
//比较剩余元素和调整堆
for (int i = 0; i < n - k; i++)
{
if (arr[k + i] > heap_k[0])
{
Swap(&heap_k[0], &arr[k+i]);
AdjustDown(heap_k, k, 0);
}
}
return heap_k;
}
这种是针对数据全部在内存中的写法,写完后可以用下面这一函数自测一下:
void TestTopK()
{
srand((unsigned)time(0));//记得要包含<stdlib.h>和<time.h>两个头文件
int arr[100];
for (int i = 0; i < 100; ++i)
{
arr[i] = rand() / 1000;
}
arr[5] = 1001;
arr[10] = 1002;
arr[15] = 1003;
arr[20] = 1004;
arr[25] = 1005;
arr[30] = 1006;
arr[35] = 1007;
arr[40] = 1008;
arr[45] = 1009;
arr[50] = 1010;
HPDataType* heap_k = GetTopK(arr, 100, 10);
for (int i = 0; i < 10; ++i)
{
printf("%d\n", heap_k[i]);
}
}
优点分析
由K + (n-K)log2 K 推得时间复杂度:O(n),确实嘎嘎快~
空间复杂度:O(K)其实就是常数级O(1)
就复杂度而言都能薄纱其他方法了。
即使数据量很大,数据存储在硬盘上也无大碍,照样给你找出前K个最大或最小的元素。
以上就是本文全部内容了,感谢观看,你的支持就是对我最大的鼓励~