堆详解:树的形,数组的心

深入理解堆的内部原理和手写实现


什么是堆

堆(Heap)是一种特殊的完全二叉树,满足堆性质:每个节点的值都大于等于(或小于等于)其子节点的值。

核心特点:用数组存储的完全二叉树,动态维护最值

两种堆

graph LR
    A[堆] --> B[最大堆 Max Heap]
    A --> C[最小堆 Min Heap]
    
    B --> B1[父节点 ≥ 子节点]
    B --> B2[根节点最大]
    
    C --> C1[父节点 ≤ 子节点]
    C --> C2[根节点最小]
    
    style A fill:#FFE4B5
    style B fill:#FF6B6B
    style C fill:#87CEEB

最大堆示例

1
2
3
4
5
6
     9            数组表示:[9, 7, 8, 3, 5, 6, 2]
/ \
7 8 索引关系:
/ \ / \ - 父节点 i
3 5 6 2 - 左子节点 2i + 1
- 右子节点 2i + 2

最小堆示例

1
2
3
4
5
     1            数组表示:[1, 3, 2, 7, 5, 6, 8]
/ \
3 2 特点:
/ \ / \ - 完全二叉树
7 5 6 8 - 父 ≤ 子

为什么用数组存储

完全二叉树的性质

完全二叉树可以用数组紧凑存储,不浪费空间:

1
2
3
4
5
6
7
        0
/ \
1 2
/ \ / \
3 4 5 6

数组:[0, 1, 2, 3, 4, 5, 6]

索引计算公式

对于索引为 i 的节点:

关系 公式 说明
父节点 (i - 1) / 2 向下取整
左子节点 2 * i + 1 -
右子节点 2 * i + 2 -
是否有左子 2 * i + 1 < size 判断是否越界
是否有右子 2 * i + 2 < size 判断是否越界
最后非叶子 size / 2 - 1 建堆时的起点

记忆技巧:从 0 开始索引,左子 = 2i+1,右子 = 2i+2


堆的核心操作

1. 上浮(Sift Up)

使用场景:插入新元素后,恢复堆性质

原理:新元素与父节点比较,若违反堆性质则交换,重复直到根节点或满足条件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
最大堆插入 10:

步骤1: 插入到末尾 步骤2: 与父节点比较
9 9
/ \ / \
7 8 → 10 8
/ \ / \ / \ / \
3 5 6 2 10 3 5 6 2 7
↑ ↑
新插入 上浮一次

步骤3: 继续比较 步骤4: 完成
10 10
/ \ / \
9 8 → 9 8
/ \ / \ / \ / \
3 5 6 2 7 3 5 6 2 7
↑
上浮到根

代码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
void siftUp(vector<int>& heap, int index) {
while (index > 0) {
int parent = (index - 1) / 2;

// 最大堆:子节点 > 父节点时上浮
if (heap[index] > heap[parent]) {
swap(heap[index], heap[parent]);
index = parent;
} else {
break;
}
}
}

时间复杂度:O(log n),最多上浮 h 层(h 为树高)


2. 下沉(Sift Down)

使用场景:删除堆顶后,恢复堆性质

原理:用最后一个元素替换堆顶,然后与较大的子节点比较,若违反堆性质则交换,重复直到叶子节点或满足条件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
最大堆删除堆顶:

步骤1: 用末尾替换堆顶 步骤2: 与子节点比较
9 2
/ \ / \
7 8 → 7 8
/ \ / \ / \ /
3 5 6 2 3 5 6
删除 ↑ ↓
2 下沉

步骤3: 交换并继续 步骤4: 完成
7 8
/ \ / \
2 8 → 7 6
/ \ / / \ /
3 5 6 3 5 2
↓
继续下沉 稳定

代码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
void siftDown(vector<int>& heap, int index, int size) {
while (2 * index + 1 < size) { // 有左子节点
int left = 2 * index + 1;
int right = 2 * index + 2;
int largest = index;

// 找出父、左子、右子中最大的
if (left < size && heap[left] > heap[largest]) {
largest = left;
}
if (right < size && heap[right] > heap[largest]) {
largest = right;
}

// 如果父节点最大,说明已满足堆性质
if (largest == index) {
break;
}

swap(heap[index], heap[largest]);
index = largest;
}
}

时间复杂度:O(log n)


3. 插入(Insert)

步骤:

  1. 将新元素添加到数组末尾
  2. 上浮调整
