完全二叉堆
堆又可称之为完全二叉堆。这是一个逻辑上基于完全二叉树、物理上一般基于线性数据结构(如数组、向量、链表等)的一种数据结构。
完全二叉树的存储结构
学习过完全二叉树的同学们都应该了解,完全二叉树在物理上可以用线性数据结构进行表示(或者存储),例如数组int a[5] = {1,2,3,4,5}就可以用来描述一个拥有5个结点的完全二叉树。那么基于完全二叉树的堆结构自然也可以使用线性结构进行描述。回顾一下这样表示时,元素的秩之间的关系。若有元素秩为k(k >= 0),则有Rank(LeftChild) = 2k+1,Rank(RightChild) = 2k+2(此处元素的秩从0开始,若从1开始则推导出的公式略有不同,注意这之间的区别)。
堆的有序性
堆可分为两种:大根堆(最大堆)、小根堆(最小堆)。
大根堆
何为大根堆?顾名思义,大根堆即指在逻辑上的二叉树结构中,根结点>子结点,总是最大的,并且在堆的每一个局部都是如此。例如{3,1,2}可以看作为大根堆,而{3,2,1}亦可以看作为大根堆。大根堆的根结点在整个堆中是最大的元素。
小根堆
小根堆的性质与大根堆类似,只不过在二叉树的结构中,根结点<子结点。例如{1,2,3}为小根堆,{1,3,2}同样也是小根堆。小根堆的根结点在整个堆中是最小的元素。
小结
- 大/小根堆中根结点即是整个序列中最大 /小的元素,那么从堆中获取最大/小的元素则非常快速,只要返回序列中的首元素即可。
- 更一般的,堆处处局部的有序性,形成了堆整体的有序性。 只要有一处局部没有满足堆的有序性,则可以说堆失序,此时便需要进行相应的调整。
堆的常用场景
1、根据之前的总结,我们可以利用堆的性质来找出一个序列中最大/小的元素,这不失为一种方法,尽管通过遍历来解决这一问题可能更好。
2、堆排序,相信学习过堆排序的同学对此都会认同。
3、建立优先级队列,根据上述的小结可知,若利用堆来建立优先级队列,可以快速的获取到队列中优先级最高/低的任务。
4、n个元素中排列出前k大的元素问题,对于此类问题,可以建立一个小根堆,依次读入n个元素并调整,并在堆的规模达到k+1时,剔除掉第1个元素,剩下k个较大元素,保持堆的规模不超过k,一直循环即可得到最终的结果。
5、其他的应用还请各路大神留言指导,互相探讨,小弟感激不尽。
堆的操作
堆的操作呢,无非就是建立、插入、删除。建立一个空的堆是非常简单的操作,而利用一个已有的序列生成一个是一个值得思考的问题;插入和删除操作,比较简单;值得思考的是插入元素或者删除元素之后如何恢复堆的堆序性。
堆的插入与上滤
- 往一个线性序列中插入一个元素是比较简单的,例如数组a[N],在秩为k(k
- 假设堆使用数组作为底层的实现,那么堆的插入又该如何实现呢?是插入到原堆的头部?还是尾部?亦或是中间? 鉴于原堆已经保持了堆序性,如果插入在头部,相当于所有元素对应的父结点和子结点都会发生变化,这样以来会导致整个堆失效;如果插入在中间的某一位置,则会导致插入位置之后的所有元素对应的父结点和子结点发生变化,这样一来会导致堆的部分失效;如果插入在堆底,则原堆的结构未遭破坏,此时唯一可能不符合堆性质的只是最后一个元素(刚刚插入的元素)的父结点是否比它大/小,即在这个局部是否还满足之前所说的堆序性。
- 好,现在,可以确定在堆底插入新元素的代价是最小的,那么接下来就是思考在这种情况下如何恢复堆序性。试想一个小根堆如下{2,3},在其尾部插入元素1,如何调整使其满足小根堆的性质呢?对,通过将元素2和元素1交换即可得到小根堆{1,3,2}。此时回顾以下小根堆的性质便不难看出,在此种情形下,对失序的堆执行以下操作即可恢复其堆序性:若新插入结点小于其父结点,则将两者交换。
- 在第三条所阐述的情况中,并没有对新插入结点的兄弟结点(如果有的话)做任何操作,甚至不需要考虑它,这是为何?假设新插入结点为N,其父结点为F(N),其兄弟结点为B(N),则在原堆(结点N插入之前)中有这样的关系:F(N) < B(N)。插入结点N后,若N < F(N),则有N < F(N) < B(N)。回顾堆序性的描述,不难知道只要将N与F(N)互换位置就可以使得堆恢复堆序性。
- 由点及面,假设上述的堆{2,3}只是另一个堆heap2中的局部(3为heap2中的堆底元素,2为3的父结点),通过上一条阐述的操作,我们可以恢复局部的堆序性,但别忘了,由于原来元素2变成了元素1,此时要继续检查元素1以及元素1的父结点、兄弟结点组成的局部堆是否满足堆序性,循环往复直到插入元素所处的局部满足了堆序性。这样的一个过程也称为堆的上滤(percolateUp)。
代码如下:
template<typename T>
void percolateUp(T *s, size_t rank)
{
while(rank)
{
size_t f = (rank-1)>>1;
if (s[f] > s[rank])
{
swap(s[f], s[rank]);
rank = f;
}
else
break;
}
}
- 时间复杂度,O(logn)。不难看出插入的结点在一层一层地向上升(与父结点互换位置),而完全二叉树的高度为logn,故其时间复杂度为O(logn)。
堆的删除与下滤
- 从一个线性序列中删除一个元素是简单的,但对于堆这样的结构而言,若以比较的方式来删除某一个元素并无意义,堆的有序性告诉我们,从堆中删除掉对顶元素是比较有价值的(时间复杂度O(1))。不妨将堆的删除操作视为取出堆顶元素。
- 对于一个堆heap而言,取出对顶元素只需要删除掉heap[0]即可,那么删除掉首元素之后,原来的堆失去了它的跟结点,整个堆面临着失效的尴尬境地。与插入操作类似,需要思考一个问题,如何调整可以使得原堆中其他部分可以依旧保持的原来的结构(各个元素之间的父子关系)?可以在被删除掉的首元素的位置再插入一个元素来保持原堆中其他结点的结构不变,可以将这样的过程视为一个新的结点替换掉了原来的根结点。
- 那么这个新的结点从何而来呢?若选取了中间的某一结点X来替换掉根结点,则会导致X之后的结点全部失效(原来的结构被破坏)。至此,相信大家不难看出,一如插入操作一般,选取堆底元素来替换根结点是最佳的选择,这使得整个堆依旧可以保持原来的结构。接下来要思考的就是替换之后整个堆是否依旧保持着堆序性。
- 前面说到堆处处局部的有序即是堆整体的有序,那么在替换之后,局部是否有序便成了判断堆整体是否有序的依据。分别用N、L(N)、R(N)表示替换之后的根结点、根结点的左孩子、根结点的右孩子。如果N < L(N)且N < R(N),那么堆序性依旧保持;否则堆序性被破坏。不如将上述的条件写成如下的形式:如果N < MIN(L(N), R(N)),那么堆序性依旧保持;否则堆序性被破坏。此种情形与在堆底插入一个元素所造成的堆序性破坏略有不同,那么接下来该思考的是如何调整使得堆的局部恢复有序性呢?是否可以通过类似于上滤的方案来解决此类失序的问题呢?
- 还是回到堆序性这个特性上,对于N、L(N)、R(N)这三个结点组成的局部堆而言,只需保证这三者中的最小值处在根结点这个位置上即可保持堆序性。至此,可以得出若N < MIN(L(N), R(N))不被满足,则将N与MIN(L(N), R(N))呼唤位置即可保持堆序性。同插入处理,在进行一次调整后还需继续对新形成的局部堆进行调整,循环往复,直至局部堆恢复堆序性或结点N成为了叶子结点。这样的一个过程也称为堆的下滤(percolateDown)。
代码如下:
template<typanme T>
void percolateDown(T *s, size_t rank, size_t size)
{
size_t lastInternal = (size-1)/2;
while (rank < lastInternal)
{
size_t lChild = rank*2+1;
size_t rChild = rank*2+2;
size_t LChild = s[lChild] < s[rChild] ? lChild : rChild;
if (s[lChild] < s[rank])
{
swap(s[LChild], s[rank]);
rank = LChild;
}
else
break;
}
if (rank == lastInternal)
{
size_t lChild = rank*2+1;
size_t rChild = rank*2+2;
size_t LChild = s[lChild] < s[rChild] ? lChild : rChild;
if (rChild == size)
LChild = lChild;
else
LChild = s[lChild] < s[rChild] ? lChild : rChild;
if (s[LChild] < s[rank])
swap(s[Lchild], s[rank]);
}
}
- 时间复杂度,渐进意义上O(logn),其内部操作比上滤过程而言要多一些,故其常系数部分略高。
利用一个已有的序列建立一个堆
- 这个问题可以通过建立一个空堆,然后逐个将序列中的元素插入至堆中来完成,大致地可以得出时间复杂度为O(nlogn)。如果将这样的操作原地进行,则相当于依次从首元素到尾元素执行上滤操作,暂且将这样的方式称之为自上而下的上滤。
- 是否可以使用下滤来实现这一功能呢?从最后一个内部结点开始,逐个对所有的内部结点进行下滤操作,完成后就可以得到一个堆。
- 在此,不妨将对内部结点下滤的过程视为该内部结点与其左右子堆的合并成一个堆的过程。设内部结点为N,左右子堆分别用HL和HR来表示。既是堆,那么HL与HR都满足堆序性,事实上在从最后一个内部结点开始时,HL与HR都至多包含一个元素,只拥有一个元素的堆自然是有序的。等到所有叶子结点的父结点都与其左右子堆(堆中只有一个元素)合并后,才会出现HL和HR中包含多个元素的情况,而经过下滤调整的子堆都是满足堆序性的。至此可以证明通过这样的方式可以使得整个堆恢复堆序性。不妨将这样的方式称之为自下而上的上滤。
- 时间复杂度为O(n),是的这可能与大家直观的感觉不一致,证明过程需要使用归纳法,在此就不进行下去了。不过呢可以通过与自上而下的上滤算法来进行简单的对比:首先上滤建堆需要对所有的元素都执行上滤操作,而且越是接近堆底的元素可能需要交换的次数就越多,这意味这可能会有较多的元素需要进行较多次数的交换;而下滤建堆只需对所有内部结点进行下滤操作,这在操作的元素数量上就少了一半,其次越接近堆底的内部结点所需要的交换次数越少,这意味大多数的内部结点只需执行较少的交换,只有离堆顶越近的元素才需要较多的交换,而越接近堆顶,元素数量越少。由此可以看出自下而上的下滤建堆算法在时间复杂度的渐进意义上来讲要优于自上而下的上滤算法。事实上自下而上的下来吧建堆算法称之为弗洛伊德建堆法。
代码如下
template<typename T>
void heapify(T *s, size_t size)
{
int rank;
auto getLastInternal = [size] {return (size-1)>>1;};
if ((rank = getLastInternal()) < 0)
return ;
while (rank>= 0)
{
percolateDown(s, rank, size);
--rank;
}
}
记录学习历程,各路大神若有指教,可留言探讨,感激不尽!