缘起
【1】中我们介绍了优先队列以及它的一种实现——堆. 其实又叫二叉堆. 因为它的结构是完全二叉树. 而这里介绍的是优先队列的另一种实现方式——左式堆(left heap). 它的引入是为了满足堆结构之间的合并这种基本操作.
如上图,小根堆A和小根堆B需要合并成为小根堆H(假设A的元素个数n>=B的元素个数m). 如果使用【1】中的二叉堆实现的话,不难想象以下两种思路
- 反复取出B中的元素插入A中,复杂度为 m(O(logm)+O(log(m+n))) = O(mlogn)
- 将A和B重新打散来建堆,使用floyd算法,复杂度是O(n+m)=O(n).
综上,如果基于二叉堆实现堆的合并的话,复杂度为 min(O(n), O(mlogn)). 其实,这种性能是会被质疑的——因为A和B本身已经有某种偏序关系了. 不应该还像完全独立的情形(floyd建堆)那种复杂度.
那有木有更快的算法呢? 于是左式堆横空出世了. 其实你想想看,为什么用二叉堆合并速度一定会带上O(n), 就是因为参与合并的元素数量不能尽量少.
为此,不妨首先打破此前形成的错觉并大胆质疑: 堆是否也必须与二叉搜索树一样,尽可能地保持平衡? 值得玩味的是,对于堆来说,为控制合并操作所涉及的节点数,反而需要保持某种意义上的“不平衡”!
所以实际上,本文介绍的左式堆不仅仅不是平衡二叉树,而且是非常不平衡的二叉树!!! 正因为远离平衡二叉树,所以实际上我们并不推荐使用数组实现左式堆结构. 而推荐使用链表.
1 2 3 4 5
| 左式堆的定义如下
1. 它是二叉树 2. 它满足堆结构,即大根堆或者小根堆. 3. 任何节点的左孩子的npl>=右孩子的npl, 因此,任意节点的npl=右孩子的npl+1,这个性质使得它不是一棵理想平衡树,因为它显然偏重于使树向左增加深度。
|
节点的npl(null path length)的定义是此节点所有到不具有两个儿子的节点的路径中的最短路径长.
例如
图1
上图左边的根节点的npl是1——因为根节点往右边走就对了. 特别的,本身就没有两个儿子的节点的npl值是0.
方便起见,规定null节点的npl是 -1.
图1的右边不是左式堆,左边是左式堆,因为右边的带 * 的节点的左孩子的npl=0, 右孩子的npl=1
不难知道左式堆的如下几个性质
- 对于左式堆的任意一个节点只能有三种情况,有两个子节点、没有子节点、仅有一个节点且该节点为左子节点。也就是说,如果存在任意一个节点只有右节点,那么这个堆一定不是左式堆。但是,如果一个节点每个节点都满足上面的条件,它不一定是左式堆,还需要满足零路径长的条件(图1的右边就满足,但是不是左式堆, 即这只是必要不充分条件)。
- 沿左式堆的右路径(即一路向右)是该堆中的最短路径。否则,就会存在某个节点 X 的左儿子的最短路径长小于右儿子的最短路径长。
给出如下左式堆的算法. 后面给出证明为什么此算法是正确的. 下面的左式堆以大根堆为例
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82
| #include "stdafx.h" #include <iostream>
using namespace std; const int INF = 0x3f3f3f3f;
typedef struct LeftHeapNode // 左式堆节点 { LeftHeapNode *left, *right; int data; int npl;
LeftHeapNode(int data):left(0), right(0), data(data),npl(0){}
}LeftHeapNode, *LeftHeap;
void swap(LeftHeap node) { LeftHeap t = node->left; node->left = node->right; node->right = t; }
LeftHeap merge(LeftHeap, LeftHeap); LeftHeap merge1(LeftHeap, LeftHeap);
LeftHeap merge1(LeftHeap node1, LeftHeap node2) { if (!node1->left) node1->left = node2; else { node1->right = merge(node1->right, node2); if (node1->left->npl < node1->right->npl) swap(node1); node1->npl = node1->right->npl + 1; } return node1; }
LeftHeap merge(LeftHeap root1, LeftHeap root2) { if (!root1) return root2; if (!root2) return root1; return root1->data > root2->data ? merge1(root1, root2): merge1(root2, root1); }
int _max(LeftHeap root) {return root->data;}
LeftHeap insert(LeftHeap root, int data) { LeftHeap node = new LeftHeapNode(data); return merge(root, node); }
LeftHeap build(int *a, int n) { LeftHeap root = 0; for (int i = 0; i<n;i++) root = insert(root, a[i]); return root; }
int pop(LeftHeap &root) { if (!root) return -INF; int ret = root->data; root = merge(root->left, root->right); return ret; }
int main() { int a[] = {21,10,14,23,17,26,3,8}; int b[] = {12,33,24,18,37,7,18,6}; LeftHeap h1 = build(a, sizeof(a)/sizeof(int)); LeftHeap h2 = build(b, sizeof(b)/sizeof(int)); LeftHeap root = merge(h1, h2); cout << _max(root) << endl; cout << pop(root) << endl; return 0; }
|
左式堆合并算法的正确性(左式堆其实就是这个合并算法,其他都只是这个合并算法的平凡推论)
就是数学归纳法. 其实我们主要就是验证 merge1的正确性
如果right和node2合并成为一个左式堆(注意,这是归纳假设,归纳起步就不验证了,那是trival的). 那么只需要证明按照merge1算法得到的node1也是一个左式堆. 这几乎是显然的. 首先, node1>node2 && node1>left && node1 > right, 所以一定符合大根堆定义. 其次,因为如果合并之后不满足npl条件的话,则做了交换操作,所以node1也是满足npl限制的. 综上,我得到了一个更大的左式堆——node1. 所以左式堆算法merge 正确.
左式堆的合并复杂度
因为取决于右子树节点个数,所以合并算法的复杂度是O(logn), 比原先二叉堆实现的堆结构合并操作快了一个数量级. 所以正应验了那句老话——没有最完美的,只有最合适的~
分析
参考
【1】https://yfsyfs.github.io/2019/05/25/%E5%A0%86%E6%8E%92%E5%BA%8F/