文章目录
一. 二叉树的顺序结构
普通的二叉树是不适合用数组来存储的,因为可能会存在大量的空间浪费。而完全二叉树更适合使用顺序结构存储。现实中我们通常把堆(一种二叉树)使用顺序结构的数组来存储,需要注意的是这里的堆和操作系统虚拟进程地址空间中的堆是两回事,一个是数据结构,一个是操作系统中管理内存的一块区域分段
二叉树由一个根节点加上两棵别称为左子树和右子树的二叉树组成
- leftchild = parent * 2+1
- rightchild = parent * 2+2
- parent = (child -1) / 2
二. 堆的概念及结构
如果有一个关键码的集合K = { k0,k1 ,k2 ,…,kn-1 },把它的所有元素按完全二叉树的顺序存储方式存储在一个一维数组中,并满足:Ki <= K2i+1 且 Ki <= K2i+2( Ki>= K2i+1 且 Ki >= K2i+2) i = 0,1,2…,则称为小堆(或大堆)。将根节点最大的堆叫做最大堆或大根堆(大堆:树中任意一个父亲 >= 孩子),根节点最小的堆叫做最小堆或小根堆(小堆:树中任意一个父亲 <= 孩子)
堆的性质:
- 堆中某个节点的值总是不大于或不小于其父节点的值
- 堆总是一棵完全二叉树
- 堆在物理结构上是一个数组,在逻辑结构上是一棵完全二叉树
三. 堆的实现
3.1 堆向下调整算法(AdjustDown)
现在我们给出一个数组,逻辑上看做一颗完全二叉树。我们通过从根节点开始的向下调整算法可以把它调整成一个小堆。向下调整算法有一个前提:左右子树必须是一个堆,才能调整
向下调整算法的本质(小堆举例):
- 向下调整是要从祖先 A(27) 开始向下调整
- 左右子树必须是小堆
- 由于小堆的性质可得,此时祖先节点 A 需要在左右子树寻找一个数值更小的节点跟祖先节点 A 交换
- 此时节点 A 不再是祖先节点,但是节点 A 变成了下层左右子树的父亲节点
- 循环以上操作,向下调整,直到整棵树成小堆
int array[] = {27,15,19,18,28,34,65,49,25,37};
void AdjustDown(HPDataType* a, int n, int parent)
{
int child = parent * 2 + 1;
while (child < n)
{
if (child + 1 < n && a[child + 1] < a[child])
{
child++;
}
if (a[parent] > a[child])
{
Swap(&a[child], &a[parent]);
parent = child;
child = parent * 2 + 1;
}
else
{
break;
}
}
}
3.2 堆向上调整算法(AdjustUP)
现在我们给出一个数组,逻辑上看做一颗完全二叉树。我们通过从最后一个叶子节点开始的向上调整算法可以把它调整成一个小堆。向上调整算法有一个前提:前面的数据都成堆
向上调整算法的本质(小堆举例):
- 向上调整是要从最后一个叶子节点开始向上调整
- 前面的数据都成堆
- 最后一个叶子节点 B(10) 与父节点比较,若不满足小堆的性质,则交换
- 此时叶子节点 B ,不再是叶子节点,已经变成了叶子节点的父节点,同时作为上一层的孩子节点
- 循环以上的操作,向上调整,直到整棵树成为小堆
int a[] = { 15,18,19,25,28,34,65,49,27,37,10 };
void AdjustUP(HPDataType* a,int child)
{
int 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;
}
}
}
3.3 堆的创建
下面我们给出一个数组,这个数组逻辑上可以看做一颗完全二叉树,但是还不是一个堆,现在我们通过算法,把它构建成一个堆。
向下调整建堆
前提条件:左右子树必须是一个堆,才能调整
根节点左右子树不是堆,我们怎么调整呢?这里我们从最后一个叶子节点的父节点开始调整,一直调整到根节点,就可以调整成堆
int a[] = {1,5,3,8,7,6};
void AdjustDown(HPDataType* a, int n, int parent)
{
int child = parent * 2 + 1;
while (child < n)
{
if (child + 1 < n && a[child + 1] < a[child])
{
child++;
}
if (a[parent] > a[child])
{
Swap(&a[child], &a[parent]);
parent = child;
child = parent * 2 + 1;
}
else
{
break;
}
}
}
for (int i = (n - 1 - 1) / 2; i >= 0; i--)//向下建堆
{
AdjustDown(php->a, n, i);
}
void AdjustDown(HPDataType* a, int n, int parent)
{
int child = parent * 2 + 1;
while (child < n)
{
if (child + 1 < n && a[child + 1] < a[child])
{
child++;
}
if (a[parent] > a[child])
{
Swap(&a[child], &a[parent]);
parent = child;
child = parent * 2 + 1;
}
else
{
break;
}
}
}
向上调整建堆
前提条件:前面的数据都成堆,才能调整
向上调整建堆的思想跟插入排序类似,数组中的第一个数据本身就是堆,所以从第二个数据开始插入并向上调整,直到数组中的数据都被插入且调整完,建成堆
int a[] = { 6,5,3,8,7,1 };
for (int i = 1; i < n; i++)//向上建堆
{
AdjustUP(a, i);
}
void AdjustUP(HPDataType* a,int child)
{
int 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;
}
}
}
3.4 建堆时间复杂度
因为堆是完全二叉树,而满二叉树也是完全二叉树,此处为了简化,使用满二叉树来证明(时间复杂度本来看的就是近似值,多几个节点不影响最终结果)
向下建堆:
时间复杂度:O(N)
向上建堆:
时间复杂度:O(N*logN)
3.5 堆的代码实现
typedef int HPDataType;
typedef struct Heap
{
HPDataType* a;
int size;
int capacity;
}HP;
//给定初始数组,将数组中的数据存入树中,排列建堆
void CreatHeap(HP* php, int* a, int n);
//堆的销毁
void HeapDestroy(HP* php);
//数据的交换
void Swap(HPDataType* p1, HPDataType* p2);
//向堆中插入数据
void HeapPush(HP* php,HPDataType x);
//删除堆中数据
void HeapPop(HP* php, int child, int parent);
//堆的判空
bool HeadEmpty(HP* php);
//取堆顶的数据
HPDataType HeapTop(HP* php);
//堆中数据的个数
int HeadSize(HP* php);
//堆的打印
void PrintHeap(HP* php);
//堆的排序
void HeapSort(int* a, int n);
堆的初始化
给定一个数组,malloc一个与数组一样大小的空间,将数组中的数据拷贝到该空间中,将该空间中的数据向下调整建堆
void CreatHeap(HP* php, int* a, int n)//将数组中的数据存入树中,排列建堆。
{
assert(php);
assert(a);
php->a = (HPDataType*)malloc(sizeof(HPDataType) * n);
if (php == NULL)
{
perror("malloc fail");
exit(1);
}
php->capacity = php->size = n;
memcpy(php->a, a, sizeof(HPDataType) * n);
//向上建堆
//for (int i = 1; i < n; i++)
//{
// AdjustUP(php->a, i);
//}
//向下建堆
for (int i = (n - 1 - 1) / 2; i >= 0; i++)
{
AdjustDown(php->a, n, i);
}
}
堆的销毁
释放该结构体变量的内存空间,将结构体中的成员指针置空,有效数据个数和容量置零
void HeapDestroy(HP* php)
{
assert(php);
free(php);
php->a = NULL;
php->size = php->capacity = 0;
}
堆中数据的交换
交换堆中任意的两个数据 (一般交换头尾两个数据)
void Swap(HPDataType* p1, HPDataType* p2)
{
HPDataType tmp = *p1;
*p1 = *p2;
*p2 = tmp;
}
向堆中插入数据
每插入一个数据时,都应该进行扩容检查,在数组的末尾插入数据后,调用向上调整函数,若堆的性质被破坏,则调整节点的位置,直到重新建成堆
void HeapPush(HP* php,HPDataType x)
{
assert(php);
if (php->size == php->capacity)
{
int Newcapacity = php->capacity == 0 ? 4 : php->capacity * 2;
HPDataType* tmp = (HPDataType*)realloc(php->a, sizeof(HPDataType)*Newcapacity);
if (tmp == NULL)
{
perror("realloc fail");
exit(1);
}
php->capacity = Newcapacity;
php->a = tmp;
}
php->a[php->size] = x;
php->size++;
AdjustUP(php->a, php->size - 1);
}
删除堆顶的数据
将堆顶数据与最后一个数据交换,后删除堆中的最后一个元素,后从堆顶数据开始向下调整,直到满足堆的性质
void HeapPop(HP* php,int child, int parent)
{
assert(php);
assert(php->size > 0);
Swap(&php->a[0], &php->a[php->size-1]);
php->size--;
AdjustDown(php->a, php->size, 0);
}
堆的判空
对堆中的有效数据个数进行判断,若为空,则返回 1
bool HeadEmpty(HP* php)
{
assert(php);
return php->size == 0;
}
取堆顶的数据
先判断堆是否为空,若不为空,则将堆顶数据返回
HPDataType HeapTop(HP* php)
{
assert(php);
if (!HeadEmpty(php))
{
return php->a[0];
}
return -1;
}
堆中数据的个数
直接将堆中的有效数据的个数返回
int HeadSize(HP* php)
{
assert(php);
return php->size;
}
堆的打印
从堆顶开始,按顺序打印出每个节点的值
void PrintHeap(HP* php)
{
assert(php);
assert(php->size);
for (int i = 0; i < php->size; i++)
{
printf("%d ", php->a[i]);
}
printf("\n");
}
堆的排序
堆排序即利用堆的性质来进行排序:
1. 建堆:排升序,建大堆。排降序,建小堆
2. 利用堆删除思想来进行排序
思路一:
- 因为堆的性质,堆顶的数据为最大(小)的数据,将堆顶数据与最后一个数据交换
- 此时最大(小)的数据已经来到了堆尾,再从堆顶向下调整,恢复成堆
- 堆顶的数据已经成为次大(小)的数据,然后再与倒数第二个数据进行交换
- 此时次大(小)的数据已经来到了堆尾,再次从堆顶向下调整,恢复成堆
- 重复以上操作,即完成排序
因为堆排序的思想是从后往前排,所以大堆进行排序后,结果为升序,小堆进行排序后,结果为降序
如果想让小堆排序的结果为升序,大堆排序的结果为降序 ,就要改变排序的思路
思路二:
- 因为堆的性质,堆顶的数据为最大(小)值数据,将堆顶的数据保留
- 让第二个数据作为堆顶,但是堆的性质被破坏
- 因为无法确保左右子树都为堆,所以无法从堆顶开始向下调整,
- 要从第二个数据开始重新建堆,此时堆顶的数据为次大(小)的数据,再将堆顶数据保留
- 重复以上操作,即完成排序
对比以上两种思路:
思路一:首先,采用向下调整算法建堆 O(N),要对 N 个数进行排序,每排序一个数都要进行一次向下调整 O(N*logN),F(N) = N + N*logN。所以,思路一排序的时间复杂度:O(N*logN)
思路二:首先,采用向下调整算法建堆 O(N),要对 N 个数进行排序,每排序一个数后,堆的性质都被破坏,且无法确保左右子树为堆,无法直接进行向下调整,所以就需要使用向下调整算法重新建堆 O(N*N),F(N) = N+N*N。所以,思路二排序的时间复杂度:O(N*N)
因为思路二效率太差,且无法发挥出堆的特性,所以排升序,建大堆,排降序,建小堆
建堆和堆删除中都用到了向下调整,因此掌握了向下调整,就可以完成堆排序
int a[] = { 6,5,3,8,7,1 };
int n = sizeof(a) / sizeof(a[0]);
void HeapSort(int* a, int n)//输入数据建堆,并排序
{
assert(a);
//向上建堆
/*for (int i = 1; i < n; i++)
{
AdjustUP(a, i);
}*/
//向下建堆
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;
}
}
3.6 TOP-K问题
TOP-K问题:即求数据结合中前K个最大的元素或者最小的元素,一般情况下数据量都比较大。比如:专业前10名、世界500强、富豪榜、游戏中前100的活跃玩家等。
对于Top-K问题,能想到的最简单直接的方式就是排序,但是:如果数据量非常大,排序就不太可取了(可能数据都不能一下子全部加载到内存中)。最佳的方式就是用堆来解决,基本思路如下:
- 用数据集合中前K个元素来建堆:求前k个最大的元素,则建小堆,求前k个最小的元素,则建大堆
- 用剩余的N-K个元素依次与堆顶元素来比较,不满足则替换堆顶元素
- 利用堆顶数据为最大(小)值的性质,求数组中前K个最大(小)的元素
- 若求前K个最大的数据,应将数组中前K个元素建小堆,因为小堆堆顶的数据为最小值
- 然后将N-K个数据依次与堆顶数据进行比较,若数组中的数据大于堆顶数据,则进行交换
- 然后进行向下调整,此时堆顶的数据又成了该堆中最小的数据
- 重复以上操作,数组剩下的元素比较完后,堆中的K个元素即为前K个最大的元素
代码实现
创建数据
首先,设置随当前时间变化的种子 srand(time(0)),然后打开文件 data.txt,向里面输入伪随机数 rand(),输入完成后,关闭文件
void CreatData()
{
int n = 10000;
srand(time(0));
FILE* file_in = fopen("data.txt", "w");
if (file_in == NULL)
{
perror("fopen fail");
exit(1);
}
for (int i = 0; i < n; i++)
{
int x = rand() % 1000000;
fprintf(file_in, "%d\n", x);
}
fclose(file_in);
}
读取数据并建堆输出
首先,打开文件 data.txt,动态内存开辟一个能容纳K个元素的内存空间,然后从文件中读取K个数据并输入到该空间中,使用向下调整算法建堆,再从文件中读取剩下的数据,依次进行比较,调整完成后,堆中的K个数据就是前K个最大的数据,最后打印前K个最大的数据
void PrintTopk(char* filename, int k)
{
FILE* file_out = fopen(filename, "r");
if (file_out == NULL)
{
perror("fopen fail");
exit(1);
}
int* minheap = (int*)malloc(sizeof(int) * k);
if (minheap == NULL)
{
perror("malloc fail");
exit(1);
}
for (int i = 0; i < k; i++)
{
fscanf(file_out, "%d", &minheap[i]);
}
for (int i = (k-1-1)/2; i >= 0; i--)
{
AdjustDown(minheap, k, i);
}
int x = 0;
while (fscanf(file_out, "%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]);
}
}