自学教程|<数据结构>挖堆堆、栽树树(手把手教你写“堆排序”)

自学教程|<数据结构>挖堆堆、栽树树(手把手教你写“堆排序”)
文章图片


文章目录

  • 1. 二叉树的顺序结构
  • 2. 堆的概念及结构
  • 3. 堆的实现(以小堆为例)
    • 3.1 Heap.h
    • 3.2 堆的构建
    • 3.3 堆的销毁
    • 3.4 辅助函数
    • 3.5 向上调整、堆的插入
    • 3.6 向下调整、堆的删除
    • 3.7 堆的判空
    • 3.8 堆的数据个数
    • 3.9 取堆顶的数据
  • 4. 堆排序
    • 4.1堆排序的优点和不足
    • 4.2 优化:直接在数组上建堆
      • 4.2.1 向上调整建堆
      • 4.2.2 向下调整建堆
      • 4.2.3 时间复杂度对比
    • 4.3 堆排序整体逻辑
  • 5. TOP-K问题

目前在不断更新<数据结构>的知识总结,已经更新完了,未来我会系统地更新等内容。
想要一步步稳扎稳打,学习编程的小伙伴可以关注我或者订阅专栏,文章都是免费的,不要错过这一个提升自己的机会!
本系列相关文章
<数据结构>倒拔二叉树
已完结系列文章总结:
c语言自学教程——博文总结
1. 二叉树的顺序结构
普通的二叉树是不适合用数组来存储的,因为可能会存在大量的空间浪费。而完全二叉树更适合使用顺序结构存储。现实中我们通常把堆(一种二叉树)使用顺序结构的数组来存储
??需要注意的是这里的堆操作系统虚拟进程地址空间中的堆 是两回事,一个是数据结构,一个是操作系统中管理内存的一块区域分段
自学教程|<数据结构>挖堆堆、栽树树(手把手教你写“堆排序”)
文章图片

2. 堆的概念及结构
如果有一个关键码的集合,把它的所有元素按完全二叉树的顺序存储方式存储在一个一维数组中,并满足:父亲节点小于(或大于)孩子节点则称为小堆(或大堆)。将根节点最大的堆叫做最大堆或大根堆,根节点最小的堆叫做最小堆或小根堆
堆的性质
  • 堆中某个节点的值总是不大于或不小于其父节点的值
  • 堆总是一棵完全二叉树。
    自学教程|<数据结构>挖堆堆、栽树树(手把手教你写“堆排序”)
    文章图片

3. 堆的实现(以小堆为例) 3.1 Heap.h
#pragma once #include #include #include #includetypedef int HPDataType; typedef struct Heap { HPDataType* a; size_t size; size_t capacity; }HP; void HeapInit(HP* php); // 堆的构建 void HeapDestroy(HP* php); // 堆的销毁void Swap(HPDataType* pa, HPDataType* pb); //交换 void HeapPrint(HP* php); //打印堆中数据void AdjustUp(HPDataType* a, size_t child); //向上调整 void HeapPush(HP* php, HPDataType x); //堆的插入void AdjustDown(HPDataType* a, size_t size, size_t root); //向下调整 void HeapPop(HP* php); // 堆的删除bool HeapEmpty(HP* php); // 堆的判空 size_t HeapSize(HP* php); // 堆的数据个数 HPDataType HeapTop(HP* php); // 取堆顶的数据

3.2 堆的构建
跟顺序表一样,在这不赘述了。如果忘记了请看还不会写顺序表?我手把手教你
void HeapInit(HP* php) { assert(php); php->a = NULL; php->capacity = php->size = 0; }

3.3 堆的销毁
跟顺序表一样,在这不赘述了。
void HeapDestroy(HP* php) { assert(php); free(php->a); php->a = NULL; php->capacity = php->size = 0; }

3.4 辅助函数
之后有用,在这先卖个关子。
void Swap(HPDataType* pa, HPDataType* pb) { HPDataType tmp = *pa; *pa = *pb; *pb = tmp; }void HeapPrint(HP* php) { assert(php); for (size_t i = 0; i < php->size; i++) { printf("%d ", php->a[i]); } printf("\n"); }

3.5 向上调整、堆的插入
首先,把数据插入到堆中,之后为了保持小堆,我们写了个向上调整的函数来调用。
自学教程|<数据结构>挖堆堆、栽树树(手把手教你写“堆排序”)
文章图片

//向上调整 void AdjustUp(HPDataType* a, size_t child) { size_t parent = (child - 1) / 2; while (child > 0) { if (a[child] < a[parent])//如果想改成大堆就改成大于号 { Swap(&a[child], &a[parent]); child = parent; parent = (child - 1) / 2; } else { break; } } }void HeapPush(HP* php, HPDataType x) { assert(php); //扩容 if (php->size == php->capacity) { size_t newCapacity = php->capacity == 0 ? 4 : 2 * php->capacity; HPDataType* tmp = realloc(php->a, sizeof(HPDataType) * newCapacity); assert(tmp); php->a = tmp; php->capacity = newCapacity; } //插入 php->a[php->size] = x; ++php->size; //保持堆,移位 AdjustUp(php->a, php->size-1); }

