Using Heap in Golang
Preface
在生活中,常会遇到根据优先级分配资源的问题,简单讲就是插队:
- 登机: 根据舱位决定登机顺序,头等舱/商务舱先登机,不受到达顺序影响
- 银行: VIP优先获得服务
- 急诊室: 根据病情严重程度决定就诊顺序
等等,在开发中,需要用到优先队列解决排队问题,这是一种非常有用的数据结构。
c++内置优先队列可以直接拿来用,如:
1 | std::priority_queue<int> pq; |
因为Golang中没有泛型支持,实现heap有点tricky,我们来一起看看吧。
概念分辨
Golang给我们提供了”container/heap”,并没有priority queue package,这两者是什么关系呢? 在课本中,我们学过用近似完全二叉树实现的binary heap, 那么再加上binary heap, tree,这四者究竟是什么关系呢?
可能很多小伙伴看到这里已经不耐烦的点关闭了: 我就想知道怎么实现priority queue, show me the code! 说这么多没用的干嘛?
这里涉及到一个我非常认同的学习方法:整体学习法,建立结构性知识[^1],广泛的建立起知识间的连接,这样才能加深理解,就不必每次用完就搁置,下次用再得从头看起啦。
一图胜千言 [^2]
A heap is a specialized tree-based data structure which is essentially an almost complete tree that satisfies the heap property[^3]
Binary Heap是heap的一种实现,所有的heap都是tree的特殊形式(满足heap property)。
那和我们最终需要的priority queue究竟是什么关系呢?
The heap is one maximally efficient implementation of an abstract data type called a priority queue[^3]
Heap只是priority queue(以下简称PQ)的一种实现罢了。 PQ作为一个抽象数据类型(ADT),只要实现满足PQ的方法即可。
在Golang中,也同样内置了heap package来实现PQ。
ADT: Priority Queue
PQ最基本的操作是push和pop:
- Push: 向PQ插入元素,并自动排序
- Pop: 从PQ头部弹出元素,并保证剩余元素仍有序
有些情况需要动态改变优先级,需要Update方法
本文的目标便是用heap package来实现PQ。
Heap Internal
简单对binary heap的实现做下回顾, binary heap其实是complete binary tree, 关于full, complete, perfect binary tree的区别,可以看这里[^4]。
如下便是一个complete binary tree:
它有如下性质:
- 从左向右依次排列,可以用数组实现
- root index从0开始, 任何node i有:
- parent index: └(i-1)/2┘
- left child: $2*i + 1$
- right child: $2*i + 2$
可以看出它的特点: 实现简单,可以找child, parent节点。
array中存储的是heap的level order traversal:
Binary heap同样遵循heap property: 节点”优先级”都比子树的高,因此每次pop都是取头节点。取决有优先级定义: 数值小优先级高的就是小顶堆,反之则是大顶堆。
这里有一个形象的比喻:叠罗汉是小顶堆[^5],缅怀这篇文章作者,有趣的Vamei大神,请见文章开篇的图片。
叠罗汉就是一个堆, 图中有三个堆,优先级是人的体重,体重最轻的在顶端,它是小顶堆。
Heap最基本的函数:
- Build: 初始建heap, $O(n)$
- Push: 插入新元素,自动排序 $O(log(n))$
- Pop: 弹出root元素,并使剩余元素扔保持heap property $O(log(n))$
Heap Support in Golang
Golang在”container/heap” package中实现了heap。由于没有泛型的支持,假设基本元素是int, float, struct的三个堆,如果想复用heap代码,只能是通过interface标准化一些操作,让这些差异的操作由用户实现。
Golang heap.Interface封装了差异操作:
1 | type Interface interface { |
sort.Interface需要我们提供的slice,实现另一个interface,果然是麻烦啊。
基于heap.Interface, heap package提供了我们上节提到的heap基本函数:
1 | // 建堆 |
这里值得注意的是不要混淆heap.Push和自己slice实现的Push函数,自己Push仅仅是slice操作,而heap.Push调用了slice的Push操作,还需要额外操作维护heap property。
Implement a heap in Golang
我们知道了Golang中使用了heap的套路:并不能直接拿来用,而是需要先实现heap.Interface和其中嵌套的sort.Interface,没有generic,很多地方会比较繁琐。但generic又使语言很快变得复杂难懂,果然trade-off无处不在啊,engineer都是天生纠结的命。
这里先看一下我的实现,采用了interface{}作为元素基本类型,可以同时兼容不同类型的元素,用interface{}来替代generic是常用套路。这里需要另外传入一个predicate,类似STL的自定义比较函数,可以通过传入自定义函数来实现对不同数据类型的比较,包括heap of objects,也能在大,小顶堆之间切换,只要改变判断结果即可。
1 | type myHeap struct { |
有了以上模板可以套用,因为interface{}可以指向任何类型。实现heap的关键就变成了实现predicate 函数:
1 | func(x, y interface{}) bool |
当x比y的优先级高时(意味着在数组中x将排在y前面),返回true。
因此小顶堆,小数获得高优先级,即 return x < y;大顶堆,大数获得高优先级,即 return x > y;
Use heap
我们接下来看下如何用我们的模板实现heap:
1 | package main |
在 heap.Init(intHeap) 之后,我们构建了heap:
注意到slice中的值正是tree的level traversal结果。同时可看到push和pop均符合预期。push得到一个新的heap:
回顾heap的push过程,是将新元素放在slice末尾,同时不断将其上升至root的过程。
Update element in heap
有时候能动态调整heap中元素的优先级会很有用,heap package提供了fix函数:
1 | func Fix(h Interface, i int) |
i为被调整元素的index, 我们需要先调整slice处于i位置的元素,然后调用此函数以维持heap property:
1 | intHeap.data = []interface{}{1,3,2} //初始化heap |
但在实际中,我们并不能知道我们要调整元素的index,因为index是在随着元素的push和pop不断的变化,因此就需要记录在元素内部,同时在swap元素时维护index的最新值,只要我们有元素的指针,就可以实现动态update元素的优先级。
完整的例子见heap doc,同时上面示例代码在golang playground
[^1]: Young, S. (n.d.). Learn more study less.
[^2]: 堆和树有什么区别?堆为什么要叫堆,不叫树呢?
[^3]: Heap (data structure)
[^4]: 完美二叉树, 完全二叉树和完满二叉树
[^5]: 纸上谈兵: 堆 (heap)
[^6]: binary heap