前言
我曾写过一篇关于数据结构堆的文章 链接,但效果并不理想,因为看完之后仍然很难手写一个堆。
通常情况下,标准库中的 priority_queue
(C++)或 PriorityQueue
(Java)已经足够使用,但它们也有局限性。对于需要自定义操作的算法,比如调整优先级或改写堆中元素位置,标准库不支持这些功能。此外,这些库仅允许访问堆顶元素,不支持迭代或通过下标访问堆。
因此,对于更灵活的需求,掌握手写堆显得尤为重要,特别是在结合反向索引堆或控制堆高度时,会更加得心应手。
编程语言:C++/Java
注意:本篇不过多涉及二叉堆的原理详解, 笔者应当熟悉二叉堆这种数据结构, 这里只是提供一种较快手写堆的思路和代码。
堆的简单回顾
-
堆的结构
- 堆是一棵完全二叉树,所有层的节点都被从左到右依次填满,只有最后一层可能未满。
- 因为是完全二叉树,堆更适合使用数组实现,通过
下标关系
快速定位父节点和子节点,而不需要额外指针,避免链式结构的复杂性。
-
堆顶元素的存储位置
- 数组下标:堆顶元素通常存储在数组下标
0
(零基索引)处,也可以选择从1
(基于一的索引)开始。 - 堆顶的意义:
- 堆顶是最大堆(大根堆)中最大的元素;
- 堆顶是最小堆(小根堆)中最小的元素。
- 数组下标:堆顶元素通常存储在数组下标
-
堆的分类
- 大根堆(最大堆):
每个父节点的值 ≥ 子节点的值,优先级高的在堆顶。 - 小根堆(最小堆):
每个父节点的值 ≤ 子节点的值,优先级低的在堆顶。
- 大根堆(最大堆):
广义的优先级比较
传统上,堆用于比较数值(比如数值的大小),以决定堆的顺序。但堆的本质是对“优先级”进行组织管理,广义的优先级比较可以扩展到任意属性:
-
数值比较
- 小根堆:数值越小优先级越高,堆顶存放最小值;
- 大根堆:数值越大优先级越高,堆顶存放最大值。
-
逻辑优先级
- 小根堆:父节点的某种属性(例如时间戳更早、距离更短等)小于子节点。
- 大根堆:父节点的某种属性(例如权重更高、收益更大等)大于子节点。
-
优先级定义
- 通过自定义比较函数,将逻辑上的“大小关系”映射到堆的组织结构中。
- 两种语言内置的广义优先级体现:在 C++ 中可以使用
std::priority_queue
的第三个模板参数;在 Java 中可以通过Comparator
实现类似功能。
通过这种扩展,堆不仅能比较数值,还可以根据任意属性或逻辑组织数据,从而实现多样化的优先级控制。
实现
下面以C++为例用数组快速实现堆
在我看来的堆操作只需要封装两个函数上浮push_up
,下沉push_down
。
插入删除本质就是要么交换一下,或者底层数组尾部插入一个元素,然后取调上浮或者下沉接口罢了。
创建初始化
下列heap数组的形式不唯一, 它也可能int heap[N][2],int heap[N][3]
, 甚至不是int类型, 取决于维护什么信息的类型和数量。
在后续学习图论算法中。
比如在Dijkstra算法
中,int heap[N][2]
行下标是堆数组下标,列下标[0]维护顶点编号,[1]维护源点到当前顶点的已知最短距离。
const int N = 1e6+10;//数据范围
int heap[N],hz;//堆底层数组,hz跟踪当前堆的大小
//初始化操作
void init(){
hz = 0;
}
初始化操作也很简单, 让数组指针hz
重置为0即可。
插入
明白插入的底层操作。
- 先进行数组尾插, 由于逻辑上要呈现处堆的特点, 但尾插元素可能破坏堆结构。 只需要对插入元素进行上浮
push_up
即可。
//插入操作
void push(int x){
//底层数组尾部进行插入
heap[hz] = x;
//此时hz为有效下标,传入push_up上浮
push_up(hz);
//堆大小+1
hz++;
}
上浮操作
while循环条件判断语句应该是逻辑上选出优先级高的。
这里仅仅以堆数组中存储的值更小的具有更高优先级这个逻辑距离。同样的, 这里是小根堆, 若想调整为大根堆,变换一下顺序即可。
while循环内部, 执行交换逻辑, 然后调整堆数组的i下标。
这里不要求i>0
, 但注意不能写成heap[i]<=heap[(i-1)/2]
, 取等会死循环。
//上浮操作
void push_up(int i){
while (heap[i]<heap[(i-1)/2]){
swap(heap[i], heap[(i-1)/2]);
i = (i-1)/2;
}
}
删除
删除堆顶元素, 且删除只能删堆顶元素, 不支持内部元素删除
删除逻辑也很好想。“懒删除”, 将堆数组最末尾的元素与堆顶元素进行交换, 然后堆大小整体减一。 这样原先的堆顶元素就移除出去了。
swap(heap[0], heap[--hz]);
这一条语句就是上面的代码。
由于原先末尾元素放置到了堆顶, 可能破坏堆顶。 因此选择下沉操作。
删除代码如下, 结合下沉代码看。
//删除堆顶元素
//堆化操作,将堆顶元素下沉,维持堆性质
void pop(){
//堆顶元素与底层数字最末尾元素交换, 堆大小-1表示堆顶被删除了。
swap(heap[0],heap[--hz]);
//新堆顶下沉调整, 找到正确的堆顶
push_down(0);
}
下沉操作
流程:’
- 从左孩子,右孩子和自身进行比较。 如果比较处理的结果不是自身优先级最高, 那么依次交换更新下标。 否则,终止下沉操作。具体看代码
//下沉操作
void push_down(int i){
int l=i*2+1;//l是左孩子
while(l<hz){
//找出最棒的孩子,优先级最高的孩子
int best = l+1<hz&&heap[l+1]<heap[l]?l