1
2
3
4
void insert(vector<int>& heap, int value) {
heap.push_back(value); // 添加到末尾
siftUp(heap, heap.size() - 1); // 上浮
}

时间复杂度:O(log n)


4. 删除堆顶(Extract Max/Min)

步骤:

  1. 记录堆顶元素
  2. 用最后一个元素替换堆顶
  3. 删除最后一个元素
  4. 下沉调整
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int extractMax(vector<int>& heap) {
if (heap.empty()) {
throw runtime_error("Heap is empty");
}

int maxValue = heap[0]; // 记录最大值
heap[0] = heap.back(); // 用末尾替换堆顶
heap.pop_back(); // 删除末尾

if (!heap.empty()) {
siftDown(heap, 0, heap.size()); // 下沉
}

return maxValue;
}

时间复杂度:O(log n)


5. 建堆(Build Heap)

方法1:自顶向下

逐个插入元素,每次插入后上浮调整。

1
2
3
4
5
6
7
vector<int> buildHeapTopDown(vector<int>& arr) {
vector<int> heap;
for (int num : arr) {
insert(heap, num); // O(log n) 插入
}
return heap;
}

时间复杂度:O(n log n)


方法2:自底向上(更优)

将数组看作完全二叉树,从最后一个非叶子节点开始,依次下沉调整。

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
原数组:[3, 2, 1, 7, 8, 4, 5]

步骤1:找最后非叶子节点 = size/2-1 = 2(值为1)
3
/ \
2 1
/ \ / \
7 8 4 5

步骤2:从索引2开始下沉
3
/ \
2 5
/ \ / \
7 8 4 1

步骤3:处理索引1
3
/ \
8 5
/ \ / \
7 2 4 1

步骤4:处理索引0(根节点)
8
/ \
7 5
/ \ / \
3 2 4 1

完成!最大堆构建完成

代码实现:

1
2
3
4
5
6
7
8
void buildHeap(vector<int>& heap) {
int n = heap.size();

// 从最后一个非叶子节点开始,向前遍历
for (int i = n / 2 - 1; i >= 0; --i) {
siftDown(heap, i, n);
}
}

时间复杂度:O(n) 🎯

为什么是 O(n) 而不是 O(n log n)?

虽然每次下沉最坏是 O(log n),但实际上:

  • 叶子节点不需要下沉(占一半)
  • 倒数第二层最多下沉 1 层(占 1/4)
  • 倒数第三层最多下沉 2 层(占 1/8)
  • …

总时间 = n/2 × 0 + n/4 × 1 + n/8 × 2 + … = O(n)


完整的堆类实现

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
class MaxHeap {
private:
vector<int> heap;

void siftUp(int index) {
while (index > 0) {
int parent = (index - 1) / 2;
if (heap[index] > heap[parent]) {
swap(heap[index], heap[parent]);
index = parent;
} else {
break;
}
}
}

void siftDown(int index) {
int size = heap.size();
while (2 * index + 1 < size) {
int left = 2 * index + 1;
int right = 2 * index + 2;
int largest = index;

if (left < size && heap[left] > heap[largest]) {
largest = left;
}
if (right < size && heap[right] > heap[largest]) {
largest = right;
}

if (largest == index) break;

swap(heap[index], heap[largest]);
index = largest;
}
}

public:
MaxHeap() {}

MaxHeap(vector<int> arr) : heap(arr) {
// 建堆
for (int i = heap.size() / 2 - 1; i >= 0; --i) {
siftDown(i);
}
}

void insert(int value) {
heap.push_back(value);
siftUp(heap.size() - 1);
}

int extractMax() {
if (heap.empty()) {
throw runtime_error("Heap is empty");
}

int maxValue = heap[0];
heap[0] = heap.back();
heap.pop_back();

if (!heap.empty()) {
siftDown(0);
}

return maxValue;
}

int getMax() const {
if (heap.empty()) {
throw runtime_error("Heap is empty");
}
return heap[0];
}

int size() const {
return heap.size();
}

bool empty() const {
return heap.empty();
}
};

堆排序详解

算法思路

利用堆的性质进行排序:

  1. 建堆:O(n)
  2. 排序:依次取出堆顶(最大值),放到数组末尾
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
原数组:[3, 2, 1, 7, 8, 4, 5]

步骤1:建最大堆
[8, 7, 5, 3, 2, 4, 1]
↑ 最大值

步骤2:堆顶与末尾交换
[1, 7, 5, 3, 2, 4 | 8]
↑ 已排序

