堆的定義和表示
說到堆排序,得先說說堆這種數據結構。(二叉)堆是一個數組,可以近似的看做一個完全二叉樹。
上圖是堆的兩種表現形式,給定一個下標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]交換,最大元素放到了正確位置,從堆中去掉結點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)。