本文已收录至《数据结构(C/C++语言)》专栏!
作者:ARMCSKGT
目录
前言
堆这个数据结构可能大家都比较陌生,堆和二叉树有什么关系呢?
大家根据字义可以想象出来“堆”就像一个小山坡一样,而二叉树从叶子节点一直到根节点也可以看作一个小山坡,两者很类似,于是二叉树的顺序结构存储方法就是堆。
堆通常可以看作是二叉树的顺序结构表示。堆的实现一般使用顺序表(数组)来存储,而且堆存储的一定是完全二叉树,因为这样不会造成空间的浪费;建堆时要么建大堆要么建小堆,这样才能真真的利用堆的特性实现Top-K和堆排序问题。
介绍了这里大家一定有些期待了吧?那就请大家继续向后阅览吧!
正文
堆的部分概念
我们在前面介绍过建堆要么建大堆,要么建小堆,那么什么是大堆什么是小堆?
![]()
大小堆
小堆(小根堆):就是每一棵树的父节点比孩子节点要小, 根节点最小!
大堆(大根堆):就是每一棵树的父节点比孩子节点要大, 根节点最大!
无论是建立大堆还是建立小堆都是为了让数据纵向有序,这样我们才能更好的进行调整!
这里要注意的是,堆存储的一定是一课完全二叉树(完全二叉树是指二叉树的前n-1层是满的,最后一层可以满可以不满,但是要求最后一层的叶子节点从左到右都是连续的,例如下图都是完全二叉树!判断完全二叉树的标准是前n-1层为满的,最后一层叶子节点是否连续!)。
![]()
完全二叉树
为什么堆一定要存完全二叉树?
1.首先堆存储完全二叉树不会有太多空间的浪费。
2.顺序表(数组)是连续的存储空间,完全二叉树节点的连续与顺序表(数组)的特性相吻合,这样会方便节点下标的计算!
堆的数据结构
堆通过顺序表(数组)实现,所以堆的数据结构依托于动态顺序表!
typedef int HPDataType; typedef struct Heap { HPDataType* a;//数据域 int size;//节点个数 int capacity;//空间大小 }Heap;
堆的实现
堆节点的计算方法
堆是通过顺序表(数组)实现的,那么我们如何找某一个节点然后访问呢?答案是通过公式用下标计算!
左孩子下标:LeftChild = Parent * 2 + 1
右孩子下标:RightChild = Parent * 2 + 2
父节点下标:Parent = (Child - 1) / 2
堆的一些基本函数
堆的一些基本函数,例如初始化,打印,销毁等,我们就不多说了,因为在线性表的中介绍过!
//初始化堆 void HeapInit(Heap* hp) { assert(hp); hp->a = NULL; hp->capacity = hp->size = 0; }
//堆的打印 void HeapPrint(Heap* hp) { assert(hp); for (int i = 0; i < hp->size; ++i) { printf("%d ", hp->a[i]); } printf("\n"); }
// 取堆顶的数据 HPDataType HeapTop(Heap* hp) { assert(hp); if (!HeapEmpty(hp)) { return (hp->a)[0]; } return -1;//为空返回-1(或其他反馈性信息) }
// 堆的数据个数 int HeapSize(Heap* hp) { assert(hp); return hp->size; }
// 堆的判空 int HeapEmpty(Heap* hp) { assert(hp); return hp->size == 0;//为空返回1 }
入堆和向上调整函数
对于入堆,就是将数据放入数组,但是在放入之前需要检查容量问题,而且数据放入堆之后,我们还需要进行调整,因为我们说过,堆要么是大堆,要么是小堆,当一个数据入堆时可能会打破当前的相对有序,所以我们需要进行调整,所以入堆的核心在向上调整的过程中。
什么是向上调整?向上调整就是将我们入堆的新数据放入到合适的位置(一开始入堆的新数据在数组的最末尾),让堆变成大堆或者小堆。
![]()
入堆向上调整动图演示
向上调整的代码思路:
1. 通过刚入堆元素下标求出其父节点(parent = (child-1) / 2)
2. 将新入堆的子节点与父节点对比,如果子节点小则交换。
3. 新节点通过交换后的新下标继续求自己的新父节点,然后对比调整,直到子节点大于父节点或子节点成为根节点时结束!
//交换 void Swap(HPDataType* Node1, HPDataType* Node2) { HPDataType tmp = *Node1; *Node1 = *Node2; *Node2 = tmp; } //向上调整 void ADjustUp(HPDataType* a, int child) { assert(a); int parent = (child - 1) / 2;//获得父亲下标 int childs = child; while (child > 0)//孩子下标必须大于0 { //if (a[childs] > a[parent])//孩子小于父亲--大堆 if (a[childs] < a[parent])//孩子小于父亲--小堆 { Swap(&a[childs], &a[parent]);//交换 childs = parent;//孩子下标变为父亲下标 parent = (childs - 1) / 2;//获得当前孩子下标的父亲下标 } else { break;//如果孩子大于父亲则停止 } } } // 入堆 void HeapPush(Heap* hp, HPDataType x) { assert(hp); if (hp->capacity == hp->size)//空间满了就扩容 { int cap = hp->capacity == 0 ? 4 : (hp->capacity) * 2;//扩容2倍 hp->a = (HPDataType*)realloc(hp->a, sizeof(HPDataType) * cap);//申请空间 if (!hp->a)//判断申请是否成功 { perror("realloc fail!\n"); exit(-1); } hp->capacity = cap; } hp->a[(hp->size)++] = x;//x入堆到最末尾 ADjustUp(hp->a, hp->size-1);//开始将新入堆的元素向上调整 }
出堆和向下调整函数
对于元素的出堆,是删除堆顶的元素,在顺序表中我们头删一个元素依靠挪动元素覆盖做到的,但是在堆中,如果我们这样操作就会打破堆所形成的完全二叉树结构,所以这样是不可取的。
于是我们这样做:用堆中最后一个元素将堆顶的根节点元素覆盖,然后将堆顶的元素向下调整,最后堆元素个数减一即可;这样既可以删除堆顶元素,又可以使堆保持有序!
![]()
出堆向下调整动图演示
向下调整代码思路:
1. 先通过父节点的下标求出左孩子节点的下标 child = parent*2 + 1
2. 左孩子节点下标加1求出右孩子节点下标(如果右孩子存在),然后找出左右孩子中最小的。
3. 将左右孩子中最小的节点与父节点对比,如果比父节点小则交换。
4. 交换后,原父节点再通过新下标求出自己的左右孩子下标继续调整。
5. 直到自己是当前的最后一个节点或者孩子节点大于父节点为止。
//交换 void Swap(HPDataType* Node1, HPDataType* Node2) { HPDataType tmp = *Node1; *Node1 = *Node2; *Node2 = tmp; } //向下调整 void ADjustDown(HPDataType* a, int n, int parent) { assert(a); int child = (parent * 2) + 1;//求出左孩子下标 int parents = parent; while (child < n)//孩子下标大于n时停止 { //小堆 //找小孩子 if (child+1 < n&&a[child] > a[child + 1])//对比左右孩子找小 { child++;//找孩子中的小的 } //如果父节点大于孩子则交换 if (a[child] < a[parents])//如果孩子节点大于双亲节点则交换 { Swap(&a[child], &a[parents]);//交换 parents = child; child = (parents * 2) + 1; } else { break; } //大堆 //if (child + 1 < n && a[child] < a[child + 1]) //{ //child++; //} //if (a[child] > a[parents]) //{ //Swap(&a[child], &a[parents]);//交换 //parents = child; //child = (parents * 2) + 1; //} //else //{ //break; //} } } // 堆的删除-删除堆顶数据 void HeapPop(Heap* hp) { assert(hp);// if (!HeapEmpty(hp))//判断堆是否为空 { (hp->a)[0] = (hp->a)[--(hp->size)];//最后一个节点覆盖根节点且堆元素个数减一 ADjustDown(hp->a, hp->size, 0);//堆顶开始向下调整 } }
建堆函数
如果给我们一个数组,让我们建小堆(或大堆)该如何操作?
我们先通过数组的元素个数开辟对应大小的空间,然后通过memcpy将原数组中的数据拷贝到我们的堆数组上,然后从堆顶开始,逐一节点进行向上或者向下调整就可以得到一个小堆(或大堆)!
但是向上调整和向下调整我们该选择哪一个呢?
据计算:向上调整的时间复杂度为:O(N*logN),而向下调整的复杂度为O(N - log(N + 1));
毋庸置疑,我们推荐向下调整建堆,这样效率最高!
// 堆的构建--向上调整建堆 void HeapCreate(Heap* hp, HPDataType* a, int n)//给定一个数组a,构建一个堆hp { assert(hp); hp->a = (HPDataType*)malloc(sizeof(HPDataType) * n);//开辟n个空间 if (!hp->a)//判断申请是否成功 { perror("realloc fail!\n"); exit(-1); } hp->capacity = hp->size = n ; memcpy(hp->a, a, sizeof(HPDataType) * n);//二进制拷贝堆 for (int i = 0; i < n; ++i)//调整节点 { ADJustUp(hp->a,i);//从根节点开始逐一节点向下调整 } }
// 堆的构建--向下调整建堆 void HeapCreate(Heap* hp, HPDataType* a, int n)//给定一个数组a,构建一个堆hp { assert(hp); hp->a = (HPDataType*)malloc(sizeof(HPDataType) * n);//开辟n个空间 if (!hp->a)//判断申请是否成功 { perror("realloc fail!\n"); exit(-1); } hp->capacity = hp->size = n ; memcpy(hp->a, a, sizeof(HPDataType) * n);//二进制拷贝堆 for (int i = (n - 2) / 2; i >= 0; --i)//从最后一棵小树开始 { ADjustDown(hp->a, n, i);//开始从下往上调整,以每一棵小树为单位一直到根节点 } } /* 这里(n-2)/2求的是最后一棵树的父节点的下标,为什么不是(n-1)/2 求父节点的下标我们需要注意奇数个节点和偶数个节点的问题,如果是奇数个节点n-1可以求出正确的父节点下标,如果是偶数个则不是,所以n-2是最佳的计算选择 */
堆的应用
堆排序
堆排序的原则,排升序建大堆,排降序建小堆!
堆排序的代码过程(以升序为例子):
1. 先将数组中的元素按照堆的形式从最后一棵树开始进行向下调整建大堆,此时最大的都在堆顶。
2. 将堆最末尾的节点与堆顶交换,最大的元素被放到了最末尾,然后对堆顶进行向下调整,选出次大的。
3. 重复第2步操作,直到根节点!
![]()
堆排序动图演示 //堆排序 void HeapSort(int* a, int n) { assert(a); 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前面未调整的部分(让最大的数沉在树底) end--;//已调整的堆顶就不需要再动了 } }
Top-K选数
我们很多时候需要从一堆数中选出前几个最大或者最小的数,例如期末考试成绩全系前10的总成绩,全球富豪榜前10,王者荣耀巅峰榜前10等,这么庞大的数据量,我们该如何使用最高效的办法选出我们所需要的数呢?这就是Top-K所解决的问题!
Top-K的代码原理及过程(以选出k个最大的数为例):
1. 先初始化一个堆,然后给堆的数组开k个空间。
2. 然后利用memcpy拷贝待选数组中的前k个数,进入堆中,并对堆进行整体向下调整。
3. 除了待选数组中的前k个数以外,将剩下的所有数与堆顶进行对比,如果比堆顶大则放入堆顶,然后对堆顶进行向下调整,让最大的数沉在堆底。
4. 此时前k个数已经选出,如果还需要按顺序输出还可以进行堆排序并依次打印出堆中的数!
这样就利用了大堆的特性,堆顶值最大,不断刷新堆中的值,留在堆中的就是最大的数,我们对其进行堆排序后就可以输出!
//从n个数中选出前k个最大数 void TopK(HPDataType* ary, int n, int k) { if (k > n)//如果建堆数大于总数则不符号实际情况 return; assert(ary); Heap hp;//申请一个堆 HeapInit(&hp);//初始化堆 HPDataType* tmp = (HPDataType*)malloc(sizeof(HPDataType) * k);//给数组申请k空间 if (!tmp) { perror("malloc fail!\n"); exit(EOF); } hp.a = tmp; hp.capacity = hp.size = k; memcpy(hp.a, ary, sizeof(HPDataType) * k);//复制数组中前k个数到堆 for (int i = (k - 2) / 2; i >= 0; --i)//开始向下调整-建小堆 { ADjustDown(hp.a, k, i); } for (int i = k; i < n; ++i) { //if (ary[i] < HeapTop(&hp))//如果当前的数比堆顶小则取代当前的堆顶-选小 if (ary[i] > HeapTop(&hp))//如果当前的数比堆顶大则取代当前的堆顶-选大 { hp.a[0] = ary[i]; ADjustDown(hp.a, k, 0);//向下调整,让最大的数沉在堆底 } } //对前k个最大的数进行堆排升序让我们更容易观察 HeapSort(hp.a, sizeof(hp.a) / sizeof(hp.a[0])); for (int i = 0; i < k; ++i)//输出前k个最大的数 { printf("%d ", hp.a[i]); } printf("\n"); HeapDestory(&hp);//销毁堆 }
最后
本篇我们介绍了二叉树的顺序存储结构-堆,实现了堆的基本操作,并介绍了堆的应用:堆排序和Top-k选数,堆的这些特性和应用非常实用,堆排序的效率和Top-K选数的效率非常高,只要选择合适的场地使用堆,那么将为我们带来前所未有的便利!
本次堆的基本知识就介绍到这里啦,希望能够尽可能帮助到大家。
如果文章中有瑕疵,还请各位大佬细心点评和留言,我将立即修补错误,谢谢!
博客中的所有代码合集:堆
🌟其他文章阅读推荐🌟
🌹欢迎读者多多浏览多多支持!🌹