揭秘“堆”:高效的优先级队列与排序利器
1. 什么是堆?
堆是一种特殊的完全二叉树。它的主要特性是:
- 完全二叉树: 堆必须是一棵完全二叉树。这意味着除了最后一层,其他层都被完全填满,并且最后一层的节点都尽可能地靠左排列。这种结构使得堆可以用数组来高效地表示,无需使用指针,节省了内存空间。
- 在一个完全二叉树中,如果一个节点的索引是 i:
- 它的左子节点的索引是 2i+1。
- 它的右子节点的索引是 2i+2。
- 它的父节点的索引是 (i−1)/2(整数除法)。
- 堆序性(Heap Property): 堆中的每个节点都满足特定的顺序关系。根据这个关系,堆可以分为两种:
- 最大堆(Max-Heap): 每个父节点的值都大于或等于其子节点的值。因此,根节点是整个堆中的最大值。
- 最小堆(Min-Heap): 每个父节点的值都小于或等于其子节点的值。因此,根节点是整个堆中的最小值。
本文后续将以最小堆为例进行讲解和实现。
为何用数组表示?
完全二叉树的这种特性使得它的节点可以紧密地存储在一个数组中,而不会有任何空隙。通过简单的数学计算,我们就能找到任何一个节点的父节点或子节点,从而避免了传统树结构中因指针操作带来的额外开销和复杂性。
2. 堆的基本操作
堆的核心操作包括:插入(Insert) 和 删除根节点(Extract Min/Max)。这两个操作都通过维护堆的“堆序性”来实现。
2.1 插入操作(Insert)
向堆中插入一个新元素时,为了保持堆的完全二叉树结构和堆序性,我们通常这样做:
- 将新元素添加到堆的末尾(数组的最后一个位置),以保持完全二叉树的特性。
- 然后,进行**“上浮”(Heapify-up / Sift-up / Bubble-up)** 操作:将新元素与其父节点进行比较。如果新元素比父节点小(最小堆)或大(最大堆),则交换它们的位置。这个过程重复进行,直到新元素到达正确的位置(满足堆序性)或者到达根节点。
这个上浮过程的时间复杂度是 O(logn),因为每次比较和交换都会使元素向上移动一层。
删除堆中的根节点(例如,从最小堆中取出最小值)是另一个核心操作:
- 取出根节点的值。
- 为了保持完全二叉树的结构,将堆中的最后一个元素移动到根节点的位置。
- 然后,进行**“下沉”(Heapify-down / Sift-down / Bubble-down)** 操作:将新根元素与其子节点进行比较。如果它比子节点大(最小堆)或小(最大堆),则与合适的子节点(最小堆选较小的子节点,最大堆选较大的子节点)交换位置。这个过程重复进行,直到元素到达正确的位置(满足堆序性)或者到达叶子节点。
这个下沉过程的时间复杂度也是 O(logn),因为它每次比较和交换都会使元素向下移动一层。
3. C 语言实现最小堆
我们用一个数组来表示堆,并实现其核心操作。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187
| #include <stdio.h> #include <stdlib.h> #include <limits.h>
#define MAX_HEAP_SIZE 100
typedef struct MinHeap { int* arr; int capacity; int size; } MinHeap;
MinHeap* createMinHeap(int capacity) { MinHeap* heap = (MinHeap*)malloc(sizeof(MinHeap)); if (heap == NULL) { printf("内存分配失败!\n"); exit(1); } heap->capacity = capacity; heap->size = 0; heap->arr = (int*)malloc(sizeof(int) * capacity); if (heap->arr == NULL) { printf("内存分配失败!\n"); exit(1); } return heap; }
int getParentIndex(int i) { return (i - 1) / 2; }
int getLeftChildIndex(int i) { return 2 * i + 1; }
int getRightChildIndex(int i) { return 2 * i + 2; }
void swap(int* a, int* b) { int temp = *a; *a = *b; *b = temp; }
void heapifyUp(MinHeap* heap, int index) { int parentIndex = getParentIndex(index); while (index > 0 && heap->arr[parentIndex] > heap->arr[index]) { swap(&heap->arr[index], &heap->arr[parentIndex]); index = parentIndex; parentIndex = getParentIndex(index); } }
void heapifyDown(MinHeap* heap, int index) { int smallest = index; int leftChild = getLeftChildIndex(index); int rightChild = getRightChildIndex(index);
if (leftChild < heap->size && heap->arr[leftChild] < heap->arr[smallest]) { smallest = leftChild; }
if (rightChild < heap->size && heap->arr[rightChild] < heap->arr[smallest]) { smallest = rightChild; }
if (smallest != index) { swap(&heap->arr[index], &heap->arr[smallest]); heapifyDown(heap, smallest); } }
void insertMinHeap(MinHeap* heap, int value) { if (heap->size == heap->capacity) { printf("堆已满,无法插入!\n"); return; } heap->arr[heap->size] = value; heap->size++; heapifyUp(heap, heap->size - 1); printf("插入 %d 成功。\n", value); }
int extractMin(MinHeap* heap) { if (heap->size <= 0) { printf("堆为空,无法提取!\n"); return INT_MAX; } if (heap->size == 1) { heap->size--; return heap->arr[0]; }
int root = heap->arr[0]; heap->arr[0] = heap->arr[heap->size - 1]; heap->size--; heapifyDown(heap, 0);
return root; }
int getMin(MinHeap* heap) { if (heap->size <= 0) { printf("堆为空。\n"); return INT_MAX; } return heap->arr[0]; }
void printHeap(MinHeap* heap) { printf("堆的数组表示: ["); for (int i = 0; i < heap->size; i++) { printf("%d", heap->arr[i]); if (i < heap->size - 1) { printf(", "); } } printf("]\n"); }
void freeMinHeap(MinHeap* heap) { if (heap == NULL) return; free(heap->arr); free(heap); printf("堆内存已释放。\n"); }
int main() { MinHeap* myHeap = createMinHeap(MAX_HEAP_SIZE);
printf("------ 插入元素 ------\n"); insertMinHeap(myHeap, 3); printHeap(myHeap); insertMinHeap(myHeap, 2); printHeap(myHeap); insertMinHeap(myHeap, 15); printHeap(myHeap); insertMinHeap(myHeap, 5); printHeap(myHeap); insertMinHeap(myHeap, 4); printHeap(myHeap); insertMinHeap(myHeap, 45); printHeap(myHeap);
printf("\n------ 提取最小元素 ------\n"); int minVal = extractMin(myHeap); printf("提取最小元素: %d\n", minVal); printHeap(myHeap);
minVal = extractMin(myHeap); printf("提取最小元素: %d\n", minVal); printHeap(myHeap);
printf("\n------ 获取最小元素 ------\n"); printf("当前最小元素 (不删除): %d\n", getMin(myHeap));
printf("\n------ 释放内存 ------\n"); freeMinHeap(myHeap);
return 0; }
|
4. 堆的应用场景
堆因其高效的插入和删除根节点操作,在许多场景下都非常有用:
- 优先级队列(Priority Queue): 这是堆最经典的应用。当需要频繁地获取或处理最高/最低优先级的元素时(例如,操作系统的任务调度、事件模拟、图算法中的 Dijkstra 算法和 Prim 算法),堆是最佳选择。
- 堆排序(Heapsort): 一种高效的排序算法,时间复杂度为 O(nlogn)。它通过将待排序元素构建成一个堆,然后重复提取根节点来实现排序。
- Top K 问题: 在大量数据中查找最大/最小的 K 个元素。例如,找出成绩最高的 10 个学生,或者找出流量最大的 10 个 IP 地址。可以使用大小为 K 的小顶堆或大顶堆来高效解决。
- 在线中位数查找: 结合两个堆(一个最大堆和一个最小堆)可以高效地维护数据流的中位数。
- 数据压缩(霍夫曼编码): 霍夫曼树的构建过程就使用了优先级队列(堆)。