为什么堆能 O(log n) 插入?拆解完全二叉树的数组魔法
摘要:堆是一种用数组存储的完全二叉树,能在 O(log n) 时间内完成插入和删除。本文从父子下标公式出发,手把手拆解上浮、下沉、建堆三大核心操作,并附完整 C 语言实现和 TopK 实战经验。
📑 目录
- 堆是什么?数组里的完全二叉树
- 堆的两种类型:大根堆与小根堆
- 堆能做什么?应用场景与性能优势
- 核心操作一:上浮(插入新元素)
- 核心操作二:下沉(删除堆顶 / 建堆)
- 建堆的两种方式:逐个插入 vs 原地堆化
- 完整代码:可运行的 C 语言小根堆
- 实战经验:TopK 问题的堆解法与 C 语言避坑指南
- 一点总结
- 互动讨论
堆是什么?数组里的完全二叉树
堆是一种特殊的完全二叉树 结构,但它在物理上是用数组存储的。
- 逻辑结构:完全二叉树(除了最后一层,其他层全满,最后一层从左到右填充)。
- 物理存储:数组(连续内存,通过下标计算父子关系)。
父子下标计算公式(数组下标从 0 开始):
| 关系 | 公式 |
|---|---|
| 父节点 → 左孩子 | left = 2 * i + 1 |
| 父节点 → 右孩子 | right = 2 * i + 2 |
| 孩子节点 → 父节点 | parent = (i - 1) / 2 |
用一个数组就能表示一棵树,不需要指针,不需要节点结构体------这就是堆的"数组魔法"。你能通过下标在树和数组之间自由切换,访问速度极快。
堆的两种类型:大根堆与小根堆
堆分为两种,取决于"父节点和子节点"的大小关系:
| 类型 | 规则 | 根节点 |
|---|---|---|
| 大根堆(Max Heap) | 每个父节点都 ≥ 其子节点 | 最大值 |
| 小根堆(Min Heap) | 每个父节点都 ≤ 其子节点 | 最小值 |
无论哪种,堆只保证父与子之间的大小关系,不保证兄弟节点之间的顺序。
堆能做什么?应用场景与性能优势
主要应用场景
| 场景 | 思路 |
|---|---|
| TopK 问题(找最大/最小的 K 个元素) | 找最大的 K 个 → 维护大小为 K 的小根堆,堆顶是"门槛",新元素比堆顶大就替换并下沉,遍历完堆里就是答案 |
| 优先队列(任务调度、Dijkstra 算法) | 堆天然支持按优先级出队------O(log n) 插入,O(log n) 弹出最高(低)优先级元素 |
| 堆排序(Heap Sort) | 先 O(n) 建堆,然后反复弹出堆顶放入有序区,每次弹出后下沉恢复堆序,总复杂度 O(n log n),原地排序 |
| 数据流中的中位数 | 用两个堆:一个大根堆存左半部分,一个小根堆存右半部分,保持二者大小平衡,堆顶就是中位数 |
| 合并 K 个有序链表 | 把 K 个链表的头节点放入小根堆,每次弹出最小值,然后将该节点的 next 入堆,重复直到所有链表合并完 |
时间复杂度
| 操作 | 时间复杂度 | 说明 |
|---|---|---|
| 插入(Push) | O(log n) | 尾部插入,向上调整(上浮) |
| 删除堆顶(Pop) | O(log n) | 堆顶与尾部交换,向下调整(下沉) |
| 获取堆顶(Top) | O(1) | 直接返回 arr[0] |
| 建堆(Heapify) | O(n) | 从最后一个父节点开始下沉 |
空间复杂度:O(1) 的额外空间(除了存储数组本身)。
核心操作一:上浮(插入新元素)
触发场景:插入新元素时,新元素放在数组尾部。
核心逻辑:新元素放在数组末尾(叶子),不断与自己的父节点比较。如果比父节点"优秀"(小根堆就比父小),就交换位置,继续向上比较,直到到达根节点或不再优于父节点。
代码实现(小根堆)
c
ini
void siftUp(int heap[], int pos) {
int cur = pos;
while (cur > 0) {
int parent = (cur - 1) / 2;
if (heap[cur] < heap[parent]) {
int tmp = heap[cur];
heap[cur] = heap[parent];
heap[parent] = tmp;
cur = parent;
} else {
break;
}
}
}
执行过程示例(小根堆,插入 1)
原堆:[5, 8, 9],插入 1 在尾部 → [5, 8, 9, 1]
- 1 和父节点 8 比较 → 交换 →
[5, 1, 9, 8] - 1 和父节点 5 比较 → 交换 →
[1, 5, 9, 8](到达根,停止)
上浮的逻辑就像"新来的员工发现领导不如自己,一路往上爬"------直到爬到根节点或遇到更强的领导。
核心操作二:下沉(删除堆顶 / 建堆)
触发场景:
- 删除堆顶(弹出最大值/最小值)
- 替换堆顶(TopK 问题中,新元素覆盖根)
- 批量建堆(Heapify)
核心逻辑:从某个父节点开始,不断与左右孩子比较,选出"最优秀"的孩子(小根堆选最小的)。如果孩子比父节点优秀,就交换位置,指针下移继续比较,直到到达叶子节点或不再需要交换。
代码实现(小根堆)
c
ini
void siftDown(int heap[], int size, int index) {
int cur = index;
while (1) {
int left = cur * 2 + 1;
int right = cur * 2 + 2;
int smallest = cur;
if (left < size && heap[left] < heap[smallest]) smallest = left;
if (right < size && heap[right] < heap[smallest]) smallest = right;
if (smallest == cur) break;
int tmp = heap[cur];
heap[cur] = heap[smallest];
heap[smallest] = tmp;
cur = smallest;
}
}
下沉的逻辑就像"空降的领导发现下属比自己强,一路往下让贤"------直到找到比自己弱的下属或到达叶子节点。
建堆的两种方式:逐个插入 vs 原地堆化
方式一:逐个插入(动态建堆)
场景 :数据是一个一个来的(流式数据)。
算法 :每次在尾部插入,执行上浮。
时间复杂度:O(n log n)。
方式二:原地堆化(给定完整数组)------ 推荐
场景 :给定一个完整的乱序数组,要求在 O(n) 时间内原地变成堆。
算法 :从最后一个非叶子节点(n/2 - 1)开始,倒着往前遍历,对每个节点执行下沉。
为什么建堆用下沉而不是上浮?
叶子节点(占一半)不需要动。只有上层父节点需要往下压,省去了处理大量叶子节点的开销,所以达到 O(n)。
c
arduino
void buildHeap(int arr[], int n) {
for (int i = n / 2 - 1; i >= 0; i--) {
siftDown(arr, n, i);
}
}
建堆示例
将乱序数组 [5, 3, 8, 4, 1, 7] 建成小根堆:
| 步骤 | 操作 | 数组状态 |
|---|---|---|
| 初始 | - | [5, 3, 8, 4, 1, 7] |
| 1 | 下沉 i=2(值 8),孩子是 7,不动 | [5, 3, 8, 4, 1, 7] |
| 2 | 下沉 i=1(值 3),孩子 4 和 1,1 最小,交换 3 和 1 | [5, 1, 8, 4, 3, 7] |
| 3 | 下沉 i=0(值 5),孩子 1 和 8,1 最小,交换 5 和 1 | [1, 5, 8, 4, 3, 7] |
| 4 | 继续下沉 5,孩子 4 和 3,3 最小,交换 | [1, 3, 8, 4, 5, 7] ✅ |
完整代码:可运行的 C 语言小根堆
这是一个完整、可直接运行的通用小根堆,包含动态扩容和所有核心操作。
c
ini
#include <stdio.h>
#include <stdlib.h>
#include <stdbool.h>
typedef struct {
int *data;
int size;
int capacity;
} MinHeap;
MinHeap* createHeap(int capacity) {
MinHeap* h = (MinHeap*)malloc(sizeof(MinHeap));
h->data = (int*)malloc(sizeof(int) * capacity);
h->size = 0;
h->capacity = capacity;
return h;
}
void swap(int *a, int *b) {
int tmp = *a; *a = *b; *b = tmp;
}
void siftUp(MinHeap* h, int idx) {
while (idx > 0) {
int parent = (idx - 1) / 2;
if (h->data[idx] < h->data[parent]) {
swap(&h->data[idx], &h->data[parent]);
idx = parent;
} else break;
}
}
void siftDown(MinHeap* h, int idx) {
int cur = idx;
while (1) {
int left = cur * 2 + 1;
int right = cur * 2 + 2;
int smallest = cur;
if (left < h->size && h->data[left] < h->data[smallest]) smallest = left;
if (right < h->size && h->data[right] < h->data[smallest]) smallest = right;
if (smallest == cur) break;
swap(&h->data[cur], &h->data[smallest]);
cur = smallest;
}
}
void push(MinHeap* h, int val) {
if (h->size >= h->capacity) {
h->capacity *= 2;
h->data = (int*)realloc(h->data, sizeof(int) * h->capacity);
}
h->data[h->size] = val;
h->size++;
siftUp(h, h->size - 1);
}
int pop(MinHeap* h) {
if (h->size == 0) return -999;
int top = h->data[0];
h->data[0] = h->data[h->size - 1];
h->size--;
siftDown(h, 0);
return top;
}
int top(MinHeap* h) {
return h->data[0];
}
void freeHeap(MinHeap* h) {
free(h->data);
free(h);
}
int main() {
MinHeap* heap = createHeap(4);
push(heap, 5);
push(heap, 3);
push(heap, 8);
push(heap, 1);
push(heap, 4);
printf("堆顶(最小值): %d\n", top(heap)); // 1
printf("弹出: %d\n", pop(heap)); // 1
printf("新的堆顶: %d\n", top(heap)); // 3
freeHeap(heap);
return 0;
}
实战经验:TopK 问题的堆解法与 C 语言避坑指南
在解决 LeetCode 347(前 K 个高频元素)时,总结出一个极其重要的经验:
| 问题 | 策略 |
|---|---|
| 找最大的 K 个 | 维护大小为 K 的小根堆。堆顶是"门槛"(当前最小的候选),新元素比堆顶大就替换根并下沉 |
| 找最小的 K 个 | 维护大小为 K 的大根堆。堆顶是"门槛"(当前最大的候选),新元素比堆顶小就替换根并下沉 |
C 语言避坑指南
在统计频次时,千万不要存结构体的值拷贝------因为值拷贝不会随后续更新而变。正确的做法是存储哈希表的索引(下标),需要用的时候通过索引去取值,保证数据永远新鲜。
c
ini
// ❌ 错误:存值拷贝(数据会过期)
items[distinct++] = hash[idx];
// ✅ 正确:存索引(永远拿到最新值)
indices[distinct++] = idx;
这个坑的核心在于:C 语言中结构体赋值是值拷贝。如果后续哈希表中该索引对应的值被修改了,你之前存的副本不会同步更新。存索引则是"引用"原数据,永远拿到最新值。
一点总结
| 操作 | 一句话记忆 |
|---|---|
| 插入走家谱 | 上浮找爹,一路向上比较 |
| 删除走族谱 | 下沉找儿,一路向下比较 |
| 建堆用下沉 | 从半山腰开始往下滚,省时又省力(O(n)) |
| TopK 用小根堆 | 找最大的 K 个,堆顶是门槛,新元素比门槛高就替换下沉 |
堆的本质是"用数组表达的完全二叉树"------它既有树的逻辑清晰,又有数组的访问速度。理解了父子下标公式,你就掌握了堆的底层密码。
互动讨论
- 为什么建堆用下沉(O(n))比用上浮(O(n log n))快? 你能从叶子节点的数量角度解释吗?
- 找最大的 K 个元素,为什么用小根堆而不是大根堆? 如果用小根堆,堆顶是什么含义?
- C 语言中结构体值拷贝的坑,在其他语言(如 Java、Python)中是否存在? 为什么?
- 堆排序的时间复杂度是 O(n log n),但建堆只有 O(n),那 O(n log n) 主要花在哪里?
- 如果数据流中不断有新的元素进来,如何用堆实时维护中位数? 需要几个堆?
📌 一点心得:堆用数组实现了树的逻辑,用 O(log n) 的代价换来 O(1) 的取最值。这种"用下标计算父子关系"的设计,是数据结构和算法中"以空间换时间"的经典体现。