3.6 向下调整、堆的删除
删除堆是删除堆顶的数据
一开始我想到的是:直接把数据往前移动,覆盖第一个,但我发现这样的话有两大坏处:
1??挪动数据是O(N)
2??堆结构破坏了,父子间关系全都乱了,不可行。
自学教程|<数据结构>挖堆堆、栽树树(手把手教你写“堆排序”)
文章图片

之后找到一种巧妙的方法:将堆顶的数据跟最后一个数据交换换,然后删除数组最后一个数据,再进行向下调整算法。 时间复杂度是O(logN),效率更高。
自学教程|<数据结构>挖堆堆、栽树树(手把手教你写“堆排序”)
文章图片

具体思路:选出左右孩子中小的孩子,如果孩子小于父亲,则它们之间交换,交换之后,将子节点看作新的父亲节点,继续向下调整,直到父亲节点的孩子不存在或孩子不小于父亲。
举个栗子:
现在我们给出一个数组,逻辑上看做一颗完全二叉树。我们通过从根节点开始的向下调整算法可以把它调整
成一个小堆。向下调整算法有一个前提:左右子树必须是一个堆,才能调整。
int array[] = {27,15,19,18,28,34,65,49,25,37} ; 自学教程|<数据结构>挖堆堆、栽树树(手把手教你写“堆排序”)
文章图片

//向下调整 void AdjustDown(HPDataType* a, size_t size, size_t root) { size_t parent = root; size_t child = root * 2 + 1; while (child < size) { //选左右孩子中小的孩子 if (child + 1 < size && a[child] > a[child + 1])//如果想改成大堆就改第二个大于号 { child++; } //如果孩子小于父亲,则交换,并继续向下调整 if (a[parent] > a[child])//如果想改成大堆就改成小于号 { Swap(&a[parent], &a[child]); parent = child; child = parent * 2 + 1; } else { break; } }}//删除堆顶的数据 void HeapPop(HP* php) { assert(php); assert(php->size > 0); Swap(&php->a[0], &php->a[php->size - 1]); php->size--; //向下调整 AdjustDown(php->a, php->size, 0); }

3.7 堆的判空
bool HeapEmpty(HP* php) { assert(php); return php->size == 0; }

3.8 堆的数据个数
size_t HeapSize(HP* php) { assert(php); return php->size; }

3.9 取堆顶的数据
HPDataType HeapTop(HP* php) { assert(php); assert(php->size > 0); return php->a[0]; }

4. 堆排序
堆排序即利用堆的思想来进行排序,总共分为两个步骤:
  1. 【自学教程|<数据结构>挖堆堆、栽树树(手把手教你写“堆排序”)】把数据一个个放入堆中,每插入一个都潜在的维持了堆的结构(大堆或小堆)
  2. 然后取堆顶数据(最大或最小)放到数组中,再把堆顶数据删除掉,删除之后向下调整,并将目前最大(或最小)的数据挪到堆顶,重复这个步骤直到堆为空。
下面的动图是用这个思路来排序的,但它是从后往前放到数组里的,这个跟我们下面的参考代码有些不同。
自学教程|<数据结构>挖堆堆、栽树树(手把手教你写“堆排序”)
文章图片

参考代码
void HeapSort(int* a, int size) { HP hp; HeapInit(&hp); for (int i = 0; i < size; i++) { HeapPush(&hp, a[i]); } int j = 0; while (!HeapEmpty(&hp)) { a[j] = HeapTop(&hp); HeapPop(&hp); j++; } HeapDestroy(&hp); } int main() { int a[] = { 5,10,6,1,-1,2,7,0 }; HeapSort(a, sizeof(a) / sizeof(a[0])); for (int i = 0; i < sizeof(a) / sizeof(a[0]); i++) { printf("%d ", a[i]); } printf("\n"); return 0; }

4.1堆排序的优点和不足
冒泡排序和堆排序的时间复杂度比较:
  • 冒泡排序:(N-1)+(N-2)+·····+2+1??时间复杂度是O(N^2)
  • 堆排序:建堆是O(N * logN),取堆顶数据是O(N)??时间复杂度是O(N * logN)
自学教程|<数据结构>挖堆堆、栽树树(手把手教你写“堆排序”)
文章图片

