各種排序演算法的C語言實現
《資料結構與演算法分析C語言描述》-第二版
1.插入排序
插入排序由N-1趟排序組成,第P趟排序之前,前P個元素已經排好序。第P趟排序時,前P個元素中大於第P+1個元素的數全部右移一位,然後將第P+1個元素插入對應的位置。
插入排序的時間複雜度為\(O(N^2)\)。
void InsertionSort(ElementType A[], int N) { int j, P; ElementType temp; for (p = 1; p < N; p++) { temp = A[p]; for (j = p; j > 0 && A[j] > temp; j--) A[j] = A[j - 1]; A[j] = temp; } }
2.希爾排序
基於增量序列h1,h2,h3,...,ht
進行t趟排序,第k趟排序相當於對\(h_k\)組子序列,每組\(N/h_k\)個元素進行插入排序。
希爾排序的依據是一個\(h_k\)-排序的檔案會保持它的\(h_k\)排序性。
希爾排序的關鍵是增量序列的選擇,只要\(h_1 = 1\),任何序列都是可行的,下面是以希爾序列為例的虛擬碼:
void ShellSort(ElementType A[], int N) { int i, j, Increment; ElementType Tmp; for(Increment = N / 2; Increment > 0; Increment /= 2) { for (i = Increment; i < N; i++) { Tmp = A[i]; for (j = i; j >= Increment; j -= Increment) { if (A[j] > Tmp) A[j] = A[j - 1]; else break; } A[j] = Tmp; } } }
希爾序列的問題是這些增量不互素,Hibbard增量為\(1,3,7,...,2^k - 1\),在實踐中有更好的表現。使用Hibbard增量的希爾排序平均為\(O(n^{5/4})\),最壞時間界為\(\Theta(N^{3/2})\),並且已經證明\(\Theta(N^{3/2})\)的界適用於廣泛的增量序列。
Sedgewick增量在實踐中比Hibbard增量要好,其中最好的是\(1,5,19,41,109,...\),該序列的項或者是\(9\cdot4^{i}-9\cdot2^{i}+1\),或者是\(4^{i}-3\cdot2^{i}+1\)。通過將序列放入一個數組中可以更容易地實現該演算法。最壞情形為\(O(N^{4/3})\)
3.堆排序
使用max堆不斷deletemax並且因為每次deletemax堆的容量減一,所以可以將每次deletemax得到的值放在deletemax之前堆的最後位置,這樣進行N-1次deletemax後得到的將會是遞增的序列。想要得到遞減序列則使用min堆。
堆排序的時間複雜度為\(O(NlogN)\),但在實踐中其表現慢於使用Sedgewick增量序列的希爾排序,原因是堆排序是一個非常穩定的演算法,它平均使用的比較只比最壞情形界指出的略少。堆排序的虛擬碼如下:
#define LeftChild(i) (2 * (i) + 1)
void PercDown(ElementType A[], int i, int N)
{
int Child;
ElementType Tmp;
for (Tmp = A[i]; LeftChild(i) < N; i = Child)
{
Child = leftChild(i);
if (Child != N - 1 && A[Child] < A[Child + 1])
++Child;
if (Tmp < A[Child])
A[i] = A[Child];
}
A[i] = Tmp;
}
void HeadSort(ElementType A[], int N)
{
int i;
// build max head;
for (i = N / 2; i >= 0; --i)
{
PercDown(A, i, N);
}
// HeadSort
for (i = N - 1; i > 0; --i)
{
Swap(&A[0], &A[i]); // deletemax
PercDown(A, 0, i);
}
}
4.歸併排序
歸併排序是經典的分治(divide-and-conquer)策略,如果\(N = 1\),那麼答案是顯然的,否則演算法遞迴的將前半部分資料和後半部分資料各自歸併排序。歸併排序的虛擬碼如下:
void Merge(ElementType A[], ElementType TmpArray[], int Lpos, int Rpos, int RightEnd)
{
int i, LeftEnd, NumElements, TmpPos;
LeftEnd = Rpos - 1;
TmpPos = Lpos;
NumElements = Rpos - Lpos + 1;
while(Lpos <= LeftEnd && Rpos <= RightEnd)
{
if (A[Lpos] <= A[Rpos])
{
TmpArray[TmpPos++] = A[Lpos++];
}
else
TmpArray[TmpPos++] = A[Rpos++];
}
// copy rest of first half
while (Lpos <= LeftEnd)
{
TmpArray[TmpPos++] = A[Lpos++];
}
// copy rest of second half
while (Rpos <= RightEnd)
{
TmpArray[TmpPos++] = A[Rpos++];
}
// copy TmpArray back
for (i = 0; i < NumElements; i++, RightEnd--)
{
A[RightEnd] = TmpArray[RightEnd];
}
}
void Msort(ElementType A[], ElementType TmpArray[], int left, int right)
{
int Center;
if (left < right)
{
Center = left + (right - left) / 2;
Msort(A, TmpArray, left, Center);
Msort(A, TmpArray, Center+1, right);
Merge(A, TmpArray, left, Center + 1, right);
}
}
void MergeSort(ElementType A[], int N)
{
ElementType *TempArray;
TmpArray = malloc(N * sizeof(ElementType));
if (TmpArray != NULL)
{
Msrot(A, TmpArray, 0, N-1);
free(TmpArray);
}
else
{
FatalError("No space for tmp array!!!");
}
}
歸併排序是分析遞迴歷程方法的經典例項:必須給出執行時間的一個遞迴關係。假設N是2的冪,從而我們總可以將它均分為偶數的兩部分。對於N = 1,用時為常數,記為1.否則對N個數歸併排序的用時等於完成兩個大小為\(N/2\)的遞迴排序所用的時間在加上合併的時間,它是線性的,如下面方程所示:
\(T(1)=1\)
\(T(N)=2T(N)+N\)
方程兩邊同除以N,得到:
\(\frac{T(N)}{N}=\frac{T(N/2)}{N/2}+1\)
\(\frac{T(N/2)}{N/2}=\frac{T(N/4)}{N/4}+1\)
\(\frac{T(N/4)}{N/4}=\frac{T(N/8)}{N/8}+1\)
......
\(\frac{T(2)}{2}=\frac{T(1)}{1}+1\)
疊縮(telescoping)求和,等號左邊的項可以被右的項消去,最後的結果為:
\(\frac{T(N)}{N}=\frac{T(1)}{1}+logN\)
兩邊同時乘以N,得到:
\(T(N)=NlogN+N=O(NlogN)\)
雖然歸併排序的執行時間為O(NlogN),但是它很難用於主存排序,主要的問題合併兩個排序的表需要線性附加記憶體,在整個演算法中還要花費將資料拷貝到臨時陣列再拷貝回來這樣一些附加的工作,嚴重放慢了排序的速度。但合併例程是大多數外部排序演算法的基石。
5.快速排序
快速排序(quicksort)實在實踐中最快的已知排序方法,它的平均執行時間是O(logN),與歸併排序一樣,快速排序也是一種分治的遞迴演算法。陣列S的快速排序基於一下的四步:
- 如果S中的元素是0或1個,返回。
- 取S中任意元素v,稱之為樞紐元(pivot).
- 將S-{v}(S中其餘元素)分成兩個不相交的集合;\(S_1=\lbrace x\in {S-\lbrace v \rbrace}\mid x\le v\rbrace\), 和\(S_2=\lbrace x\in {S-\lbrace v \rbrace}\mid x \ge v\rbrace\)。
- 返回\(\lbrace quicksort(S_1) \rbrace\)後,繼而\(\lbrace quicksort(S_2) \rbrace\)。
第2步樞紐元的選擇是快速排序的關鍵,比較好的策略是隨機選取樞紐元,但是隨機數的生成一般是昂貴的,根本減少不了演算法其餘部分的平均執行時間。
三數中值分割法(Median-of-Three Partitioning)是一個更好的策略,選擇陣列左,中,右三個元素中的最大值作為樞紐元,並將最小值放置在最左邊left處,最大值放在right處,而樞紐元放在right-1處,這樣\(S[left]\)可以作為遍歷時從右開始的指標j的警戒標記,不必擔心j越界,同時\(S[right-1]\)處的元素即樞紐元也可以作為從左開始的指標i的警戒標記。程式開始時將i,j分別初始化為left和right-1,而不是left+1和right-2可以將其和其他樞紐元選擇策略(如隨機選擇)相容,因為其他策略可能不存在這樣具有警戒標記的元素。
對重複元的處理,最好的處理方法是遇到等於樞紐元的元素就交換它們,顯然如果遇到與樞紐元相同的元素,只有i停止或j停止,會導致分割結果偏向其中一方。如果i,j都不停止,則需要一個條件(如i<=j
)來防止越界,但這樣每一次i自增或j自減時都需要檢查是否滿足條件,並不能實際節省時間。
快速排序的虛擬碼如下:
ElementType Median3(ElementType A[], int left, int right)
{
int center = left + (right - left);
if(A[left] > A[center])
Swap(&A[left], &A[right]); // 最好將Swap宣告稱行內函數
if(A[left] > A[right])
Swap(&A[left], &A[right]);
if (A[center] > A[right])
Swap(&A[center], &A[right]);
/* Invariant: A[ left ] <= A[ center ] <= A[right] */
Swap(&A[center], &A[right - 1]); // Hide pivot
return A[right-1]; // Return pivot
}
#define Cutoff (3)
void Qsort(ElementType A[], int left, int right)
{
int i, j;
ElementType pivot;
if (left + Cutoff <= right)
{
pivot = Median3(A, left, right);
i = left; j = right - 1;
for ( ; ; )
{
while(A[++i] < pivot) {}
while(A[--j] > pivot) {}
if (i < j)
Swap(&A[i], &A[j]);
else
break;
}
Swap(&A[i], &A[right-1]); // Restore pivot
Qsort(A, left, i - 1);
Qsort(A, i + 1, right);
}
else // Do an insertion sort on the subarray
InsertionSort(A + left, right - left + 1);
}
對於小陣列(N<=20),快速排序不如插入排序好,因為快排是遞迴的,通常的解決方法是對小陣列使用插入排序。一種好的截至範圍(cutoff range)是N=10,雖然5到20之間任意截至範圍都可以產生類似的結果,這種做法也避免了三數中值分割時只有一個或兩個元素的情況。
快速排序的平均情形分析:
\(T(N) = \frac{2}{N}\lbrack \sum\limits_{j=0}^{N-1}{T(j)} \rbrack + cN\)
兩邊同乘以N
\(NT(N) = 2\lbrack \sum\limits_{j=0}^{N-1}{T(j)} \rbrack + cN^2\)
需要除去求和符號以簡化計算,因此在次套用上式:
\((N-1)T(N-1) = 2\lbrack \sum\limits_{j=0}^{N-2}{T(j)} \rbrack + c(N-1)^2\)
兩式相減得到:
\(NT(N)-(N-1)T(N-1) = 2T(N-1) + 2cN - c\)
移項、合併並去除無關緊要的-c,得到:
\(NT(N) = (N+1)T(N-1) + 2cN\)
兩邊同除以N(N+1),得到:
\(\frac{T(N)}{N+1}=\frac{T(N-1)}{N}+\frac{2c}{N+1}\)
進行疊縮求和:
\(\frac{T(N-1)}{N}=\frac{T(N-2)}{N-1}+\frac{2c}{N}\)
...
\(\frac{T(2)}{3} = \frac{T(1)}{2}+\frac{2c}{3}\)
求和的結果為:
\(\frac{T(N)}{N+1}=\frac{T(1)}{2}+2c\sum\limits_{i=3}^{N+1}{\frac{1}{i}}\)
該和大約為\(\log\nolimits_{e}{N+1}+\gamma - \frac{3}{2}\),其中\(\gamma \approx 0.577\)叫做尤拉常數,於是:
\(\frac{T(N)}{N+1} = O(logN)\),從而\(T(N) = O(NlogN)\)。
快速選擇
基於快速排序可以給選擇問題提供一個O(N)時間複雜度的演算法,選擇問題的內容是給定一個數組S,找到第k個最小(大)元素。快速選擇的具體步驟如下:
- 如果\(\mid S \mid = 1\),那麼k=1,將S中的元素作為答案返回。如果用小陣列的截至方法且\(\mid S \mid \le CUTOFF\),則將S排序並返回第k個最小元。
- 選取一個樞紐元\(v \in S\)。
- 將集合\(S - \lbrace v \rbrace\)分割成\(S_1\)和\(S_2\),就像我們在快速排序中做的那樣。
- 如果\(k \le \mid S_1 \mid\),那麼第k個最小元必然在\(S_1\)中。在這種情況下,返回\(quickselect(S_1, k)\)。如果\(k = 1 + \mid S-1 \mid\),那麼樞紐元就是第k個最小元,直接返回。否則第k個最小元在\(S_2\)中,它是\(S_2\)中第\((k - \mid S_1 \mid - 1)\)個最小元。這種情況下,返回\(quickselect(S_2, k - \mid S_1 \mid - 1)\)。
快速選擇相比快速排序的差別是,快速選擇只做了一次遞迴呼叫而不是兩次,但其最壞情形與快速排序相同,即\(S_1\)和\(S_2\)中有一個為空,此時時間複雜度為\(O(N^2)\),不過快速選擇的平均時間是\(O(N)\)。實現快速選擇的虛擬碼如下:
// the kth smallest element will placed in the k-1 index because array start at 0;
void Qselect(int A[], int k, int left, int right)
{
int i, j;
ElementType pivot;
if (left + Cufoff <= right)
{
pivot = Median3(A, left, right);
i = left;
j = right - 1;
for (;;)
{
while (A[--i] < pivot) {}
while (A[++j] > pivot) {}
if (i > j)
Swap(&A[i], &A[j]); // Swap宣告成內聯
else
break;
}
Swap(&A[i], &A[right - 1]); // restore pivot
if (k <= i)
Qselect(A, k, left, i - 1);
if (k > i + 1)
Qselect(A, k - i - 1, i + 1, right);
}
else
InsertionSort(A + left, right - left + 1);
}
使用五分化中項的中項可以消除二次最壞情況而保證演算法時O(N)的。可是這麼做的額外開銷很大,因此最終的演算法主要在於理論的意義。