Bagaking细讲算法 - 堆 I
堆 I: 堆的原理和应用场景
堆这种数据结构由 J. W. J. Williams 于 1964 年在介绍堆排时提出. 原理简单, 而有着广泛的应用.
我们直接来看其在 wiki 上的定义:
a heap is a specialized tree-based data structure which is essentially an almost complete tree that satisfies the heap property
堆是一个满足堆的性质的近乎完全的树型数据结构
heap property: in a max heap, for any given node C, if P is a parent node of C, then the key (the value) of P is greater than or equal to the key of C. In a min heap, the key of P is less than or equal to the key of C. The node at the “top” of the heap (with no parents) is called the root node.
堆的性质: 对于任一节点 C, 其父节点 P 的值始终大于等于(或始终小于等于) C, 则称这个堆结构具备堆的性质. 和树一样, 定义堆结构的根节点为唯一没有父节点的节点. 对于每个C始终有P >= C的为最大堆, 反之为最小堆.
显然, 最大堆的根是所有项里的最大值, 所以又叫大根堆或大顶堆. 同理, 最小堆的根则是最小值, 又叫小根堆或小顶堆.
堆的结构实现
一般来说会用链式结构来实现一棵树, 用这种方法实现的数据结构叫做 explicit data structure, 即除了原本的信息之外还保存了很多. 相对的, 有 implicit 的实现方式.
implicit data structure or space-efficient data structure is a data structure that stores very little information other than the main or required data: a data structure that requires low overhead. They are called “implicit” because the position of the elements carries meaning and relationship between elements
implicit data structure: 在主要数据之外只需要存储非常少的额外信息的数据结构, 即需要很低额外开销(Overhead) 的数据结构. 之所以叫做 “implicit”, 是因为每个元素所在的位置已经完全可以说明元素之间的关系.
N叉树(包含2叉树), 或任意分叉规则明确的树(比如 K-D Tree), 都可以实现为 implicit data structure 的数据结构. 以2叉树为例, 一个常见的规则是将2叉树表示为一个数组.
如果用元素从0开始的数组实现: 根节点为 0, 对于任意一个元项 i 其左子节点和右子节点的下标分别是 2i + 1, 2i + 2, 而其父节点为 (i - 1) / 2. 也常见 1 为根节点, 则 i 的左右子节点分别为 2i, 2i + 1, 父节点为 i / 2, 用O(1)空间换取略小的计算常数. 在此基础上, 扩展到N叉树, 或是改变左右子节点的寻址规则, 都是简单的.
不过这种实现对于一般情况有一个问题, 则是当树节点不完全, 甚至比较稀疏的情况下, 有较多的空间浪费. 对于平衡树或近乎完全的树, 则不会有这个问题. 由于堆定义上就是近乎的树结构, 因此我们一般用 implicit 的方式实现堆.
堆的实现 性质的保持
为了便于理解和举例, 我们基于最常见的二叉堆讨论.
二叉堆可以认为是一棵符合堆的性质的二叉树, 因此它也是一个"近乎完全"的二叉树:
In a complete binary tree every level, except possibly the last, is completely filled, and all nodes in the last level are as far left as possible.
在一个完全二叉树中, 除了最后一层的其它各层是完全填满的, 并且最后一层中的所有节点全部集中在左边, 这样的二叉树叫做完全二叉树. 虽然堆的定义中, 只需要 almost complete tree, 但我们一般当成一棵完全二叉树来讨论.
结构上, 如上一节所说:
- 访问根节点:
seq[0] - 访问i的左子节点:
seq[2*i+1] - 访问i的左子节点:
seq[2*i+2] - 访问i的父子节点:
seq[(i-1)/2]
这样就有了二叉树的实现.
现在我们来赋予其堆的性质, 堆只需要查询最大值或最小值, 也就是堆顶项的值. 为了保持其值始终是最值, 我们需要在每个项插入时, 保持堆的性质即可. 对于没有任何项的堆, 显然是符合堆的性质的, 而堆的性质被改变, 只可能发生在插入 insert 或弹出 extract (extract_min, extract_max) 项时.
为了方便表述, 最大/最小的表达统一为最小, 较大/较小的表达统一为较小. extract_max/extract_min 的表达统一为 extract.
insert: 插入时只有 C 本身有可能不对, 于是我们可以使C逐次上浮 siftUp, 当C上浮到其正确的位置, 则整个树满足堆的性质: 先将待插入的项 C 插入到二叉堆的末端 seq[length++] = v, 此时这个项的位置是可能不对的. 于是将其和其父节点 P 进行比较, 如果 P <= C, 或 P 不存在, 则 C 已经在其正确的位置了. 否则将 C 上移 swap(i, (i-1)/2), 直到满足条件.
extract: 堆的弹出操作弹出的是 root 元素, 为了维持堆的性质, 我们需要找出整个堆里面最小的值, 显然, 这个值必然是 root 的左右子节点的其中一个. 所以要找出较小的那个, 将其至于堆顶 seq[1] < seq[2] ? swap(0, 1) : swap(0, 2). 但这样空位就被下移了, 还好堆的左右子树必也都为一个堆, 所以我们可以迭代的对这个子树进行上述流程. 可以使当前序列恢复成堆.
这个操作相当于是把堆顶的空位移到了堆底, 但这样有两个问题, 一是每次都需要把空位从顶移到底, 而是可能会导致树的最后一层存在 gap, 大多数情况下需要额外维护.
所以一般的做法, 是将整个列表的长度先缩短, 并同时将最后一项移到root上 seq[0] = seq[--length], 这样堆里面只有 root 的位置是不对的, 只要让这个项下沉 siftDown 到其应该在的位置就行了.
伪代码:
switch(posOfMinInThree){
case i: return;
case left: swap(i, left);
case right: swap(i, right);
}
i = posOfMinInThree
上面这段代码只为说明原理, 有性能浪费, 实际写的时候直接用 if 条件分支来写.
基于从1开始的数组实现
最早版本的堆, 其实是基于从 1 开始的数组实现的, 即根节点的下标是 i. 这种实现下, 寻址更为简单. 也有更少的指令数.
- 访问根节点:
seq[1] - 访问i的左子节点:
seq[2*i]或seq[i<<1] - 访问i的左子节点:
seq[2*i+1] - 访问i的父子节点:
seq[i/2]或seq[i>>1]
在查阅堆相关算法的论文或源码时, 这两种下标的选择方式都是常见的. 有时在代码中看到直接用 (i << 1) 求取子节点的, 基本就考虑是下标从 1 开始的堆.
复杂度
堆可以插入随机项, 但只能弹出最值. 而不支持随机查询: 只要满足子元素与父元素有统一的大于等于或小于等于关系即可, 因此对于查询随机元素而言, 并没有太多帮助. 查询随机元素接近于遍历整个数组.
显然 sift-up 和 sift-down 操作的复杂度都是 O ( l g n ) O(lgn) O(lgn), 因此 insert, extract 显然也是 O ( l g n ) O(lgn) O(lgn) 的.
也正是由于堆的性质使得堆每次 insert 或 extract 元素只需要 O ( l g n ) O(lgn) O(lgn) 的维护时间, 而查询最值只需要 O ( 1 ) O(1) O(1) 的操作时间. 而维护有序列表的通常需要 O ( n ) O(n) O(n) 的交换, 这使得堆在优化算法性能上有广泛的应用. 这种优化的本质也是减少交换次数.
优化
一个常数上的优化: 在每一次迭代中, 计算条件分支需要 2 次比较, 进行 swap 一般来说需要 3 次写. temp = nums[i], nums[i] = nums[i_minChild], nums[i_minChild] = temp. 但观察第 i 次迭代和第 i+1 次迭代, nums[i_minChild] 被写入了两次, 由于迭代中值的交换是连续的, 在第 i 次迭代中可以不对 nums[i_minChild] 写入, 在第 i + 1 次迭代时, 直接用 temp 参与 minChild 的比较, 只需要
O
(
1
)
O(1)
O(1) 可以把迭代中的写操作降到 <= 2, 这可以优化写代价高的情况.
优化比较次数: 对于一般的 sift down 或 heapify 而言, 由于随机值的存在, 所以在每一层计算时需要计算三个值的最小值, 这需要两次比较. 假设堆不包含叶子节点的高度为 d, 最多需要 2d 次的比较, 和 d 次的交换. 而对于 Extract 和 Delete 操作, 我们有一个先验证信息是, 临时填充到 root 位置的项在 sift down 之后很大概率会回到叶子节点.
用这个方法实现堆排, 又叫 bottom up heapsort, 也被称作 heapsort with bounce. 实现并不复杂, 可以先从 root 搜到叶子, 每次搜索只用一次比较, 然后回溯, 从第一个小于 root 的值开始往前交换, 直到回到root. 我特地写了个root下标为 1 的版本, 代码如下:
private void SiftDownBottomUp() {
var iTarget = 1;
int left;
while ((left = iTarget << 1) < Count) {
var right = left + 1;
iTarget = Less(left, right) ? left : right;
}
if (iTarget << 1 == Count) iTarget = Count; // special leaf
var curVal = _items[1];
for (; iTarget > 1; iTarget >>= 1) {
if (Less(curVal, _items[iTarget])) continue;
var temp = _items[iTarget];
_items[iTarget] = curVal;
curVal = temp;
}
_items[1] = curVal;
}
要注意的是, 这个方法在找到最佳路径的时候, 由于不保存路径, 所以第二遍遍历和修改过程的方向不能从root项leaf, 而只能向上回溯. 向上回溯的交换过程不是连续的, 因此所以无法用到前面提到的减少交换常数的优化.
应用场景
堆是优先队列的主要实现方式. 优先队列的特点是, 每个元素入队列时都有个优先级, 并按优先级出队. 实际上由于太过经典, 我们说堆和优先队列的时候, 经常指同一个东西.
解决排序问题. 有堆的情况下, 将所有元素依次输入堆, 再依次弹出堆, 自然就是有序的. 输入和弹出过程都是 O ( n l g n ) O(nlgn) O(nlgn) 因此总的复杂度也是 O ( n l g n ) O(nlgn) O(nlgn).
解决第K顺序统计量问题. 在统计学中,经常需要用到第K顺序统计量(kth order statistic), 即列表从小到大排列的第K项. 我们一般引申为求序列里第K大/第K小的数. 以求第K小的数为例, 建立一个最大堆, 维持堆的大小为k, . 然后逐个扫描项, 对于每个项i, 如果堆大小未达到k, 直接入堆. 如果堆已经有K项, 则用peek检查堆顶是否小于i的, 小于的话就用i项replace堆顶, 否则什么都不做. 最终结果堆顶即为所求.
显然, 这也是一个窗口算法, 可以用作在线算法.
解决多路归并问题. 多个有序序列的合并, 很显然, 只要保持所有未空队列的头在一个堆中, 每次从堆中extract一项并从对应队列补充, 则算法复杂度为 O ( n l g k ) O(nlgk) O(nlgk), k为序列数.
在Dijkstra算法中, 优化查找开放列表中距离最短的节点的过程. Dijkstra算法的原始实现复杂度为 O ( n 2 ) O(n^2) O(n2), 其中一个n在与其每次迭代中都要找出开放列表中当前权值最小的点, 作为当前次遍历的出节点. 原始算法找这个点的方式为遍历, 所以复杂度 O ( n ) O(n) O(n), 因此可以直接使用堆来做开放列表, 可以将复杂度优化到 O ( n l g n ) O(nlgn) O(nlgn)
Reference
https://en.wikipedia.org/wiki/Binary_tree
https://en.wikipedia.org/wiki/Heap_(data_structure)
https://en.wikipedia.org/wiki/Binary_heap
https://en.wikipedia.org/wiki/Implicit_data_structure
BOTTOM-UP-HEAPSORT, a new variant of HEAPSORT beating, on an average, QUICKSORT (if n is not very small)
本文详细介绍了堆这种数据结构的原理和应用场景,包括堆的定义、性质、实现方式以及复杂度分析。堆通常用于实现优先队列,解决排序、第K顺序统计量和多路归并等问题。通过优化,可以降低插入和删除操作的时间复杂度,提高算法性能。
2788

被折叠的 条评论
为什么被折叠?



