Simon-【深入理解数据结构】有根树的不同实现② —— 二叉堆

堆这种数据结构,用以解决一个朴素的问题:从一个集合的元素中取出最大值或最小值,该集合可能进行频繁的增加删除元素操作。一般说堆,指的是二叉堆,也是最常用的堆。注意:数据结构的堆跟操作系统的堆内存是完全不同的两个概念,后者的“堆”的含义是“收集没用资源(空闲内存块)的存储区”,数据结构一般是链表,而不是数据结构的堆。
二叉堆有两个重要的应用:(1)堆排序 (2)优先队列 堆排序是应用堆能够快速选出最值的性质而得到的排序算法;优先队列(Priority Queue)则是一种抽象数据类型:维护一组元素构成的集合,有如下操作,插入元素insert、返回最大/最小元素top、返回并删除最大/最小元素pop。在C++的STL库和Java对优先队列的实现,就是用的二叉堆。

二叉堆是一个完全二叉树。所谓完全二叉树,就是除最后一层以外为满二叉树,且最后一层元素是从最左到右“紧挨着”排布。根据从堆中取出最大值还是最小值,分为最大堆和最小堆。图1(a)就是一个最大堆,以下讨论都以最大堆为例子。

为什么要采用完全二叉树作为二叉堆的实现?在介绍完堆的所有操作后,本文的最后,将尝试从直观意义上给出解释。
[图1] 一个堆的例子
完全二叉树有一个很优美的性质:如果从编号1开始,从上到下,每一层从左到右,对元素进行编号(图1),任意节点编号i,其父节点编号为PARENT(i)=i/2,左儿子节点编号LEFT(i)、右儿子节点编号RIGHT(i)分别为i*2和i*2+1。这意味着,只要我们确定了完全二叉树的大小(即节点个数),整棵树的结构也随之完全确定下来。 (能不能从0开始编号?当然可以。此时父节点和左右儿子节点的编号依次为(i-1)/2, i*2+1, i*2+2)
既然一个节点编号就能够确定它的父子节点的编号,实现二叉堆最直接的方法就是数组了。数组下标代表节点编号,数组元素即为节点元素(图1b)。

二叉堆的特性:对于最大堆,除根节点以外的任意节点满足以下特性:
A[PARENT(i)] >= A[i] 其中A为存储堆的数组。即对于任意节点,其节点元素比他的子节点元素大,保持这种性质的完全二叉树即为二叉堆。
易知,二叉堆的根节点A[1]即为最大元素。

下面对二叉堆的实现和操作分两个部分讨论,第一部分着重堆的建立过程以及堆排序算法,第二部分讲基于堆实现的优先队列的插入、删除、返回最大元素操作的实现。


堆的建立
所谓堆的建立,就是给定一个无序的数组,对数组的元素重新排列,使其满足堆的性质。在讲堆的建立过程之前,先引入一个中间过程:“保持堆的性质”,即:如果堆中某个元素所在位置违反了堆的性质(比子节点元素小),如何通过一系列操作使其回到正确的位置上。
“保持堆的性质”(max-heapify)操作:在错误节点及其左右节点(若有的话)中找出元素最大的节点,若最大节点就是错误节点本身,则说明已保持了堆的性质,返回;否则将最大节点元素与错误节点元素交换,迭代到原最大节点位置重复上一步操作。实际过程就是,把错误位置的元素不断“沉降”,直到到达正确的位置。具体过程如图4所示。复杂度为树的高度,即O(lg(n))。
[图2] 保持堆的性质操作过程

建堆操作:有了以上保持堆性质操作,建堆过程就很简单了:从最后一个节点的父节点(编号即为heap_size/2)开始,自底向上依次作“保持堆性质”操作,直到第一个节点为止。
为什么这样做是正确的?《算法导论》给出了基于循环不变式的证明,这里试图给出更加直观的解释:实际上,前面给出的“保持堆的性质”操作,仅仅保证了从错误节点为根的子树保持了堆的性质,如果一开始错误节点的元素比它的父节点元素要大,该操作不会作任何修改,换句话说元素只能“下沉”而不能“上浮”。这种限制决定了建堆的过程只能是从下往上的方向。下层节点满足堆的性质了,上层节点即使位置错误,还是能通过“下沉”回到正确位置,而这个过程不会影响已经在下层的节点的正确性。
复杂度分析:每次维护堆性质时间为O(lg(n)),一共n/2次,所以复杂度为O(nlg(n))。但这个上界比较粗糙,《算法导论》证明了,实际时间复杂度为O(n)
八卦一下,这个建堆的算法是由Floyd提出,就是Floyd算法的那个Floyd。

[图3] 建堆的过程


