文章目录
二叉树概念和堆的实现应用
前面讲完了多种数据结构的类型:
如顺序表、链表、栈、队列
这些数据结构在逻辑上都是线性连续的。是一对一的关系
今天这篇文章将进入一个新的环节——树
树在逻辑上是一对多的关系
首先会先讲树的概念,性质。以及二叉树的性质。然后对其中的一种——堆进行实现和应用。
树
生活中,树是非常常见的。但是应用到数据结构当中是个什么样子的呢?
文章的第一个部分将会对这个树进行探究
树的定义
树的定义:
树是n(n>=0)个结点的有限集。当n = 0时,称为空树。在任意一棵非空树中应满足:
1.有且仅有一个特定的称为根的结点。
2.当n>1时,其余节点可分为m(m>0)个互不相交的有限集T1,T2,…,Tm,其中每个集合本身又是一棵树,并且称为根的子树。
根据这段话其实很快就能看得出来:树其实是由递归定义的。一棵树可以拆分成子树,子树又可以拆分。直到某个节点没有子树(叶子节点)。且节点只能与根相连,不是自己的子树对应的根不能连:
如图所示,这样子就是一个树。可以把它当成一棵树倒着放,从顶上的根部不断向下生长,长出一些根和叶子。
需要再次强调的是:树的节点只能连自己的根和自己的叶子,不能连其他的。也就是说,子树与子树之间不能有相交:
子树与子树之间不能相交。
树的基本术语
树的基本术语:
节点的度:一个节点含有的子树数量。
叶节点:度为0的节点,即没有子节点的节点。
根节点:没有父节点的节点。
路径:从一个节点到另一个节点的节点序列。
层:根节点定义为第1层,其子节点为第2层,以此类推。
深度:从根节点到指定节点的唯一路径的长度。
高度:从指定节点到叶节点的最长路径的长度。
森林:由多棵树组成的集合。
双亲节点:如果一个节点又子节点,则称为这个节点的双亲节点
孩子节点:如果某个节点隶属于某个子树根节点直接相连,就是该子树根节点的孩子接待你
祖先节点:从根到该节点所经分支上的所有节点
子孙节点:以某节点为根的子树下面的任意节点都是该节点的子孙节点
兄弟节点:具有相同父亲节点的节点
在描述树的节点之间的关系的时候,是使用人类的亲缘关系进行描述的。
树的结构
就像之前讨论的一样,每个数据结构都要有自己的结构方式。而树很显然,是靠着一个又一个的节点链接而成的。那应该如何定义树节点的结构呢?
1.每个节点中存储数据的同时还存放着顺序表
typedef int TreeDataType;
typedef struct TreeNode{
TreeDataType data;
SeqList SubTree;
}TreeNode;
这里抽象化的认为第二个数据类型是SeqList,因为每个节点它对应的子树个数不清楚。可能多也可能少,甚至没有。所以可以使用一个顺序表存储每个子树的根。但是这种方法很显然,会非常麻烦且浪费空间。
2.使用指针
typedef int TreeDataType;
typedef struct TreeNode{
TreeDataType data;
struct TreeNode* SubTree1;
struct TreeNode* SubTree2;
......
}TreeNode;
也可以使用指针,使每个节点中存放着自己孩子节点的位置。但是这个方法只适用于每个节点孩子个数较为明确的场景。
有没有一种方式能够适应上述所有缺陷呢?
答案是有的:
3.使用左孩子右兄弟结构
typedef int TreeDataType;
typedef struct TreeNode{
TreeDataType data;
struct TreeNode* LeftChild;
struct TreeNode* RightBro;
}TreeNode;
节点中的第一个指针指向的是自己的左孩子(如果有)
节点中的第二个指针指向的是和自己处在同一层的最近的兄弟节点(也称右兄弟)
这个方法是非常天才的,我们举个例子看看:
这样子就能满足所有形式的树了。
但是今天这篇文章只对这个数据结构的类型进行概念的介绍,具体的实现是比较困难的。需要从一些基础概念开始学起。在这里先只做了解即可。
二叉树的性质
上面的树的结构都是五花八门的,现在来讲解一下最常见,也是最基础的树——二叉树
二叉树,如其名,就是分开两条岔路。
对于树来讲,就是一个节点对应的子节点最多只能有两个。当然一个或者零个都是可以的。这样子对于节点的定义就简单很多了:
typedef int TreeDataType;
typedef struct TreeNode{
TreeDataType data;
struct TreeNode* Left;
struct TreeNode* Right;
}TreeNode;
因为每个节点最多只有两个孩子,所以只需要区分是左还是右孩子就可以了。如果没有,则对应的指针置空即可。
特殊二叉树
现在让我们来看看一些特殊的二叉树:
满二叉树
满二叉树:
如果一个二叉树,每一层的节点树都达到了该层能容纳节点的最大值,则称为满二叉树。
就如这个图所示,这是一个标准的满二叉树。
一共有四层:
第1层:节点个数为1
第2层:节点个数为2
第3层:节点个数为4
第4层:节点个数为8
根据上述推导:第h层的节点个数应为2^(h - 1)个
且层数为h的满二叉树的节点总数:
N = 2 ^ 0 + 2 ^ 1 + 2 ^ 2 + …+ 2 ^ (h - 2) + 2 ^ (h - 1) = f(h)
由上述公式根据等比数列求和公式计算可得:N = 2^h - 1 = f(h)
完全二叉树
说完满二叉树,我们再来看看什么是完全二叉树:
假设有个完全二叉树,节点个数为N,层数为h:
需要满足前h-1层是满的,最后一层必须连续的(对应编号)。
我们先来解释一下什么是编号:
编号就是从第一层第一个节点开始,编号记为0,然后每一层都是从左到右依次递增。
也就是说,完全二叉树如果由N个节点,那么对应的编号就应该是0 ~ N - 1才对。不能中断:
这样就是连续的。
而这样就不连续了。
当然,最后一层如果没有节点也算是连续的。因为满二叉树其实就是完全二叉树的一种。
完全二叉树的性质:
h层完全二叉树:
1.最多节点个数为 2^h - 1个(对应k层满二叉树)
2.最少节点个数为 2^(h - 1) + 1个(对应h - 1层满二叉树再多一个节点到第k层)
所以当一个完全二叉树的节点个数为N时,可以算出完全二叉树的深度:
假设为第一种情况:N = 2^h - 1 ——> h = Log2(N + 1)
假设为第二种情况:N = 2^(h - 1) + 1 ——> h = Log2(N - 1) + 1
所以我们会发现深度跟节点个数的关系就是取对数。这个很重要! 后面讲到堆的时候会用到。
完全二叉树的存储方式
在前面讲到二叉树的时候就已经介绍了二叉树的结构以及链接方式了。即每个节点存放着数据之外,还存放着两个节点指针,指向自己的左右孩子。
但是对于完全二叉树来讲(包括满二叉树),使用数组其实是效率更高的。
因为刚刚讲到了,完全二叉树每个节点是有编号的。编号从0开始刚好到节点数 - 1。发现,这个规律刚好就是N个元素数组的下标。
我们现在再来看一下这个树,能发现一些规律:
如果某个节点对应的编号为i 那么它的左孩子编号为 2 * i + 1 右孩子的编号为 2 * i + 2
(注:如果左孩子和右孩子存在的话)
那反过来呢?如果要求编号为i的节点的父亲节点编号呢?
假设为左孩子,父亲节点编号 = (i - 1)/2
那很多人会想,如果是右孩子,那应该是(i - 2) / 2
其实这个想法是合理的。因为左孩子右孩子的编号是不一样的。
但是需要注意的是:
在c语言中,int类型数据 / int类型数据得到的数据类型仍为int
所以哪怕是右孩子,使用(i - 1)/2得到的答案其实和左孩子这样算出来的是一样的。
就是因为完全二叉树双亲的关系明确,数组可以实现随机访问,访问效率极高,所以使用数组对完全二叉树进行存储是最为方便的。
堆的实现应用
前一个部分重点的对树这种数据结构的概念。现在来对完全二叉树里面的一种特殊类型——堆来进行讲解。
堆的实现
这个部分先来重点讲解一下堆的概念
堆的概念
这个堆并不是我们c语言内存区中的那个堆区。之前实现过的栈也不是那个栈区。
堆分为大堆和小堆
首先堆是一个完全二叉树
1.大堆
对于一个完全二叉树来讲,如果任何一个子树的根节点的值都比其他的节点大,则为大堆
就像这样,任意取一个子树(包括整棵树),其根节点的值都会大于子树中其余节点的值
2.小堆
与大堆相反,即在一个完全二叉树中,任意一个子树的根节点的值都会比子树中其余节点的值要小。
我们总结一下就是:
大堆:所有的父亲节点 > 孩子节点
小堆:所有的父亲节点 < 孩子节点
堆的代码实现
这个部分将重点讲解一下如何对这个堆来实现。
在树的概念部分中,讲到了完全二叉树的最佳存储方式是数组。对于堆的实现,底层逻辑就是使用顺序表。所以这部分的基本逻辑就是顺序表的使用,只不过对于一些操作需要重点讲解。
堆的结构
typedef int HPDataType;
typedef struct Heap
{
HPDataType* a;
int size;
int capacity;
}Heap;
堆的初始化和销毁
这个部分很简单,就是之前顺序表的初始化和销毁 就不再赘述了
void HeapInit(Heap* php) {
assert(php);
php->a = NULL;
php->capacity = php->size = 0;
}
void HeapDestory(Heap* php) {
assert(php);
free(php->a);
php->a = NULL;
php->capacity = php->size = 0;
}
入堆
对于顺序表来说,入表的方式有很多种,可能是尾插,头插等。但是对于堆来讲,因为要保证插入元素后还是为堆,所以是需要一定的方法的:
1.先在堆中最后一个位置(即size坐标位置)插入,如下图插入的4所示:
2.需要使用向上调整算法,将新插入的数据不断的调整
在这里,我们默认调整为小堆。
向上调整的规则是:从新插入的节点开始,与自己的父亲节点开始比较,如果小于就交换,然后将孩子节点调整为原来的父亲节点,然后再将原来父亲的节点调节到其原本父亲的位置。如果小于父亲节点的值,则不用再向上调整。
流程:
代码实现:
void AdjustUp(HPDataType* a, int child) {//默认调整小堆
//向上调整小堆的逻辑:(但是得保证当前已经是一个小堆)
//从child位置开始 和自己的父亲节点比较 小于就交换 反之退出循环
//然后将child变为父亲节点的位置
//然后原本父亲节点找到自己的父亲节点位置
//直到child = 0的时候 此时对应的父亲节点为 (0 - 1)/2 其实还是0 不需要再调整
int Child = child;
int Parent = (Child - 1) / 2;
while (Child > 0) {
if (a[Child] < a[Parent]) {//想要调整为大堆就把if里面的 '<'改为 '>'
swap(&a[Child], &a[Parent]);//交换
Child = Parent;
Parent = (Parent - 1) / 2;
}
else break;
}
}
swap函数实现十分容易,在这里就不进行赘述了。
如果向上调整为大堆,那么判断逻辑取反即可,上面注释有说。
最坏的情况就是,插入的元素可能会一直向上调整一直到整个树的根节点。直到child向上调整到编号为0的地方,就可以停止调整了。
这个位置的参数也值得注意一下,我们并没有选择传入堆,而是传堆中元素表的指针。这是因为建堆其实本质上就是数组。有时候建立顺序表十分麻烦,而这个函数本身就是对数组进行操作的。当我们不想写这么多接口而直接调用这个函数对数组操作的时候,就可以直接使用了。在一定程度上脱离了堆这个数据结构。这一点先介绍一下,后面讲堆排序的时候会说到。
写好向上调整算法后,我们就可以进行入堆操作的实现了:
void HeapPush(Heap* php, HPDataType x) {
assert(php);
if (php->capacity == php->size) {
//需要扩容
int tmpsize = (php->capacity == 0) ? 4 : (php->capacity) * 2;
HPDataType* tmp = (HPDataType*)realloc(php->a, tmpsize * sizeof(HPDataType));
if (!tmp) {
perror("Heap Realloc");
exit(-1);
}
php->a = tmp;
php->capacity = tmpsize;
}
php->a[php->size] = x;
AdjustUp(php->a, php->size);
php->size++;
}
与以往顺序表尾插相比,就是多了一个向上调整的过程。
出堆
既然有入堆,那当然要有出堆。如果出堆仅仅只是把堆尾的最后一个元素出掉,虽然剩下的数据仍能组成一个堆,但是实际上没有什么意义。
我们知道,大堆的所有父亲节点 > 孩子节点 ,小堆的所有父亲节点 < 孩子节点。
所以对于整个堆来说:大堆的根节点的值是最大的,小堆的根节点值是最小的。应用这一点性质,常常用来解决找到数组中前k个最大和最小的值。这点放在后面的应用部分会细讲。
所以,出堆应该出的是堆顶的数据,这样是有实际意义在的。
现在我们来讲一下出堆的过程:
对于这个小堆(默认操作小堆),要出的元素是1。但是我们如果仅仅将1这个元素移除,并且让后面的元素依次往前移动一个编号的位置,会发现新的数组已经不再是堆了。这样子就会把原来父亲与孩子之间的关系搞乱。就要重新建堆,这是非常麻烦的。
对于出堆,我们可以这样做,先将堆顶和堆尾的数据进行交换,然后使用向下调整算法
向下调整的规则(调整为小堆)是:
从某个节点开始,记该位置为Parent,找到其左右孩子(如果有)较小的那个,记为Child
如果Parent的值 > Child的值 ,则交换,然后继续往下找。直到走到叶子节点(没有左、右孩子)
如果Parent的值 < Child的值,则直接停止向下调整。
如果要调整为大堆,逻辑与上述相反即可。下面代码中注释有说明:
void AdjustDown(HPDataType* a, int Heapsize, int parent) {//默认调整小堆
//向下调整小堆的逻辑:(但是得保证当前已经是一个小堆)
//从传入的parent位置开始 找到parent左右孩子较小的那个 (注意:可能会没有左右孩子)
//如果大于较小的那个孩子 就交换 反之退出循环
int Parent = parent;
while ((Parent * 2 + 1) < Heapsize) { //如果不是叶子节点就向下调整
//假设法找到左右孩子较小的那个
//如果能进入循环 说明有左孩子 但不一定有右孩子
int Child = Parent * 2 + 1;
if (Child + 1 < Heapsize && a[Child] > a[Child + 1]) Child = Child + 1; //调为大堆要改第二个符号 第一个不能改
if (a[Parent] > a[Child]){//想要调整为大堆就改符号 找左右孩子大的
swap(&a[Parent], &a[Child]);
Parent = Child;
}
else break;
}
}
在这里需要注意的是,怎么判断某个节点是否没有左右孩子呢?这就需要我们传入删除后堆中元素个数了,也就是参数Heapsize。只要某节点的左孩子坐标超出了堆中最后一个元素的坐标,那就说明当前该节点没有左孩子,也就一定没有右孩子(完全二叉树的性质导致),那此时就可以不必再向下调整了。
还需要注意一点的是:我们找左右孩子较小的那个的时候,只要进入了循环,就一定是有左孩子的,否则不会进入循环。但是有左孩子不一定会有右孩子。所以在假设法找左右孩子较小的那个的时候,为了防止数组访问越界,需要判断一下是否有右孩子
也就是先判断 child + 1 < Heapsize。可能会好奇为什么不能是<=,因为数组的下标是从0开始的,Heapsize个元素,最后一个坐标为Heapsize - 1,如果等于就越界了。
然后就可以执行出堆操作了:
void HeapPop(Heap* php) {
assert(php);
int end = php->size - 1;
swap(&(php->a[0]), &(php->a[end]));//交换逻辑
//刚好此时end的值 就是 pop一个元素后堆中剩余元素的数量
AdjustDown(php->a, end, 0);
php->size--;
}
注意,end的坐标需要处理一下,因为size个元素的堆,最后一个元素下标为size - 1;
然后此时的end正好是pop一个元素之后剩余堆的元素个数,所以直接传给参数Heapsize即可
与顺序表尾删相比多了一个交换操作和向上调整过程。
堆的其他接口
剩下的一些堆的接口其实和顺序表的操作没有什么太大的区别:
HPDataType HeapTop(Heap* php) {//获得堆顶元素
assert(php);
assert(php->size > 0);
return php->a[0];
}
int HeapSize(Heap* php) {//堆元素个数
assert(php);
return php->size;
}
int HeapEmpty(Heap* php) {//判断堆是否为空
assert(php);
return (php->size == 0);
}
以上就是堆这个数据结构的实现。
堆的应用
前面讲了那么多,是因为堆的应用还是很重要的,下面部分将重点介绍两个最经典的用法
堆排序
在此之前,我们已经学过一些排序的算法。如最经典的冒泡排序、还有c语言库中的qsort,这个是快速排序。(这个后面会专门开一个文章讲排序算法)
在上个部分已经了解到了堆的概念和实现了。堆这个结构就十分适合排序。
选择合理方法
假设我们现在给一些数据进行排序,有什么办法呢?假设排成降序
方法1
我们可以建造一个大堆,把数组中所有的元素都往里面插入形成大堆。然后再依次出堆直至堆空。这个思路很简单。
但是这个思路最大的问题是:需要写一个堆的数据结构,需要一些对应的接口函数。而且,这个并不是真正的排序,只是打印有序。因为排序是需要将原数组里面进行排序,是需要改动数组的。如果真的需要把原数组进进行改变,那需要把出堆的元素记录下来赋值回数组中。
这个思路乍一看简单,但是需要很繁杂的操作,要写堆这个数据结构。
方法2
我们刚刚写的向上向下调整算法是直接传数组的进行使用的。也就是说,我们可以假设当前数组就是堆中的那个顺序表,直接对其操作即可。
堆排序之建堆
现在我们来讲一下堆排序的思路:
堆排序的第一步永远都是建堆,只不过是在元素组上进行操作。假设要排降序,应该建大堆还是小堆呢?
很多人会不假思索的说,既然排降序,当然是建立大堆。事实真的如此吗?
我们知道,大堆堆顶数据是堆中最大的那个,那要排降序,就要将这个数放到最前,也就是说当前堆顶的数据不能动。
假设对这个堆排降序。11不能动,那剩下的数据因为失去了原来的堆顶,并不是堆。那就需要重新建立一个大堆,把剩下的数据再插入。这是非常麻烦的,效率也是十分低下。
让我们来看看建一个小堆呢?
此时堆顶数据最小,排降序需要放在最后面,那就需要堆头数据和堆尾数据来进行交换。交换完后,最后一个元素就可以不用看作堆中元素,就相当于出堆过程。
然后可以使用写过的向下调整算法,往下调整出新的小堆。再不断重复上述过程(出堆),直至堆中元素为0个的时候,就不需要再调整了。
很明显,建立小堆是更合理的、效率也更高。
所以得到结论:
排升序,建大堆;排降序,建小堆!!!
如何建堆
我们直接使用写过的向上调整算法和向下调整算法来实现建堆
向上调整算法建堆
这个过程就像是模拟入堆的过程:
给定一个数组,从下标为0的元素开始遍历。把每一次遍历到的元素都当作新插入到堆中的。
具体实现方法很简单:
for (int i = 0; i < n; i++) {
AdjustUp(a, i);
}//模仿入堆过程
但是我们可以来看看时间复杂度是多少。
大部分人会不假思索得到答案是O(N),因为遍历n次,每次执行一次向上调整算法。这样算是绝对不对的,因为向上调整算法函数内部也有语句执行。而时间复杂度的定义是语句的总执行次数。所以包括函数内部的语句。
让我们来大致估算一下:
因为向上调整的次数跟树的层数有关,也跟相互之间关系有关。是不确定的。算时间复杂度的时候要做好最坏打算,所以选取最复杂的情况来计算:
假设有h层满二叉树,每一个节点都要向上调整到堆顶:
来找一下规律:
第几层 | 节点个数 | 向上调整最多的次数 |
---|---|---|
1 | 2^0 | 0 |
2 | 2^1 | 1 |
3 | 2^2 | 2 |
… | … | … |
h-1 | 2^(h-2) | h-2 |
h | 2^(h-1) | h-1 |
那么总的调整次数是:
F(h) = 1 * 21 + 2 * 22 + 3 * 23 + … + (h-2) * 2h-2 + (h-1) * 2h-1 ①
很明显,用一下高中学到的错位相减法
2F(h) = 1 * 22 + 2 * 23 + 3 * 24 + … + (h-2) * 2h-1 + (h-1) * 2h ②
由② - ①可得:
F(h) = 1 * 21 - (22 + 23 + … + 2h-2 + 2h-1) + (h-1) * 2h ③
③还可改成:F(h) = 4 - ( 21 + 22 + 23 + … + 2h-2 + 2h-1 + 2h) + h * 2h
可以直接用等比数列求和公式对③进行化简:
得F(h) = 2h+1 + 2 + h * 2h ④
因为算的时间复杂度是关于节点个数n的表达式(入堆n次)
由上面满二叉树性质,h层满二叉树的节点个数为N = 2h - 1 ,h = Log2(N + 1) ⑤
将 ⑤ 代入 ④中,得到移动次数与节点个数N的关系
F(N) = 2 * (N+1) + 2 + (N + 1) * Log2(N + 1)
对于时间复杂度,只取最大项考虑,F(N)大致为O(NLogN)级别。
所以向上调整算法建堆的时间复杂度是O(NLogN)
向下调整算法建堆
向下调整算法建堆的逻辑是:
保证某个节点的左右子树都是堆,然后从堆中最后一个非叶子节点开始,不断地向下调整。
如这个图所示,从元素8开始调整,把8的左右子树都调为小堆,然后不断地向上选取节点向下调整,直至达到头节点调整完毕:
流程如图所示。
代码实现:
int begin = (n - 1 - 1) / 2;//找到第一个非叶子节点
while (begin >= 0) {
AdjustDown(a, n, begin);
begin--;
}
//for循环写法
for(int i = (n - 1 - 1) / 2; i>=0; i--){
AdjustDown(a, n, i);
}
这个时候来看看时间复杂度为多少:
还是选取刚刚那个极端情况:
第几层 | 节点个数 | 最多调整次数 |
---|---|---|
h | 2^(h - 1) | 0 |
h-1 | 2^(h - 2) | 1 |
h-2 | 2^(h - 3) | 2 |
h-3 | 2^(h - 4) | 3 |
… | … | … |
2 | 2^1 | h-2 |
2 | 2^0 | h-1 |
总的调整次数:
F(h) = 1 * 2h-2 + 2 * 2h-3 + 3 * 2h-4 + … + (h-2) * 21 + (h - 1) * 20 ①
使用错位相减法
2F(h) = 1 * 2h-1 + 2 * 2h-2 + 3 * 2h-3 + … + (h-2) * 22 + (h - 1) * 21 ②
由 ② - ①可得
F(h) = 1 * 2h-1 + (2h-2 + 2h-3 + 2h-4 + … + 22 + 21) + 1 ③
对③化简一下:F(h) =2h-1 + 2h-2 + 2h-3 + 2h-4 + … + 22 + 21 + 20
由等比数列求和公式得:
F(h) = 2h - 1 ④
由上面满二叉树性质,h层满二叉树的节点个数为N = 2h - 1 ,h = Log2(N + 1) ⑤
将 ⑤ 代入 ④中,得到移动次数与节点个数N的关系
F(N) = N
大概是N这个量级的
所以向下调整算法建堆的时间复杂度是O(N)
向上调整建堆和向下调整建堆的区别
最大的不同就是时间复杂度的不同。
另外再来仔细观察一下:
发现向上调整算法节点多的层反而调整次数多,导致了总的调整次数偏大
而向下调整算法节点少的调整多,节点多的调整少,所以总的调整次数小
这就是两种算法建堆导致的时间复杂度不同。
对堆排序
就是模仿出堆的过程,收尾元素交换后,堆中元素减一,然后从堆顶开始向下调整:
while (n > 0) {
int end = n - 1;
swap(&a[0], &a[end]);
AdjustDown(a, end, 0);
n--;
}
每次的end对应的就是未删除前堆尾数据下标,也正好是出堆一个元素后剩余数据的个数。直到出堆到剩余元素个数为0结束。
堆排序代码
void HeapSort(HPDataType* a, int n) {
//1.先建堆
//从第一个数据开始遍历到最后一个数据 不断采用向上调整算法进行建堆
//for (int i = 0; i < n; i++) {
// AdjustUp(a, i);
//}
//
//可以选择向下调整算法建堆
//int begin = (n - 1 - 1) / 2;
//while (begin >= 0) {
// AdjustDown(a, n, begin);
// begin--;
//}
//当然可以用for循环写
/*for (int i = (n - 1 - 1) / 2; i >= 0; i--) {
AdjustDown(a, n, i);
}*/
//
//建堆的代码选择一个即可
//排序
//
//正常来讲 如果是建立了堆再来找出前k个最小或者最大的数据 只需要执行pop操作即可
//但是不想建堆 就可以模仿出堆的过程
while (n > 0) {
int end = n - 1;
swap(&a[0], &a[end]);
AdjustDown(a, end, 0);
n--;
}
//排升序的话只需要改一下向上向下调整算法的 > < 符号即可 函数内部有注释说明
}
注意,因为写的调整算法默认是小堆,所以最后排序完是降序。
如果需要排为升序只需要调整一下向上/向下调整算法中的比较逻辑即可。在前面代码的注释中有说。
建堆的过程时间复杂度最好是O(N),但是再后续模拟出堆的过程中,需要向下调整,次数最坏为LogN,所以N个节点向下调整的次数大致为NLogN。
所以堆排序HeapSort的时间复杂度为O(NLogN)
TopK问题
现在来看另一个经典问题,求数组中前k个最大/最小的数据,即TopK问题。
假设要求N个元素中前k个最大的(这种问题一般N >> k),有两种方法:
1.建立一个大小为N的大堆,出堆k次
这个很好理解,因为大堆的堆顶的数据总是堆中最大的。出堆一次再调整会找到新的最大数。
当N >> k的时候,出堆的时间复杂度O(k*LogN),建堆为O(N)(向下调整建堆)
总的时间复杂度为O(N)
时间复杂度不算很高,但是有个很大的问题是,空间复杂度为O(N)。
假设数据N大小为十亿。存储十亿个整形数据大约为40亿byte,约1GB。这个空间损耗非常大。所以这个方法只适合N不太大的时候
2.建立一个大小为k的小堆
那我们反其道而行之,假设建立一个大小为k的小堆呢?
也就是说,先把一群数据的前k个插入建立小堆(采用向下调整建堆),先把前k个数据当最大。
时间复杂度为O(k)
然后将后续N-k个数据与堆顶的数据比较,如果大于堆顶数据,就交换,并向下调整。然后切换下一个数据判断。如果小于堆顶数据,则直接切换到下一个数据。
这是很合理的思路,因为建立的是小堆,那么堆顶的数据最小。当数据大于堆顶的数据时,说明原来堆和这个新的数据这k + 1个数据中,原堆顶数据最小,那理应被挤出最大的k个数范围
代码实现:
void TopK_Test() {
printf("请输入k:>>>>\n");
int k = 0;
scanf("%d", &k);
int N = 100000;
//让数据存储在文件中 现在可以使用随机数捏造N个数据在文件中 (造完数据之后 如果后面不想改数据可以把这一段注释掉)
//
/*srand((unsigned int)time(NULL));
FILE* pf = fopen("data.txt", "w");
if (!pf) {
perror("fopen");
exit(-1);
}
for (int i = 0; i < N; i++) {
int random = (rand() + i) % 10000000;
fprintf(pf , "%d\n", random);
}
fclose(pf);
pf = NULL; */
//
//读数据前k个
FILE* ppf = fopen("data.txt", "r");
int* HeapK = (int*)malloc(sizeof(int) * k);
for (int i = 0; i < k; i++) {
fscanf(ppf, "%d", &HeapK[i]);
}
//
//向下调整建堆
for (int i = (k - 1 - 1) / 2; i >= 0; i--) {
AdjustDown(HeapK, k, i);
}
//
//剩下的N-k个数据和堆顶比较
//得知道怎么样能读完? fscanf函数如果读取成功 返回读取的个数 反之返回<=0的值
int x = 0;
while (fscanf(ppf, "%d", &x) > 0) {
if (x > HeapK[0]) {
swap(&x, &HeapK[0]);
AdjustDown(HeapK, k, 0);
}
}
//
//打印结果
printf("前%d个最大的数据:>>>>\n",k);
for (int i = 0; i < k; i++) {
printf("%d ", HeapK[i]);
}
fclose(ppf);
ppf = NULL;
}
为了节省空间,将大量数据存储在文件中,从文件进行读取。
这样的操作,空间复杂度降到O(k)
而后续比较的时间复杂度为O((N-k)*Logk),趋近于N这个级别。
最后时间复杂度为O(N)。
当然,想要找出前k个最小的,建一个k大小的大堆,与堆顶比较即可。逻辑与上述相反即可。
测试TopK
由于上面的随机数生成可能会有重复,所以无法确定是否这个功能能正常应用。
所以我们可以想办法进行测试一下:
我们在data.txt文件内随机找k个数让其扩大1000倍。然后再来执行该功能,发现找到的是那k个数,功能就是能正常应用的。
如图所示:
本篇文章就到此结束。