目录
1.二叉树顺序存储结构
2.堆的概念及结构
3.堆的相关接口实现
3.1 堆的插入及向上调整算法
3.1.1 向上调整算法
3.1.2 堆的插入
3.2 堆的删除及向下调整算法
3.2.1 向下调整算法
3.2.2 堆的删除
3.3 其它接口和代码实现
4.建堆或数组调堆的两种方式及复杂度分析
4.1 向上调整建堆
4.1.1 建堆步骤
4.1.2 代码实现
4.1.3 时间复杂度分析 --- O(N*logN)
4.2 向下调整建堆
4.2.1 建堆步骤
4.2.2 代码实现
4.2.3 时间复杂度分析 --- O(N)
5.堆的应用
5.1 堆排序(假设升序)
5.1.1 堆排序步骤
5.1.2 代码实现
5.2 TopK问题
5.2.1 TopK解决步骤
5.2.2 代码实现(数据从文件读取)
顺序存储结构就是用数组来存储,一般是用数组只适合来表示完全二叉树,因为不是完全二叉树会有空间浪费的现象。
二叉树的顺序存储结构在物理上是一个数组,在逻辑上是一个二叉树。
现实中我们通常把堆使用顺序存储结构的数组来存储,而什么又是堆呢?
需要注意的是这里的堆和操作系统虚拟进程地址空间中的堆是两回事,一个是数据结构,一个是操作系统中管理内存的一块区域分段。
其实堆就是一个完全二叉树,堆中的所有元素按完全二叉树的顺序存储方式存储在一个一维数组中,并且满足:堆中某个结点的值总是不大于其父节点的值(大堆)或者堆中某个结点的值总是不小于其父节点的值(小堆)。
总结来说:
注意:所有的数组都可以表示成完全二叉树,但是他并不一定是堆。
补充:
对于具有n个结点的完全二叉树,如果按照从上至下从左至右的数组顺序对所有节点从0开始编号,则对于序号为i的结点有:
1.若i>0,i位置节点的双亲序号:(i-1)/2; i=0,i为根节点编号,无双亲节点
2.若2i+1 < n,左孩子序号:2i+1;若2i+1>=n(数组越界), 无左孩子
(叶子就是没有左孩子,也就是叶子结点的左孩子下标2i+1>=n越界)
3.若2i+2 < n,右孩子序号:2i+2; 若2i+2>=n(数组越界), 无右孩子
- 先将元素插入到堆的末尾,即最后一个数组元素之后
- 插入之后如果堆的性质遭到破坏,插入的结点就根据向上调整算法找到合适位置即可
那么什么是向上调整算法呢?如何实现?
例如: 堆:[4, 27, 11, 28, 35, 19, 15, 89, 2] Push:2
图片展示:
代码实现:
void AdjustUp(int* a, int child)
{
int parent = (child - 1) / 2;
while (child > 0)
{
if (a[child] > a[parent])
{
Swap(a + child, a + parent);
child = parent;
parent = (parent - 1) / 2;
}
else
{
break;
}
}
}
代码注意事项:
注意:堆的物理结构是一个数组,也就是用顺序表实现的,插入时容量不够要记得扩容。
void HeapPush(Heap* php, HPDataType x)
{
assert(php);
if (php->capacity == php->size)
{
int newCapacity = php->capacity == 0 ? 4 : php->capacity * 2;
HPDataType* tmp = (HPDataType*)realloc(php->a, sizeof(HPDataType) * newCapacity);
if (tmp == NULL)
{
perror("HeapPush:");
exit(-1);
}
php->a = tmp;
php->capacity = newCapacity;
}
php->a[php->size] = x;
php->size++;
AdjustUp(php->a, php->size - 1);
}
这里堆的删除是删除堆顶数据,因为只有堆顶数据才有意义(堆顶数据都是最值,删除堆顶后能获得次大或者次小的数)
这里我们能直接删除堆顶数据吗?很明显不可以,根据顺序表删除数据的特点,后面的元素会依次覆盖前面的元素,删除堆顶数据后,堆的结构就被破坏了。
其实删除思想是这样的:
- 将堆顶元素与堆中最后一个元素交换
- 删除堆中最后一个元素
- 将堆顶元素向下调整直到满足堆的结构
这种思想很巧妙,在后面的堆排序中也会用到这种思想。
代码实现:
void AdjustDown(int* a, int size, int parent)
{
int child = parent * 2 + 1;
while (child < size)
{
if (child + 1 < size && a[child + 1] > a[child])
child++;
if (a[child] > a[parent])
{
Swap(a + child, a + parent);
parent = child;
child = child * 2 + 1;
}
else
{
break;
}
}
}
代码注意事项:
void HeapPop(Heap* php)
{
assert(php);
assert(php->size > 0);
Swap(&php->a[0], &php->a[php->size - 1]);
php->size--;
AdjustDown(php->a, php->size, 0);
}
#pragma once
#include
#include
#include
#include
typedef int HPDataType;
typedef struct Heap
{
HPDataType* a;
int size;
int capacity;
}Heap;
void Swap(int* a, int* b);
void AdjustUp(int* a, int child);//a:要调整的孩子结点所在的数组 child:要调整的孩子节点的下标
void AdjustDown(int* a, int size, int parent);//a:要调整的父亲结点所在的数组 size:数组的size parent:要调整的父亲节点的下标
void HeapPrint(Heap* php);
void HeapInit(Heap* php);
void HeapDestory(Heap* php);
void HeapPush(Heap* php, HPDataType x);
void HeapPop(Heap* php);
HPDataType HeapTop(Heap* php);
int HeapSize(Heap* php);
int HeapEmpty(Heap* php);
#include "Heap.h"
void Swap(int* a, int* b)
{
int tmp = *a;
*a = *b;
*b = tmp;
}
//调da堆
void AdjustUp(int* a, int child)
{
int parent = (child - 1) / 2;
while (child > 0)
{
if (a[child] > a[parent])
{
Swap(a + child, a + parent);
child = parent;
parent = (parent - 1) / 2;
}
else
{
break;
}
}
}
//调da堆
void AdjustDown(int* a, int size, int parent)
{
int child = parent * 2 + 1;
while (child < size)
{
if (child + 1 < size && a[child + 1] > a[child])
child++;
if (a[child] > a[parent])
{
Swap(a + child, a + parent);
parent = child;
child = child * 2 + 1;
}
else
{
break;
}
}
}
void HeapInit(Heap* php)
{
assert(php);
php->a = NULL;
php->capacity = php->size = 0;
}
void HeapDestory(Heap* php)
{
free(php->a);
php->capacity = php->size = 0;
}
void HeapPrint(Heap* php)
{
for (int i = 0; i < php->size; i++)
{
printf("%d ", php->a[i]);
}
printf("\n");
}
void HeapPush(Heap* php, HPDataType x)
{
assert(php);
if (php->capacity == php->size)
{
int newCapacity = php->capacity == 0 ? 4 : php->capacity * 2;
HPDataType* tmp = (HPDataType*)realloc(php->a, sizeof(HPDataType) * newCapacity);
if (tmp == NULL)
{
perror("HeapPush:");
exit(-1);
}
php->a = tmp;
php->capacity = newCapacity;
}
php->a[php->size] = x;
php->size++;
AdjustUp(php->a, php->size - 1);
}
//这里删除的是堆顶数据,只有堆顶数据才有意义
//1.swap(堆顶数据,最后一个数据)
//2.删除最后一个数据
//3.堆顶数据AdjustDown
void HeapPop(Heap* php)
{
assert(php);
assert(php->size > 0);
Swap(&php->a[0], &php->a[php->size - 1]);
php->size--;
AdjustDown(php->a, php->size, 0);
}
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;
}
前面我们说过,所有的数组都可以表示成完全二叉树,但是他并不一定是堆。那么我们如何将这个数组调整成堆呢?
我们首先会想到:把数组的值依次push到堆中,再把堆中数据依次赋值给数组,这样就把数组调整成了堆。但是实际应用中我们不会再写一个堆这样的数据结构,其次这种方式会有空间复杂度的消耗,所以我们不提倡这么做。
调堆方式有两种:向上调整建堆和向下调整建堆
参考堆插入的思想,数组中的每个元素都可以看做新插入的节点。
从根结点开始调整,一直调整到最后一个结点。
想要调成成小堆:如果该结点小于父节点,就一直向上交换,直到不小于其父节点或者调整到根结点。
int main()
{
int a[] = { 27,15,19,28,35,11,4,89,2 };
int size = sizeof(a) / sizeof(a[0]);
//这里我们建小堆
//方法一:向上调整算法
for (int i = 0; i < size; i++)//从根结点开始调整,一直调整到最后一个结点。
{
AdjustUp(a, i);
}
for (int i = 0; i < size; i++)
{
printf("%d ",a[i]);
}
return 0;
}
在解释向下调整算法之前,先说明一下:
向下调整算法有一个前提:左右子树必须是一个堆,才能调整。
那么我们如何调整呢?
这里我们可以利用递归思想来解决:先从倒数第一个非叶子结点的子树开始调整,一直调整到根结点的树。也就是倒着调整。
int main()
{
int a[] = { 27,15,19,28,35,11,4,89,2 };
int size = sizeof(a) / sizeof(a[0]);
//这里我们建小堆
//方法二:向下调整算法
for (int i = (size - 1 - 1) / 2; i >= 0; i--)
{
AdjustDown(a, size, i);
}
for (int i = 0; i < size; i++)
{
printf("%d ",a[i]);
}
return 0;
}
代码注意:
size - 1是最后一个数组元素的下标,对他减1除2后,就是他父节点的下标,也就是倒数第一个非叶子结点;
总结:向下调整算法建堆要比向上调整算法建堆要高效一些,并且向下调整算法要更常用(通常对堆顶数据进行向下调整操作),所以我们一般使用向下调整来建堆。
那么我们为什么要学堆呢?为什么要设计堆这种数据结构呢?
主要用于解决两个问题:
注意:这里堆的应用问题和前面数组调堆的问题是同一个道理,我们不能使用堆数据结构的相关接口,需要在原生数组上进行操作。
首先建堆这里就有一个坑了,正常思维来看,我们升序是建小堆,因为小堆的堆顶是最小值。
我们来看看升序建小堆的效率如何:
此时的时间复杂度:建堆的时间复杂度O(n),建了n次堆,时间复杂度O(n*n),这种效率还不如暴力遍历排序来的直接。
这里花里胡哨的建堆选堆顶的最值进行排序,结果效率和冒泡差不多,显然不是我们想要的结果。
那么堆排序到底是怎么排的呢,下面给出步骤
1.建堆
升序:建大堆
降序:建小堆
2.利用堆删除思想进行排序
(1)升序建的大堆,堆顶是最大元素,
(2)把堆顶(最大元素)和最后一个元素交换,
(3)最后一个元素(最大值)不看做堆中元素,堆顶元素向下调整,堆顶元素就变成了次大值,
(4)依次类推,重复(2)~(3)
可以计算一下这里的时间复杂度来和上面的建小堆方法来比较一下:
建大堆:n次向下调整,每次调整时间复杂度为O(logn),所以时间复杂度为:O(n*logn)
建小堆的时间复杂度O(n*n),很显然,数据非常多时,这两种方法的效率是天差地别
//堆排序,升序
void HeapSort(int* a, int n)
{
//第一步:建大堆
for (int i = (n - 1 - 1) / 2; i >= 0; i--)
{
AdjustDown(a, n, i);
}
//第二步:堆删除思想进行排序(依次选数,调堆)
for (int i = n - 1; i > 0; i--)
//最后一步交换后就一个堆顶元素(最小值),AdjustDown没有进行调整
{
//将堆顶元素和最后一个元素交换,交换后最后一个元素不计入堆内
Swap(&a[0], &a[i]);
AdjustDown(a, i , 0);//这里第二个参数是数据个数,最后一个元素不计入堆内,正好是i
}
}
代码注意事项:
Topk问题就是在N个数中找最大或者最小的前k个。(这里的N一般非常大,大到内存装不下)
第一次碰到这个问题,我们的惯性思维会去怎么解决呢?
但是当N非常大时,甚至内存都放不下,很显然这两种方法不靠谱。
我们可以算一下时间复杂度:
方法一:O(N*logN)
方法二:O(N+klogN) 建堆:N,k次pop :klogN
我们直接来说说Topk问题的实际解决办法:
前k个最大的元素:建小堆
前k个最小的元素:建大堆
2.用剩余的N-k个元素依次与对顶元素比较,找最大(小)的k个:比堆顶大(小),替换,向下调整。
3.最后堆中的k个元素就是最大(最小)的k个数
计算时间复杂度:O(k+(N-k)logk)~O(Nlogk)
int* TopK(int k)
{
int* retArr = (int*)malloc(sizeof(int) * k);
//打开文件
FILE* pf = fopen("data,txt", "r");
if (pf == NULL)
{
perror("TopK:");
exit(-1);
}
//前k个数据读入数组
for (int i = 0; i < k; i++)
{
fscanf(pf, "%d", &retArr[i]);
}
//数组建堆(小堆)
for (int i = (k - 2) / 2; i >= 0; i--)
{
AdjustDown(retArr, k, i);
}
//剩余N-k个数据,依次和堆顶数据比较
for (int i = 0; i < N - k; i++)
{
int x;
fscanf(pf, "%d", &x);
if (x > retArr[0])
{
retArr[0] = x;
AdjustDown(retArr, k, 0);
}
}
fclose(pf);
return retArr;
}
void testTopK()
{
int* arr = TopK(10);
for (int i = 0; i < 10; i++)
printf("%d ", arr[i]);
printf("\n");
free(arr);
}