Files
912-notes/thu_dsa/chp10/heap.md
2019-11-13 16:35:11 +08:00

220 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
完全二叉堆知识总结
================
## 知识脉络
本章的主要内容是完全二叉堆和左式堆,它们都是用于[优先级搜索]这一特定的应用场景的,即每次只是找到其中的最值元素。完全二叉堆的关键在于`堆序性``结构性`——得益于`结构性`,完全二叉堆可以使用`向量`来存储,在相当地程度上简化了它的实现;左式堆主要用于`堆的合并`该操作是通过递归地将堆A的右子堆与堆B合并再将合并后的堆重新连接在堆A的右孩子上面来实现其性能正比于两堆的最右侧通路长度之和。左式堆的关键在于`堆序性``左倾性`,即任意节点左孩子的`空节点路径长度`(npl, null path length)都不小于右孩子,基于此左式堆的最右侧路径即是全局最短路径,从而保证了`堆的合并`高效地进行。由于需要频繁地提取和接入堆的右子堆,左式堆显然不能基于`向量`实现,而是需要使用`二叉树`作为底层结构。
堆在前面讨论过的算法中有不少应用,比如第六章的`优先级搜索`(PFS, Priority First Search)中,每一步迭代都需要找到所有节点中优先级最高的节点,使用一个堆结构可以将该算法的时间复杂度提升至`O((n + e)logn)`,需要指出的是这里的堆结构还应该支持节点的优先级修改(提升)操作,这可以简单地通过`上滤`(percolate up)来实现。可以注意到,在稠密图的情况下,引入了堆结构以后该算法的性能反而下降了,为了解决这个问题,可以引入一个`多叉堆`(d-heap),其`上滤``下滤`操作的时间复杂度分别为`O(log_d^n)`以及`O(dlog_d^n)`,如果令`d = e/n + 2`,此时的整体时间性能为`O(ndlog_d^n + elog_d^n)`,则无论在稀疏图还是稠密图,都可以自适应得到最优的性能。
优先级队列在外部排序中也有相应的应用。例如在归并排序中为了减少I/O的访问次数往往采用`多路归并`的方法,此时每一次归并都需要从`m`路的数据中选择出最值。在实际应用中,往往是使用`锦标赛树`来实现,顾名思义,`锦标赛树`是采用类似于锦标赛的模式,对数据进行两两比较,层层筛选最终得到全局的最值(即冠军)。在这种应用场合下,锦标赛树的性能是优于堆的,尽管只是在常系数的层面上,这是因为堆的`下滤`每次需要两次比较,而锦标赛树的每一步`重赛`(replay)只需要一次,同理在[从n个元素中选取最小的k个](k << n)问题中,锦标赛树也具有更优的性能。
锦标赛树还具有一些问题,例如每次`重赛`都需要沿着路径迂回访问邻居节点,为了避免这些不必要的迂回,可以把每次比较的[败者]记录在父亲节点当中,这样每次`重赛`就只需要和父亲节点进行比较,这就是`败者树`,在`多路归并选择排序`当中,实际上用的就是`败者树`
`线段树`(segment tree)并不是这里的内容,但是昨晚也学习了一下,简单做一个总结。`线段树`主要是用于一维区间的统计问题,感觉很类似`kd树`,应该是`kd树`的退化情况。它的基本思想是将一个区间做平均划分,划分的两个子区间分别作为当前节点的左右子树,再将上述过程递归地进行,直至每个区间都是单位区间大小。线段树要解决的问题需要满足`可加性`,即问题的解可以通过若干个子区间加和而得到,比如说每个区间都有一个权重,给定一个查询区间,求其中的权重之和——平凡的方法是针对每一个单位区间分别统计求和,时间复杂度为`O(r)``r`为查询区间包含的单位区间个数,但是对于区间权重相对固定,查询区间动态变化的场合(即`在线`问题),该算法的时间性能未免太差。而通过`线段树`,则可以不用访问每个单位区间,而是直接查询作为内部节点的某一些较大区间,即可完成统计。
`线段树`主要有三种操作,一是`建树`,然后是`区间修改``区间查询`。可以简明地使用向量来作为底层存储结构,尽管它本身并非是一棵完全二叉树。设区间长度为`n`,则线段树至多需要`4n`的存储空间,这是因为`线段树`的平衡性很好,其左右子树至多相差两个节点,因此若`线段树`的高度为`h`,则它的高度为`h - 1`的子树一定是满二叉树。这样,对于一个叶节点数量为`n`的线段树,总结点数量为`2n - 1`,因此`4n`的空间足以容纳高度为`h`的满二叉树的全部节点。实际上,`2n - 1`向上取整到最近的`2^k`的空间就足够了。
为了建立`线段树`,可以采用递归的策略,自上而下地对区间进行二分,直至区间成为单位区间;`线段树``区间修改`采用`懒惰修改`的策略,即并不对所有叶节点进行修改,而是将要修改的量保存在它们的祖先结点当中,称为`lazy_tag`,由于对这些祖先结点的值进行了修改,因此不会影响到查询的正确性。`区间查询`沿用二叉树的区间查询的方法,即自顶向下不断判断区间的关系,从而定位要查询的区间,如果在向下深入的过程中,发现了`lazy_tag`,则需要将之向下传递,并且修改路径上区间的权重。
## 优先级队列
在很多具体的应用条件下我们都只关心一组数据中的最大值或者最小值比如考完试大家首先都是看谁是第一名谁又是最后一名比如我只知道世界最高峰是珠穆朗玛峰却不知道后面的第二第三都是什么比如在操作系统中的诸多算法都是基于优先级来进行的像是页面置换算法还有进程调度算法这个时候总是选出其中优先级最高的页面将它换出或者优先级最高的进程让它占用CPU。
支持上面这种操作,即每次只是获得其中取到最值的元素,的抽象数据类型,就是我们这里要讲的优先级队列(`Priority Queue`)。除了获得其中的最值元素外,优先级队列显然还应该支持元素的动态插入与删除,以及判空操作与获得元素的个数。据此,可以给出优先级队列的抽象类:
```cpp
template <typename K, typename V>
class PriorityQueue{
public:
virtual int size() = 0;
virtual bool empty() = 0;
virtual V* getMax() = 0;
virtual void insert(K key, V value) = 0;
virtual void delMax() = 0;
}
```
> 优先级队列的实现
可以有很多方法来实现上面定义的优先级队列ADT。例如我可以直接用一个最简单的向量`Vector`作为优先级队列的底层结构。调用`getMax`时,就对向量中的所有元素一一遍历,并返回其中的最大值。同理,调用`delMax`时,首先调用`getMax`找到这个最大元素,并且将它从向量中移除。很明显,这两个操作的时间复杂度都是`O(n)`
我们不难找到一种更加高效的底层结构比如在前面说过的多种平衡二叉搜索树比如AVL树或者红黑树。根据前面的知识无论是插入元素还是删除最大值元素都只需要`O(logn)`的时间;而为了实现`getMax`操作,只需沿二叉树的最右侧路径不断深入,直到最后一个结点,它的时间复杂度正比与二叉树的高度,即也是`O(logn)`
利用平衡搜索树的确可以做到非常高效地实现优先级队列。让我们进一步考察平衡搜索树,在平衡搜索树中,本质上维护了所有元素的一个全序关系,实际上,搜索树的中序遍历正是对应了所有元素的一个有序序列。但是应该注意到,优先级队列并不需要一个这么强的条件,它只是要求每次访问到最大元素就可以了,并不关心最大元素外的其他元素是否是按序排列的。因此,`BBST`的功能实际上远远超出了优先级队列的要求,而为了维护这些额外的信息也是需要成本的。
因此,或许有更为简单的底层结构,它的维护更为简单,因此上面几个基本操作的成本也要更优于`BBST`,至少在常系数的意义下。而这就是我们要详细叙述的平衡二叉堆和左式堆,其中左式堆在[这篇文章](leftistHeap.md)中做了说明。
## 完全二叉堆的基本概念
完全二叉堆的基本概念有两个关键点,即`结构性``堆序性`
`结构性`不言而喻,完全二叉堆在结构上是一棵完全二叉树。如下图所示:
![cb_tree](cb_tree.png)
这意味着,可以简明地利用向量`Vector`来作为它的底层结构,因为此时父子结点之间的链接关系,可以直接通过它们的秩`Rank`来体现,而不需要像一般的树结构那样显式地给出链接。具体说来,假设根节点从`0`开始编号,则对于任意秩为`i`的节点,它的父节点的秩一定为`(i - 1) / 2`,它的左孩子的秩一定为`2i + 1`,右孩子的秩一定为`2i + 2`(如果左右孩子存在的话)。因此,可以给出下面的宏定义:
```cpp
#define PARENT(i) ((i - 1) >> 1)
#define LEFTCHILD(i) ((i << 1) + 1)
#define RIGHTCHILD(i) ((i << 1) + 2)
```
`堆序性`则是指完全二叉堆维护的内部元素的偏序关系,具体说来就是任意节点的值(或者优先级)不小于它的两个孩子节点。即
```cpp
CBHeap[i] >= MAX(CBHEAP[LEFTCHILD(i)], CBHEAP[RIGHTCHILD[i]]);
```
很明显,`堆序性`是相比于二叉搜索树的有序性弱得多的条件。在`堆序性`的前提下,只能保证沿任意一条路径,元素的值是自上而下递减的,而在完全二叉堆的左右子树之间,没有任何的大小关系。但是通过`堆序性`我们可以得到,完全二叉堆的堆顶`CBHEAP[0]`是全局的最大元素,这样,只要直接取出堆顶,就可以实现了`getMax`函数:
```cpp
template <typename K, typename V>
V* CBHeap<K, V>::getMax(){
if(empty()) return nullptr;
return _elem[0];
}
```
下面重点阐述如何基于完全二叉堆实现优先级队列ADT即实现其中的`insert``delMax`函数。实际上任何数据结构的实现都必然在于两个方面即首先利用它的某些内部性质完成相应的功能比如插入与删除然后通过有限的调整保证操作之后这些性质仍然是满足的。前面的AVL树是如此伸展树是如此B树和红黑树都是如此这里的完全二叉堆也是如此。
## 完全二叉堆的实现
### 插入接口的实现
由于完全二叉堆的底层是采用向量`Vector`来实现的,为了将一个新的元素插入到向量中,最简明最高效的策略是直接将这个元素放到向量的末尾,这个操作只需要`O(1)`的时间。但是这样操作以后,堆的`堆序性`很可能遭到破坏,因此下面主要讨论如何恢复`堆序性`
应该注意到,将一个元素插入到向量的末尾后,只有可能在新插入的元素及它的父亲节点之间不满足`堆序性`,而不会影响到所有其他元素,这是因为前面讲到的,堆的左右子树之间不存在任何的次序关系。
如果新插入的元素和它的父节点之间的确不满足`堆序性`,即新插入的元素大于它的父亲节点,为了恢复`堆序性`,只需要将这两个元素交换即可。这样一次操作之后,虽然原有的堆序性得到满足,但是新插入的元素又有可能与它的新的父亲节点之间不满足`堆序性`。为此,我们只需要不断地重复交换操作,直到这种冲突不再发生。插入的过程如下图所示:
![percolate_up](percolate_up.png)
可以看到,在这种插入策略下,新插入的节点是由下而上不断攀升的,因此这种插入又被称为`上滤插入`。为了实现`上滤插入`,可以首先将`上滤`操作进行抽象,形成一个内部函数,在`insert`中调用该函数即可。具体的代码如下:
```cpp
template <typename K, typename V>
void CBHeap<K, V>::percolate_up(int pos){
entry<K, V> tmp = get(pos);
while(HASPARENT(pos)){
if(get(PARENT(pos)) >= tmp) break;
get(pos) = get(PARENT(pos));
pos = PARENT(pos);
}
get(pos) = tmp;
}
template <typename K, typename V>
void CBHeap<K, V>::insert(entry<K, V> e){
push_back(e);
percolate_up(getSize() - 1);
}
```
容易证明,这种插入策略是正确的,因为每次交换都可以解决原有的`堆序性`冲突。虽然这种冲突可能在插入结点与新的父亲节点之间再次出现,但是此时插入结点的高度已经提升了一个单位,并且至多提升到树根,因此至多进行`h`次交换操作,这里的`h`为完全二叉树的高度。
在这里,已经可以看出完全二叉堆相对于`BBST`的优势了——对于`BBST`而言,虽然插入操作也只需要`O(logn)`的时间,但是正如我们前面也提到过的,平衡二叉树的平衡并非一种绝对平衡,而是相对的平衡,它的树高只是在渐进意义上可以达到`O(logn)`,却往往具有一个比较大的常系数。而对于完全二叉堆而言,它的树高就是严格的`logn`,因此在常系数的意义下是优于`BBST`的。
### 删除接口的实现
为了删除堆中的最大元素,只需要将向量的首元素摘除就可以了,但是接下来的问题是,如何在摘除首元素后保证`堆序性`,即找到一个新的最大元素作为首元素。
首先,为了摘除首元素,是不可能调用向量的`pop_front`接口的,因为这将涉及到剩下`n - 1`个元素的整体移动,时间复杂度为`O(n)`。为此,只需要仿照插入时的策略,将向量中的最后一个元素`last_elem`填补到首元素,这样就保证了堆的`结构性`,以及除了新的树根与它的两个孩子节点以外的`堆序性`
为了修复树根处的`堆序性`,可以将`last_elem`与它的两个孩子节点进行比较,并且将其中的最大者(堪为父者)作为新的树根,此时树根处的`堆序性`将得到修复。但是和插入节点的情形一样,此时`堆序性`冲突有可能传递到`last_elem`的新位置处,此时只需要简明地重复上面的操作,直到这种`堆序性`冲突不再发生。删除最大元素的过程如下图:
![percolate_down](percolate_down.png)
可以看到,为了调整堆的`堆序性`,被放置到树根的末元素是由上至下不断下降的,因此这种删除策略也被称为`下滤删除`。我们同样可以将下滤操作进行抽象,然后在`delMax`函数中调用下滤操作来实现`delMax`函数。具体的代码如下:
```cpp
template <typename K, typename V>
int CBHeap<K, V>::proper_parent(int pos){
if (!HASLCHILD(pos)) return pos;
if (!HASRCHILD(pos)) return get(pos) >= get(LCHILD(pos)) ? pos : LCHILD(pos);
int proper = get(pos) >= get(LCHILD(pos)) ? pos : LCHILD(pos);
return get(proper) >= get(RCHILD(pos)) ? proper : RCHILD(pos);
}
template <typename K, typename V>
void CBHeap<K, V>::percolate_down(int pos){
entry<K, V> tmp = get(pos);
for(int p = proper_parent(pos); p != pos; p = proper_parent(pos)){
get(pos) = get(p);
pos = p;
get(pos) = tmp;
}
}
template <typename K, typename V>
entry<K, V> CBHeap<K, V>::delMax(){
entry<K, V> max = get(0);
_elem[0] = _elem[_size-- - 1];
percolate_down(0);
return max;
}
```
`上滤插入`一样,容易证明`下滤删除`策略的正确性,因为这种下滤至多只会进行`h`次,`h`是完全二叉树的树高。因此,删除最大结点的时间复杂度仍然是`O(logn)`
## 批量建堆
很多具体的应用场景中,初始的输入都是成批给出的,此时需要考虑的一个问题是,如何高效地将这些初始的数据组织成一个完全二叉堆,也就是这里的批量建堆问题。
### 蛮力算法
实际上,这个问题可以借助上面已经实现的`insert`接口快速地解决。为了将n个初始给定的元素组织成一个完全二叉堆只需要对这n个元素依次调用`insert`函数即可,于是形成了下面的代码:
```cpp
template <typename K, typename V>
void CBHeap<K, V>::heapify(entry<K, V>* elems, int n){
for(int idx = 0; idx != n; ++idx)
insert(elems[idx]);
}
```
这种实现方法虽然简单,性能却比较糟糕。根据上面的分析,每次调用`insert`函数的时间复杂度为`O(logn)`,这样,将`n`个元素组织成一个完全二叉堆需要的时间成本为`O(nlogn)`,而在相同的时间内,已经可以对这`n`个元素做一个全排序了,我们这里却只得到了一个偏序关系,可见,这里的批量建堆算法是还有提升的余地的。
进一步考察这里的蛮力算法可以看到将所有的输入元素都组织成一个向量之后第一次将一个元素插入到空堆当中即形成了一个规模为1的堆接下来插入第二个元素等价于对向量中的第二个元素进行一次上滤操作`percolate_up`于是形成了一个规模为2的堆以下同理再对接下来的元素进行上滤形成一个规模为3的堆。
因此,这里的蛮力算法,其实是等价于将向量中的每一个元素,依次调用一次上滤操作`percolate_up`,就可以使之成为一个完全二叉堆。每个元素上滤的成本正比于它的深度,设最终完全二叉堆的高度为`h`,所以蛮力算法的时间复杂度为
$$
2\times 1 + 2^2\times 2 + \cdots + 2^k\times k + \cdots + 2^h \times h = (h - 1)\times 2^{h + 1} + 2 = O(nlogn)
$$
与上面分析的结论一致。
### `Floyd`算法
首先来考虑一个问题,对于两个给定的堆,和任意一个独立的节点,如何将它们组织成一个更大的堆。
实际上,这个问题已经在前面有过讨论了——在删除最大节点的算法中,不正是将向量末尾的节点作为新的树根,然后将该节点与左右子树两个堆进行合并吗?因此,只需要将两个给定的堆分别作为独立节点的左子树与右子树,然后对独立节点调用一次下滤操作`percolate_down`就可以了。
我们可以将上面的讨论推广到批量建堆的算法当中。首先需要明确,单个节点本身也形成一个完全二叉堆,满足`结构性``堆序性`。为了将所有的输入元素组织成一个完全二叉堆首先仍然将它们组织成在一个向量当中并且将它视作一棵完全二叉树在初始状况下只有树的所有叶节点各自构成一个规模为1的完全二叉堆。为了将这些完全二叉堆合并成一个更大的完全二叉堆首先对于完全二叉树中的最后一个内部节点调用上面的合并算法此时该节点连通它的孩子节点将构成一个更大的堆此后只需要自后向前不断重复上述过程就可以将各个子堆逐层合并最终`堆序性`将在全局得到恢复。这就是`Floyd`建堆算法,它的代码如下:
```cpp
#define LASTINTERNAL(n) PARENT(n - 1)
template <typename K, typename V>
void CBHeap<K, V> heapify(entry<K, V>* elems, int n){
copyfrom(elems, 0, n);
for(int idx = LASTINTERNAL(n); idx >= 0; --idx)
percolate_down(idx);
}
```
`Floyd`算法进行分析,每一个内部节点,都将调用一次`percolate_down`函数,它的成本正比于每个节点的高度,因此整体的时间复杂度应该取决于所有内部节点的高度总和。假设完全二叉树的高度为`h`,则`Floyd`算法的时间复杂度为:
$$
h \times 1 + (h - 1)\times 2 + \cdots + (h - k)\times 2^{k} + \cdots + 1 \times 2^{h-1} = 2^{h + 1} - h - 2 = n - log(n) = O(n)
$$
可以看到,`Floyd`算法和蛮力算法相比,蛮力算法是每个节点都调用一次上滤操作`percolate_up`,而`Floyd`算法是每个节点调用一次下滤操作`percolate_down`,而这就导致了它们性能上的巨大差别。具体说来,上滤操作的时间复杂度正比于节点的深度,而下滤操作的时间复杂度正比于节点的高度,对于完全二叉堆中的所有节点,大部分都是处于底层的节点,它们的高度较小而深度较大,因此`Floyd`算法就是让多数节点的操作较快,而蛮力算法则是使多数节点的操作更慢,这就是它们性能上差别的本质原因。实际上,`Floyd`算法就类似于`Huffman`树的思想——使处于底层的多数节点具有更小的权重,或者更快的操作。
## 堆排序
堆的一个具体的应用就是堆排序算法。它的本质其实就是选择排序`SelectSort`,只是在选择当前最大元素的过程中,调用了堆的`getMax`函数,使得一次选择的时间消耗减少了。因为太简单,这里就不多叙述了。