堆(数据结构)及堆排序

1

这里的堆是指一种数据结构(或数据结构属性),非指堆内存。堆属性用二叉树来体现,具堆属性的数据结构才可被叫做为堆。具堆属性的数据结构满足以下笔记的“顺序”和“形状”两个条件。


(1)顺序

将某数据结构如数组,将数组的元素依次安排在二叉树中的根结点、根结点的左孩子、根结点的右孩子位置之上,再将剩余元素依次安排在根结点的左孩子的左孩子、根结点左孩子的右孩子、根结点右孩子的左孩子、根结点右孩子的右孩子位置之上……按照如此顺序得到的二叉树,若每个根结点元素都小(大)于其左右孩子,则称此二叉树为小(大)堆,称对应的数组具有堆属性(或此数组此时就是堆数据结构)


(2)形状

[1]用来表示堆的二叉树中不能有“洞”。

[2]用来表示堆的二叉树最多有两层具“终止结点”。

这两点主要是为了保证堆中任意一个结点(主要是指最后一个终止结点)距离根结点的距离不超过logn(底数为2n表示二叉树中的元素个数)。在n十分大的时候,lognn小太多了。这对于减少程序的时间复杂度很有用。


如果二叉树不具有“堆形状”属性,那么它就有可能不能限制二叉树中任一结点到根结点的最大距离不超过logn。下图包含不满足“堆形状”的二叉树,它们表现出来的性质表现在图中:

Figure13层具终止结点具洞的二叉树不构成堆

堆具有以下一些简单的性质:

  • 二叉树的基本性质(如深度不超过logn)。

  • 最后一个结点与父节点的关系:LastParentNodeIndex = lastNodeIndex / 2。这往往用来求堆的最后一个父节点下标。

堆的第一个元素下标为1(如果是自己编写代码,为0也可以,找到堆数据结构中父结点和子结点之间的下标关系即可)。


2为什么要用堆

因为看上了可以利用堆来实现时间复杂度不超过O(nlogn)的特色。


堆是建立在二叉树之上的数据结构,只要将堆属性赋予某一种数据结构,那么此数据结构便也可以按照堆来操作。堆除了可以向二叉树那样进行元素遍历之外,平常还主要操作堆的子节点跟父节点,这样的操作即使在最坏的情况下长度也只有logn(底数为2n为元素总个数)。

Figure2:游走堆的父子结点

这是在堆中的父子结点的操作。这一点还可以用到建立某数据结构(如数组)的堆属性上。ProgrammingPearls,编程珠玑中主要介绍了用堆属性来实现两个功能:实现优先队列和堆排序,都用到了堆在父子结点之间游走时时间复杂度不超过logn的特性。


3堆操作的核心

看完编程珠玑堆14章“堆”后,也觉得堆操作的最核心函数是游走于父子结点的具上下移结点功能的函数(siftupsiftdown)。因为编程珠玑中使用堆操作不与《数据结构》课程一样采取纯粹“建堆”操作来让一个数组具“堆属性”。如在前面“常见内部排序”笔记中的“堆排序”就是采用“建堆”和“堆筛选”的思路来完成堆排序,所以关于堆的核心函数是“建堆函数”,这个过程中不需要额外的空间。编程珠玑中以往堆a[i]中加入新元素的方式来形成堆a[i+1],在“优先队列”和“堆排序”之上也是靠这个思想实现。每次新加入的元素和其父(子)节点发生可能的交换,直到小(大)等于当前父(子)结点或到了根(终止)结点,操作复杂度不超过lognlog(n-数组中剩余元素个数))。再加上外围的O(n),就得到了时间复杂度不超过O(nlogn)的堆算法,为数组赋予堆属性也可以不需要额外的空间。将与父结点比较交换的函数定义为siftup,与子结点比较交换的函数定义为siftdown,那么在实现优先队列和堆排序的时候,调用这两个函数就可以了。


(1) siftup()

siftup()函数的功能是往已经是堆的结构中加入新元素,以小堆为例,siftup()函数的伪码可如下所示:

siftup(n)    //n表示堆最后一个元素下标
{
	i    = n;
	while( (1 != i) && a[i] < a[i / 2]){
    		swap(a[i], a[i / 2]);
    		i   = i / 2;
	}
}

