您的位置:首页 > 理论基础 > 数据结构算法

排序(3) 堆与堆排序

2016-04-14 17:29 260 查看

堆的定义和表示

说到堆排序,得先说说堆这种数据结构。(二叉)堆是一个数组,可以近似的看做一个完全二叉树。



上图是堆的两种表现形式,给定一个下标i,我们可以很容易计算其父节点、左孩子、右孩子的下标:

#在二叉堆中计算节点i的父节点、左孩子、右孩子的下标
def GetParent(i):
return ((i-1)>>1)

def GetLeftChild(i):
return (((i+1)<<1)-1)

def GetRightChild(i):
return ((i+1)<<1)


二叉堆可以分为最大堆(大根堆)和最小堆(小根堆)两种形式,这两种堆中,所有节点都要满足堆的性质。

最大堆要满足:除了根节点外的所有节点i都要满足A[GetParent(i)] >= A[i]

最小堆则满足:除了根节点外的所有节点i都要满足A[GetParent(i)] <= A[i]

维护堆的性质

这里我们主要说说最大堆。维护最大堆顺序有下沉法和上浮法。当某个结点的值(优先级)变小(例如将根节点替换为一个较小的元素)时,需要将该结点下沉到合适的位置以维持堆的顺序。当某个结点的值变大或是在堆底加入一个较大的新元素时,需要将该结点上浮到合适的位置以维护堆的顺序。过程如下图所示:



两种方法的实现代码如下:

#维护最大堆递归版(下沉法)
def MaxHeapSinkRec(A,i,heapSize):
l = GetLeftChild(i)
r = GetRightChild(i)

if l < heapSize and A[l] > A[i]:
largest = l
else:
largest = i

if r < heapSize and A[r] > A[largest]:
largest = r

if largest != i:
A[i],A[largest] = A[largest],A[i]
MaxHeapSinkRec(A,largest,heapSize)

#维护最大堆非递归版(下沉法)
def MaxHeapSinkLoop(A,i,heapSize):
while True:
l = GetLeftChild(i)
r = GetRightChild(i)

if l < heapSize and A[l] > A[i]:
largest = l
else:
largest = i

if r < heapSize and A[r] > A[largest]:
largest = r

if largest != i:
A[i],A[largest] = A[largest],A[i]
i = largest
else:
break

#维护最大堆递归版(上浮法)
def MaxHeapSwimRec(A,i):
p = GetParent(i)
if p >= 0 and A[p] < A[i]:
A[p],A[i] = A[i],A[p]
MaxHeapSwimRec(A,p)

#维护最大堆非递归版(上浮法)
def MaxHeapSwimLoop(A,i):
while i > 0 and A[GetParent(i)] < A[i]:
A[GetParent(i)],A[i] = A[i],A[GetParent(i)]
i = GetParent(i)


建堆

我们可以利用上面的方法把一个数组转换成最大堆,这里利用下沉法自底向上(从右到左)的调整数组可以比上浮法自顶向下(从左至右)的调整数组需要更少的操作。因为用下沉法可以跳过叶结点,只需扫描一半的数组,而上浮法需要扫描整个数组。实现如下:

#构建最大堆 T = O(n)
def BuildMaxHeap(A):
last = (len(A)>>1)-1 #最大非叶结点下标
for i in range(last,-1,-1): #从最大非叶结点开始逆序扫描
MaxHeapSinkLoop(A,i,len(A))


下图展示了构建最大堆的过程:



堆排序

首先将数组A[0…n]建成最大堆,利用最大堆根节点总为最大元素,把A[0]和A
交换,最大元素放到了正确位置,从堆中去掉结点n,然后调整A[0…n-1]使其保持为最大堆,继续交换A[0]和A[n-1],第二大元素放到了正确位置……不断重复这一过程,直到所有的元素放到了正确位置。实现代码如下:

#堆排序 T = O(nlgn)
def HeapSort(A):
BuildMaxHeap(A)
heapSize = len(A)
for i in range(len(A)-1, 0, -1):
A[0],A[i] = A[i],A[0]
heapSize -= 1
MaxHeapSinkRec(A,0,heapSize)


其过程可如下图所示:



