[C++]三種常見排序
希爾排序
希爾排序,也稱遞減增量排序演算法,是插入排序的一種更高效的改進版本。希爾排序是非穩定排序演算法。
希爾排序是基於插入排序的以下兩點性質而提出改進方法的:
- 插入排序在對幾乎已經排好序的資料操作時,效率高,即可以達到線性排序的效率
- 但插入排序一般來說是低效的,因為插入排序每次只能將資料移動一位
演算法實現
原始的演算法實現在最壞的情況下需要進行O(n2)的比較和交換。V. Pratt的書[1]對演算法進行了少量修改,可以使得效能提升至O(nlog2 n)。這比最好的比較演算法的O(n log n)要差一些。
希爾排序通過將比較的全部元素分為幾個區域來提升插入排序的效能。這樣可以讓一個元素可以一次性地朝最終位置前進一大步。然後演算法再取越來越小的步長進行排序,演算法的最後一步就是普通的
假設有一個很小的資料在一個已按升序排好序的陣列的末端。如果用複雜度為O(n2)的排序(氣泡排序或插入排序),可能會進行n次的比較和交換才能將該資料移至正確位置。而希爾排序會用較大的步長移動資料,所以小資料只需進行少數比較和交換即可到正確位置。
一個更好理解的希爾排序實現:將陣列列在一個表中並對列排序(用插入排序)。重複這過程,不過每次用更長的列來進行。最後整個表就只有一列了。將陣列轉換至表是為了更好地理解這演算法,演算法本身僅僅對原陣列進行排序(通過增加索引的步長,例如是用i += step_size
而不是i++
)。
例如,假設有這樣一組數[ 13 14 94 33 82 25 59 94 65 23 45 27 73 25 39 10 ],如果我們以步長為5開始進行排序,我們可以通過將這列表放在有5列的表中來更好地描述演算法,這樣他們就應該看起來是這樣:
13 14 94 33 82
25 59 94 65 23
45 27 73 25 39
10
然後我們對每列進行排序:
10 14 73 25 23
13 27 94 33 39
25 59 94 65 82
45
將上述四行數字,依序接在一起時我們得到:[ 10 14 73 25 23 13 27 94 33 39 25 59 94 65 82 45 ].這時10已經移至正確位置了,然後再以3為步長進行排序:
10 14 73
25 23 13
27 94 33
39 25 59
94 65 82
45
排序之後變為:
10 14 13
25 23 33
27 25 59
39 65 73
45 94 82
94
最後以1步長進行排序(此時就是簡單的插入排序了)。
步長序列
步長的選擇是希爾排序的重要部分。只要最終步長為1任何步長序列都可以工作。演算法最開始以一定的步長進行排序。然後會繼續以一定步長進行排序,最終演算法以步長為1進行排序。當步長為1時,演算法變為普通插入排序,這就保證了資料一定會被排序。
Donald Shell最初建議步長選擇為{\displaystyle {\frac {n}{2}}}並且對步長取半直到步長達到1。雖然這樣取可以比{\displaystyle {\mathcal {O}}(n^{2})}類的演算法(插入排序)更好,但這樣仍然有減少平均時間和最差時間的餘地。可能希爾排序最重要的地方在於當用較小步長排序後,以前用的較大步長仍然是有序的。比如,如果一個數列以步長5進行了排序然後再以步長3進行排序,那麼該數列不僅是以步長3有序,而且是以步長5有序。如果不是這樣,那麼演算法在迭代過程中會打亂以前的順序,那就不會以如此短的時間完成排序了。
步長序列 | 最壞情況下複雜度 |
---|---|
{\displaystyle {n/2^{i}}} | {\displaystyle {\mathcal {O}}}{\displaystyle (n^{2})} |
{\displaystyle 2^{k}-1} | {\displaystyle {\mathcal {O}}}{\displaystyle (n^{3/2})} |
{\displaystyle 2^{i}3^{j}} | {\displaystyle {\mathcal {O}}}{\displaystyle (n\log ^{2}n)} |
已知的最好步長序列是由Sedgewick提出的(1, 5, 19, 41, 109,...),該序列的項來自{\displaystyle 9\times 4^{i}-9\times 2^{i}+1}和{\displaystyle 2^{i+2}\times (2^{i+2}-3)+1}這兩個算式[1]。這項研究也表明“比較在希爾排序中是最主要的操作,而不是交換。”用這樣步長序列的希爾排序比插入排序要快,甚至在小陣列中比快速排序和堆排序還快,但是在涉及大量資料時希爾排序還是比快速排序慢。
另一個在大陣列中表現優異的步長序列是(斐波那契數列除去0和1將剩餘的數以黃金分割比的兩倍的冪進行運算得到的數列):(1, 9, 34, 182, 836, 4025, 19001, 90358, 428481, 2034035, 9651787, 45806244, 217378076, 1031612713,…)[2]
程式程式碼
template <class _Tp>
void shell_sort(_Tp *arr, int length) {
int h = 1;
while (h < length / 3) {
h = 3 * h + 1;
}
while (h >= 1) {
for (int i = h; i < length; ++i) {
for (int j = i; j >= h && arr[j] < arr[j - h]; j -= h) {
swap(arr[j], arr[j - h]);
};
};
h = h / 3;
};
};
快速排序
快速排序(英語:Quicksort),又稱劃分交換排序(partition-exchange sort),簡稱快排,一種排序演算法,最早由東尼·霍爾提出。在平均狀況下,排序{\displaystyle n}個專案要{\displaystyle \ O(n\log n)}(大O符號)次比較。在最壞狀況下則需要{\displaystyle O(n^{2})}次比較,但這種狀況並不常見。事實上,快速排序{\displaystyle \Theta (n\log n)}通常明顯比其他演算法更快,因為它的內部迴圈(inner loop)可以在大部分的架構上很有效率地達成。
演算法實現
快速排序使用分治法(Divide and conquer)策略來把一個序列(list)分為兩個子序列(sub-lists)。
步驟為:
- 從數列中挑出一個元素,稱為“基準”(pivot),
- 重新排序數列,所有比基準值小的元素擺放在基準前面,所有比基準值大的元素擺在基準後面(相同的數可以到任何一邊)。在這個分割結束之後,該基準就處於數列的中間位置。這個稱為分割(partition)操作。
- 遞迴地(recursively)把小於基準值元素的子數列和大於基準值元素的子數列排序。
遞迴到最底部時,數列的大小是零或一,也就是已經排序好了。這個演算法一定會結束,因為在每次的迭代(iteration)中,它至少會把一個元素擺到它最後的位置去。
正規分析
從一開始快速排序平均需要花費{\displaystyle O(n\log n)}時間的描述並不明顯。但是不難觀察到的是分割運算,陣列的元素都會在每次迴圈中走訪過一次,使用{\displaystyle O(n)}的時間。在使用結合(concatenation)的版本中,這項運算也是{\displaystyle O(n)}。
在最好的情況,每次我們執行一次分割,我們會把一個數列分為兩個幾近相等的片段。這個意思就是每次遞迴呼叫處理一半大小的數列。因此,在到達大小為一的數列前,我們只要作{\displaystyle \log n}次巢狀的呼叫。這個意思就是呼叫樹的深度是{\displaystyle O(\log n)}。但是在同一層次結構的兩個程式呼叫中,不會處理到原來數列的相同部分;因此,程式呼叫的每一層次結構總共全部僅需要{\displaystyle O(n)}的時間(每個呼叫有某些共同的額外耗費,但是因為在每一層次結構僅僅只有{\displaystyle O(n)}個呼叫,這些被歸納在{\displaystyle O(n)}係數中)。結果是這個演算法僅需使用{\displaystyle O(n\log n)}時間。
另外一個方法是為{\displaystyle T(n)}設立一個遞迴關係式,也就是需要排序大小為{\displaystyle n}的數列所需要的時間。在最好的情況下,因為一個單獨的快速排序呼叫牽涉了{\displaystyle O(n)}的工作,加上對{\displaystyle n/2}大小之數列的兩個遞迴呼叫,這個關係式可以是:
{\displaystyle T(n)=O(n)+2T(n/2)}
解決這種關係式型別的標準數學歸納法技巧告訴我們{\displaystyle T(n)=O(n\log n)}。
事實上,並不需要把數列如此精確地分割;即使如果每個基準值將元素分開為99%在一邊和1%在另一邊,呼叫的深度仍然限制在{\displaystyle 100\log n},所以全部執行時間依然是{\displaystyle O(n\log n)}。
然而,在最壞的情況是,兩子數列擁有大各為{\displaystyle 1} 和{\displaystyle n-1},且呼叫樹(call tree)變成為一個{\displaystyle n}個巢狀(nested)呼叫的線性連串(chain)。第{\displaystyle i} 次呼叫作了{\displaystyle O(n-i)}的工作量,且{\displaystyle \sum _{i=0}^{n}(n-i)=O(n^{2})}遞迴關係式為:
{\displaystyle T(n)=O(n)+T(1)+T(n-1)=O(n)+T(n-1)}
這與插入排序和選擇排序有相同的關係式,以及它被解為{\displaystyle T(n)=O(n^{2})}。
程式程式碼
template <class _Tp>
void quick_sort(int start, int end, _Tp *arr) {
int i = start, j = end, pivot = arr[start];
if (i >= j) return;
while (i != j) {
while (i < j && arr[j] >= pivot) --j;
while (i < j && arr[i] <= pivot) ++i;
if (i < j) swap(arr[i], arr[j]);
};
swap(arr[i], arr[start]);
quick_sort(start, i - 1, arr);
quick_sort(i + 1, end, arr);
};
歸併排序
歸併排序(英語:Merge sort,或mergesort),是建立在歸併操作上的一種有效的排序演算法,效率為{\displaystyle O(n\log n)}(大O符號)。1945年由約翰·馮·諾伊曼首次提出。該演算法是採用分治法(Divide and Conquer)的一個非常典型的應用,且各層分治遞迴可以同時進行。
歸併操作
歸併操作(merge),也叫歸併演算法,指的是將兩個已經排序的序列合併成一個序列的操作。歸併排序演算法依賴歸併操作。
遞迴法(Top-down)
- 申請空間,使其大小為兩個已經排序序列之和,該空間用來存放合併後的序列
- 設定兩個指標,最初位置分別為兩個已經排序序列的起始位置
- 比較兩個指標所指向的元素,選擇相對小的元素放入到合併空間,並移動指標到下一位置
- 重複步驟3直到某一指標到達序列尾
- 將另一序列剩下的所有元素直接複製到合併序列尾
迭代法(Bottom-up)
原理如下(假設序列共有{\displaystyle n}個元素):
- 將序列每相鄰兩個數字進行歸併操作,形成{\displaystyle ceil(n/2)}個序列,排序後每個序列包含兩/一個元素
- 若此時序列數不是1個則將上述序列再次歸併,形成{\displaystyle ceil(n/4)}個序列,每個序列包含四/三個元素
- 重複步驟2,直到所有元素排序完畢,即序列數為1
程式程式碼
int buf[MAX_SIZE]; template <class _Tp>
void merge_sort(int start, int end, _Tp *arr) {
if (start == end) return;
int mid = (start + end) / 2;
merge_sort(start, mid, arr);
merge_sort(mid + 1, end, arr);
int i = start, j = mid + 1, k = start;
while (i <= mid && j <= end) if (arr[i] <= arr[j]) buf[k] = arr[i], ++k, ++i; else buf[k] = arr[j], ++k, ++j;
while (i <= mid) buf[k] = arr[i], ++k, ++i;
while (j <= end) buf[k] = arr[j], ++k, ++j;
for (int i = start; i <= end; ++i) arr[i] = buf[i];
};