头图

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的数组存放,图上已经标出了各节点在对应数组中的位置

图片太单调不好理解?试试看二叉堆的在线可视化展示页面!

https://visualgo.net/zh/heap

因为上文中这种性质的存在,可以为每个节点赋予一个特定的编号(这个编号被用作数组索引)并通过这个编号查找其左右子节点,进而可以直接用数组来存储整个二叉堆。

在以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 > 1
  • 若i不等于1,则比较它和父节点的大小,如果它大于父节点的值,则将它和父节点进行交换(通过exchange方法)。下图中,被插入的新元素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一样

  • 首先找到左右子节点的索引,命名为lr
  • 分别检查其是否越界 - 通过leftright计算出的索引位置未必真的存放着元素,甚至可能超出了当前的堆容量
  • 在未越界的情况下,比较data[l] 与 data[s]的大小,将值较大的那个索引赋给s
  • 在未越界的情况下,比较data[r] 与 data[s]的大小,将值较大的那个索引赋给s (前面和l已经比较过了,此时s存放的应该是l和i中元素较大的那一方)
  • 检查s是否与i相等,如果相等,说明data[i]data[l]data[r]都要大,考虑到子堆的性质并没有被破坏,data[i]已经比子树中的所有元素都要大了,终止循环,退出。
  • s不等于i,那么交换si的值,因为原本的元素位置下移了,将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个元素便很轻松了 - 不过,insertdelete有些边界条件的处理方式没有写出,如在容量用尽时进行扩容。完整的代码分享于此: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

从配对堆的定义和各操作可以发现,它没有在结构上大做文章保留各种额外的树大小,深度、排名等信息,这使得它避免了斐波那契堆那样的糟糕常数,在实践中为自己赢得了效率优秀实现简单灵活的良好声誉。

鸣谢:刘新宇老师和他的开源书籍"基本算法" https://github.com/liuxinyu95/AlgoXY

MoonBit 编程实践 

欢迎来到「MoonBit编程实践」!本栏目将为你提供如何使用MooBit强大功能,轻松实现各种工业应用与创意项目。我们将与你分享实用的示例代码、项目构建步骤以及技术见解,无论你是编程新手还是经验丰富的开发者,都可以轻松玩转MoonBit。

我们也期待你积极地分享你的编程实践!让我们一起开启MoonBit编程之旅🎉


Moonbit
1 声望3 粉丝

IDEA基础软件中心打造的下一代智能开发平台