diff --git a/thu_dsa/chp10/cbtree.png b/thu_dsa/chp10/cbtree.png new file mode 100644 index 0000000..6e8ce55 Binary files /dev/null and b/thu_dsa/chp10/cbtree.png differ diff --git a/thu_dsa/chp10/heap.md b/thu_dsa/chp10/heap.md new file mode 100644 index 0000000..8cf5d46 --- /dev/null +++ b/thu_dsa/chp10/heap.md @@ -0,0 +1,144 @@ +完全二叉堆知识总结 +================ + +## 优先级队列 + +在很多具体的应用条件下,我们都只关心一组数据中的最大值或者最小值,比如考完试大家首先都是看谁是第一名,谁又是最后一名;比如我只知道世界最高峰是珠穆朗玛峰,却不知道后面的第二第三都是什么;比如在操作系统中的诸多算法,都是基于优先级来进行的,像是页面置换算法还有进程调度算法,这个时候总是选出其中优先级最高的页面将它换出,或者优先级最高的进程,让它占用CPU。 + +支持上面这种操作,即每次只是获得其中取到最值的元素,的抽象数据类型,就是我们这里要讲的优先级队列(`Priority Queue`)。除了获得其中的最值元素外,优先级队列显然还应该支持元素的动态插入与删除,以及判空操作与获得元素的个数。据此,可以给出优先级队列的抽象类: + +```cpp +template +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`,至少在常系数的意义下。而这就是我们要详细叙述的平衡二叉堆和左式堆,其中左式堆在[这篇文章](leftist_heap.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 +V* CBHeap::getMax(){ + if(empty()) return nullptr; + return _elem[0]; +} +``` + +下面重点阐述如何基于完全二叉堆实现优先级队列ADT,即实现其中的`insert`和`delMax`函数。实际上,任何数据结构的实现,都必然在于两个方面,即首先利用它的某些内部性质完成相应的功能,比如插入与删除,然后通过有限的调整保证操作之后这些性质仍然是满足的。前面的AVL树是如此,伸展树是如此,B树和红黑树都是如此,这里的完全二叉堆也是如此。 + +## 完全二叉堆的实现 + +### 插入接口的实现 + +由于完全二叉堆的底层是采用向量`Vector`来实现的,为了将一个新的元素插入到向量中,最简明最高效的策略是直接将这个元素放到向量的末尾,这个操作只需要`O(1)`的时间。但是这样操作以后,堆的`堆序性`很可能遭到破坏,因此下面主要讨论如何恢复`堆序性`。 + +应该注意到,将一个元素插入到向量的末尾后,只有可能在新插入的元素及它的父亲节点之间不满足`堆序性`,而不会影响到所有其他元素,这是因为前面讲到的,堆的左右子树之间不存在任何的次序关系。 + +如果新插入的元素和它的父节点之间的确不满足`堆序性`,即新插入的元素大于它的父亲节点,为了恢复`堆序性`,只需要将这两个元素交换即可。这样一次操作之后,虽然原有的堆序性得到满足,但是新插入的元素又有可能与它的新的父亲节点之间不满足`堆序性`。为此,我们只需要不断地重复交换操作,直到这种冲突不再发生。插入的过程如下图所示: + +![percolate_up](percolate_up.png) + +可以看到,在这种插入策略下,新插入的节点是由下而上不断攀升的,因此这种插入又被称为`上滤插入`。为了实现`上滤插入`,可以首先将`上滤`操作进行抽象,形成一个内部函数,在`insert`中调用该函数即可。具体的代码如下: + +```cpp +template +void CBHeap::percolate_up(int pos){ + entry tmp = get(pos); + while(HASPARENT(pos)){ + if(get(PARENT(pos)) >= tmp) break; + + get(pos) = get(PARENT(pos)); + pos = PARENT(pos); + } + get(pos) = tmp; +} + +template +void CBHeap::insert(entry 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 +int CBHeap::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 +void CBHeap::percolate_down(int pos){ + entry 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 +entry CBHeap::delMax(){ + entry max = get(0); + _elem[0] = _elem[_size-- - 1]; + percolate_down(0); + return max; +} +``` + +和`上滤插入`一样,容易证明`下滤删除`策略的正确性,因为这种下滤至多只会进行`h`次,`h`是完全二叉树的树高。因此,删除最大结点的时间复杂度仍然是`O(logn)`。 diff --git a/thu_dsa/chp10/percolate_down.png b/thu_dsa/chp10/percolate_down.png new file mode 100644 index 0000000..8b3ec12 Binary files /dev/null and b/thu_dsa/chp10/percolate_down.png differ diff --git a/thu_dsa/chp10/percolate_up.png b/thu_dsa/chp10/percolate_up.png new file mode 100644 index 0000000..070b8f8 Binary files /dev/null and b/thu_dsa/chp10/percolate_up.png differ