堆排序评价:同时具有原址排序和线性对数级的时间复杂度的优势,但是它是非稳定的,并且在现代系统中无法很好的利用缓存,数组元素很少和其相邻的元素比较,使得缓存命中率低。

优先队列

高效优先队列是堆的一个常见的应用,优先队列分最大优先队列和最小优先队列,我们这里只说说基于最大堆实现的最大优先队列。

假如集合Q为最大优先队列,其有以下操作:

HeapMaximum(Q) 返回Q中最大关键字的元素 O(1)

HeapExtractMax(Q) 去掉并返回Q中最大关键字的元素 O(lgn)

HeapAlterKey(Q,i,key) 修改Q中结点i的关键字值为key O(lgn)

HeapInsert(Q,key) 将key插入到Q中 O(lgn)

HeapDelete(Q,i) 将结点i从Q中删除 O(lgn)

代码实现如下:

def HeapMaximum(Q):
if len(Q):
return Q[0]
else:
return None

def HeapExtractMax(Q):
if len(Q):
last = len(Q)-1
maxKey = Q[0]
Q[0],Q[last] = Q[last],Q[0]
Q.pop(last)  #去掉最后一个元素,等价Q.remove(Q[last])
MaxHeapSinkLoop(Q,0,len(Q))
return maxKey
else:
return None

def HeapAlterKey(Q,i,key):
if i < len(Q):
if key > Q[i]: #增大了关键字,需要将其上浮到合适位置
Q[i] = key
MaxHeapSwimLoop(Q,i)
elif key < Q[i]: #减小了关键字,需要将其下沉到合适位置
Q[i] = key
MaxHeapSinkLoop(Q,i,len(Q))
else:
pass
return True
else:
return False

def HeapInsert(Q,key):
Q.append(key)
MaxHeapSwimLoop(Q,len(Q)-1)

def HeapDelete(Q,i):
if i < len(Q):
last = len(Q)-1
Q[i],Q[last] = Q[last],Q[i]
Q.pop(last)
MaxHeapSinkLoop(Q,i,len(Q))
return True
else:
return False


用堆实现的优先队列在插入操作和删除最大元素操作的混合动态场景中保证对数级别的运行时间,这在现代应用程序中越来越重要,大家可以自己编写程序测试下上述实现。

几个问题

①:将k个有序列表合并成一个有序列表。

②:topk问题。在一个大小为n的数组中,找出最大的k个元素。

③:计算数论。a,b,c,d为0到N的整数,写一个算法找出所有满足a^3+b^3=c^3+d^3的不同整数a,b,c,d。

④:同时面向最大元素和最小元素的优先队列。设计一个数据类型,支持如下操作:插入元素、删除最小元素、删除最大元素所需时间均为对数级,找到最大、最小元素所需时间为常数级。

解法

①:最直接的解法:从k个有序列表的首元素中找到最小元素,删除并把它放入结果列表的首元素位置,以此循环,直到所有的元素都被找出。该算法的复杂度是O(kn)。

其二利用最小堆,将各个有序列表的首元素构建成小根堆,取出最小值O(1),然后将包含该最小值的子列表的次小值放入堆顶O(1),调整堆使其维持最小堆的性质O(lgk),以此循环,直到所有元素被找出,总的算法复杂度为O(nlgk)。但是在这里,上面的代码没有实现小根堆,留给大家自己去实现,我们这里利用大根堆合并成一个逆序的数组,最后再逆序一下数组便是,实现代码如下:

#合并k个有序链表
#S = [A1, A2, ... , An],An为有序列表 如[1,3,5,6,8],S为列表的列表
def MergeOrderList(S):
for i in range(len(S)): #将S中的所有列表逆序
S[i].reverse()

retS = []
BuildMaxHeap(S) #构建最大堆,
while S[0]: #等价于 S[0] != [] 即S[0]不是空列表
retS.append(S[0].pop(0)) #堆顶子列表中最大值弹出并放入结果列表
MaxHeapSinkLoop(S,0,len(S)) #调整堆,使其维持最大堆的性质
retS.reverse() #逆序结果列表,使其变成正序

return retS


