笔试算法题(57):基于堆的优先级队列实现和性能分析(Priority Queue based on Heap)
议题:基于堆的优先级队列(最大堆实现)
分析:
-
堆有序(Heap-Ordered):每个节点的键值大于等于该节点的所有孩子节点中的键值(如果有的话),而堆数据结构的所有节点都按照完全有序二叉树 排。当使用数组存储这种数据结构时,在数组大小限制和堆大小限制下,如果当前节点下标为i,其父亲节点下标为i/2,左右孩子结点下标分别为 2i,2i+1(如果计算值没有超出队列大小范围);
-
使用堆有序完全二叉树(Complete Binary Tree)表示优先队列,所有操作即使最坏情况下的运行时间也只是对数时间,因为任何操作(除合并)表现在完全二叉树中只是从某一层的节点到另一层节点的路径,而这种路径永远不会使得运行时间超过㏒N
-
堆有序完全二叉树表示有如下性质(在一棵有N个节点的完全树种):
所有从根节点到叶节点的路径上大约有㏒N个节点;
大约有N/2的节点位于树的底部(叶子节点);
大约有N/4个带孩子的节点位于次最底层;
大约有N/8个带孙子的节点位于次次最底层;
每一代的节点数大约为下一代节点数的一半,也就是说完全树最多有㏒N层;
-
针对优先队列的算法都是首先进行一些修改,然后遍历堆以确保所有的节点都满足优先队列的性质,这个过程称为被堆化(Heapifying)。而具体的修改有两种情况:
提升一些节点的优先级(或者在堆的底部添加新的节点),需要向上遍历堆以恢复堆的限制性质;
降低一些节点的优先级(或者使用一个新的节点替代根节点处的节点),需要向下遍历堆以恢复堆的限制性质;
-
fixup和fixdown操作:首先需要两个函数维护队列满足堆的基本性质,fixup和fixdown维护堆的性质(数组下标从1开始,0作为某些标志);
-
insert操作:实际上是在队列末尾添加新元素项,队列大小增加1,并使用fixup进行恢复;
-
getMaximum操作:获取最大项操作实际上是返回根节点元素,然后将队列末尾的项放置到根节点,队列大小减1,并使用fixDown进行恢复;
-
MyMaximumHeap是类构造函数:利用其结合insert操作,也就是自顶向下的构建策略,向一个空堆里面不断插入新元素 (insert,fixUp),并增加堆大小,最坏构建时间为O(NlogN),平均构建时间为O(N);这个方法运行时间在最坏情况下与N㏒N成正比 (当每个新元素都是当前堆中的最大项,恢复时需要向上遍历到根节点,所以N个元素进行N次的fixup回复,每次fixup移动㏒N步),但平均时间为线 性(如果元素随机出现,则每一项平均只向上移动少数的几层);
-
constructMaximumHeap操作:是将给定的数组a构建成一个最大堆;也就是自底向上的构建策略,从原始数组的位置length/2处开始 向位置1循环向下处理每一个元素(fixDown),最坏构建时间为O(NlogN),平均构建时间为O(N);使用自底向上的构建方法,从最后一个拥有 子节点的子树节点开始,由于在随机情况下先前进行的的fixDown操作会为后来执行的的fixDown操作提供排序信息(不断将指定子树中的最大值向上 移动),所以运行时间为线性;
-
resetPriority操作:是改变array中指定位置k的元素的优先级,如果新的优先级较原来的大,则向上移动;否则向下移动;时间复杂度为O(lgN);
-
priorityQueueSort操作:优先队列首先在给定的数组中构建最大堆,然后将堆最末尾的元素和根节点进行交换,将堆大小减一,使用 fixdown进行恢复,重复上述过程。下向(sortdown)排序过程和选择排序类似,都是在剩余的序列中查找最大的元素,不同的是下向排序使用一中 更加有效的方式处理剩余元素,从而进行最大元素的选择;
-
maximumKthSelect操作:堆排序同样可以用于求N个项中的第K个最大项的选择问题,当从堆中取出K-1个元素之后,当前堆顶处就是第K个最 大项。对于从N个元素中选择最大Kth元素的问题,有两种堆实现策略:一种是利用所有N个元素构建最大堆,并k-1此删除堆顶元素,之后array[1] 的元素就是最大Kth元素,比较次数为2N+2klgN;一种是利用原始数据的前k个元素构建一个最小堆,然后将剩余的N-k个元素依次插入此最小堆,如 果新元素小于当前堆顶元素则删除当前堆顶元素,并恢复最大堆的性质,所有元素都插入之后得到的堆顶元素就是最大Kth元素,比较次数为2k+2(N- k)lgk;第二种方法的优势在于:首先当N非常大的时候,构建k大小的堆只有较少的空间消耗;然后当k远小于N的时候,lgk具有O(1)的时间上限;
样例:
1 struct MyMaximumHeap { 2 /** 3 * 堆数据从索引1开始存储 4 * 1表示堆顶元素的位置 5 * capacity表示堆的最大容量 6 * count表示最后一个有效堆元素的位置 7 * */ 8 int *array; 9 int capacity; 10 int count; 11 /** 12 * 此构造函数可以作为: 13 * 无参数构造函数 14 * 指定cap参数构造函数 15 * 构建一个空的最大堆(没有任何初始项),需要通过调用 16 * insert方法插入具体的堆数据;如果按照此方法构建大小为N 17 * 的最大堆,最坏时间为O(NlogN),但是平均时间为O(N) 18 * */ 19 MyMaximumHeap(int cap=10): 20 capacity(cap), count(0), array((int*)malloc(cap*sizeof(int))) {} 21 /** 22 * 此方法将指定位置k的元素向堆顶移动 23 * 由于仅需要当前节点与其父亲节点的比较,所以从 24 * 的比较次数为lgN 25 * */ 26 void fixUp(int *array, int k) { 27 int temp; 28 /** 29 * 首先检查当前节点是否为根节点, 30 * 然后检查当前节点k是否比其父亲节点k/2大 31 * 接着如果满足条件则进行交换 32 * 最后循环处理父亲节点 33 * */ 34 while(k>1 && array[k/2] < array[k]) { 35 temp=array[k]; 36 array[k]=array[k/2]; 37 array[k/2]=temp; 38 39 k=k/2; 40 } 41 } 42 /** 43 * 此方法将制定位置k的元素向堆底部移动 44 * 由于需要从父亲,左儿子和右儿子之间选出一个最大值,所以需要两次比较 45 * 所以总计2*lgN次比较 46 * */ 47 void fixDown(int *array, int k, int count) { 48 int temp, max; 49 /** 50 * 外循环保证以当前节点k至少有左儿子节点 51 * 然后寻找父亲节点,和左右儿子节点中的最大 52 * 节点,如果父节点k最大,则跳出循环 53 * */ 54 while(2*k <=count) { 55 max=k; 56 if(array[2*k] > array[max]) 57 max=2*k; 58 /** 59 * 注意访问右儿子之前需要确定其存在 60 * */ 61 if(2*k+1<=count && array[2*k+1]>array[max]) 62 max=2*k+1; 63 /** 64 * 如果父亲节点是最大的节点,则方法结束 65 * */ 66 if(max==k) break; 67 temp=array[max]; 68 array[max]=array[k]; 69 array[k]=temp; 70 /** 71 * 循环处理交换之后的儿子节点 72 * */ 73 k=max; 74 } 75 } 76 77 bool isEmpty() { 78 return count==0; 79 } 80 bool isFull() { 81 return count==capacity; 82 } 83 /** 84 * 插入操作是将新元素插到array的末尾, 85 * 然后调用fixUp操作进行最大堆化 86 * 此方法的比较次数小于lgN 87 * */ 88 bool insert(int n) { 89 if(isFull()) 90 return false; 91 array[++count]=n; 92 fixUp(array, count); 93 return true; 94 } 95 /** 96 * 获取最大项操作是将array末尾的元素 97 * 复制到array[1]处,然后调用fixDown 98 * 操作进行最大堆化,然后返回原始的堆顶 99 * 元素; 100 * 此方法的比较次数小于2*lgN 101 * */ 102 bool getMaximum(int *n) { 103 if(isEmpty()) 104 return false; 105 int temp=array[1]; 106 array[1]=array[count--]; 107 fixDown(array,1, count); 108 *n=temp; 109 return true; 110 } 111 /** 112 * 此方法将指定位置k上的元素的优先级修改成n 113 * 注意如果k的范围超出有效堆元素范围,则直接返回 114 * 如果n的值与array[k]相等,则直接返回 115 * */ 116 void resetPriority(int k, int n) { 117 if(k<=1 || k>=count) 118 return; 119 if(n==array[k]) 120 return; 121 122 array[k]=n; 123 if(n>array[k]) { 124 fixUp(array, k); 125 } else if(n<array[k]) { 126 fixDown(array, k, count); 127 } 128 } 129 /** 130 * 此方法将给定的array构建成最大堆,与MyMaximumHeap 131 * 类没有任何关系,仅是一个为其他数组提供的工具方法 132 * */ 133 void constructMaximumHeap(int *a, int length) { 134 /** 135 * index初始化为堆中最后一个拥有孩子节点的内部节点 136 * */ 137 int index=length/2; 138 while(index>=1) { 139 /** 140 * 对以index索引的根节点的子树调用fixDown 141 * */ 142 fixDown(a, index, length); 143 index--; 144 } 145 } 146 /** 147 * 堆排序使用少于2*NlgN此比较实现N个元素的排序 148 * */ 149 void priorityQueueSort(int *a, int length) { 150 /** 151 * 首先利用自底向上的策略将数组a最大堆化 152 * */ 153 constructMaximumHeap(a, length); 154 int temp; 155 /** 156 * 然后依次将堆顶元素array[1]取出,由于取出之后 157 * 堆大小减小,所以实现数组的末尾会空出位置用于 158 * 存放当前取出的最大值; 159 * 在调用fixDown之前需要检查length的长度是否已经 160 * 小于2; 161 * */ 162 while(true) { 163 temp=a[length]; 164 a[length]=a[1]; 165 a[1]=temp; 166 length--; 167 if(length<=1) break; 168 fixDown(a, 1, length); 169 } 170 } 171 172 int maximumKthSelect(int *a, int length, int k) { 173 174 constructMaximumHeap(a, length); 175 int temp; 176 /** 177 * 如果打算取第K大的元素,则取出最大的K-1个最大元素之后 178 * 当前堆顶的元素array[1]就是滴K大的元素 179 * 如果不考虑数组a的完整性,则取出的最大K-1个元素可以 180 * 不同复制到数组末尾,而直接丢弃; 181 * */ 182 for(int i=0;i<k-1;i++) { 183 temp=a[length]; 184 a[length]=a[1]; 185 a[1]=temp; 186 length--; 187 if(length<=1) { 188 printf(" current heap's elements are less than k "); 189 return -1; 190 } 191 fixDown(a, 1, length); 192 } 193 return array[1]; 194 } 195 }; 196 197 int main() { 198 MyMaximumHeap *pq=new MyMaximumHeap(); 199 int temp; 200 printf(" please input integers: "); 201 printf(" 1. you can at most input 10 integers "); 202 printf(" 2. you can input -1 to finish "); 203 scanf("%d",&temp); 204 while(pq->insert(temp)) { 205 scanf("%d",&temp); 206 if(temp==-1) break; 207 } 208 if(temp!=-1) { 209 printf(" the last integer (%d) is dismissed for not enough room ", temp); 210 } 211 printf(" get the maximual integer in order "); 212 while(pq->getMaximum(&temp)) { 213 printf("%d, ",temp); 214 } 215 return 1; 216 }
补充:
-
由于在大小为N的堆中没有一条路径包含的元素会多于㏒N,所以当作用于一个N个元素的序列时,插入运算需要的比较次数不会超过㏒N次(仅与其父节点比较),每个节点进行一次比较判断是否比父节点大;
-
获取最大项操作需要的比较次数不会超过2㏒N(左右子节点谁大,父子节点谁大),每个节点需要进行两次比较:一次选出较大的孩子结点,另一次决定父节点是否与孩子结点交换;
-
另外当改变队列中某一个节点的优先级时,同样可以使用fixup和fixdown实现,提升优先级使用fixup,降低优先级时使用fixdown;
-
自底向上构建最大堆:首先使用一个循环从数组的中间到左端开始构建堆,然后不断缩减堆的大小并结合fixDown恢复堆性质从而保证每一次取出的都是剩下的堆中最大的元素。自底向上的构建过程花费线性时间,第二个循环中的堆排序使用少于2N㏒N次比较来排序N个元素;
-
堆的优先队列实现中,不存在让堆排序运行时间大大上升的输入序列(不同于快速排序),不存在需要使用与待排序序列N成比例的额外空间(不同于归并排序);
-
对于堆排序算法而言,优势:首先使用一个循环从数组的中间到左端开始构建堆,然后不断缩减堆的大小并结合fixDown恢复堆性质从而保证每一次取出的都 是剩下的堆中最大的元素。性质:不存在让堆排序运行时间大大上升(效率低下)的输入序列(不同于快速排序),不存在需要使用与待排序序列N成比例的额外空 间(不同于归并排序)时间:自底向上的构建过程花费线性时间,第二个循环中(while排序循环)的堆排序使用少于2N㏒N次比较来排序N个元素。使用堆 排序求第K最大项时,当k与N接近时,时间开销为线性,否则为K㏒N;
-
在已经实现了的优先级队列实现中没有哪一种实现能同时有效的解决删除最大项,插入和合并这三种操作(这里的有效实现指的是使用㏒N时间实现):
无序链表: 可快速插入(常数)和合并(常数), 但删除最大项慢(线性);
有序链表: 可快速删除最大项(常数), 但插入和合并慢(线性);
堆实现: 可快速插入(㏒N)和删除最大项(常数), 但合并慢(N㏒N):
堆排序算法的总结:
-
堆排序算法为非稳定排序;
-
可以用于解决求序列中第K个最大项(最小项)的选择问题,也就是在第K次删除最大项的时候停止即可;
-
一开始在构建堆的时候,从最后一个拥有子节点的内部节点开始构建,这样可以保证自底向上的每三个相邻节点(父节点,左子节点,右子节点)遵守堆的性质,到 了堆上部的时候就能保证靠近根节点的节点是相对较大的节点。在之后删除根节点,并将堆最后一个节点(较小节点)移到根节点处,然后进行fixDown,这 样做的目的是在根节点的左右两个节点中又选出最大的节点放到根节点处,但是几乎每一次的fixDown都会使得移动节点几乎到达堆的底部,这对 fixDown来讲是明显的性能损耗;
-
如果需要改进堆排序算法,可以考虑使用三叉树或者更多的完全分支,这样会降低树的高度,从而使得对数的底从2变成3或者更高,从而可以提升运行性能;
-
堆排序与归并排序之间的选择实际上简化为非稳定排序和需要使用额外内存方法之间的选择;
-
堆排序与快速排序之间的选择实际上简化为最坏情况最好和平均性能最好之间的选择;