第一个解决的问题-堆用于解决什么算法问题?
-先不要纠结堆是如何实现的,先了解堆解决了什么问题。
堆其实就是一种数据结构,数据结构是为算法服务的,那堆这种数据结构是为哪种算法服务的?
简单的来说,堆就是动态地帮你取极值的。当你需要动态地取极大或极小值,就使用它。
我们先简单的把堆和优先级队列进行等价,不作区分。
使用堆解决问题
堆的两个核心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
}
}