堆
堆 (Heap)
堆也称作优先队列, 是一种特殊的树形数据结构,满足堆性质:在最大堆中,父节点的值总是大于等于其子节点的值;在最小堆中,父节点的值总是小于等于其子节点的值。
在 C++ 中,可以使用标准库中的 std::priority_queue
来实现堆, 需要头文件
<queue>。默认情况下,std::priority_queue
实现的是最大堆。如果需要实现最小堆,可以通过自定义比较函数来实现:
std::priority_queue<Type, std::vector<Type>, std::greater<Type>> minHeap;
函数签名:
1 | template< |
T:堆中存储的元素类型。 -
Container:底层容器类型,默认是
std::vector<T> -
Compare:比较函数对象类型,默认是
std::less,用于实现最大堆。使用 std::greater
可以实现最小堆。
构造函数: -
priority_queue():默认构造函数,创建一个空堆。 -
priority_queue(InputIt first, InputIt last):使用范围
[first, last) 内的元素构造堆。
不能在构造函数中直接传入 n 来表示堆的大小,因为堆的大小是动态变化的,可以通过
push和pop操作来调整。
标准库中的堆主要支持以下操作: -
push(const Type& value):将元素 value 插入堆中,
复杂度为 O(log n)。 - pop():移除堆顶元素, 复杂度为 O(log
n), 因为需要重新调整堆结构。 - top():返回堆顶元素,
复杂度为 O(1)。 - empty():检查堆是否为空。 -
size():返回堆中元素的数量。
1 |
|
对顶堆 (Dual Heaps)
对顶堆是一种同时维护最大堆和最小堆的数据结构,常用于动态数据流中查找中位数等问题。通过将数据分为两部分,最大堆存储较小的一半元素,最小堆存储较大的一半元素,可以高效地获取中位数。
例如 LC295: 数据流的中位数, 实现一个支持以下两种操作的数据结构: -
void addNum(int num) - 从数据流中添加一个整数到数据结构中。
- double findMedian() - 返回目前所有元素的中位数。
1 | class MedianFinder { |
addNum
函数的详细步骤说明(先假定再平衡)
插入新元素到最大堆
首先将num插入到maxHeap,即maxHeap.push(num);。此时,maxHeap可能出现一个“叛徒”元素(即最大值),它实际上应该属于“后半部分”。维护排序不变性
将maxHeap的堆顶元素(最大值)移动到minHeap:
这样可以保证1
2
3int elementToMove = maxHeap.top();
maxHeap.pop();
minHeap.push(elementToMove);maxHeap.top() <= minHeap.top(),即“排序不变性”得到满足。维护平衡不变性
经过前两步,maxHeap和minHeap的元素数量可能失衡。需要检查并调整:
这样可以保证1
2
3
4
5if (minHeap.size() > maxHeap.size()) {
int elementToMoveBack = minHeap.top();
minHeap.pop();
maxHeap.push(elementToMoveBack);
}maxHeap.size() >= minHeap.size(),即“平衡不变性”得到满足。
总结:每次
addNum操作的时间复杂度为 O(log N)。
堆中元素是pair
堆中的元素类型可以是
std::pair,这在需要根据多个属性进行排序时非常有用。堆的排序是基于
pair
的第一个元素进行的,如果第一个元素相等,则比较第二个元素。
例如LC347: 给你一个整数数组 nums 和一个整数 k ,请你返回其中出现频率前 k 高的元素。你可以按 任意顺序 返回答案。
1 | class Solution { |
O(N) 建堆
堆的构建可以通过两种主要方法完成:逐个插入元素和使用“堆化”过程。逐个插入元素的时间复杂度为 O(N log N),而使用堆化过程的时间复杂度为 O(N)。下面介绍如何使用堆化过程来高效地构建堆。
标准库中的
std::make_heap函数, 以及构造函数中直接传入迭代器起始位置都是使用堆化的方法将一个范围内的元素转换为堆结构,时间复杂度为 O(N)。
1 | void sift_down(std::vector<int>& heap, int n, int rootIndex) { |
我们将一个数组(arr)视为一个“完全二叉树”。所有的叶子节点(在数组的后半部分,约 N/2 个)天然地满足堆的定义(因为它们没有子节点)。
我们从最后一个非叶子节点开始,向前遍历到根节点(arr[0])。
- 最后一个非叶子节点在数组中的索引是 (N/2) - 1。
- 对遍历到的每个节点,执行一次
sift-down(下滤)操作。
- sift-down 就是比较该节点与其子节点,如果不满足堆属性(例如最小堆中父节点比子节点大),就将它与较小的子节点交换,交换后,原节点 i 的值(现在位于 left或right 处)可能又小于了它新的子节点,因此必须递归地对 left 位置继续调用 sift_down
复杂度数学证明(O(N)): - 假设堆的高度为 h(h ≈ log N)。 - 堆中高度为 0 的节点(即叶子节点)有 N/2 个。它们不需要下滤,工作量为 0。 - 堆中高度为 1 的节点(叶子的父节点)有 N/4 个。它们最多下滤 1 层。 - 堆中高度为 2 的节点有 N/8 个。它们最多下滤 2 层… - 堆中高度为 h 的节点(根节点)有 1 个。它最多下滤 h 层。 - 总工作量 S = (N/4 * 1) + (N/8 * 2) + (N/16 * 3) + … + (1 * h), 可以近似计算为 S ≈ N * (1/2 + 1/4 + 1/8 + … ) = N * 1 = O(N)。