C语言数据结构-堆
1 堆
1.1 堆的概念及结构
如果有一个集合能够满足把它的所有元素按照二叉树的顺序存储结构存储在一个一维数组之中,并满足父节点始终大于等于子节点,那么这个就叫做大根堆或者叫做最大堆,如果父节点始终小于等于子节点,这个堆就叫做小根堆或者叫做最小堆,或者说,将他们转换数组的形式,从0号下标一直到数组结束位置,如果元素是升序则是小根堆,如果是降序则是大根堆
1.2 堆的实现
1.2.1 向下调整算法
数组建堆主要依赖的就是向下调整算法,没有这个算法,建堆的过程将会很难
向下调整算法:
1. 需要先找到左右孩子之中较小的一个,然后交换父亲和孩子的数据
2. 父亲变成原孩子的位置,孩子位置更新,继续循环
3. 直到父亲小于孩子,或者孩子的位置大于等于数组大小即退出循环
//前提:左右子树都是小堆(大堆)
void AdjustDown(HPDataType* a, int n,int root)//参数部分(数组,数组大小,父节点)
{//选出左右孩子之中最小的一个int parent = root;//创建变量parent接收rootint child = parent * 2 + 1;//默认较小的孩子是左孩子while (child < n)//当child还在数组范围内就继续循环{if (child+1 < n && a[child + 1] < a[child])//如果右孩子比左孩子还小,就让右孩子做较小的孩子//并且为了避免边界问题,没有右孩子,就要限制child+1<n{child += 1;//让最小的孩子做child}if (a[child] < a[parent])//如果孩子比父亲小,说明可以交换{Swap(&a[child], &a[parent]);//交换父亲和孩子的数据parent = child;//父亲变成孩子child = parent * 2 + 1;//重新更新child}else//如果父亲小于等于孩子,说明建堆已经完成,退出循环{break;//退出循环}}
}
然而我们的向下调整算法具有局限性,必须保证左右字数是小堆(大堆),为了避免这种情况,我们选择从底部开始,找到最后一个父节点(n - 1 -1)/2,因为数组是从0开始计算的,最后一个节点的下标是n-1,所以父节点是(n - 1 -1)/2从最后一个父节点开始使用向下调整算法,最后一个父节点调整完成之后,i–,找到倒数第二个父节点,然后调整,然后循环,直至找到根节点调整完毕,完成建堆
1.2.2 向上调整算法
如果需要给堆中添加数据只能从堆底添加,如果从堆顶添加的话会导致父子关系乱套,代码效率低等问题导致复杂化,所以如果我们从堆底添加数据,那么只影响从堆底到堆顶这一条高度次路径的数据,时间复杂度将会降至最低,所以我们需要向上调整算法为堆中添加数据做好基础
在使用向上调整算法时需要频繁使用到交换数据,对于经常使用的函数就抽象出来成为函数,避免反复书写
//经常使用的函数就抽象出来成为函数,避免反复书写
void Swap(HPDataType* p1, HPDataType* p2)
{HPDataType tmp = *p1;//此时p1为父节点或者子节点的地址,需要解引用使用*p1 = *p2;*p2 = tmp;//交换算法
}
//因为向上调整算法只影响从节点位置到根节点位置这一条路径上的节点,所以不用考虑兄弟节点之间的大小关系
void AdjustUp(HPDataType* a, int n, int child)//参数部分(数组指针,数组个数,孩子节点位置)
{int parent = (child - 1) / 2;//找到孩子节点的父节点while (child > 0)//如果孩子节点大于0,说明孩子节点没有到根节点,继续循环{if (a[child] < a[parent])//如果孩子节点小于父节点,说明需要交换{Swap(&a[child], &a[parent]);//交换孩子节点和父节点child = parent;//将孩子作为父亲继续向上调整parent = (child - 1) / 2;//找到孩子节点的父节点}else{break;//否则跳出循环}}
}
### 1.2.2 堆的定义,初始化和销毁
目前我们只演示小堆的相关代码,大堆的代码大同小异
小堆的定义:
```c
//小堆
typedef int HPDataType;//重定义堆所保存的数据类型typedef struct Heap//创建并重定义结构体Heap
{HPDataType* _a;//堆所保存的数据,该指针将指向一块可以自定义空间大小的数组int _size;//堆所保存的数据个数int _capacity;//堆的空间大小
}Heap;//重定义为Heap
小堆的初始化:
//初始化堆
void HeapInit(Heap* php, HPDataType* a, int n)//初始化函数需要从参数a之中获取建堆所需要的数据
{php->_a = (HPDataType*)malloc(sizeof(HPDataType) * n);if (php->_a == NULL)//一般情况下malloc是不会失败的,只有内存不够,或者开辟空间太大才会失败,所以很多代码是没有检查的{perror("内存开辟失败\n");//提示报错信息exit(-1);//挂掉程序}memcpy(php->_a, a, sizeof(HPDataType) * n);//内存拷贝函数memcpy(拷贝目标地址,拷贝数据地址,拷贝数据大小),浅拷贝php->_capacity = n;//堆的大小php->_size = n;//数组数据个数//构建堆//我们的向下调整算法具有局限性,必须保证左右字数是小堆(大堆)//为了避免这种情况,我们选择从底部开始,找到最后一个父节点(n - 1 -1)/2,因为数组是从0开始计算的,最后一个节点的下标是n-1,所以父节点是(n - 1 -1)/2,从最后一个父节点开始使用向下调整算法//最后一个父节点调整完成之后,i--,找到倒数第二个父节点,然后调整,然后循环,,直至找到根节点调整完毕,完成建堆for (int i = (n - 1 - 1) / 2; i >= 0; i--){AdjustDown(php->_a, php->_size, i);//使用向下调整算法}
}
堆的销毁:
void HeapDestory(Heap* php)
{assert(php);//断言free(php->_a);//释放php->_aphp->_a = NULL;//将该指针置为空php->_size = php->_capacity = 0;//将其置为0
}
1.2.3 添加和删除堆的数据
添加堆的数据:
//堆的插入不能考虑头插,因为顺序结构是以数组作为基础结构,在头部插入会导致大量数据的挪动
//并且堆内容父节点变成子节点,兄弟节点变成父子节点,全部都乱套了
//所以只能尾插,尾插过后可以发现他只和他的父节点有关系,只影响从他到根节点这一条路径,所以时间复杂度为高度次log^n
//因为需要和他的父亲比,所以应该是向上调整,需要额外写一个向上调整算法
void HeapPush(Heap* php, HPDataType x)
{assert(php);//断言if (php->_size == php->_capacity)//如果size==capacity{php->_capacity *= 2;//将capacity扩容至以前的2倍HPDataType* tmp = (HPDataType*)realloc(php->_a, sizeof(HPDataType) * php->_capacity);//扩容if (tmp == NULL)//如果没有扩容成功{perror("内存不足\n");//报错exit(-1);//结束程序}php->_a = tmp;//扩容成功,将tmp地址给php->_a}php->_a[php->_size] = x;//将x值给到数组之中php->_size++;//size+1AdjustUp(php->_a, php->_size, php->_size - 1);//向上调整
}
删除堆的数据:
//删除堆顶的数据
//如何删除呢,如果是从堆顶直接删除,那么父子兄弟关系全都乱套了,并且时间复杂度将会变得很大
//所以我们将堆顶和堆底进行交换,将堆的size减1,堆顶使用向下调整算法排序,就可以完成堆删除数据的操作
void HeapPop(Heap* php)
{assert(php);assert(php->_size > 0);//断言保证size>0Swap(&php->_a[0], &php->_a[php->_size - 1]);//将堆顶元素与堆底末尾元素交换,堆顶为0,堆底则是size-1php->_size--;//size--,删除堆底数据AdjustDown(php->_a, php->_size, 0);//使用向下调整算法
}
1.2.4 堆排序的实现
堆排序主要分为两个步骤:
- 建堆
- 排升序:建大堆
- 排降序:建小堆
那肯定有人会问了,我如果要排升序的话应该是建小堆啊,小堆才是根节点最小的排序方式啊,为什么会是建大堆呢,不是搞反了吗?
如果排升序建小堆会出现这样一个问题,小堆的定义是父节点小于子节点,但是没有强制规定左孩子小于右孩子,在建小堆的过程之中就有可能导致左孩子是大于右孩子的,这样建出来的小堆并不满足升序的条件,并且难以通过修改实现升序
所以我们如果想排升序,就建大堆,大堆的特点是根节点是最大值,我们通过建大堆获得根节点最大值,与末尾元素进行交换,这样可以保证最大的数在最末尾,然后将堆的元素个数减1,将最大的数排除堆之外,继续建大堆,按照此思想可保证建大堆完成升序
排降序的话道理类似
- 排序
//排降序,建小堆
void HeapSort(int* a, int n)
{//建小堆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--;//将最小的元素排除在堆中}
}
堆排序时间复杂度为O(n*logn);
1.3 关于堆的TOPK问题
在N个数之中找到最大的或者最小的前K个数?
- 排序(内排序) 但是排序的时间复杂度是O(N*logN),加入N是一亿,则效率得不到保证
- 建K个数的堆来解决 如果我要找到最大的前K个数,那就建大堆,(如果我要找最小的前K个数,就建小堆),比堆顶大就代替堆顶进堆,其他数在堆里面的时候,都会被最大的前K个数挤出来,时间复杂度等于O(N*logK)
2 总结
需要补充各操作的时间复杂度:
-
建堆:O(n)
-
插入/删除:O(logn)
-
堆排序:O(n logn)
-
TOPK:O(n logk)
场景堆的应用解决场景:
-
优先级队列:堆是实现优先级队列的理想数据结构。堆可以在O(logn)时间内完成插入和删除最高优先级元素的操作。
-
堆排序:堆排序是一种基于堆的选择排序,时间复杂度为O(n logn)。
-
求Top K问题:在一组数据中找出最大(或最小)的K个数。可以使用一个大小为K的最小堆(找最大K个数)或最大堆(找最小K个数)来高效解决,时间复杂度为O(n logK)。