堆(Heap):优先队列的高效实现
在数据结构的世界里,堆(Heap)是一种非常实用的结构。它专门用来解决一类问题:快速找到一堆元素中的最大值或最小值。比如任务调度系统需要优先处理紧急任务,游戏排行榜需要快速找到最高分玩家,这些场景都离不开堆。
一、什么是堆?
1.1 堆的定义
堆(Heap) 是一种特殊的树形数据结构,它支持以下核心操作:
| 操作 | 功能 | 时间复杂度 |
|---|---|---|
insert(x) |
插入元素 | O(log n) |
getMax() / getMin() |
查看最大/最小元素 | O(1) |
extractMax() / extractMin() |
取出并删除最大/最小元素 | O(log n) |
根据堆顶元素的特性,堆分为两类:
- 大根堆(Max Heap) :根节点的值 大于等于 其左右子树中所有节点的值
- 小根堆(Min Heap) :根节点的值 小于等于 其左右子树中所有节点的值
1.2 二叉堆:最常见的堆实现
我们通常使用 二叉堆(Binary Heap) 来实现堆,它具有以下特点:
- 结构性质 :必须是 完全二叉树
- 堆序性质:满足大根堆或小根堆的定义
完全二叉树(Complete Binary Tree) 是一种特殊的二叉树:
- 除了最后一层,其他层的节点都是满的
- 最后一层的节点必须从左到右连续排列
text
完全二叉树 ✅ 非完全二叉树 ❌
16 16
/ \ / \
14 10 14 10
/ \ / \ / \
8 7 9 3 8 3
/ \ / \
2 4 2 4
二、数组表示法:堆的精妙之处
2.1 为什么用数组?
完全二叉树的特性使得我们可以用 数组 来高效存储堆,无需使用指针!
存储规则(从索引 1 开始):
text
数组:[-, 16, 14, 10, 8, 7, 9, 3, 2, 4, 1]
索引: 0 1 2 3 4 5 6 7 8 9 10
对应的树结构:
16 (1)
/ \
14 (2) 10 (3)
/ \ / \
8 (4) 7 (5) 9 (6) 3 (7)
/ \ /
2 (8) 4 (9) 1 (10)
2.2 索引计算公式
对于索引为 i 的节点:
| 关系 | 公式 | 时间复杂度 |
|---|---|---|
| 父节点 | ⌊i / 2⌋ |
O(1) |
| 左子节点 | 2 * i |
O(1) |
| 右子节点 | 2 * i + 1 |
O(1) |
示例验证:
text
节点 14 (索引 2):
- 父节点:⌊2/2⌋ = 1 → 16 ✅
- 左子节点:2*2 = 4 → 8 ✅
- 右子节点:2*2+1 = 5 → 7 ✅
这种表示法的优势:
- 空间效率高:无需存储指针
- 访问速度快:通过简单计算即可定位父子节点
- 缓存友好:数组连续存储,CPU 缓存命中率高
三、插入操作:乒乓球队的挑战赛
3.1 形象比喻
想象一个乒乓球队,教练想找出队里最强的球员。他设计了一套 挑战赛机制:
- 初始状态:随机选一个球员作为"擂主"(不一定是最强的)
- 新人入队:新球员不能直接挑战擂主,必须从最底层开始
- 逐层挑战 :
- 新球员先站在最后一个空位(叶节点)
- 如果比父节点强,就和父节点交换位置
- 继续向上挑战,直到遇到更强的对手或到达顶端
这个过程保证了:
- 擂主永远是最强的(堆顶元素最大)
- 每个父节点都比子节点强(堆序性质)
- 左右子节点之间无需比较(只需维护父子关系)
3.2 插入算法:上浮(Percolate Up)
核心思想:新元素先放在数组末尾,然后不断与父节点比较,如果比父节点大就交换,直到找到合适位置。
cpp
class MaxHeap {
private:
vector<int> heap; // heap[0] 不使用,从 heap[1] 开始
int size;
// 上浮操作
void percolateUp(int index) {
int value = heap[index];
// 当不是根节点 且 比父节点大时,继续上浮
while (index > 1 && value > heap[index / 2]) {
heap[index] = heap[index / 2]; // 父节点下移
index = index / 2; // 继续向上
}
heap[index] = value; // 找到最终位置
}
public:
MaxHeap() {
heap.push_back(-1); // 占位,使索引从 1 开始
size = 0;
}
// 插入元素
void insert(int value) {
heap.push_back(value); // 先放在末尾
size++;
percolateUp(size); // 上浮到正确位置
}
// 查看最大元素
int getMax() {
if (size == 0) throw runtime_error("Heap is empty");
return heap[1];
}
};
3.3 插入过程图解
插入元素 15 到现有堆中:
text
初始堆:
16
/ \
14 10
/ \ / \
8 7 9 3
/ \
2 4
数组:[-, 16, 14, 10, 8, 7, 9, 3, 2, 4]
步骤 1:将 15 放在末尾
16
/ \
14 10
/ \ / \
8 7 9 3
/ \ /
2 4 15
数组:[-, 16, 14, 10, 8, 7, 9, 3, 2, 4, 15]
索引 10,父节点索引 5 (值为 7)
步骤 2:15 > 7,交换
16
/ \
14 10
/ \ / \
8 15 9 3
/ \ /
2 4 7
数组:[-, 16, 14, 10, 8, 15, 9, 3, 2, 4, 7]
索引 5,父节点索引 2 (值为 14)
步骤 3:15 > 14,交换
16
/ \
15 10
/ \ / \
8 14 9 3
/ \ /
2 4 7
数组:[-, 16, 15, 10, 8, 14, 9, 3, 2, 4, 7]
索引 2,父节点索引 1 (值为 16)
步骤 4:15 < 16,停止
最终堆构建完成!
时间复杂度分析:
- 最坏情况:新元素从叶节点上浮到根节点
- 上浮次数 = 树的高度 = log₂(n)
- 时间复杂度:O(log n)
四、删除操作:擂主退役后的重组
4.1 形象比喻
有一天,最强的球员(堆顶)退役了,队伍需要选出新的擂主。但问题来了:
- 左右两边都有高手:左子树和右子树都可能藏着第二强的球员
- 不能直接提拔子节点:如果把左子节点提上来,它原来的子节点怎么办?结构会乱
解决方案:
- 把 最后一个球员(数组末尾元素)临时放到擂主位置
- 让他 逐层向下挑战 :
- 比较左右两个子节点,选出更强的那个
- 如果临时擂主打不过,就和更强的子节点交换
- 继续向下,直到找到合适位置
这个过程叫做 下沉(Percolate Down)。
4.2 删除算法:下沉(Percolate Down)
cpp
class MaxHeap {
private:
// 下沉操作
void percolateDown(int index) {
int value = heap[index];
int child;
// 当存在左子节点时
while (index * 2 <= size) {
child = index * 2; // 左子节点
// 如果右子节点存在且更大,选择右子节点
if (child + 1 <= size && heap[child + 1] > heap[child]) {
child++;
}
// 如果当前值比最大的子节点大,停止下沉
if (value >= heap[child]) {
break;
}
// 否则,子节点上移
heap[index] = heap[child];
index = child;
}
heap[index] = value; // 找到最终位置
}
public:
// 删除并返回最大元素
int extractMax() {
if (size == 0) throw runtime_error("Heap is empty");
int maxValue = heap[1]; // 保存最大值
heap[1] = heap[size]; // 用最后一个元素替换根节点
heap.pop_back(); // 删除最后一个元素
size--;
if (size > 0) {
percolateDown(1); // 从根节点开始下沉
}
return maxValue;
}
};
4.3 删除过程图解
从堆中删除最大元素 16:
text
初始堆:
16
/ \
14 10
/ \ / \
8 7 9 3
/ \
2 4
数组:[-, 16, 14, 10, 8, 7, 9, 3, 2, 4]
步骤 1:用最后一个元素 4 替换根节点
4
/ \
14 10
/ \ / \
8 7 9 3
/
2
数组:[-, 4, 14, 10, 8, 7, 9, 3, 2]
索引 1,左子节点 2 (值 14),右子节点 3 (值 10)
步骤 2:4 < max(14, 10),与 14 交换
14
/ \
4 10
/ \ / \
8 7 9 3
/
2
数组:[-, 14, 4, 10, 8, 7, 9, 3, 2]
索引 2,左子节点 4 (值 8),右子节点 5 (值 7)
步骤 3:4 < max(8, 7),与 8 交换
14
/ \
8 10
/ \ / \
4 7 9 3
/
2
数组:[-, 14, 8, 10, 4, 7, 9, 3, 2]
索引 4,左子节点 8 (值 2),右子节点不存在
步骤 4:4 > 2,停止下沉
最终堆重组完成!
时间复杂度:O(log n)
五、完整实现与测试
5.1 完整代码
cpp
#include <iostream>
#include <vector>
#include <stdexcept>
using namespace std;
class MaxHeap {
private:
vector<int> heap;
int size;
void percolateUp(int index) {
int value = heap[index];
while (index > 1 && value > heap[index / 2]) {
heap[index] = heap[index / 2];
index = index / 2;
}
heap[index] = value;
}
void percolateDown(int index) {
int value = heap[index];
int child;
while (index * 2 <= size) {
child = index * 2;
if (child + 1 <= size && heap[child + 1] > heap[child]) {
child++;
}
if (value >= heap[child]) break;
heap[index] = heap[child];
index = child;
}
heap[index] = value;
}
public:
MaxHeap() {
heap.push_back(-1); // 占位
size = 0;
}
void insert(int value) {
heap.push_back(value);
size++;
percolateUp(size);
}
int extractMax() {
if (size == 0) throw runtime_error("Heap is empty");
int maxValue = heap[1];
heap[1] = heap[size];
heap.pop_back();
size--;
if (size > 0) percolateDown(1);
return maxValue;
}
int getMax() {
if (size == 0) throw runtime_error("Heap is empty");
return heap[1];
}
bool isEmpty() { return size == 0; }
int getSize() { return size; }
// 打印堆(用于调试)
void print() {
cout << "Heap: ";
for (int i = 1; i <= size; i++) {
cout << heap[i] << " ";
}
cout << endl;
}
};
int main() {
MaxHeap heap;
// 插入元素
cout << "=== 插入操作 ===" << endl;
int values[] = {16, 14, 10, 8, 7, 9, 3, 2, 4, 1};
for (int val : values) {
heap.insert(val);
cout << "插入 " << val << " 后: ";
heap.print();
}
// 查看最大元素
cout << "\n当前最大元素: " << heap.getMax() << endl;
// 删除操作
cout << "\n=== 删除操作 ===" << endl;
while (!heap.isEmpty()) {
int max = heap.extractMax();
cout << "删除 " << max << " 后: ";
if (!heap.isEmpty()) heap.print();
else cout << "堆已空" << endl;
}
return 0;
}
5.2 运行结果
text
=== 插入操作 ===
插入 16 后: Heap: 16
插入 14 后: Heap: 16 14
插入 10 后: Heap: 16 14 10
插入 8 后: Heap: 16 14 10 8
插入 7 后: Heap: 16 14 10 8 7
插入 9 后: Heap: 16 14 10 8 7 9
插入 3 后: Heap: 16 14 10 8 7 9 3
插入 2 后: Heap: 16 14 10 8 7 9 3 2
插入 4 后: Heap: 16 14 10 8 7 9 3 2 4
插入 1 后: Heap: 16 14 10 8 7 9 3 2 4 1
当前最大元素: 16
=== 删除操作 ===
删除 16 后: Heap: 14 8 10 4 7 9 3 2 1
删除 14 后: Heap: 10 8 9 4 7 1 3 2
删除 10 后: Heap: 9 8 3 4 7 1 2
删除 9 后: Heap: 8 7 3 4 2 1
删除 8 后: Heap: 7 4 3 1 2
删除 7 后: Heap: 4 2 3 1
删除 4 后: Heap: 3 2 1
删除 3 后: Heap: 2 1
删除 2 后: Heap: 1
删除 1 后: 堆已空
六、性能分析与应用
6.1 时间复杂度总结
| 操作 | 时间复杂度 | 说明 |
|---|---|---|
insert |
O(log n) | 最多上浮 log n 层 |
extractMax |
O(log n) | 最多下沉 log n 层 |
getMax |
O(1) | 直接访问 heap1 |
buildHeap |
O(n) | 批量建堆(后续讲解) |
6.2 实际应用场景
- 优先队列:操作系统任务调度、网络数据包处理
- Top K 问题:找出数据流中最大的 K 个元素
- 堆排序:时间复杂度 O(n log n) 的排序算法
- 图算法:Dijkstra 最短路径、Prim 最小生成树
- 中位数维护:使用大根堆和小根堆配合
七、核心总结
7.1 堆的本质
堆是一种 用数组实现的完全二叉树 ,通过维护 堆序性质 来快速访问极值元素。
7.2 关键操作
- 插入 :放在末尾,然后 上浮(与父节点比较)
- 删除 :用末尾元素替换根节点,然后 下沉(与子节点比较)
7.3 设计精髓
- 数组表示:利用完全二叉树的特性,通过索引计算父子关系
- 局部调整:每次只需调整一条路径,无需重建整个堆
- 时间保证:所有核心操作都在 O(log n) 时间内完成
7.4 形象记忆
- 插入 = 新人挑战赛:从底层逐级向上挑战
- 删除 = 擂主退役重组:临时擂主逐级向下找位置
- 堆顶 = 当前擂主:永远是最强的(或最弱的)
掌握堆不仅能解决优先队列问题,更是理解高级算法的基础。