堆是什么?
堆是一种二叉树,大顶堆是指父亲大于等于孩子的堆,堆顶为最大值。小顶堆是指父亲小于等于孩子的堆,堆顶为最小值。为节省内存,一般用数组模拟二叉树。对于 0-indexed 数组(第一个下标是 0),父子坐标关系如下,
parent = (child - 1) / 2
child_1 = 2 * parent + 1
child_2 = child_1 + 1 = 2 * parent + 2
堆是一个弱序,并没有像二叉搜索树(BST)那么严格的有序,
- 二叉搜索树:左子树 < 父节点 < 右子树
- 二叉堆:左右孩子 < 父节点。但左右孩子之间的关系不保证有序,可以混杂。由此可得,父节点 > 左右子树。
flowchart TB subgraph 二叉搜索树 direction TB a[50] b[30] c[70] d[20] e[40] f[60] g[80] a --> b a --> c b --> d b --> e c --> f c --> g end subgraph 二叉堆 direction TB A[80] B[40] C[70] D[20] E[30] F[60] G[50] A --> B A --> C B --> D B --> E C --> F C --> G style A fill:#f9f,stroke:#333,stroke-width:2px style B fill:#f9f,stroke:#333,stroke-width:2px style C fill:#f9f,stroke:#333,stroke-width:2px style D fill:#f9f,stroke:#333,stroke-width:2px style E fill:#f9f,stroke:#333,stroke-width:2px style F fill:#f9f,stroke:#333,stroke-width:2px style G fill:#f9f,stroke:#333,stroke-width:2px end
因此,维护堆的性质相对比较轻量。要维护堆,一般涉及以下几种操作,
- bubble_up(array, i); // 在堆尾插入元素,上浮到合适位置,使得堆的性质得以保持
- bubble_down(array, i); // 弹出堆顶,将堆尾元素换上来,进行下沉(堆化),过程中维持堆的性质
- get_top(); // 返回堆顶,一般就是array[0]
- pop(); // 弹出堆顶,此时要将堆尾换上来,进行下沉以维护堆的性质
C++的标准库除了priority_queue这种封装好的优先队列外,还提供了下面几个单独的接口操作堆:
- push_heap:将范围内最后一个元素入堆
- pop_heap:将堆顶换到堆尾,然后堆的容量减 1,维护堆的性质。等效于将堆顶弹出,放在了原来的堆尾(现在不是了,因为堆容量减 1 了)
- is_heap:判断范围内元素是否满足堆的性质
- sort_heap:把一个堆转换成有序数组,堆的性质不在维持
- make_heap:把范围内元素转换为堆
而 priority_queue 也仅提供如下接口:
- push:元素入堆
- pop:弹出堆顶
- top:返回堆顶
注意
std::priority_queue默认是大顶堆,即堆顶元素是最大值。小顶堆可通过std::priority_queue<int, vector<int>, greater<int>>来获得。同样的,std::push_heap之类的接口默认也都是按大顶堆维护。
为什么用堆尾元素填补?
- 不破坏结构:堆必须保持完全二叉树,直接拿末尾元素补到根节点,不会留下中间空洞 。
- 代价最低:数组里删末尾元素是 ,如果拿中间元素来补位,还要额外移动很多元素 。
- 后续好调整:补到堆顶后,只需要从上往下做一次下沉,就能恢复堆性质,时间复杂度是 。
为什么用数组模拟?
- 如果像BST一样,那么指针占用的空间,甚至比我们感兴趣的数据要多,这是一种内存的浪费。
- 二叉堆是一棵完全二叉树,中间没有空洞,只有最后一层可以不满。这样的结构也有利于用数组去模拟,通过下标来隐性表示父子关系。
用数组模拟的代价
- 无法在不浪费空间的情况下,有效存储任意拓扑结构的二叉树。因为节点间靠下标确定父子关系,如果多个内部节点只有左孩子或右孩子,数组中间会有很多空洞(hole)。
- 无法像二叉树那样通过指针挪动一整个子树。
因此,不是所有的二叉树都适合用数组模拟。但在这里,堆很适合!
设高度为 的二叉堆有 个节点,则
实现示例
要维护堆的性质,最重要的就是下沉和上浮两个操作。同时再复习一遍堆的性质(不失一般性,假设是大顶堆):父亲支配孩子。即父节点不能比孩子节点还小,父节点大于等于子节点。
- 下沉:父亲如果比孩子小,找到父子三者(或者二者)之间的最大值,把大的换上去,小的换下来。继续比较换下来之后的元素和其儿子的大小。
- 上浮:叶子节点变更时,孩子如果比父亲大,就把大的换上去,小的换下来。继续比较换上去的元素和其父亲的大小。
inline size_t parent(size_t i) {
if (i > 0) {
return (i - 1) / 2;
}
return 0;
}
// left child of i
inline size_t lchild(size_t i) {
return 2 * i + 1;
}
void bubble_down(std::vector<int>& v, size_t i, size_t heap_size) {
while (i < heap_size) {
size_t c = lchild(i);
if (c >= heap_size) {
return;
}
// get top element between parent and two children
size_t top_idx = i;
for (size_t j = c; j <= c + 1; ++j) {
if (j < heap_size && v[top_idx] < v[j]) {
top_idx = j;
}
}
if (top_idx != i) { // got a top elem
std::swap(v[top_idx], v[i]);
i = top_idx;
} else { // parent already dominates children, which satisfies heap property
break;
}
}
}
void bubble_up(std::vector<int>& v, size_t i) {
while (i > 0) {
size_t p = parent(i);
if (v[p] < v[i]) {
std::swap(v[p], v[i]);
i = p;
} else { // parent already dominates children, which satisfies heap property
break;
}
}
}有了这两个操作,我们就可以继续其他的操作了。
// 堆尾加入新元素,进行上浮
void push_heap(std::vector<int>& v, size_t heap_size, int e) {
v.resize(heap_size + 1);
v[heap_size] = e;
bubble_up(v, heap_size);
}
// 交换堆顶和堆尾,堆容量减1,从堆顶下沉。等价于弹出堆顶。
void pop_heap(std::vector<int>& v, size_t heap_size) {
if (v.empty()) {
throw std::runtime_error("pop on empty heap!");
}
std::swap(v[0], v[heap_size-1]);
bubble_down(v, 0, heap_size - 1);
}
// 将数组堆化,heapify.
void make_heap(std::vector<int>& v) {
const size_t heap_size = v.size();
if (heap_size < 2) {
return;
}
size_t last_parent = parent(heap_size - 1); // 大于last_parent的均为叶子,不用下沉了
for (size_t i = last_parent + 1; i > 0; --i) {
bubble_down(v, i - 1, heap_size);
}
}以及利用堆进行排序,著名的 heap sort 算法。
复杂度
- bubble_down/bubble_up 的复杂度和堆高成正比,所以是
- 建堆调用了 n/2 次 bubble_down,看起来复杂度是 ,其实复杂度是 ,想一想为啥?
- 获得最值显然是 ,因为堆顶就是最值,返回堆顶就可以了
- push/pop 均调用一次 bubble_up/bubble_down,所以复杂度是
- 堆排序,执行 n 次 bubble_down,所以是