这俩的区别可大着呢!如果不会时间复杂度和空间复杂度请看:数据结构自学教程——算法的时间复杂度和空间复杂度
这样看来,堆排序还是很棒的,但我们一般不会这样使用堆排序,因为它需要另建一个堆来存放数据,空间复杂度是O(N)
那么有没有什么办法,能够在原本的数组上建堆,使用堆排序呢?
4.2 优化:直接在数组上建堆
利用1??向上调整函数,插入数据的思路建堆或2??向下调整从下往上建堆
4.2.1 向上调整建堆
//在原数组中向上调整,建堆 for (int i = 1; i < size; i++)//i没必要从0开始,第一次没必要向上调整 { AdjustUp(a, i); }

4.2.2 向下调整建堆
向下调整函数是有要求的,左子树右子数都为大堆(或小堆)才能用。
那样的话就从下往上调,先让子树满足条件,再逐渐向上。
并且叶子节点不用往下调(它们就是最下面的),就从倒数第一个非叶子节点(也就是最后一个节点的父亲)开始调整。
//向下调整,建堆(从倒数第一个非叶子节点开始,也就是最后一个节点的父亲) for (int i = (size - 1 - 1) / 2; i >= 0; --i) { AdjustDown(a, size, i); }

4.2.3 时间复杂度对比
1??向上调整
第二层的2个节点最多要向上调整1次,第三层的4个节点最多要向上调整2次,最后累积,我们会得到下面这个公式
自学教程|<数据结构>挖堆堆、栽树树(手把手教你写“堆排序”)
文章图片

2??向下调整
第一层的1个节点最多要向下调整h-1次,第二层的2个节点最多要向下调整h-2次,最后累积,我们会得到下面这个公式
自学教程|<数据结构>挖堆堆、栽树树(手把手教你写“堆排序”)
文章图片

4.3 堆排序整体逻辑
提问:能够建小堆实现升序排序?
不能!
建小堆的话最小的数在第一个位置了,接下来要把第二小的数放到第二个位置。
但是将第一个位置的数从堆中除去,剩下的数关系已经乱了,要通过重新建堆才能找到次小的数,时间复杂度为O(N),实在太麻烦了。
同理可得不能建大堆实现降序排序
应该将排好的部分留在数组后部,不影响前方的排序
因此:
  • 升序:建大堆
  • 降序:建小堆
    下面的动图用了向上调整建堆,建大堆实现升序排序

void HeapSort(int* a, int size) { 在原数组中向上调整,建堆 //for (int i = 1; i < size; i++) //{ // AdjustUp(a, i); //} //向下调整,建堆(从倒数第一个非叶子节点开始,也就是最后一个节点的父亲) for (int i = (size - 1 - 1) / 2; i >= 0; --i) { AdjustDown(a, size, i); } size_t end = size - 1; while (end > 0) { Swap(&a[0], &a[end]); AdjustDown(a, end, 0); //将排完了的数,不看做堆里的数 end--; }}int main() { int a[] = { 5,10,6,1,8,2,7,0 }; HeapSort(a, sizeof(a) / sizeof(a[0])); for (int i = 0; i < sizeof(a) / sizeof(a[0]); i++) { printf("%d ", a[i]); } printf("\n"); return 0; }

自学教程|<数据结构>挖堆堆、栽树树(手把手教你写“堆排序”)
文章图片

5. TOP-K问题
TOP-K问题:即求数据结合中前K个最大的元素或者最小的元素,一般情况下数据量都比较大。
比如:专业前10名、世界500强、富豪榜、游戏中前100的活跃玩家等。
对于Top-K问题,能想到的最简单直接的方式就是排序,但是:如果数据量非常大,排序就不太可取了(可能
数据都不能一下子全部加载到内存中,举个例子:100亿个整数需要40GB的空间存储)。
最佳的方式就是用堆来解决,基本思路如下:
1?? 用数据集合中前K个元素来建堆
前k个最大的元素,则建小堆
前k个最小的元素,则建大堆
2?? 用剩余的N-K个元素依次与堆顶元素来比较,不满足则替换堆顶元素
将剩余N-K个元素依次与堆顶元素比完之后,堆中剩余的K个元素就是所求的前K个最小或者最大的元素。
void PrintTopK(int* a, int n, int k) { // 1. 建堆--用a中前k个元素建堆 // 2. 将剩余n-k个元素依次与堆顶元素交换,不满则则替换 }void TestTopk() { int n = 10000; int* a = (int*)malloc(sizeof(int)*n); srand(time(0)); for (size_t i = 0; i < n; ++i) { a[i] = rand() % 1000000; } a[5] = 1000000 + 1; a[1231] = 1000000 + 2; a[531] = 1000000 + 3; a[5121] = 1000000 + 4; a[115] = 1000000 + 5; a[2335] = 1000000 + 6; a[9999] = 1000000 + 7; a[76] = 1000000 + 8; a[423] = 1000000 + 9; a[3144] = 1000000 + 10; PrintTopK(int* a, n, 10); }

如果有帮助还请点赞、评论、收藏支持一下博主。
小小预告一下:下一期讲链式二叉树、分治思想。很精彩!敬请期待

    推荐阅读