说明一下,如果你对python的语言规则特性不了解的话,很可能看不懂上述实现。在构建最大堆的过程中,实际是通过比较S中的元素(有序列表)的首元素来建堆的。例如a = [1,2,3], b = [5,8], c = [],他们的大小顺序是b>a>c,这是通过比较它们的首元素来决定它们的最终次序的,有了这点我们就很方便操作。

基于python来实现算法,可以让我们不去注重编码层的细节,而是去关心算法本身这个核心问题,这就是我为什么选择python而不是C++来实现算法的最重要的原因。

②:topk问题,这个问题很简单,实现方法也很多,这里不再给出实现,大家去做一下。

简单说一下朴素方法:将n个数排序,取最后k个数即为最大的k个数,算法复杂度O(nlgn)。

简单选择法:在n个数中,取出k个数组成一个子数组,找出这个子数组的最小值m,然后用n-k中的一个数x1去跟m比较,如果x1<=m,继续用n-k-1中的一个数x2去跟m比较,而如果x1>m,则用x1替换m,并在新子数组中找到最小值,重复这个过程,直到topk被筛选出。这个简单选择过程算法复杂度为O(nk)。

改进:如果把上面的k个数中选出最小值的简单选择过程改为用小根堆维护,则算法复杂度降低为O(nlgk)。

另外还有可以利用基数排序或快速排序切分在O(n)内求解,后文会提到。

③:朴素解法:O(n^4) 这里不再多说这个方法。

根据题意, a^3 + b^3 = c^3 + d^3 且a,b,c,d均为不相等的非负整数,则有这样的性质: c < min(a,b) < max(a,b) < d ,因为对称性质,(a,b)和(b,a)其实是一样的,有了前者,后者就不必要计算了,(c,d)和(d,c)同样如此,我们仅仅只是要找出这种组合而已。所以这里我们可以假设c< a< b< d。

基于此实现代码如下:

#根据问题,还确实不好取函数名呢,姑且将就一下
def CountA3B3EqC3D3(n): #0-n的整数
retS = []
for a in range(1,n): #a取[1,...,n-1]
for b in range(a+1,n+1): #确保b取值大于a
for c in range(a): #c取a之前的数,确保c小于a
d3 = a**3 + b**3 - c**3 #求得d^3
d = int(d3**(1/3) + 0.000001) #求得一个d
if d**3 == d3 and d <= n: #验证d是否符合条件
retS.append([a,b,c,d])
return retS


根据代码,算法复杂度为O(n^3)。

能不能再进一步提高效率呢。注意到形如(a, b)(a和b与顺序无关,可以假设a< b)这样的数对里:(0, n), (0, n-1), …, (0, 1) 可以构成一个逆序的子列表,而(1, n), (1, n-1), …, (1, 2)也可以构成另一个逆序的子列表,以此类推,直到(n-1, n)构成一个逆序子列表,共有n个子列表,所有子表的元素总数是n(n+1)/2,根据问题①的启发,我们如果用最大堆对其有序合并,那么需要O((n^2)lgn),依次找出堆顶最大值,如果有相同的值,那必然就是符合要求的两对数。实现如下:

def CountA3B3EqC3D3Heap(n):
S = []
for i in range(n-1,-1,-1):
S.append([i**3+n**3, i, n]) #用形如[a^3+b^3,a,b]表示一个元素

retS = []
maxNumPair = [0,0,0]
while S[0]:
if maxNumPair[0] == S[0][0]:
retS.append([maxNumPair[1], maxNumPair[2], S[0][1], S[0][2]])
maxNumPair = S[0][:] #将S[0]拷贝给maxNumPair 注意这里必须是深拷贝。
S[0][2] -= 1
if S[0][1] < S[0][2]:
S[0][0] = S[0][1]**3 + S[0][2]**3
else:
S[0] = []
MaxHeapSinkLoop(S,0,len(S))
return retS


上述算法利用堆,运用O(n)的辅助空间在O((n^2)lgn)时间内解决了问题。光从时间上来看非最优,利用后文将要介绍的基数排序(非基于比较)的排序将会在O(n^2)时间内解决,但是辅助空间会增涨到O(n^2)。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息