堆排序及其应用
Posted 我就是程序员
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了堆排序及其应用相关的知识,希望对你有一定的参考价值。
团队文化:进取,分享,快乐,责任!
团队愿景:做最好的产品,打造有影响力的团队!
码农常常自嘲自己是增删改查工程师,殊不知,增删改查也有很多可以研究的地方。面对常用的数据结构,你是否仔细思考过它底层的实现,以及它这样实现的艺术呢。比如,当你想构造一个大小确定的List时,为什么使用指定容器大小的ArrayList(int n)构造方法比无参的构造方法效率要高呢?再比如,为什么常说快排的效率非常高,但是JDK中实现的排序却是按照数据长度分阶段处理呢?再或者,redis,mysql或者其他种种开源软件,为什么要采用相对应的存储结构,以及它们都有哪些有点呢?了解这背后的逻辑,不仅可以帮你更好的解决业务难题,更能提高你的架构思想。这里介绍一下一个在各种应用中常见的数据结构--堆,堆排和它性质的相关应用。
堆是什么?
一种特殊的树,满足以下两点:
1.堆是一棵完全二叉树,即除了最后一层之外,其他层节点都是满的树,且最后一层节点靠左排列。
2.堆中的每一个节点的值都必须大于等于(或小于等于)其子树中每个节点的值。下图就是两个标准的堆。我们将根结点(亦称为堆顶)的关键字是堆里所有结点关键字中最大者,称为大顶堆。大根堆要求根节点的关键字既大于或等于左子树的关键字值,又大于或等于右子树的关键字值。根结点(亦称为堆顶)的关键字是堆里所有结点关键字中最小者,称为小顶堆。小根堆要求根节点的关键字既小于或等于左子树的关键字值,又小于或等于右子树的关键字值。
很多人会对堆有误解,认为它就是一棵二叉查找树,其实不然,下面将介绍堆排序的流程,通过它,让你了解这两者的区别。
堆排序:堆排序可以分为两个阶段,堆的构造与下沉排序。
我们用大小为N+1的数组pq[]来表示一个大小为N的堆,pq[0]不使用,从pq[1]开始构建堆。这样可以直观的感受到堆的性质:
堆是一棵完全二叉树,从根节点pq[1]开始,依次到N排列满数组。
根的每一个节点的值都大于(或小于)其子树中所有节点的值,如数组中pq[k]>pq[2k]且pq[k]>pq[2k+1]。K=[1……N/2]
将一个数组构造成堆
构建堆的过程可以理解为向一个已经有序的堆中插入节点的过程。如果堆的有序状态因为某个节点变得比它的父节点更大而被打破,那么我们就需要通过交换它和它的父节点来修复堆。交换后,这个节点比它的两个子节点都大(一个是曾经的父节点,另一个比它小,因为它是曾经父节点的子节点)。很显然我们从头到尾遍历和从尾到头遍历都可以构建出堆来,这里介绍一种巧妙的建堆思想,我们从数组的一半位置处开始遍历,从中间后往前,每个数据进行从上往下堆化的操作。流程如图
private staticvoid buildHeap(int[] a,int n){
for(int i=n/2;i>=1;i--){
heapify(a,n,i);
}
}
private static void heapify(int[] a,int n,int i){
while(true){
int maxPos=1;
if(i*2<=n&&a[i]<a[i*2]) maxPos=i*2;
if(i*2+1<=n &&a[maxPos]<a[i*2+1] ) maxPos=i*2+1;
if(maxPos ==i) break;
exch(a,i,maxPos);
i=maxPos;
}
}
排序
建堆结束之后,数组中的数据已经是按照大顶堆的特征来组织的。数组的第一个元素就是堆顶,也就是最大的元素。我们把它跟最后一个元素交换,最大元素就放到了下标为n的位置,此时该位置的元素就确定了。之后,我们对剩下的n-1个元素重新构建成堆。堆化完成之后,我们再取堆顶的元素,放到下标是n-1的位置,重复n次,最后得到的数组就是有序的了。过程如下图所示:
publicstatic void heapSort(int[] a,int n){
buildHeap(a,n);
int k = n;
while(k>1){
exch(a,1,k);
--k;
heapify(a,k,1);
}
}
以上就是通过堆排进行数组排序的过程,通过堆排的过程,我们可以很多相关场景的应用,下面介绍几个堆的应用:
1 海量数据找TopK
业界求TopK问题性能最优的方法是BFPRT算法,该算法思想是利用优化的partion思路,这里不多展开。但是bfprt只适合处理静态数据,即数据集合事先确定的数组。那么针对动态数组求TopK就是实时TopK,这里使用堆就方便很多
我们可以维护一个k大小的小顶堆,依次顺序遍历数组,从数组中取出数据与堆顶元素比较,如果比堆顶元素大,因为小顶堆的堆顶一定是该堆中最小的元素,所以可以把堆顶元素删除,并且将这个元素插入堆中;如果比堆顶元素小,则不作处理,继续遍历数组,这样等数组中的数据遍历完成后,堆中的数据就是前K大的数据了。如果还有其他元素插入,可以继续与堆顶元素比较,这样总是实时构造一个小顶堆。每个节点的复杂度最坏为(lgK),所以该算法的时间复杂度为O(nlgK)。当n足够大时,k较小时,近似于线性时间复杂度。
2 优先队列
优先队列首先是一个队列,队列具有先进先出的特性,在优先队列中,给数据加了一个权值(优先级),此时就不是先进先出了,而是优先级最高的最先出队。
可以看出,堆和优先队列非常相似。一个堆就可以看作一个优先级队列。在优先级队列中插入一个元素,就相当于往堆中插入一个元素,从优先级队列中取出优先级最高的元素,就相当于取出堆顶元素。JDK中PriorityQueue就是通过堆化来实现的。
优先队列在实际的应用场景中使用的非常广泛,比如我们可以用优先队列处理会员优先排队抢购问题,实现定制派单问题等,这里不一一展开了。
3 求TP99
TP99,TP90,TP50,TP999是方法性能统计中的重要指标,求TP99,看起来和求TopK问题很像,TopK问题求的是一个定量大小的集合,而TP99求的是一个给定百分比的集合,该集合的大小不能确定,会随着时间的变长而逐渐增大,使用固定大小的堆明显不合适,因此,我们可以使用两个堆来求TP99。
在求TP99之前,首先,介绍如何使用两个堆来计算动态数据集合中的中位数。
我们约定,如果数组中元素个数为奇数,从小到大排列,第n/2+1个数据就是中位数;如果元素个数为偶数,取第n/2个元素为中位数。
我们通常想的求中位数算法是排序之后取中间的值,然而,对于动态数据集合,中位数在不断变化,如果再用先排序的方法,每次找中位数的话,效率就不高了。
借用堆这种数据结构,我们不用排序,就可以非常高效的实现中位数操作。要实现中位数操作,我们需要维护两个堆,一个大顶堆,一个小顶堆。大顶堆中存储前半部分数据,小顶堆中存储后半部分数据,且小顶堆中的数据都大于大顶堆中的数据。
也就是说,如果有n个数据,n是偶数,那么前n/2个数据存储在大顶堆中,后n/2个数据存储在小顶堆中。这样,大顶堆中的堆顶元素就是我们要找的中位数。如果n是奇数,情况是类似的,大顶堆存储n/2+1个数据,小顶堆就存储n/2个数据。
之前提到过,数据是动态变化的,当新增一个元素时,就需要动态调整两个堆,使大顶堆中的元素继续是中位数。
如果新加入元素小于等于大顶堆的堆顶元素,我们就将这个新数据插入到大顶堆;如果新加入的数据大于等于小顶堆的堆顶元素,我们就将这个新元素插入小顶堆。
此时可能出现两个堆中元素个数不符合之前约定的情况,这时我们需要移动堆中元素使堆保持平衡。我们可以从一个堆中不停将堆顶元素移动到另一个堆。最终使得当n为偶数时,两个堆中元素个数都是n/2;n为奇数时,大顶堆有n/2+1个元素,小顶堆有n/2个元素。这样就能保证大顶堆的堆顶元素总是数组的中位数。
回到计算TP99的问题,99百分位数的概念可以类比中位数,如果将一个数组数据从小到大排列,这个99百分位数就是大于前面99%数据的那个元素。如果有n个元素,则TP99就是第n*99%个元素。我们可以根据两个堆求中位数的思想,维护两个堆,一个大顶堆,一个小顶堆。假设当前总数据的个数是n,大顶堆中保存n*99%个数据,小顶堆保存n*1%个数据。使大顶堆堆顶为要找的TP99。
每次插入一个数据的时候,我们要判断这个数据跟大顶堆和小顶堆堆顶数据的大小关系,然后决定插入哪个堆中。如果这个新插入的数据比大顶堆的堆顶数据小,那就插入大顶堆;如果这个新插入的数据比小顶堆的堆顶数据大,那就插入小顶堆。
同时,为了保持大顶堆中的数据占99%,小顶堆中的数据占1%,在每次新插入数据之后,我们都要重新计算,这个时候大顶堆和小顶堆中的数据个数比例是否还符合99:1.如果不符合,则将一个堆中的数据移动到另一个堆,知道满足比例即可。移动的方法参考上面求中位数的方法。这样我们每次插入元素时,时间复杂度最坏为O(lgn),而每次求TP99的时候,直接返回大顶堆中的堆顶即可,时间复杂度为O(1)。
从上述例子可以看出,堆的引入,为我们处理海量动态的数据打开了一扇新的大门,从常规的排序后查找的思路跳出去,可以高效实时的处理动态数据,为许多场景的处理提供了方便。
以上是关于堆排序及其应用的主要内容,如果未能解决你的问题,请参考以下文章