引言
(最近学习一些冷门但是高级的数据结构,颇为吃力,但非常实用,故做篇笔记)
实践上经常使用基于完全二叉树的堆来实现优先队列,其访问最小值的操作getTop
、插入操作push
以及删除操作pop
,时间复杂度均不超过O(logn),而且结构简单使用方便,所以应用也最广泛。
今天介绍另一款重量级嘉宾:基于左偏树的可并堆(Mergeable Heap)。从应用层面来讲,它完全兼容二叉堆的操作,而且另外添加了一个扩展功能:将两个堆合并起来。可并堆的优势在于:如果将两个普通的沙堆进行合并,那么只能是对其中某个沙堆一铲子一铲子地把沙土扬到另一个沙堆上去,时间代价是O(nlogn)。但是可并堆能够将时间代价降低至对数级别O(logn),接近一次性混合,挖掘机技术我最强。
一、什么是左偏?
二叉堆是利用完全二叉树实现的,而可并堆是利用左偏树实现的。关于左偏,先给出这么几个设定:
1.1 外结点
左子树或右子树为NULL的结点,即为外结点。要注意叶子结点,也是外结点。
1.2 结点的距离
结点具有距离属性,它的值为:从该结点出发向下找到最近的外结点时,所经过的路径长度。
外结点本身的距离,值为0;
规定空结点NULL也具有距离属性,值为-1.
1.3 左偏
对于某个结点来说,如果左子树的距离不低于右子树的距离,则称该结点左偏。
下面这棵树(Fig-1.1)中,标记为蓝色的即为外结点,结点旁边的数字表示其距离值,由于结点C
的左子树NULL
具有相对于右子树F
更低的距离属性,所以C是右偏结点,不是左偏结点。
二、左偏树
左偏树是指所有结点都左偏的二叉树。递归定义时,它要么是个空树,要么是满足下列性质的二叉树:
1. 根结点左偏;
2. 左子树和右子树也都是左偏树。
根据这种定义,可以得到一些非常重要的推论:
2.1 推论一:左偏树的距离只与右子树有关
由于左子树的距离不低于右子树的距离(左偏性质),所以结点向下寻找最近的外结点,一定总是位于右子树,路径也一定总是位于树的最右侧。所以,一个结点的距离便等于向下的最右侧路径的长度。
轻而易举地我们也可以推知,非空结点的距离dist
,等于该结点的右子结点的距离dist-rchild
,再加上1,公式如下:
2.2 推论二:左偏树距离的最大值(左偏树定理)
可证得定理:若一棵左偏树具有N
个结点,那么左子树的距离(即根结点的距离)dist
有上界,关系式为:
严格的证明方法这里不赘述,只简述思路如下:因为左子树的距离一定不低于右子树的距离,所以在最坏情况下如果右子树的距离取到了上界,那么左子树的距离也一定取到了上界,那么左偏树便被填满成为满二叉树,这时根结点距离为k
的满二叉树,总共有2^(k+1)-1
个结点,与上述公式吻合。
2.3 左偏树的形状
左偏树不是二叉堆那样的完全二叉树,也不是平衡的二叉树,它可能是任意形状。在最坏情况下,所有非叶结点都只有左子树而没有右子树,这时整棵左偏树退化为一个单链表,但它仍然是一棵高效的、距离为0的左偏树:
这里我们一定要注意,左偏树的高效在于:它总是腾出并且能够腾出右侧的空间以供插入,不论要插入的规模有多大。这便是可并堆的核心思想。
左偏树最好以二叉链表方式实现,因为顺序数组只在完全二叉树的表示上有优势。
三、可并堆
左偏树无需考虑元素的优先性、比较性;但是堆的定义要求堆顶元素总是最小的,自顶向下也都是有序的。所以利用左偏树实现堆的时候,元素之间的关系成为了问题的主要方面:
- 两个堆的合并(可并堆的基本性质);
- 弹出堆顶的元素(最小元);
- 插入一个元素。
3.1 左偏树的合并操作
我们以左偏树来表示可并堆,合并过程便是两棵左偏树合并的过程:将优先级靠后的堆(堆顶元素相对较大的那个堆)插入到另一个堆的右子树上,然后如果右侧比重变大导致了右偏,那就交换左右子树。算法(伪代码)如下:
typedef priority_queue Mergeable_Heap; //优先队列类型:可并堆
function merge(Mergeable_Heap& A, & B)
if A is null, then return B; //空堆不用混合
if B is null, then return A;
if (B.heapTop() takes more priority than A.heapTop())
then swap(A, B); //将A置为主堆,B往A里面插
A.rchild = merge(A.rchild, B); //B与A的右子树合并,作为A的新的右子树
if (dist(A.lchild) < dist(A.rchild))
then swap(A.lchild, A.rchild); //交换左右子树,保持左偏性质
dist(A) = dist(A.rchild) + 1; //更新A的距离,依据是推论一
return A; //返回主堆
end
可以从下图加深理解。每一步merge之前,都要先比较两个堆顶元素的优先级,必要时进行swap以确立新的堆顶:
3.2 访问并弹出堆顶元素
这个简单,取出堆顶元素,然后左右子树合并即可:
function pop(Mergeable_Heap& A)
temp = A;
A = merge(A.lchild, A.rchild);
return temp;
end
3.3 插入一个元素
这个也简单,将元素视为只有一个容量的堆,再合并:
function insert(Mergeable_Heap& A, Elem X)
A = merge(A, (Mergeable_Heap)X);
end
3.4 合并操作的时间复杂度分析(重点):
递归地合并到右子树上,最坏情况下,每一次合并都要swap一下主堆和副堆的次序,即两个堆的最右侧路径都走了一遍。
假设两个队的元素分别有N1
和N2
个,那么最右侧路径的长度,也就是左偏树的距离,可以表示为log(N1) + log(n2)
量级,所以时间复杂度是O(logn)。
补充源代码(C++)
// 结点类型定义
typedef struct BiNode {
int data;
struct BiNode* lchild;
struct BiNode* rchild;
int distance;
BiNode(int data_) {
data = data_;
lchild = rchild = NULL;
distance = 0;
}
};
// 求结点的距离
int dist(BiNode* r) {
if (r == nullptr) return -1;
else return r->distance;
}
// 优先级比较器类
typedef struct Cmp {
bool operator() (BiNode* const a, BiNode* const b) const {
return a->data < b->data; //小值元素具有更高的优先级
}
};
//交换
void mswap(BiNode*& ra, BiNode*& rb) {
BiNode* tmp = rb;
rb = ra;
ra = tmp;
}
//合并
BiNode* merge(BiNode* ra, BiNode* rb) {
if (ra == nullptr) return rb;
if (rb == nullptr) return ra;
Cmp cmp;
if (cmp(ra, rb) == false) mswap(ra, rb);
ra->rchild = merge(ra->rchild, rb);
if (dist(ra->lchild) < dist(ra->rchild))
mswap(ra->lchild, ra->rchild);
ra->distance = dist(ra->rchild) + 1;
return ra;
}