本文是算法与数据结构的学习笔记第六篇,将持续更新,欢迎小伙伴们阅读学习。有不懂的或错误的地方,欢迎交流
引言
当涉及到高效的数据存储和检索时,堆(Heap)是一种常用的数据结构。上一篇文章中介绍了树和完全二叉树,堆就是一个完全二叉树,可以分为最大堆和最小堆两种类型。在这篇博客中,我们将深入探讨堆的概念、特点、常见应用、操作以及实现。
什么是堆?
在计算机科学中,堆是一种具有特殊属性的树形数据结构。堆通常被用来实现优先队列(Priority Queue),它允许快速地找到具有最高(或最低)优先级的元素。
堆的特点
堆的主要特点如下:
- 堆是一种完全二叉树结构,即除了最后一层外,其他层的节点都是满的,并且最后一层的节点从左到右依次填满,不能有间隔。
- 在最大堆中,每个节点的值都大于或等于其子节点的值。根节点的值是最大的。在最小堆中,每个节点的值都小于或等于其子节点的值。根节点的值是最小的。
- 堆通常被表示为一个数组,可以通过索引直接访问堆中的元素,堆的根节点通常是数组中的第一个元素。
- 堆的插入和删除操作的时间复杂度都为 O ( log n ) O(\log n) O(logn),其中 n n n 是堆中元素的数量。
堆的应用
堆在计算机科学中有广泛的应用,其中一些主要应用包括:
- 堆排序
堆排序是一种高效的排序算法,它利用堆的性质进行排序。它的时间复杂度为 O ( n log n ) O(n\log n) O(nlogn),并且具有原地排序的特性。 - 优先队列
堆可以实现高效的优先级队列,允许以常数时间复杂度找到具有最高优先级的元素,并支持快速的插入和删除操作。 - Top K 问题
在一组元素中,查找前 K 个最大(或最小)的元素是一个常见的问题。使用堆可以高效地解决这个问题,通过维护一个大小为 K 的最小堆或最大堆,可以快速地找到前 K 个元素。 - 图算法
在图算法中,堆常用于实现最短路径算法(如Dijkstra算法)和最小生成树算法(如Prim和Kruskal算法)。 - 数据流中的中位数
对于一个不断变化的数据流,查找其中的中位数也是一个常见的问题。使用两个堆(一个最大堆和一个最小堆),可以高效地实现对数据流中的中位数的查找。
为什么使用数组实现堆
用数组来实现树相关的数据结构也许看起来有点古怪,但是它在时间和空间上都是很高效的。
我们准备将上面图中的大根堆这样存储:
[ 50, 45, 40, 20, 25, 35, 30, 10, 15 ]
就这么多!我们除了一个简单的数组以外,不需要任何额外的空间。
如果我们不允许使用指针,那么我们怎么知道哪一个节点是父节点,哪一个节点是它的子节点呢?问得好!节点在数组中的位置 index 和它的父节点以及子节点的索引之间有一个映射关系。
如果 i 是节点的索引,那么下面的公式就给出了它的父节点和子节点在数组中的位置:
parent(i) = floor((i - 1)/2)
left(i) = 2i + 1
right(i) = 2i + 2
注意:right(i)
就是简单的 left(i) + 1
。左右节点总是处于相邻的位置。
我们将这些公式放到前面的例子中验证一下。
Node | Array index (i) | Parent index | Left child | Right child |
---|---|---|---|---|
50 | 0 | -1 | 1 | 2 |
45 | 1 | 0 | 3 | 4 |
40 | 2 | 0 | 5 | 6 |
20 | 3 | 1 | 7 | 8 |
25 | 4 | 1 | 9 | 10 |
35 | 5 | 2 | 11 | 12 |
30 | 6 | 2 | 13 | 14 |
10 | 7 | 3 | 15 | 16 |
15 | 8 | 3 | 17 | 18 |
注意:根节点(50)没有父节点,因为 -1 不是一个有效的数组索引。同样,节点(25),(35),(30),(10)和(15)没有子节点,因为这些索引已经超过了数组的大小,所以我们在使用这些索引值的时候需要保证是有效的索引值。
复习一下,在最大堆中,父节点的值总是要大于(或者等于)其子节点的值。这意味下面的公式对数组中任意一个索引 i 都成立:
array[parent(i)] >= array[i]
可以用上面的例子来验证一下这个堆属性。
如你所见,这些公式允许我们不使用指针就可以找到任何一个节点的父节点或者子节点。
堆的基本操作
以下是堆的一些基本操作:
- 插入:将一个元素插入到堆中,并保持堆的特性。
- 删除根节点:删除堆的根节点,并保持堆的特性。
- 获取根节点:获取堆的根节点的值,通常是堆中最大或最小的值。
- 堆化(Heapify):对一个无序的数组进行堆化操作,将其转换为一个堆。
C语言
以下是使用C语言实现堆(包括创建堆、插入数据、删除根结点、获取根节点和堆化等基础操作)的示例代码:
#include <stdio.h>
#define MAX_HEAP_SIZE 100
typedef struct {
int heap[MAX_HEAP_SIZE];
int size;
} Heap;
void initializeHeap(Heap *h) {
h->size = 0;
}
void insert(Heap *h, int value) {
if (h->size >= MAX_HEAP_SIZE) {
printf("Heap is full.\n");
return;
}
int i = h->size;
h->heap[i] = value;
h->size++;
// 调整堆的结构
while (i > 0 && h->heap[(i - 1) / 2] < h->heap[i]) {
int temp = h->heap[i];
h->heap[i] = h->heap[(i - 1) / 2];
h->heap[(i - 1) / 2] = temp;
i = (i - 1) / 2;
}
}
int removeRoot(Heap *h) {
if (h->size <= 0) {
printf("Heap is empty.\n");
return -1;
}
int root = h->heap[0];
h->size--;
h->heap[0] = h->heap[h->size];
// 调整堆的结构
int i = 0;
while (2 * i + 1 < h->size) {
int leftChild = 2 * i + 1;
int rightChild = 2 * i + 2;
int largerChild = leftChild;
if (rightChild < h->size && h->heap[rightChild] > h->heap[leftChild]) {
largerChild = rightChild;
}
if (h->heap[i] >= h->heap[largerChild]) {
break;
}
int temp = h->heap[i];
h->heap[i] = h->heap[largerChild];
h->heap[largerChild] = temp;
i = largerChild;
}
return root;
}
void heapify(Heap *h, int arr[], int n) {
initializeHeap(h);
// 将数组元素逐个插入堆中
for (int i = 0; i < n; i++) {
insert(h, arr[i]);
}
}
int getRoot(Heap *h) {
if (h->size <= 0) {
printf("Heap is empty.\n");
return -1;
}
return h->heap[0];
}
int main() {
Heap h;
initializeHeap(&h);
insert(&h, 5);
insert(&h, 2);
insert(&h, 10);
insert(&h, 8);
int root = removeRoot(&h);
printf("Root: %d\n", root);
int arr[] = {9, 4, 7, 1, 3};
int arrSize = sizeof(arr) / sizeof(arr[0]);
heapify(&h, arr, arrSize);
printf("Root: %d\n", getRoot(&h));
return 0;
}
结论
堆是一种重要的数据结构,它提供了高效的数据存储和检索方式。我们可以使用数组来实现堆,并实现插入、删除和堆化等操作。堆在排序、优先队列、Top K 问题、图算法以及中位数查找等方面具有广泛的应用。
希望这篇博客能够帮助你理解堆的概念、应用和实现。