二叉树的顺序存储
顺序结构存储就是使用数组来存储,一般使用数组只适合表示完全二叉树,因为不是完全二叉树会有空间的浪费,所以现实中使用中只有堆才会使用数组来存储。
二叉树顺序存储在物理上是一个数组,在逻辑上是一颗二叉树。
图示:
堆的概念
堆就是一种特殊的完全二叉树,特点是节点的值有如下关系:
父亲大于等于孩子——大堆
父亲小于等于孩子——小堆
堆一般都用数组存储。
图示
堆的实现
堆结构定义
typdef int HPDataType;
typdef struct Heap
{
HPDataType* a;
int size;
int capacity;
}HP;
堆初始化
void HeapInit(HP* hp)
{
assert(hp);
hp->a = NULL;
hp->size = 0;
hp->capacity = 0;
}
堆的销毁
void HeapDestroy(HP* hp)
{
assert(hp);
free(hp->a);
hp->size = hp->capacity = 0;
}
堆的插入
堆的插入在物理结构上就是在数组最后一个数据后面插入一个数据,
在逻辑结构上就是在最后一层最后一个结点右边加上一个结点(如果最后一层已满,则在新的一层加上一个结点)
堆插入数据需要保证插入数据后仍然是堆,注意到插入一个数据只会影响该插入结点到根节点路径上的节点关系,对其他结点没有影响。
所以需要实现向上调整算法。
向上调整思路是从要调整的位置开始,比较该结点和其父亲节点的值并根据比较结果交换。
以小堆为例,如果孩子的值大于父亲,就交换孩子和父亲的值,并调整此时的child迭代比较到根节点;否则break结束调整。
当child == 0时,调整就结束。(注意不能写parent >=0作为判断条件,否则虽然结果正确,却不是按照我们预期的逻辑结束的)
向上调整
void AdjustUp(int* a, int child)
{
assert(a);
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;
}
}
}
void HeapPush(HP* hp, HPDataType x)
{
assert(hp);
if (hp->size == hp->capacity)
{
size_t newCapacity = hp->capacity == 0 ? 4 : hp->capacity * 2;
HPDataType* tmp = realloc(hp->a, sizeof(HPDataType) * newCapacity);
if (tmp == NULL)
{
printf("relloc fail");
exit(-1);
}
hp->a = tmp;
hp->capacity = newCapacity;
}
hp->a[hp->size] = x;
hp->size++;
AdjustUp(hp->a, hp->size - 1);
}
堆中元素个数
int HeapSize(HP* hp)
{
assert(hp);
return hp->size;
}
堆的判空
bool HeapEmpty(HP* hp)
{
assert(hp);
return hp->size == 0;
}
堆的删除以及取堆顶元素
堆的删除是删除堆顶的元素,因为大堆堆顶元素是所有元素的最大值;小堆堆顶元素是所有元素的最小值。
堆的删除实际上就是删除原来最大/最小元素,使次大/次小元素到达堆顶。
实现方法是把堆顶元素和最后一个元素交换,删除最后一个元素之后再堆堆顶元素进行向下调整,以保证删除后仍然是堆。
向下调整的思路是
以小堆为例,把堆顶元素和它的两个孩子比较,和左右孩子中小的那个交换,结束条件是父亲小于等于小的孩子,或者已经调整到叶子(也就是这时被向下调的结点的位置在逻辑结构上没有孩子了)。
向下调整
void AdjustDown(int* a, int n, int parent)//n代表数组中元素个数
{
int child = parent * 2 + 1;
while (child < n)//等价于child <= n-1
{
//选出左右孩子中小的一个
if (child + 1 < n && a[child + 1] < a[child])//大堆选出大孩子;小堆选出小孩子
{
++child;
}
//如果小的孩子小于父亲,则交换,并继续向下调整
if (a[child] < a[parent])//大堆孩子大于父亲向下调;小堆孩子小于父亲向下调
{
Swap(&a[child], &a[parent]);
parent = child;
child = parent * 2 + 1;
}
else//这里是另一个出口
{
break;
}
}
}
void HeapPop(HP* hp)
{
assert(hp);
assert(!HeapEmpty(hp));
Swap(&hp->a[0], &hp->a[hp->size - 1]);
hp->size--;
AdjustDown(hp->a, hp->size, 0);
}
取堆顶元素(取出最值)
HPDataType HeapTop(HP* hp)
{
assert(hp);
assert(!HeapEmpty(hp));
return hp->a[0];
}
堆的构建
需要注意,这只是构建堆的一种方式。
构建堆的本质实际上是向上调整/向下调整。(向上调整和向下调整都可以建堆)
void HeapCreate(Heap* hp, HPDataType* a, int n)
{
for(int i = 0;i < n;++i)
{
HeapPush(hp, a[i]);
}
}
堆的应用
TopK问题
TopK问题就是在N个数中找最大/最小的前K个。
这里给出三种思路
方式1:先排升序,前K个就是最大的。
假设使用快速排序,则时间复杂度为O(N*logN)
显然这里有杀鸡用牛刀的操作,我们只需要前K个,但是这里把后面N-K个也排序了。
方式2:N个数依次插入大堆,PopK次,每次取堆顶的数据就是前K个。
建堆的时间复杂度为O(N),PopK次就是向下调整K次,而每次向下调整时间复杂度为O(logN),那么时间复杂度就是O(KlogN),总的时间复杂度就是O(N+KlogN)。
考虑到TopK问题中K一般远小于K,则时间复杂度近似O(N)
堆最大的优势体现在下面这种情况:
假设N非常大,内存中存不下这些数,它们存在文件中。
比如10亿个整数,需要占用接近4G,内存可能就放不下这么多数据了,以上两种方法都需要把数据全部放在内存中,无法应付这种情况。
方式3:
步骤
- 用前K个数建立一个K个数的小堆。
- 剩下的N-K个数,依次跟堆顶的数据进行比较,如果比堆顶数据大,就替换堆顶的数据,再向下调整。
- 最后堆里面K个数就是最大的K个数。
下面对实现方式3
为模拟情景,这里使用随机数替代文件中的数据。
情景模拟:
void TestTopK()
{
int n = 10000;//一万个数
int* a = (int*)malloc(sizeof(int) * n);
srand(time(0));
for (int i = 0; i < n; ++i)
{
a[i] = rand() % 1000000;//全部小于一百万
}
a[5] = 1000000 + 1;
a[1459] = 1000000 + 2;
a[9938] = 1000000 + 3;
a[349] = 1000000 + 4;
a[994] = 1000000 + 5;
a[3404] = 1000000 + 6;
a[9454] = 1000000 + 7;
a[459] = 1000000 + 8;
a[7343] = 1000000 + 9;
a[6349] = 1000000 + 10;
PrintTopK(a, n, 10);
}
TopK问题的解法
//在N个数中找出最大的前K个(或最小)
void PrintTopK(int* a, int n, int k)
{
//1、用a中前k个数建堆
//2、将剩余n-k个元素依次与堆顶元素交换,不满则替换
HP hp;
HeapInit(&hp);
//创建一个k个数的小堆
for (int i = 0; i < k; ++i)
{
HeapPush(&hp, a[i]);
}
//剩下N-k个数跟堆顶数据比较,比堆顶大就替换
for (int i = k; i < n; ++i)
{
if (a[i] > HeapTop(&hp))
{
HeapPop(&hp);//替换有两种写法,前者不需要知道堆底层实现
HeapPush(&hp, a[i]);
//hp.a[0] = a[i];
//AdjustDown(hp.a, hp.size, 0);
}
}
HeapPrint(&hp);
HeapDestroy(&hp);
}
堆排序
堆排序本质上是一种选择排序
最容易想到的方法是把数组元素先全部插入到一个堆里面(插入的过程就是建堆的过程)
则每次从堆顶拿出最值,Pop掉堆顶元素后次小的元素会来到堆顶,重复N-1C次就可以完成排序了。
但是用这种方式排序的前提是我们要先写好堆这种数据结构(用C语言)。
如果要求用空间复杂度为O(1)的算法实现排序有以下两种思路。
比如我们要把一个数组中的数排升序
思路1:
- 建小堆,则堆顶元素,也就是数组第一个位置就是最小值;
- 从数组第二个位置开始建小堆,那么次小值就在数组第二个位置
- 重复建堆步骤直到只剩下最后一个数,就可以完成排序
需要注意,这种方法每次都要重新建堆,而
第一次建堆时间复杂度为O(N),第二次为O(N-1),以此类推,加起来就是O(N^N),这样用堆排序还不如直接用冒泡排序,没有体现堆的优势。
思路2:
- 排升序建大堆,
- 把堆顶元素与最后一个元素换位置,
- 把最后一个数不看做堆里面的数,对堆顶元素向下调整。
- 反复执行2和3直到只剩下最后一个数就停止。
思路2时间复杂度分析:
建堆时间复杂度为O(N),每次向下调整时间复杂度为O(logN),执行N-1次向下调整,那么总的时间复杂度就是O(N*logN)。
这里实现思路2
思路有了,如何实现原地建堆呢,这里也有两种方法
方法1:借鉴堆插入的写法,从第二个元素开始向上调整。
之所以从第二个元素往后依次向上调整,是因为向上调整的前提是不看插入的这个数,其他数已经排成了一个堆。
void HeapSort(int* a, int n)
{
//方法1
for (int i = 1; i < n; ++i)
{
AdjustUp(a, i);
}
for (int end = n - 1; end > 0; --end)//对n-1个节点执行向下调整,时间复杂度为O(N*logN)
{
Swap(&a[end], &a[0]);
AdjustDown(a, end, 0);
}
}
方法2:借鉴堆删除的写法,从最后一个父亲结点开始向下调整
之所以从最后一个父亲结点依次往前是因为向下调整构建堆要求这个结点的左右孩子子树都已经是堆。
void HeapSort(int* a, int n)
{
//方法2
for (int i = (n - 1 - 1) / 2; i >= 0; --i)//构建堆时间复杂度为O(N)
{
AdjustDown(a, n, i);//如果要排升序,则构建大堆;降序反之
//可以类比TopK问题,找N个数中最大的前K个,建立小堆来完成
}
for (int end = n - 1; end > 0; --end)//对n-1个节点执行向下调整,时间复杂度为O(N*logN)
{
Swap(&a[end], &a[0]);
AdjustDown(a, end, 0);
}
}
可见,实现堆排序,只要写出向下调整算法就可以了