1964 年,J.W.J Williams 的论文"Algorithm 232 - Heapsort"将二叉堆引入了程序世界,之后的几十年中,人们发现这种数据结构在一般性的排序之外也有着不少用处(例如带优先级的任务调度,图算法等),在它的基础上扩展产生的各种变体层出不穷。本文将探讨使用数组实现的二叉堆和使用列表实现的配对堆的工作原理,以及如何使用 MoonBit 语言实现。
顺带一提,MoonBit 即将推出的标准库中最早一批可供使用的数据结构就有堆。
堆这个名字很容易让人联想到与栈相对的不连续的程序数据存储区域,但就和栈一样,在数据结构领域堆这个名字有着截然不同的定义。翻开那本 1964 年最早的关于堆的文献,开篇即提到堆排序是树排序的一种改进版本,所以堆的逻辑结构是一种树.
图源于: https://www.happycoders.eu/algorithms/heapsort/
在一个堆中,任意节点所存放的值都比它的子树中的节点值要大或小,这样的性质保证了堆中的任意子树同样是一个合乎条件的堆。
而对于用户而言,堆使用起来很像一种"队列"
let h = MinHeap::new()
用户可以通过insert
方法向堆中添加元素
h.insert(6)
h.insert(4)
h.insert(13)
还可以用pop
方法弹出一个元素,只是每次弹出堆的都是当前堆中最小的元素,而非按照insert
时的顺序弹出.
h.pop() // 此处结果应该为 4
在我们给的例子中,堆存放的元素类型是整数,但是实际上堆只要求元素类型可以比较大小,在 MoonBit 中,> <
等比较运算符都是Compare
接口下的方法,实现该接口的类型都可以使用堆存储。
此处实现的是大根堆,即堆顶元素最大
二叉堆的逻辑结构是一种近乎全充满的二叉树(通用的名词是"完全二叉树") - 假设有一个 n 层的二叉堆,其前 n-1 层都是满的,第 n 层的节点则倾向于先布满左侧。下图是一个具有 6 个元素的二叉堆arr
,它可以用一个长度为 6 的数组存放,图上已经标出了各节点在对应数组中的位置:
图片太单调不好理解?试试看二叉堆的在线可视化展示页面!
因为上文中这种性质的存在,可以为每个节点赋予一个特定的编号(这个编号被用作数组索引)并通过这个编号查找其左右子节点,进而可以直接用数组来存储整个二叉堆。
在以 1 为索引起始的数组上,节点 i 的左侧子节点是 2i, 右侧子节点是 2i+1, 父节点则需要除以 2 后向下取整。
fn parent(self : Int) -> Int {
self / 2
}
fn left(self : Int) -> Int {
self * 2
}
fn right(self : Int) -> Int {
self * 2 + 1
}
// 假设要获取节点 i 的父节点对应的数组索引,使用 i.parent()即可
MoonBit 中的数组以 0 为索引起始,为了保持形式上的一致,我们将最开始索引为 0 的位置保留不使用,在堆的相关操作中都将 1 做为索引起始。
在创建堆的BHeap::new
函数中,为了让真实的堆容量与参数中保持一致,创建数组时要让它的长度长一位。相应的是获取堆容量的capacity()
函数要在数组长度上减去 1.
MoonBit 内置的 Array[T]是定长的,所以我们会建立一个带有实际元素计数和数组的 struct 。
struct BHeap[T] {
mut data : Array[T]
mut count : Int
min_value : T
}
fn BHeap::new[T : Compare](capacity : Int, minValue : T) -> BHeap[T] {
{ data: Array::make(capacity + 1, minValue), count: 0, min_value: minValue }
}
fn capacity[T](self : BHeap[T]) -> Int {
self.data.length() - 1
}
创建数组需要默认值,建议使用 T 类型的最小值填充。
接下来要实现二叉堆的两项相关操作:插入与弹出。
向空二叉堆中插入元素很好处理,只要把元素放到data[1]
就好了,但当我们需要插入更多元素时,应该怎样通过比较它们之间的大小关系找到合适的位置呢?
一种广为流传的做法是,首先将需要插入的新元素放到数组末尾 - 相当于在二叉树的最后一层找个最左边的空闲位置将它设为叶节点,如果最下一层已经充满,则另起一层。下图中是将新元素 20 插入到右侧子节点:
fn insert[T : Compare](self : BHeap[T], x : T) {
......
self.data[self.count + 1] = x
self.count = self.count + 1
self.heap_fix(self.count)
......
}
这一举动有非常大的可能破坏堆的性质,我们根本不知道这个新元素和我们随便为它安排的父节点的大小关系如何。不过,虽然堆的性质被破坏了,但排除掉这个新来的不速之客,之前的堆元素仍然服从父节点元素最大的规则,在这个基础上进行修补并没有那么困难。
假设新元素对应的数组索引存储在变量 i 中,那么
i
是否等于 1 ,等于 1 则什么都不必做 - 已经没有父节点了。由于不使用索引 0 ,i
不等于 1 也可以写成 i > 1exchange
方法)。下图中,被插入的新元素 20 大于其父节点元素 17 ,于是将 17 向下调整,让 20"上浮"。i
赋值为i.parent()
, 因为这才是新元素现在所在的位置fn heap_fix[T : Compare](self : BHeap[T], i : Int) {
var i = i
while i > 1 && self.data[i] > self.data[i.parent()] {
self.data.exchange(i, i.parent()) // 交换元素位置
i = i.parent() // 标记被插入元素现在所对应的索引
}
}
fn exchange[T](self : Array[T], x : Int, y : Int) {
let tmp = self[x]
self[x] = self[y]
self[y] = tmp
}
另一项重要操作是弹出堆顶元素,同样,我们采取先更改再重新平衡的策略,先把数组的第一个和最后一个元素交换一下,然后对count
减一。下图中,原本的堆顶元素是 45 ,堆末端元素则是 9 ,现在交换位置并通过递减count
把 45 先删除掉
删除的过程可看作让被换到堆顶的这个元素逐步"下沉"到一个合适的位置,这一步会通过heapify
函数完成。
假设被交换到堆顶的元素其索引存放在变量i
中,同时每一次比较都用变量s
存放元素最大的节点索引,初值和 i 一样
l
与r
left
和right
计算出的索引位置未必真的存放着元素,甚至可能超出了当前的堆容量data[l]
与 data[s]
的大小,将值较大的那个索引赋给s
data[r]
与 data[s]
的大小,将值较大的那个索引赋给s
(前面和 l 已经比较过了,此时 s 存放的应该是 l 和 i 中元素较大的那一方)data[i]
比data[l]
和data[r]
都要大,考虑到子堆的性质并没有被破坏,data[i]已经比子树中的所有元素都要大了,终止循环,退出。s
不等于i
,那么交换s
与i
的值,因为原本的元素位置下移了,将i
赋值为s
,然后继续循环。在下图这个例子中,插入堆顶的元素 9 和左右子节点分别比较,最后把最大的 36 交换到了堆顶。上述过程对应的 MoonBit 代码实现如下
fn pop[T : Compare](self : BHeap[T]) -> Option[T] {
if self.count == 0 {
return None
}
let x = self.data[1] // 保存堆顶元素
let n = self.count
self.data.exchange(1, n)
self.count = self.count - 1 // 删除掉被换到数组尾部的原堆顶元素
if self.count > 0 {
self.heapify(1)
}
return Some(x)
}
fn heapify[T : Compare](self : BHeap[T], index : Int) {
let n = self.count
var i = index
var s = i
while true {
let l = i.left()
let r = i.right()
if l <= n && self.data[l] > self.data[s] {
s = l
}
if r <= n && self.data[r] > self.data[s] {
s = r
}
if s != i {
self.data.exchange(s, i)
i = s
} else {
break
}
}
}
有了这两种基本操作,实现通用的数组排序和选出最大的 k 个元素便很轻松了 - 不过,insert
和delete
有些边界条件的处理方式没有写出,如在容量用尽时进行扩容。完整的代码分享于此:BHeap.mbt
在小数据量且元素出入频繁的情况下,二叉堆的性能相当不错,资料[^1]则提到数据量较大且内存受限时,使用多叉堆会更好。
上文所实现的二叉堆具有明显的命令式风格,而 MoonBit 作为一种多范式语言对函数式编程同样有较好的支持,接下来我们会使用不可变的数据结构List
以及 MoonBit 中的枚举类型为基础,实现一个配对堆。
配对堆
此处实现的是小顶堆
配对堆是一种基于多叉树的堆,实现简单,性能优越,GNU libstdc++库中的优先级队列即用它实现。它的困难之处体现在操作的复杂度分析上,本文不会涉及。
由于配对堆的结构是较为一般的多叉树,这里我们用enum
定义PHeap
, 一个PHeap
要么是一棵空树,要么是一个存放着一个元素与 N 棵子树的节点,这样的概念可以通过枚举类型很自然地表达。
enum PHeap[T] {
Empty
Node(T, List[PHeap[T]])
}
在配对堆这边,先定义两个堆的合并操作会方便一些。通过模式匹配能很清晰地描述合并的逻辑:
h1 和 h2 中一方为空则保留另一方
均不为空,那就拿出各自的堆顶元素进行比较,把较大的那个堆放进另一堆的列表中。
fn merge[T : Compare](h1 : PHeap[T], h2 : PHeap[T]) -> PHeap[T] {
match (h1, h2) {
(Empty, h2) => h2
(h1, Empty) => h1
(Node(x, ts1), Node(y, ts2)) => if x < y {
Node(x, Cons(Node(y, ts2), ts1))
} else {
Node(y, Cons(Node(x, ts1), ts2))
}
}
}
插入可以看做一个单元素的堆被合并进原来的堆
fn insert[T : Compare](self : PHeap[T], x : T) -> PHeap[T] {
merge(self, Node(x, Nil))
}
弹出栈顶元素只需对一整个列表的堆使用 merge 进行折叠即可, 使用consolidate
(此处它的含义是"整合")函数。需要注意的是,consolidate
函数实际上通过递归实现了一个两阶段的整合,先是对列表中的子堆两两合并,然后再将这些新生成的堆合并到一起,这表现为代码中嵌套的merge
函数调用。
fn consolidate[T : Compare](ts : List[PHeap[T]]) -> PHeap[T] {
match ts {
Nil => Empty
Cons(t, Nil) => t
Cons(t1, Cons(t2, ts)) => merge(merge(t1, t2), consolidate(ts))
}
}
fn pop[T : Compare](self : PHeap[T]) -> Option[PHeap[T]] {
match self {
Empty => None
Node(_, ts) => Some(consolidate(ts))
}
}
match
与枚举类型的使用让配对堆的相关操作实现非常简洁, 完整代码于此:try.moonbitlang.com/#a2f1dd62
从配对堆的定义和各操作可以发现,它没有在结构上大做文章保留各种额外的树大小,深度、排名等信息,这使得它避免了斐波那契堆那样的糟糕常数,在实践中为自己赢得了效率优秀实现简单灵活的良好声誉。
[^1]:You're Doing It Wrong - Think you've mastered the art of server performance? Think again.
鸣谢:刘新宇老师和他的开源书籍"基本算法" https://github.com/liuxinyu95/AlgoXY
欢迎来到「 MoonBit 编程实践」!本栏目将为你提供如何使用 MooBit 强大功能,轻松实现各种工业应用与创意项目。我们将与你分享实用的示例代码、项目构建步骤以及技术见解,无论你是编程新手还是经验丰富的开发者,都可以轻松玩转 MoonBit 。
我们也期待你积极地分享你的编程实践!让我们一起开启 MoonBit 编程之旅🎉