堆heap

601 阅读6分钟

第一个解决的问题-堆用于解决什么算法问题?

-先不要纠结堆是如何实现的,先了解堆解决了什么问题。

堆其实就是一种数据结构,数据结构是为算法服务的,那堆这种数据结构是为哪种算法服务的?

简单的来说,堆就是动态地帮你取极值的。当你需要动态地取极大或极小值,就使用它。

我们先简单的把堆和优先级队列进行等价,不作区分。

使用堆解决问题

堆的两个核心API是 push 和 pop。

我们暂时把堆想象成一个盒黑,提供了两个API:

  • push:推入一个数据,内部怎么组织的不用管,可以实现按优先级权重进行排队和插队;
  • pop:弹出一个数据,该数据一定是最小的(最大的),内部怎么实现先不管。

以小顶堆为例:

heap.push(person, '12:00')
heap.push(person, '13:00')
// 进行插队,时间复杂度为O(logN)
heap.push(person, '12:30')
// 叫号,来得最早的进行服务,这里弹出时间也是O(logN)
heap.pop(person)

上面的这个场景,单纯使用数组和链表都可以实现,但是使用堆结构,在插队这种情况下时间复杂度会更低:

  • 如果使用数组来维护一个有序列表,取极值会很容易,但是插队的情况下,需要对被插入元素后面的所有元素进行调整,时间复杂度为O(N)

  • 如果使用链表来维护一个有序列表,取极值同样很容易,但是插队情况下,需要去查找对应的插入位置,时间复杂度为O(N),但是我们也可以通过跳表索引等来提高查询速度,这种情况对应的就是优先级队列的跳表实现

  • 如果通过维护一个树的方式来取极值,即根节点就是极值,这样取值也是O(1),只是调整过程为O(logN)。这种实现对应的就是优先级队列的二叉堆实现

队列和优先级队列

在具体实现之前,我们先解决一个问题,优先级队列是队列吗?

我认为,实际上,队列也是一种优先级队列,只是队列是使用时间这个变量作为优先级的权重的,时间越早,优先级越高,越早出队。

那我们平时都用优先级队列不就行了?不行,因为普通队列的入队出队时间复杂度为O(1),而优先级队列的入队和出队都为O(logN),N为当前队列的大小。

堆的两种实现

使用跳表的实现,我们这里仅讲述它的原理。我们主要讲述基于二叉树的实现。

跳表

跳表也是一种数据结构,因此它也是服务于某种算法的。跳表中的设计思路很值得我们去学习,比如空间换时间、效率的取舍问题等。

上面提到,解决插入问题是堆需要首先解决的问题。堆的跳表如何解决这个问题呢?

我们知道,在不借助额外空间的情况下,在链表中查询一个值需要按顺序一个一个地进行查找,时间复杂度为O(N)。

一种常见的优化方式是:采用哈希表将所有节点放在哈希表中,以空间换时间的方式解决这个问题。但是这种做法的空间复杂度为O(N)。更加致命的问题是,哈希表对于查询极值没有任何帮助。

使用跳表:

注意这个算法是要求链表是有序的。

  • 入堆操作,只需要根据索引插到链表中,并更新索引
  • 出堆操作,只需要删除头部(或尾部),并更新索引

二叉堆

二叉堆的两个核心操作:heappush、heappop。实现之后的使用效果如下:

h = min_heap()
h.build_heap([5,6,2,3])

h.heappush(1)
h.heappop() # 1
h.heappop() # 2
h.heappush(1)
h.heappop() # 1
h.heappop() # 3

基本原理

本质上,二叉堆就是一个完全二叉树,一切的一切都源于这句话,父节点的权值不大于儿子节点的权值(小顶堆)

由于父节点的值不大于子节点,所以自然而然,根节点的值就是最小的,这里就起到了取极值的作用。

那么动态性是如何做到的呢?

出堆

我们将根节点pop出去后,需要将第二小的顶替上去,怎么顶替上去呢?还是上面那句话,父节点的权值不大于儿子节点的权值。

对上图中的小顶堆进行出堆操作,一种常见做法是,把根节点和最后一个节点交换。但是新的节点可能不满足 父节点的权值不大于儿子的权值,如下图:

这个时候,其实只需要将新的根节点 下沉 到正确位置即可。这里的正确位置,指的还是那句话,父节点的值不大于子节点的值。如果不满足这一点,我们就继续下沉,直到满足。

我们知道,根节点下沉过程中,有两个方向可供选择,对于小顶堆,即选择下沉到较小的节点处。这里我们需要下沉到节点3上。

下沉到如图位置,还是不满足 父节点的权值不大于儿子节点的权值,于是我们继续执行同样的操作。

下沉后,还满足堆的性质吗?

满足,因为:

  • 下沉路径上的节点一定满足堆的性质
  • 不在下沉路径上的节点都保持了堆之前的相对关系,因此也满足堆的性质

对于时间复杂度,树高为logN,所以时间复杂度大概为O(logN),N为二叉树节点数量

入堆

我们可以直接往树的尾部插入一个节点,但是,这样的操作和上面一样,可能会导致破坏堆的性质。

注:我们这里是用数组模拟的完全二叉树

这次我们发现,不满足堆的节点是刚刚被插入的尾部节点,因此不能进行下沉操作。这一次,我们需要执行上浮操作

叶子节点只能上浮,根节点只能下沉,其他节点既可以上浮也可以下沉

上浮操作,只需要和父节点比较,更加的简单。

注意,用数组模拟完全二叉树,我们最好从下标1开始存储,tree[0]可以存储树的元素大小之类的数据

总结:具体来说就是上浮过程和比它大的父节点进行交换,下沉过程和两个子节点中较小的进行交换,当然前提是它有子节点且子节点比它小。

代码实现:

// 这里主要实现节点下沉和上浮的伪代码 (小顶堆)
function shift_up(x) {
	while (x>1 && h[x] < h[x/2]) {
  	swap(h[x], h[x/2])
  }
  x = Math.floor(x/2)
}

function shift_down(x) {
	while (x*2 <= n) {
  	// minChild 获取更小的子节点的索引
    let minIndex = minChild(x)
    if (h[x] <= h[minIndex]) break
    swap(h[x], h[minIndex])
    x = minIndex
  }
}