siftup()函数能够在堆为空的情况或者a[1...i]为堆的情况下,依次在堆的最后位置加入新元素后,可以将堆扩展一个元素后形成新堆。这段代码的时间复杂度不超过O(logn),操作n个元素时时间复杂度不超过O(nlogn)


可以通过默认第一个元素有序的方式通过以下代码为一个数组实现堆属性,不需要额外的空间:

for(i = 2; i <=n, ++i)
    siftup(i);

时间复杂度不超过O(nlogn),经此操作后,数组a[1...i]也就具有了堆属性。也可以说此时的数组就是一个堆数据结构。


(2) siftdown()

siftdown()的对象是堆。是将元素往下移和较小的子结点发生可能的交换,所以新加入的元素是在堆的根元素处。伪码如下所示:

siftdown(n)    //n表示堆的的大小,最后一个位置
{
	i    = 1;
	c    = 2 * i;
	while(c <= n){
    		if(c+1 <= n && a[c+1] < a[c]) 
			c++;
		if(a[i] < a[c])
    			swap(a[i], a[c]);
		i    = c;
		c    = 2 * i;
	}
}

操作单个、n个元素的复杂度跟siftup()相同。但单用siftdown()不能直接为一个数组赋予堆属性,因为它默认从所有的操作都会从第一个元素到最后一个。


4堆的应用

(1)优先队列

优先队列被定义时为空,然后提供插入、删除最小最大等操作。那么就可以不等优先队列的所有元素插入完毕后,再对优先队列进行“建堆”操作以赋予优先队列“堆属性”。可以利用siftup()函数,在每向优先队列插入一个元素时就将所有的元素调整为堆(往优先队列中插入第一个元素时,siftup()函数也能将一个元素形成堆),插入n个元素的时间复杂度不超过O(nlogn)


将优先队列赋予堆属性主要是为了方便优先队列的其它操作,如删除。当要寻找、删除优先队列的最小(大)元素时,只需要直接访问小(大)堆的根元素(q[1]),然后将优先队列最后一个元素赋值到第一个元素位置(优先队列的大小--),再重新调用siftdown()将优先队列调整为堆即可。寻找、删除n个最小(大)元素的操作的时间复杂度不超过O(nlogn)


那么用堆就可以形成一个时间复杂度不超过O(nlogn)的函数或接口,属比较高效的接口。在《Programming Pearls,编程珠玑》作者Jon Bentley那个年代指定的机器上运行,O(nlogn)运行10^7(100M左数据)数量级需要11s,若n10倍增长,时间会以大于10倍的速度增长


(2)堆排序

如面前摆着一个数组a[n]。要求时间复杂度不超过O(nlogn)


[1] 利用优先队列排序

a[n]数组元素一个一个插入到优先队列中,在优先队列内部形成小(大)堆。然后再由优先队列的找最小(大)函数返回堆顶元素即可。时间复杂度符合要求。但确实在这个过程中会需要优先队列中的堆结构来保存来保存a[n]数组,会需要额外的sizeof(a)字节空间。


[2]利用堆筛选排序

这个过程分建堆和堆筛选。与前面笔记的“常见内部排序笔记”不同之处在于“建堆”的方法,这里的简单得多。


建堆,首先用前面笔记到的用siftup()赋予数组对属性的代码建立堆:

for(i = 2; i <=n, ++i)
    siftup(i);


堆筛选,思路及代码实现跟“常见内部排序算法笔记”堆筛选一样,都是通过交换最后一个未被交换过的元素和堆顶元素来筛选出最小(大)的元素,从而使得整个堆有序。


利用这个方法得到有序堆的时间复杂度同样不超过O(nlogn),而且不需要额外的空间。


利用堆的Onlogn)的时间复杂度可能还不是最好的选择。如快速排序在排序对象随机情况下的时间复杂度为O(logn)。所以在进行程序设计时要在审题清楚后选择最合适的数据结构。当然程序的效率要根据需求来设计,如果根本不需要效率,用最简单的算法就天下太平了。


读《Programming Pearls,编程珠玑》第14章获得的关键字:堆、库、接口


ANote Over。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值