步骤3:缩小堆范围并下沉调整
[7, 3, 5, 1, 2, 4 | 8]
↑ 已排序

步骤4:重复
[4, 3, 5, 1, 2 | 7, 8]
[5, 3, 4, 1, 2 | 7, 8]
...
[1, 2, 3, 4, 5, 7, 8] 完成!

代码实现

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
void heapSort(vector<int>& nums) {
int n = nums.size();

// 1. 建堆(从最后一个非叶子节点开始)
for (int i = n / 2 - 1; i >= 0; --i) {
heapify(nums, n, i);
}

// 2. 排序(依次取出堆顶)
for (int i = n - 1; i > 0; --i) {
swap(nums[0], nums[i]); // 堆顶与末尾交换
heapify(nums, i, 0); // 调整堆
}
}

void heapify(vector<int>& nums, int n, int i) {
int largest = i;
int left = 2 * i + 1;
int right = 2 * i + 2;

if (left < n && nums[left] > nums[largest]) {
largest = left;
}
if (right < n && nums[right] > nums[largest]) {
largest = right;
}

if (largest != i) {
swap(nums[i], nums[largest]);
heapify(nums, n, largest);
}
}

复杂度分析

  • 时间复杂度:O(n log n)
    • 建堆:O(n)
    • n-1 次调整,每次 O(log n)
  • 空间复杂度:O(1) 原地排序
  • 稳定性:❌ 不稳定

堆排序的优缺点

优点 缺点
✅ 时间复杂度稳定 ❌ 不稳定排序
✅ 原地排序,空间 O(1) ❌ 缓存不友好
✅ 不依赖输入分布 ❌ 实际性能不如快排

堆 vs 其他数据结构

操作复杂度对比

数据结构 插入 删除最值 查看最值 查找任意元素
堆 O(log n) O(log n) O(1) O(n)
无序数组 O(1) O(n) O(n) O(n)
有序数组 O(n) O(1) O(1) O(log n)
平衡二叉搜索树 O(log n) O(log n) O(log n) O(log n)

堆的优势场景

graph LR
    A[什么时候用堆?] --> B[需要频繁获取最值]
    A --> C[不需要查找任意元素]
    A --> D[动态数据流]
    A --> E[Top K 问题]
    
    style A fill:#FFE4B5
    style B fill:#90EE90
    style E fill:#87CEEB

经典应用场景

1. Top K 问题

1
2
3
4
5
6
7
8
9
10
11
12
13
// 第 K 大元素
int findKthLargest(vector<int>& nums, int k) {
priority_queue<int, vector<int>, greater<int>> minHeap; // 最小堆

for (int num : nums) {
minHeap.push(num);
if (minHeap.size() > k) {
minHeap.pop();
}
}

return minHeap.top();
}

2. 合并 K 个有序链表/数组

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
vector<int> mergeKSorted(vector<vector<int>>& arrays) {
auto cmp = [](tuple<int,int,int>& a, tuple<int,int,int>& b) {
return get<0>(a) > get<0>(b); // 最小堆
};
priority_queue<tuple<int,int,int>, vector<tuple<int,int,int>>, decltype(cmp)> minHeap(cmp);

// 初始化:每个数组的第一个元素
for (int i = 0; i < arrays.size(); ++i) {
if (!arrays[i].empty()) {
minHeap.push({arrays[i][0], i, 0});
}
}

vector<int> result;
while (!minHeap.empty()) {
auto [val, arrIdx, elemIdx] = minHeap.top();
minHeap.pop();
result.push_back(val);

// 加入下一个元素
if (elemIdx + 1 < arrays[arrIdx].size()) {
minHeap.push({arrays[arrIdx][elemIdx + 1], arrIdx, elemIdx + 1});
}
}

return result;
}

3. 数据流中位数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class MedianFinder {
private:
priority_queue<int> maxHeap; // 较小的一半
priority_queue<int, vector<int>, greater<int>> minHeap; // 较大的一半

public:
void addNum(int num) {
maxHeap.push(num);
minHeap.push(maxHeap.top());
maxHeap.pop();

if (minHeap.size() > maxHeap.size()) {
maxHeap.push(minHeap.top());
minHeap.pop();
}
}

double findMedian() {
if (maxHeap.size() > minHeap.size()) {
return maxHeap.top();
}
return (maxHeap.top() + minHeap.top()) / 2.0;
}
};

最大堆 vs 最小堆

何时使用?

