這個系列是我多年前找工作時對資料結構和演算法總結,其中有基礎部分,也有各大公司的經典的面試題,最早釋出在CSDN。現整理為一個系列給需要的朋友參考,如有錯誤,歡迎指正。本系列完整程式碼地址在 這裡。
0 概述
本文要描述的堆是二叉堆。二叉堆是一種陣列物件,可以被視為一棵完全二叉樹,樹中每個結點和陣列中存放該結點值的那個元素對應。樹的每一層都是填滿的,最後一層除外。二叉堆可以用於實現堆排序,優先順序佇列等。本文程式碼地址在 這裡。
1 二叉堆定義
使用陣列來實現二叉堆,二叉堆兩個屬性,其中 LENGTH(A)
表示陣列 A
的長度,而 HEAP_SIZE(A)
則表示存放在A中的堆的元素個數,其中 LENGTH(A) <= HEAP_SIZE(A)
,也就是說雖然 A[0,1,...N-1]
都可以包含有效值,但是 A[HEAP_SIZE(A)-1]
之後的元素不屬於相應的堆。
二叉堆對應的樹的根為 A[0]
,給定某個結點的下標 i ,可以很容易計算它的父親結點和兒子結點。注意在後面的示例圖中我們標註元素是從1開始計數的,而實現程式碼中是從0開始計數。
#define PARENT(i) ( i > 0 ? (i-1)/2 : 0)
#define LEFT(i) (2 * i + 1)
#define RIGHT(i) (2 * i + 2)
複製程式碼
注:堆對應的樹每一層都是滿的,所以一個高度為 h
的堆中,元素數目最多為 1+2+2^2+...2^h = 2^(h+1) - 1
(滿二叉樹),元素數目最少為 1+2+...+2^(h-1) + 1 = 2^h
。
由於元素數目 2^h <= n <= 2^(h+1) -1
,所以 h <= lgn < h+1
,因此 h = lgn
。即一個包含n個元素的二叉堆高度為 lgn
。
2 保持堆的性質
本文主要建立一個最大堆,最小堆原理類似。為了保持堆的性質,maxHeapify(int A[], int i)
函式讓堆陣列 A
在最大堆中下降,使得以 i
為根的子樹成為最大堆。
void maxHeapify(int A[], int i, int heapSize)
{
int l = LEFT(i);
int r = RIGHT(i);
int largest = i;
if (l <= heapSize-1 && A[l] > A[i]) {
largest = l;
}
if (r <= heapSize-1 && A[r] > A[largest]) {
largest = r;
}
if (largest != i) { // 最大值不是i,則需要交換i和largest的元素,並遞迴呼叫maxHeapify。
swapInt(A, i, largest);
maxHeapify(A, largest, heapSize);
}
}
複製程式碼
-
在演算法每一步裡,從元素
A[i]
和A[left]
以及A[right]
中選出最大的,將其下標存在largest
中。如果A[i]
最大,則以i
為根的子樹已經是最大堆,程式結束。 -
否則,
i
的某個子結點有最大元素,將A[i]
與A[largest]
交換,從而使i及其子女滿足最大堆性質。此外,下標為largest
的結點在交換後值變為A[i]
,以該結點為根的子樹又有可能違反最大堆的性質,所以要對該子樹遞迴呼叫maxHeapify()
函式。
當 maxHeapify()
函式作用在一棵以 i
為根結點的、大小為 n
的子樹上時,執行時間為調整A[i]
、A[left]
、A[right]
的時間 O(1)
,加上對以 i
為某個子結點為根的子樹遞迴呼叫 maxHeapify
的時間。i
結點為根的子樹大小最多為 2n/3
(最底層剛好半滿的時候),所以可以推得 T(N) <= T(2N/3) + O(1)
,所以 T(N)=O(lgN)
。
下圖是一個執行 maxHeapify(heap, 2)
的例子。A[] = {16, 4, 10, 14, 7, 9, 3, 2, 8, 1}
,堆大小為 10
。
3 建立最大堆
我們可以知道,陣列 A[0, 1, ..., N-1]
中,A[N/2, ..., N-1]
的元素都是樹的葉結點。如上面圖中的 6-10
的結點都是葉結點。每個葉子結點可以看作是隻含一個元素的最大堆,因此我們只需要對其他的結點呼叫 maxHeapify()
函式即可。
void buildMaxHeap(int A[], int n)
{
int i;
for (i = n/2-1; i >= 0; i--) {
maxHeapify(A, i, n);
}
}
複製程式碼
之所以這個函式是正確的,我們需要來證明一下,可以使用迴圈不變式來證明。
迴圈不變式:在for迴圈開始前,結點 i+1、i+2...N-1
都是一個最大堆的根。
初始化:for迴圈開始迭代前,i = N/2-1
, 結點 N/2, N/2+1, ..., N-1
都是葉結點,也都是最大堆的根。
保持:因為結點 i
的子結點標號都比 i
大,根據迴圈不變式的定義,這些子結點都是最大堆的根,所以呼叫 maxHeapify()
後,i
成為了最大堆的根,而 i+1, i+2, ..., N-1
仍然保持最大堆的性質。
終止:過程終止時,i=0,因此結點 0, 1, 2, ..., N-1
都是最大堆的根,特別的,結點0就是一個最大堆的根。
雖然每次呼叫 maxHeapify()
時間為 O(lgN)
,共有 O(N)
次呼叫,但是說執行時間是 O(NlgN)
是不確切的,準確的來說,執行時間為 O(N)
,這裡就不證明了,具體證明過程參見《演算法導論》。
4 堆排序
開始用 buildMaxHeap()
函式建立一個最大堆,因為陣列最大元素在 A[0]
,通過直接將它與A[N-1]
互換來達到最終正確位置。去掉 A[N-1]
,堆的大小 heapSize
減1,呼叫maxHeapify(heap, 0, --heapSize)
保持最大堆的性質,直到堆的大小由N減到1。
void heapSort(int A[], int n)
{
buildMaxHeap(A, n);
int heapSize = n;
int i;
for (i = n-1; i >= 1; i--) {
swapInt(A, 0, i);
maxHeapify(A, 0, --heapSize);
}
}
複製程式碼
5 優先順序佇列
最後實現一個最大優先順序佇列,主要有四種操作,分別如下所示:
insert(PQ, key)
:將 key 插入到佇列中。maximum(PQ)
: 返回佇列中最大關鍵字的元素extractMax(PQ)
:去掉並返回佇列中最大關鍵字的元素increaseKey(PQ, i, key)
:將佇列 i 處的關鍵字的值增加到 key
這裡定義一個結構體 PriorityQueue
便於操作。
typedef struct PriorityQueue {
int capacity;
int size;
int elems[];
} PQ;
複製程式碼
最終優先順序佇列的操作實現程式碼如下:
/**
* 從陣列建立優先順序佇列
*/
PQ *newPQ(int A[], int n)
{
PQ *pq = (PQ *)malloc(sizeof(PQ) + sizeof(int) * n);
pq->size = 0;
pq->capacity = n;
int i;
for (i = 0; i < pq->capacity; i++) {
pq->elems[i] = A[i];
pq->size++;
}
buildMaxHeap(pq->elems, pq->size);
return pq;
}
int maximum(PQ *pq)
{
return pq->elems[0];
}
int extractMax(PQ *pq)
{
int max = pq->elems[0];
pq->elems[0] = pq->elems[--pq->size];
maxHeapify(pq->elems, 0, pq->size);
return max;
}
PQ *insert(PQ *pq, int key)
{
int newSize = ++pq->size;
if (newSize > pq->capacity) {
pq->capacity = newSize * 2;
pq = (PQ *)realloc(pq, sizeof(PQ) + sizeof(int) * pq->capacity);
}
pq->elems[newSize-1] = INT_MIN;
increaseKey(pq, newSize-1, key);
return pq;
}
void increaseKey(PQ *pq, int i, int key)
{
int *elems = pq->elems;
elems[i] = key;
while (i > 0 && elems[PARENT(i)] < elems[i]) {
swapInt(elems, PARENT(i), i);
i = PARENT(i);
}
}
複製程式碼
參考資料
- 演算法導論第6章《堆排序》