堆排序
堆排序本身是一个“拆堆”的过程:交换第一个节点和最后一个节点的元素;把堆大小减一;维护堆的性质。以上三个步骤的结果是,把最大的元素从堆中释放掉,然后放到数组的最后一个位置。重复以上步骤n-1次,即可得到排好序的数组。原理太简单,就不赘述了。需要强调的是,这属于一种原地排序的算法,即不需要额外的空间,复杂度为O(nlg(n))
[图4] 堆排序过程


以下是建堆和堆排序的代码:
public class Heap {
	protected int[] heap;
	protected int sz;
	
	Heap() {}
	
	Heap(int[] A) {
		this.sz = A.length;
		this.heap = new int[this.sz+1];
		for (int i = 1; i <= this.sz; i++)
			this.heap[i] = A[i-1];
	}
	
	protected int LEFT(int i) { return i*2; }
	protected int RIGHT(int i) { return i*2+1; }
	protected int PARENT(int i) { return i/2; }
	protected void swap(int i, int j) { 
		int k = heap[i]; heap[i] = heap[j]; heap[j] = k; 
	}
	
	public void maxHeapify(int i) {
		int l = LEFT(i);
		int r = RIGHT(i);
		int largest;
		if (l <= sz && heap[l] > heap[i]) largest = l;
		else largest = i;
		if (r <= sz && heap[r] > heap[largest]) largest = r;
		if (largest != i) {
			swap(largest, i);
			maxHeapify(largest);
		}
	}
	
	public void buildMaxHeap() {
		for (int i = sz / 2; i >= 1; i--) 
			maxHeapify(i);
	}
	
	public void heapSort() {
		buildMaxHeap();
		for (int i = sz; i >= 2; i--) {
			swap(i, 1);
			sz--;
			maxHeapify(1);
		}
	}
}


优先队列

有了堆为基础,优先队列的实现就很简单了。首先引入一个“增加元素大小”操作increase(i, key):对原先位于正确位置的节点i增加它的元素大小为key,通过不断“上浮”找到正确的位置。以下是各种操作的实现:
返回最大元素top(): 直接返回堆的第一个节点元素,O(1)
返回并删除最大元素pop(): 取出堆的第一个节点元素后,用最后一个节点元素覆盖第一个节点,堆大小减一,再做一次maxHeapify,O(lg(n))
插入一个元素push(x): 先将堆大小加一,新的最后一个节点元素置为无穷小,再进行increase(heap_size, x)操作,O(lg(n))。

代码实现
public class PriorityQueue extends Heap {
	private static int MAX_LEN = 100000;
	private static int INF = 1000000000;
	
	PriorityQueue(int[] A) {
		super(A);
	}
	
	PriorityQueue() {
		heap = new int[MAX_LEN];
		sz = 0;
	}
	
	protected void increase(int i, int key) {
		assert(key >= heap[i]);
		heap[i] = key;
		while(i > 1 && heap[PARENT(i)] < heap[i]) {
			swap(i, PARENT(i));
			i = PARENT(i);
		}
	}
	
	public void push(int x) {
		sz++;
		heap[sz] = -INF;
		increase(sz, x);
	}
	
	public int pop() {
		int ret = top();
		heap[1] = heap[sz];
		sz--;
		maxHeapify(1);
		return ret;
	}
	
	public int top() { return heap[1]; }
}
简单说一下二叉堆的合并问题:如何合并两个二叉堆的元素成一个大的二叉堆。方法很简单:每次从较小的堆拆出一个节点,添加到较大的堆上,直到较小的堆被拆完。假设两个堆的大小分别为m、n(m <= n),删除节点总花销O(mlg(m)),插入节点总花销O(mlg(m+n)),总的时间复杂度为O(mlg(m+n))。


最后,为什么二叉堆采用完全二叉树实现?在我看来,至少存在以下两个原因:
(1) 方便数组实现,前驱后继结点可以通过简单计算得到,不需要额外的域存储父子节点信息
(2) 节点元素实现了完美分层。
对于本文开头提出的问题【从一个集合的元素中取出最大值或最小值,该集合可能进行频繁的增加删除元素操作】,考虑另外一种可能的实现:有序链表(图5)。链表中每个元素代表“一层”,最大值即为第一层的元素,最大值取出后原来的第二层元素即为当前最大元素。也就是说,不同层代表他们的相对大小关系。显而易见的是,有序链表的缺点是层数太多,在插入元素时尤为明显,为了找到新插入元素所在的层,需要遍历头节点开始O(n)层。
与有序链表对比,二叉堆在分层上更有优势:每一层不再只有一个元素,对于第i层有2^(i-1)个元素,一共只有O(lg(n))层(图5)。层次之间也是体现了相对大小的关系,虽然上层元素不一定都比下层元素大,但沿着任意路径从上层到下层一定是严格的单调关系。这使得插入/删除操作仅需改变某一路径上的元素位置即可,这是分层少+堆特性所带来的优势。
[图5] 另一种可能的实现:有序链表 与二叉堆的比较
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值