前言:
前篇学习了 数据结构之树和二叉树的基本概念知识,那么这篇继续学习怎么实现基本的操作。所以先建议看完上篇知识点,才有助于消化知识和理解。
/知识点汇总/
概念:堆(Heap)是计算机科学中一类特殊的数据结构,是最高效的优先级队列。堆通常是一个可以被看作一棵完全二叉树的数组对象。
堆总是满足下列性质:
1.堆中某个结点的值总是不大于或不小于其父结点的值;
2.堆总是一棵完全二叉树。
3.堆的物理结构本质上是顺序存储的,是线性的。但在逻辑上不是线性的,是完全二叉树的这种逻辑储存结构。
将根结点最大的堆叫做最大堆或大根堆,根结点最小的堆叫做最小堆或小根堆。常见的堆有二叉堆、斐波那契堆等。
一般是把数组数据看作一颗完全二叉树,并有以下要求:
小堆:任意一个父亲结点 <= 孩子结点
大堆:任意一个父亲结点 >= 孩子结点
树/堆/二叉树的存储结构
通过前篇知识点就可以清楚的知道,有三种存储形式。不管哪种方式,取决于实际应用场景和需求即可。
方法一:
#define N 6
struct TreeNode
{
int val;
struct TreeNode* childArr[N];//指针数组
};
方法二:
struct TreeNode
{
int val;
SeqList childSL;//顺序表
//SeqList,C++的库可调用
};
方法三,最优方法:左孩子右兄弟表示法
struct TreeNode
{
int val;
struct TreeNode* leftChild;
struct TreeNode* rightBother;
};
那么接下来就采用普遍的数组形式,定义结构体成员和变量进行学习。
typedef int HPDataType;
typedef struct Heap
{
HPDataType* a;
int size;
int capacity;
}HP;
另外,书上还有常见的存储形式如下所示:
#define MaxSize 100
typedef char DataType;
typedef struct
{
DataType data[MaxSize];
int bitTreeNum; //结点个数
}SeqBitTree;
void HeapInit(HP* php);
void HeapDestory(HP* php);
//向上调整法
void HeadPush(HP* php, HPDataType x);
void HeapPop(HP* php);//规定默认删除堆顶的数据,即根结点
//因为,堆顶被删除,那么小堆就是删除最小值,大堆就是删除最大值。
//删除后就是次大值,次小值,为排序这些操作做铺垫。
//而且尾删的代价很低,容易操作。
HPDataType HeapTop(HP* php);
size_t HeapSize(HP* php);
bool HeapEmpty(HP* php);
void HeapSort(int* a, int n);
void Swap(HPDataType* p1, HPDataType* p2);
void AdjustDown(int* a, int size, int parent);
void AdjustUp(HPDataType* a, int child);
队列的操作算是比较简单,理解性掌握即可。只需要注意一下,操作空队列和一个元素的情况的处理即可。
堆的初始化和前面学过的栈的初始化几乎相同,其次初始化一般都是先置空,操作简单。
void HeapInit(HP* php)
{
assert(php);
php->a = NULL;
php->size = 0;
php->capacity = 0;
}
这里使用的指针数组,那么相应的销毁,肯定就需要对应的释放开辟的空间,对防止野指针或者其它的内存泄漏等情况的处理。
void HeapDestory(HP* php)
{
assert(php);
free(php->a);
php->a = NULL;
php->size = 0;
php->capacity = 0;
}
堆的核心操作就是涉及到怎么取得根结点数据,以及叶子结点之间的关系。那么结合大根堆和小根堆的思想,那要解决此类问题,不妨封装两个功能函数,一个负责把数据向上排序调整,另一个负责把数据向下排序调整,即,建大堆:升序,建小堆:降序,大小堆的区别就是调整上调下调,本质算法思路一致。交换其中的顺序就是升序和逆序的区别。
首先,结合上篇知识点中,二叉树的基本性质其中的父子结点的位置关系,得到parent = (child - 1) / 2,写法多种但目的就是比较大小,将大的数据给父结点。直到child≤0结束循环,因为此时到的根节点了,没有比较对象了。
//交换
void Swap(HPDataType* p1, HPDataType* p2)
{
HPDataType tmp = *p1;
*p1 = *p2;
*p2 = tmp;
}
//向上调整
void AdjustUp(HPDataType* a, int child)
{
int parent = (child - 1) / 2;
while (child > 0)
{
if (a[child] < a[parent])//小于就交换
{
Swap(&a[child], &a[parent]);//交换数据
//上调父亲和孩子的位置
//写法1:
child = parent;
//写法2:child = parent;
//写法3:child = (child-1)/2;
parent = (child - 1) / 2;
}
else//大于等于父亲,停止交换break
{
break;
}
}
}
向下调整就是建小堆的思想,把小的数据向根结点挪动,起初不知道左右孩子的大小,采用假设法,初步比较大小,如果假设错误就交换假设对象即可,目的就是保证child变量存放的是为较小孩子,才能继续后面的交换,接下来就得考虑是否有两个孩子,所以判断语句if((child+1) 基于核心函数的理解,那么堆的插入操作就是调用之前,判断一下容量是否满即可。 堆的删除基于核心函数AdjustDown()删除掉根节点即可。 不管大堆还是小堆,a[0]放的都是根结点数据,直接return即可。 判断大小或个数多少,当时定义结构体成员时定义了size,那么此时就体现了优势,直接返回size的大小即可。 同理判断空,当时定义结构体成员时定义了size,那么此时就体现了优势,直接返回size的大小即可。 基于上面的操作,实现堆的排序就是大根堆和小根堆,建大堆就是升序,建小堆就是降序,但是有了核心函数,可以根据下标之间的关系,只用AdjustDown函数中的比较的大小关系,就可以操作升序和降序了。 问题探究:N个数里面找最大前K个,(N远大于K) 思路1: 因为堆是完全二叉树,而满二叉树也是完全二叉树,此处为了简化使用满二叉树来证明(时间复杂度本来看的就是近似值,多几个结点不影响最终结果) //满二叉树: //向下调整建堆的累积调整次数是T(h) //结合,h是树的高度,N是树的节点个数: 结语:下篇就根据排序 — 算法效率优化等问题,学习算法思想的巩固。//交换
void Swap(HPDataType* p1, HPDataType* p2)
{
HPDataType tmp = *p1;
*p1 = *p2;
*p2 = tmp;
}
//向下调整
void AdjustDown(int* a, int size, int parent)
{
//假设法,假设左孩子小,如果假设错了,就交换为右孩子小
//根本目的就是保证child为较小孩子
int child = parent * 2 + 1;
while (child < size)
{
//假设错误,则交换
//if (a[child + 1] < a[child])//但是这样写,存在一定的问题,能保证有左孩子,不能保证右孩子的情况
if((child+1) <size && a[child + 1] < a[child])
{
++child;
}
//如果,孩子小于父亲,则交换父子结点数据
//向下调整
if (a[child] < a[parent])
{
Swap(&a[child], &a[parent]);
parent = child;
child = parent * 2 + 1;
}
else
{
break;
}
}
}
2.2.4、堆的插入操作
void HeadPush(HP* php, HPDataType x)
{
assert(php);
//检测容量
if (php->size == php->capacity)
{
size_t newCapacity = php->capacity == 0 ? 4 : php->capacity * 2;
HPDataType* tmp = (HPDataType*)realloc(php->a, sizeof(HPDataType) * newCapacity);
if (tmp == NULL)
{
perror("realloc fail");
//return;
exit(-1);
}
php->a = tmp;
php->capacity = newCapacity;
}
//判满
php->a[php->size] = x;
php->size++;
//二叉树/堆,插入后需要向上调整 -- 封装一个函数AdjustUp()
AdjustUp(php->a, php->size-1);//因为size++了,所以插入数据的下标就是size-1
}
2.2.5、堆的删除操作
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);
}
2.2.6、取堆的根节点
HPDataType HeapTop(HP* php)
{
assert(php);
assert(php->size > 0);
return php->a[0];//a[0]始终放的最值
}
2.2.7、取堆的结点个数
size_t HeapSize(HP* php)
{
assert(php);
return php->size;
}
2.2.8、堆的判空
bool HeapEmpty(HP* php)
{
assert(php);
return php->size;
}
2.2.9、堆的排序
void HeapSort(int* a, int n)
{
//建大堆:升序
//建小堆:降序 --- O(N*logN)
//for (int i = 1; i < n; i++)
//{
// AdjustUp(a, i);
//}
//向下调整 --- O(N)
for (int i = (n-1-1)/2; i >= 0; i--) //(n-1-1)/2 -- (n-1)是下标
{
AdjustDown(a, n, 1);
}
int end = n - 1;
while (end > 0)
{
Swap(&a[0], &a[end]);
AdjustDown(a, end, 0);
--end;
}
}
2.3、堆的main.c
#include "Heap.h"
//测试一:
void TestHp1(void)
{
int a[8] = { 4,6,2,1,5,8,2,9 };
HP hp;
HeapInit(&hp);
for (int i = 0; i < sizeof(a) / sizeof(a[0]); i++)
{
HeadPush(&hp, a[i]);
}
//实现:top k
//int k = 3;
//while (k--)
//{
// printf("%d ", HeapTop(&hp));
// HeapPop(&hp);
//}
//小堆实现方法一:升序
while (HeapEmpty(&hp))
{
printf("%d ", HeapTop(&hp));
HeapPop(&hp);
}
printf("\n");
HeapDestory(&hp);
return 0;
}
//测试二:
void TestHp2(void)
{
int a[8] = { 4,6,2,1,5,8,2,9 };
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;
}
int main()
{
//TestHp1();
TestHp2();
}
3、堆的top K问题的延申
小数据很好解决,那么对于,假如N为100亿,k为10(100亿数据量相当于40G),该如何处理top k问题呢?
N个数插入到堆里面,Pop k次
时间复杂度:NlogN+klogN -->O(NlogN)
此题,对于思路1,就是存在内存不足
提供思路2:
步骤1.读取前k个值,建立k个数的小堆
步骤2.依次再读取后面的值,跟堆顶比较,如果比堆顶大,替换堆顶然后进堆(替换堆顶值,再向下调整算法)
这里利用小堆的特点,大的值被替换为堆顶后,会执行向下调整算法,将大的值不断向叶子节点沉下去。
时间复杂度:O(N*logk)3.1、top k 问题实现
#include "Heap.h"
void CreateNDate()
{
//造数据
int n = 1000000;
srand((unsigned int)time(0));
const char* file = "data.txt";
FILE* fin = fopen(file, "w");
if (fin == NULL)
{
perror("fopen error");
return;
}
for (int i = 0; i < n; i++)
{
int x = (rand() + i) % 1000000;
fprintf(fin, "%d\n", x);//方便,写入文件·方便后面的读出以空格或换行作为分割。
}
fclose(fin);
}
void PrintTopk(const char* file, int k)
{
FILE* fout = fopen(file, "r");
if (fout == NULL)
{
perror("fopen error");
return;
}
//建k个数的小堆
int* minheap = (int*)malloc(sizeof(int) * k);
if (minheap == NULL)
{
perror("malloc error");
return;
}
//读取前k个,建小堆
for (int i = 0; i < k; i++)
{
fscanf(fout, "%d", &minheap[i]);
AdjustUp(minheap, i);
}
int x = 0;
while (fscanf(fout, "%d", &x) != EOF)
{
if (x > minheap[0])
{
minheap[0] = x;
AdjustDown(minheap, k, 0);
}
}
for (int i = 0; i < k; i++)
{
printf("%d ", minheap[i]);
}
printf("\n");
free(minheap);
fclose(fout);
}
int main()
{
//CreateNDate();
PrintTopk("data.txt",5);
return 0;
}
4、建堆时间复杂度分析
证明如下:
//第一层, 2^0个结点,需要向下移动 h-1层;
//第二层, 2^1个结点,需要向下移动 h-2层;
//第三层, 2^2个结点,需要向下移动 h-3层;
//第四层, 2^3个结点,需要向下移动 h-4层;
//…
//第h-1层, 2^(h-2)个结点,需要向下移动 1层;
//第h层,2^(h-1)个结点
//T(h) = 2^(h-2)*1 + 2(h-3)*2+…+21(h-2)+2^0(h-1)
//错位相减法:
//T(h) = 2^(h-1)1 + 2(h-3)+…+21 - 2^0(h-1)
//T(h) = 2^(h-1) +2^(h-2) + 2(h-3)+…+20 - h
//T(h) = 2^h-1 - h
//满二叉树:2^(h-1) = N --> h = log(N+1)
//得到:
//T(h) = 2^h-1-h --> T(N) = N -log(N+1)
//向上调整建堆的累积调整次数是T(h)
//T(h) = 2^11 + 2^22 + 2^33 +…+2^(h-2)(h-2) + 2^(h-1)*(h-1)
//所以衡量对比知道:向上调整劣于向下调整
//T(h) = N + (N+1)*log((N+1)-1) + 1