文章目录
一起来学数据结构——堆
前言
今天,我们来介绍一种特殊的数据结构——堆。
这个堆可不是堆栈的那个堆。堆是一种特殊的二叉树——完全二叉树
所以,一定要记住堆不是堆,是树。
这就是我们的一个堆。
堆的存储结构——数组
因为堆(完全二叉树)的这种连续存储的方式,我们可以采用数组来进行存储。
逻辑结构:
物理结构:
我们可以观察到一个规律:
对于一个非叶节点的两个子节点:
左孩子节点的下标=父亲节点*
2+1;右孩子节点的下标=父亲节点*
2+2;
堆的分类——大堆和小堆
前面我们给出了堆的存储结构——数组,但是,是给一个数组就是堆吗?
答案是:不是。
堆还有一个很严格的标准:父节点大于等于它的子节点或父节点小于等于它的字节点。
大堆
对于父节点大于等于它的字节的的堆,我们叫他大堆。
大堆的最大值就是它的根节点。
每个父节点都大于它的子节点
但是,大堆可不是以递减的顺序排列的,只是父节点比子节点大
小堆
父节点小于等于子节点的堆,我们叫它为小堆。
小堆的根就是最小值
每个父节点都小于它的子节点,但是小堆可不是递增的
堆的操作
正是因为堆的特殊的存储结构和堆的特殊的性质。
堆的操作更加特殊且有趣。
下面我们就来看一看经典的算法。
堆的向下调整算法(专门用于根节点不成堆问题)
我们就先来看一个基础简单的一个小操作。
**如果根节点的左右子树都是堆。**但是根节点不符合要求,使这个整体无法成为堆。
我们就可以采用堆的向下调整算法来重新使它变为堆。
例:
我们让根节点为p,两个中较大的子节点为c
15仍然比23和25小
重复上面的操作,
上面的就是一个完整的向下调整算法。
我们来分析一下它的时间复杂度,如果考虑到最坏的情况:
设整个堆的个数是N,层数是h,h和N的关系是h=log(N+1+x).
根节点从最开始的第一层,一直移动到最后一层。
进行了h-1次交换和比较,所以时间复杂度是O(logN)
void AdjustDown(int arr[], int sz, int parent)
{
int child = 2 * parent + 1;
while (child < sz)//直到child的值为不再数组范围内后停止
{
//找到孩子中较大的那一个
//注意:此时要判断右孩子是否存在
if (arr[child + 1] > arr[child] && child + 1 < sz)
{
child++;
}
//父亲和孩子进行比较
//如果孩子比较大
if (arr[child] > arr[parent])
{
Swap(&arr[child], &arr[parent]);
parent = child;
child = 2 * parent + 1;
}
//如果父亲比最小的孩子还小,说明很正常
else
{
break;
}
}
}
建堆(根的左右子树都不是堆的只是一个数组的情况)
但是,对于那种堆的左右子树不是堆的那种情况,这种方法就不太使用了。
但是,我们还是可以发现一些线索:
没有条件我们可以创造条件啊!我们可以将堆的左右子树变为堆。
那我们采用什么样的方法呢?
具体方法:
从下往上的让它变为堆。
我们从最后一个非叶节点的值开始,使用我们上面的向下调整,让他的子树全部都是堆。
实例演示:
将下面的数组建成大堆
下面的例子就是一个普通的数组,它不是一个堆,根节点的左右子树也不是堆。
现在就按照我们上面的方法来进行依次建堆
示意图:
每一步具体介绍:
- 从第一个非叶子节点开始向下调整,第一个非叶子节点是10,大于它的子节点1,所以10的子树不需要向下调整。
- 接着判断其他的非叶子节点,10的左边是4,
- 我们发现4比7小,需要向下调整.
- 调整完之后,在像左判断,3比9小,需要向下调整。
- 调整完3后调整6,这里我们就会发现我们刚才从无到有建立子堆的重要性了。它的两个子树全部都是大堆,可以正常的运用向下调整算法。
- 对于根5也可以体现我们这个从上到下依次建堆的优越性,根5的左右子树都是堆,还是可以使用我们的向下调整算法。
- 直到我们的下标p到达0为止,建堆完成
时间复杂度分析
知道了如何建堆,我们就要来计算一下时间复杂度。
设堆的深度是h,堆的元素是N。
对于堆的第n层,堆的元素个数是
2 n − 1 2^{n-1} 2n−1
如果是最坏的情况,该层的所有元素都要进行向下调整,在最坏条件下每一次的向下调整需要比较的次数是h-n,
所以该层需要比较的次数就是
2
n
−
1
∗
(
h
−
n
)
2^{n-1}*(h-n)
2n−1∗(h−n)
所以n层,也就是整个堆,需要比较的次数就是
S
(
h
)
=
2
0
∗
(
h
−
1
)
+
2
1
∗
(
h
−
2
)
+
.
.
.
.
.
.
+
2
h
−
2
∗
1
+
2
h
−
1
∗
0
S(h)=2^{0}*(h-1)+2^1*(h-2)+......+2^{h-2}*1+2^{h-1}*0
S(h)=20∗(h−1)+21∗(h−2)+......+2h−2∗1+2h−1∗0
我们可以使用错位相减法来计算S(h)
2
∗
S
(
h
)
=
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
+
2
1
∗
(
h
−
1
)
+
.
.
.
.
.
.
+
2
h
−
2
∗
2
+
2
h
−
1
∗
1
+
2
h
∗
0
2*S(h)=.................+2^1*(h-1)+......+2^{h-2}*2+2^{h-1}*1+2^{h}*0
2∗S(h)=.................+21∗(h−1)+......+2h−2∗2+2h−1∗1+2h∗0
用上面减去下面,
S
(
h
)
=
2
1
+
.
.
.
.
.
.
+
2
h
−
2
+
2
h
−
1
S(h)=2^1+......+2^{h-2}+2^{h-1}
S(h)=21+......+2h−2+2h−1
再利用等比数列求和公式,就可以求出总比较次数为:
S
(
h
)
=
2
−
2
∗
2
h
−
1
1
−
2
=
2
h
−
2
S(h)=\frac{2-2*2^{h-1}}{1-2}=2^h-2
S(h)=1−22−2∗2h−1=2h−2
所以时间复杂度为S(h)
因为堆的层数为h,个数为N
N
=
2
h
−
1
N=2^h-1
N=2h−1
时间复杂度也为O(N)
代码实现
注意:第一个非叶子节点是数组最后一个下标php->size
,无论是对于左孩子还是右孩子,找到父亲的下标都可以将它们的下标-1后再除以2.
for (int i = (php->size - 1 - 1) / 2; i >= 0; i--) {
AdjustDown(php->a,php->size, i);
}
堆的排序
现在我们掌握了向下调整和建堆的算法,还有一个值得注意的一个重要算法,那就是堆的排序算法。
我们想要将堆排成降序,那应该怎么来排呢?
将堆排成大堆,然后选出来最大的数,然后再重新建堆。但是这样的时间复杂度就是O(N*N),还不如直接用冒泡排序来排序了,我们建堆也就没有意义了。
思路:
所以,充分考虑到堆的特性,我们考虑先将堆建成小堆,再将然后将根和最后一个数进行互换,这样最小的数就到了最后,新的根的左右子树还是堆,我们使用向下调整算法。就是这样的重复,直到将所有的数字遍历一遍为止,整个的时间复杂度是O(N*logN)
.这和自前的O(N*N)
可是减少了不少的操作。
具体实例
将一个小堆排成降序。
- 因为小堆的第一个一定是最小的,所以将根和最后一个元素交换,最小的根就到最后了。
- 此时将最小的元素从堆中排除出去,并向下调整堆,继而就选出来第二小的元素
- 再重复第一个操作,直到进行了n-1次以后(堆的元素是n个,所以选n-1次,自然排除序列)停止。
代码分析
void HeapSort(int arr[], int sz)
{
int end = sz - 1;
while (end > 0)
{
Swap(&arr[0], &arr[end]);
AdjustDown(arr, end, 0);
end--;
}
}
堆的实现
那么如果我们要模拟实现一个堆的话,应该怎么实现呢?
堆的结构
首先我们先来考虑一下堆的结构,我们要用数组来表示堆。所以,我们最好使用结构体进行封装,记录下堆的个数。
typedef int HPDataType;
typedef struct Heap
{
HPDataType* a;
int size;
int capacity;
}HP;
堆的初始化
堆的结构有了之后,我们就可以对堆进行初始化了。这里我们采用外部给出数字,我们将它填入到堆中的方式。
- 首先先开辟堆的大小的空间
- 将外部给出的数字全部都拷贝到新开辟的空间中去
- 建堆,建成大堆或小堆。
void HeapInit(HP* php, HPDataType* a, int n)
{
assert(php);
php->a = (HPDataType*)malloc(sizeof(HPDataType) * n);
if (php->a == NULL)
{
perror("HeapInit");
exit(-1);
}
php->capacity = n;
php->size = n;
//将数组a拷贝到php的数组中
memcpy(php->a, a, sizeof(HPDataType) * n);
//建堆
for (int i = (php->size - 1 - 1) / 2; i >= 0; i--) {
AdjustDown(php->a,php->size, i);
}
}
向堆中插入元素
在堆的最后插入元素非常好操作,直接在后面再添加一个元素就可以了(注意扩容)。
但是,要是插入元素后还是成堆就需要向上调整了(和我们的向下调整代码类似)
向上调整代码:
注意操作结束的条件。
void AdjustUp(HP* php,int size)
{
int father = (size - 1 - 1) / 2;
int child = size - 1;
//这个条件是不对的,child=0,father=0,将会循环,但是恰好
//a[child]==a[father]
//while (father>=0)
while(child>0)
{
if (php->a[child] > php->a[father])
{
Swap(&php->a[child], &php->a[father]);
child = father;
father = (child - 1) / 2;
}
else
{
break;
}
}
}
向堆中插入元素的代码:
注意判断是否需要扩容。
void HeapPush(HP* php, HPDataType x)
{
if (php->size == php->capacity)
{
HPDataType* tmp=(HPDataType*)realloc (php->a, sizeof(HPDataType) * php->capacity * 2);
if (tmp == NULL)
{
perror("HeapPush");
exit(-1);
}
php->a = tmp;
}
php->a[php->size] = x;
php->size++;
php->capacity *= 2;
AdjustUp(php,php->size);
}
删除堆中元素(做法和排序堆类似)
删除堆中元素,这里指的是堆中的第一个元素,
如果直接将第一个元素删掉,还要重新建堆,时间复杂度是O(N)。
反而如果还是将最后一个元素和第一个元素互换,将最后一个元素删掉后(也就是删掉了原来的第一个元素),再向下调整重新成为堆,这样的时间复杂度就是O(logN)
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);
}
得到堆中第一个元素
直接返回堆的元素的第一个就行。
HPDataType HeapTop(HP* php)
{
assert(php);
assert(php->size > 0);
return php->a[0];
}
计算堆的大小
直接返回堆的size
int HeapSize(HP* php)
{
assert(php);
return php->size;
}
判断堆是否为空
bool HeapEmpty(HP* php)
{
assert(php);
return php->size == 0;
}
释放堆
堆是动态开辟的,不能只是开辟,还要释放
void HeapDestory(HP* php)
{
free(php->a);
php->capacity = php->size = 0;
}
这就是堆的知识点,希望有错误给出指正,谢谢。