graph TD
    A[选择堆类型] --> B{需要什么?}
    
    B -->|最大值| C[最大堆]
    B -->|最小值| D[最小堆]
    
    C --> E[直接获取]
    D --> F[直接获取]
    
    style A fill:#FFE4B5
    style C fill:#FF6B6B
    style D fill:#87CEEB

Top K 问题口诀

求大用小,求小用大

问题 使用的堆 原因
第 K 大元素 最小堆 保留最大的 K 个
第 K 小元素 最大堆 保留最小的 K 个
前 K 大 最小堆 淘汰小的,保留大的
前 K 小 最大堆 淘汰大的,保留小的

代码转换

1
2
3
4
5
6
7
8
9
// 最大堆(默认)
priority_queue<int> maxHeap;

// 最小堆(需要指定比较器)
priority_queue<int, vector<int>, greater<int>> minHeap;

// 自定义比较器
auto cmp = [](int a, int b) { return a > b; }; // 最小堆
priority_queue<int, vector<int>, decltype(cmp)> customHeap(cmp);

堆的变种

1. 二叉堆(Binary Heap)

我们刚才讲的就是二叉堆,最常用。

2. 斐波那契堆(Fibonacci Heap)

  • 优点:合并操作 O(1)
  • 缺点:实现复杂,常数因子大
  • 应用:Dijkstra、Prim 算法的优化版本

3. 二项堆(Binomial Heap)

  • 特点:多个二项树组成
  • 应用:理论研究

常见陷阱与技巧

陷阱1:索引计算错误

1
2
3
4
5
6
7
// ❌ 错误:从1开始索引
int parent = i / 2; // 不适用于从0开始的数组

// ✅ 正确:从0开始索引
int parent = (i - 1) / 2;
int left = 2 * i + 1;
int right = 2 * i + 2;

陷阱2:比较器方向搞反

1
2
3
4
5
6
7
// C++ priority_queue 的比较器与 sort 相反!

// 最小堆(堆顶最小)
auto cmp = [](int a, int b) { return a > b; }; // 注意是 >

// 最大堆(堆顶最大)
auto cmp = [](int a, int b) { return a < b; }; // 注意是 <

技巧1:建堆优化

1
2
3
4
5
6
7
// ❌ 低效:逐个插入 O(n log n)
for (int num : nums) {
minHeap.push(num);
}

// ✅ 高效:批量建堆 O(n)
priority_queue<int> maxHeap(nums.begin(), nums.end());

技巧2:记住口诀

  • 求大用小,求小用大
  • 父节点 = (i-1)/2
  • 左子 = 2i+1,右子 = 2i+2

思维导图

graph TD
    A[堆] --> B[基本概念]
    A --> C[核心操作]
    A --> D[应用场景]
    A --> E[经典算法]
    
    B --> B1[完全二叉树]
    B --> B2[数组存储]
    B --> B3[最大堆/最小堆]
    
    C --> C1[上浮 O log n]
    C --> C2[下沉 O log n]
    C --> C3[插入 O log n]
    C --> C4[删除堆顶 O log n]
    C --> C5[建堆 O n]
    
    D --> D1[Top K 问题]
    D --> D2[优先队列]
    D --> D3[堆排序]
    D --> D4[数据流统计]
    
    E --> E1[堆排序]
    E --> E2[Dijkstra]
    E --> E3[Huffman编码]
    
    style A fill:#FFE4B5
    style C fill:#87CEEB
    style D fill:#90EE90

总结

核心要点

  1. 堆 = 完全二叉树 + 数组存储
  2. 两个关键操作:上浮、下沉
  3. 建堆优化:自底向上 O(n)
  4. Top K 口诀:求大用小,求小用大

时间复杂度总结

操作 时间复杂度 备注
插入 O(log n) 上浮
删除堆顶 O(log n) 下沉
查看堆顶 O(1) 直接访问
建堆 O(n) 自底向上
堆排序 O(n log n) n 次删除堆顶

代码模板

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 最大堆核心操作
class MaxHeap {
// 上浮
void siftUp(int i) {
while (i > 0 && heap[i] > heap[(i-1)/2]) {
swap(heap[i], heap[(i-1)/2]);
i = (i - 1) / 2;
}
}

// 下沉
void siftDown(int i) {
while (2*i+1 < size) {
int j = 2*i+1; // 左子
if (j+1 < size && heap[j+1] > heap[j]) j++; // 右子更大
if (heap[i] >= heap[j]) break;
swap(heap[i], heap[j]);
i = j;
}
}
};

推荐练习:

推荐阅读: