常用排序演算法總結(一)
目錄
我們通常所說的排序演算法往往指的是內部排序演算法,即資料記錄在記憶體中進行排序。
排序演算法大體可分為兩種:
一種是比較排序,時間複雜度O(nlogn) ~ O(n^2),主要有:氣泡排序,選擇排序,插入排序,歸併排序,堆排序,快速排序等。
另一種是非比較排序,時間複雜度可以達到O(n),主要有:計數排序,基數排序,桶排序等。
這裡我們來探討一下常用的比較排序演算法,非比較排序演算法將在下一篇文章中介紹。下表給出了常見比較排序演算法的效能:
有一點我們很容易忽略的是排序演算法的穩定性(騰訊校招2016筆試題曾考過)。
排序演算法穩定性的簡單形式化定義為:如果Ai = Aj,排序前Ai在Aj之前,排序後Ai還在Aj之前,則稱這種排序演算法是穩定的。通俗地講就是保證排序前後兩個相等的數的相對順序不變。
對於不穩定的排序演算法,只要舉出一個例項,即可說明它的不穩定性;而對於穩定的排序演算法,必須對演算法進行分析從而得到穩定的特性。需要注意的是,排序演算法是否為穩定的是由具體演算法決定的,不穩定的演算法在某種條件下可以變為穩定的演算法,而穩定的演算法在某種條件下也可以變為不穩定的演算法。
例如,對於氣泡排序,原本是穩定的排序演算法,如果將記錄交換的條件改成A[i] >= A[i + 1],則兩個相等的記錄就會交換位置,從而變成不穩定的排序演算法。
其次,說一下排序演算法穩定性的好處。排序演算法如果是穩定的,那麼從一個鍵上排序,然後再從另一個鍵上排序,前一個鍵排序的結果可以為後一個鍵排序所用。基數排序就是這樣,先按低位排序,逐次按高位排序,低位排序後元素的順序在高位也相同時是不會改變的。
氣泡排序(Bubble Sort)
氣泡排序是一種極其簡單的排序演算法,也是我所學的第一個排序演算法。它重複地走訪過要排序的元素,依次比較相鄰兩個元素,如果他們的順序錯誤就把他們調換過來,直到沒有元素再需要交換,排序完成。這個演算法的名字由來是因為越小(或越大)的元素會經由交換慢慢“浮”到數列的頂端。
氣泡排序演算法的運作如下:
- 比較相鄰的元素,如果前一個比後一個大,就把它們兩個調換位置。
- 對每一對相鄰元素作同樣的工作,從開始第一對到結尾的最後一對。這步做完後,最後的元素會是最大的數。
- 針對所有的元素重複以上的步驟,除了最後一個。
- 持續每次對越來越少的元素重複上面的步驟,直到沒有任何一對數字需要比較。
由於它的簡潔,氣泡排序通常被用來對於程式設計入門的學生介紹演算法的概念。氣泡排序的程式碼如下:
#include <stdio.h> // 分類 -------------- 內部比較排序 // 資料結構 ---------- 陣列 // 最差時間複雜度 ---- O(n^2) // 最優時間複雜度 ---- 如果能在內部迴圈第一次執行時,使用一個旗標來表示有無需要交換的可能,可以把最優時間複雜度降低到O(n) // 平均時間複雜度 ---- O(n^2) // 所需輔助空間 ------ O(1) // 穩定性 ------------ 穩定 void Swap(int A[], int i, int j) { int temp = A[i]; A[i] = A[j]; A[j] = temp; } void BubbleSort(int A[], int n) { for (int j = 0; j < n - 1; j++) // 每次最大元素就像氣泡一樣"浮"到陣列的最後 { for (int i = 0; i < n - 1 - j; i++) // 依次比較相鄰的兩個元素,使較大的那個向後移 { if (A[i] > A[i + 1]) // 如果條件改成A[i] >= A[i + 1],則變為不穩定的排序演算法 { Swap(A, i, i + 1); } } } } int main() { int A[] = { 6, 5, 3, 1, 8, 7, 2, 4 }; // 從小到大氣泡排序 int n = sizeof(A) / sizeof(int); BubbleSort(A, n); printf("氣泡排序結果:"); for (int i = 0; i < n; i++) { printf("%d ", A[i]); } printf("\n"); return 0; }
上述程式碼對序列{ 6, 5, 3, 1, 8, 7, 2, 4 }進行氣泡排序的實現過程如下
使用氣泡排序為一列數字進行排序的過程如右圖所示:
儘管氣泡排序是最容易瞭解和實現的排序演算法之一,但它對於少數元素之外的數列排序是很沒有效率的。
氣泡排序的改進:雞尾酒排序
雞尾酒排序,也叫定向氣泡排序,是氣泡排序的一種改進。此演算法與氣泡排序的不同處在於從低到高然後從高到低,而氣泡排序則僅從低到高去比較序列裡的每個元素。他可以得到比氣泡排序稍微好一點的效能。
雞尾酒排序的程式碼如下:
#include <stdio.h> // 分類 -------------- 內部比較排序 // 資料結構 ---------- 陣列 // 最差時間複雜度 ---- O(n^2) // 最優時間複雜度 ---- 如果序列在一開始已經大部分排序過的話,會接近O(n) // 平均時間複雜度 ---- O(n^2) // 所需輔助空間 ------ O(1) // 穩定性 ------------ 穩定 void Swap(int A[], int i, int j) { int temp = A[i]; A[i] = A[j]; A[j] = temp; } void CocktailSort(int A[], int n) { int left = 0; // 初始化邊界 int right = n - 1; while (left < right) { for (int i = left; i < right; i++) // 前半輪,將最大元素放到後面 { if (A[i] > A[i + 1]) { Swap(A, i, i + 1); } } right--; for (int i = right; i > left; i--) // 後半輪,將最小元素放到前面 { if (A[i - 1] > A[i]) { Swap(A, i - 1, i); } } left++; } } int main() { int A[] = { 6, 5, 3, 1, 8, 7, 2, 4 }; // 從小到大定向氣泡排序 int n = sizeof(A) / sizeof(int); CocktailSort(A, n); printf("雞尾酒排序結果:"); for (int i = 0; i < n; i++) { printf("%d ", A[i]); } printf("\n"); return 0; }
使用雞尾酒排序為一列數字進行排序的過程如右圖所示:
以序列(2,3,4,5,1)為例,雞尾酒排序只需要訪問一次序列就可以完成排序,但如果使用氣泡排序則需要四次。但是在亂數序列的狀態下,雞尾酒排序與氣泡排序的效率都很差勁。
選擇排序(Selection Sort)
選擇排序也是一種簡單直觀的排序演算法。它的工作原理很容易理解:初始時在序列中找到最小(大)元素,放到序列的起始位置作為已排序序列;然後,再從剩餘未排序元素中繼續尋找最小(大)元素,放到已排序序列的末尾。以此類推,直到所有元素均排序完畢。
注意選擇排序與氣泡排序的區別:氣泡排序通過依次交換相鄰兩個順序不合法的元素位置,從而將當前最小(大)元素放到合適的位置;而選擇排序每遍歷一次都記住了當前最小(大)元素的位置,最後僅需一次交換操作即可將其放到合適的位置。
選擇排序的程式碼如下:
#include <stdio.h> // 分類 -------------- 內部比較排序 // 資料結構 ---------- 陣列 // 最差時間複雜度 ---- O(n^2) // 最優時間複雜度 ---- O(n^2) // 平均時間複雜度 ---- O(n^2) // 所需輔助空間 ------ O(1) // 穩定性 ------------ 不穩定 void Swap(int A[], int i, int j) { int temp = A[i]; A[i] = A[j]; A[j] = temp; } void SelectionSort(int A[], int n) { for (int i = 0; i < n - 1; i++) // i為已排序序列的末尾 { int min = i; for (int j = i + 1; j < n; j++) // 未排序序列 { if (A[j] < A[min]) // 找出未排序序列中的最小值 { min = j; } } if (min != i) { Swap(A, min, i); // 放到已排序序列的末尾,該操作很有可能把穩定性打亂,所以選擇排序是不穩定的排序演算法 } } } int main() { int A[] = { 8, 5, 2, 6, 9, 3, 1, 4, 0, 7 }; // 從小到大選擇排序 int n = sizeof(A) / sizeof(int); SelectionSort(A, n); printf("選擇排序結果:"); for (int i = 0; i < n; i++) { printf("%d ", A[i]); } printf("\n"); return 0; }
上述程式碼對序列{ 8, 5, 2, 6, 9, 3, 1, 4, 0, 7 }進行選擇排序的實現過程如右圖
使用選擇排序為一列數字進行排序的巨集觀過程:
選擇排序是不穩定的排序演算法,不穩定發生在最小元素與A[i]交換的時刻。
比如序列:{ 5, 8, 5, 2, 9 },一次選擇的最小元素是2,然後把2和第一個5進行交換,從而改變了兩個元素5的相對次序。
插入排序(Insertion Sort)
插入排序是一種簡單直觀的排序演算法。它的工作原理非常類似於我們抓撲克牌
對於未排序資料(右手抓到的牌),在已排序序列(左手已經排好序的手牌)中從後向前掃描,找到相應位置並插入。
插入排序在實現上,通常採用in-place排序(即只需用到O(1)的額外空間的排序),因而在從後向前掃描過程中,需要反覆把已排序元素逐步向後挪位,為最新元素提供插入空間。
具體演算法描述如下:
- 從第一個元素開始,該元素可以認為已經被排序
- 取出下一個元素,在已經排序的元素序列中從後向前掃描
- 如果該元素(已排序)大於新元素,將該元素移到下一位置
- 重複步驟3,直到找到已排序的元素小於或者等於新元素的位置
- 將新元素插入到該位置後
- 重複步驟2~5
插入排序的程式碼如下:
#include <stdio.h> // 分類 ------------- 內部比較排序 // 資料結構 ---------- 陣列 // 最差時間複雜度 ---- 最壞情況為輸入序列是降序排列的,此時時間複雜度O(n^2) // 最優時間複雜度 ---- 最好情況為輸入序列是升序排列的,此時時間複雜度O(n) // 平均時間複雜度 ---- O(n^2) // 所需輔助空間 ------ O(1) // 穩定性 ------------ 穩定 void InsertionSort(int A[], int n) { for (int i = 1; i < n; i++) // 類似抓撲克牌排序 { int get = A[i]; // 右手抓到一張撲克牌 int j = i - 1; // 拿在左手上的牌總是排序好的 while (j >= 0 && A[j] > get) // 將抓到的牌與手牌從右向左進行比較 { A[j + 1] = A[j]; // 如果該手牌比抓到的牌大,就將其右移 j--; } A[j + 1] = get; // 直到該手牌比抓到的牌小(或二者相等),將抓到的牌插入到該手牌右邊(相等元素的相對次序未變,所以插入排序是穩定的) } } int main() { int A[] = { 6, 5, 3, 1, 8, 7, 2, 4 };// 從小到大插入排序 int n = sizeof(A) / sizeof(int); InsertionSort(A, n); printf("插入排序結果:"); for (int i = 0; i < n; i++) { printf("%d ", A[i]); } printf("\n"); return 0; }
上述程式碼對序列{ 6, 5, 3, 1, 8, 7, 2, 4 }進行插入排序的實現過程如下
使用插入排序為一列數字進行排序的巨集觀過程:
插入排序不適合對於資料量比較大的排序應用。但是,如果需要排序的資料量很小,比如量級小於千,那麼插入排序還是一個不錯的選擇。 插入排序在工業級庫中也有著廣泛的應用,在STL的sort演算法和stdlib的qsort演算法中,都將插入排序作為快速排序的補充,用於少量元素的排序(通常為8個或以下)。
插入排序的改進:二分插入排序
對於插入排序,如果比較操作的代價比交換操作大的話,可以採用二分查詢法來減少比較操作的次數,我們稱為二分插入排序,程式碼如下:
#include <stdio.h> // 分類 -------------- 內部比較排序 // 資料結構 ---------- 陣列 // 最差時間複雜度 ---- O(n^2) // 最優時間複雜度 ---- O(nlogn) // 平均時間複雜度 ---- O(n^2) // 所需輔助空間 ------ O(1) // 穩定性 ------------ 穩定 void InsertionSortDichotomy(int A[], int n) { for (int i = 1; i < n; i++) { int get = A[i]; // 右手抓到一張撲克牌 int left = 0; // 拿在左手上的牌總是排序好的,所以可以用二分法 int right = i - 1; // 手牌左右邊界進行初始化 while (left <= right) // 採用二分法定位新牌的位置 { int mid = (left + right) / 2; if (A[mid] > get) right = mid - 1; else left = mid + 1; } for (int j = i - 1; j >= left; j--) // 將欲插入新牌位置右邊的牌整體向右移動一個單位 { A[j + 1] = A[j]; } A[left] = get; // 將抓到的牌插入手牌 } } int main() { int A[] = { 5, 2, 9, 4, 7, 6, 1, 3, 8 };// 從小到大二分插入排序 int n = sizeof(A) / sizeof(int); InsertionSortDichotomy(A, n); printf("二分插入排序結果:"); for (int i = 0; i < n; i++) { printf("%d ", A[i]); } printf("\n"); return 0; }
當n較大時,二分插入排序的比較次數比直接插入排序的最差情況好得多,但比直接插入排序的最好情況要差,所當以元素初始序列已經接近升序時,直接插入排序比二分插入排序比較次數少。二分插入排序元素移動次數與直接插入排序相同,依賴於元素初始序列。
插入排序的更高效改進:希爾排序(Shell Sort)
希爾排序,也叫遞減增量排序,是插入排序的一種更高效的改進版本。希爾排序是不穩定的排序演算法。
希爾排序是基於插入排序的以下兩點性質而提出改進方法的:
- 插入排序在對幾乎已經排好序的資料操作時,效率高,即可以達到線性排序的效率
- 但插入排序一般來說是低效的,因為插入排序每次只能將資料移動一位
希爾排序通過將比較的全部元素分為幾個區域來提升插入排序的效能。這樣可以讓一個元素可以一次性地朝最終位置前進一大步。然後演算法再取越來越小的步長進行排序,演算法的最後一步就是普通的插入排序,但是到了這步,需排序的資料幾乎是已排好的了(此時插入排序較快)。
假設有一個很小的資料在一個已按升序排好序的陣列的末端。如果用複雜度為O(n^2)的排序(氣泡排序或直接插入排序),可能會進行n次的比較和交換才能將該資料移至正確位置。而希爾排序會用較大的步長移動資料,所以小資料只需進行少數比較和交換即可到正確位置。
希爾排序的程式碼如下:
#include <stdio.h> // 分類 -------------- 內部比較排序 // 資料結構 ---------- 陣列 // 最差時間複雜度 ---- 根據步長序列的不同而不同。已知最好的為O(n(logn)^2) // 最優時間複雜度 ---- O(n) // 平均時間複雜度 ---- 根據步長序列的不同而不同。 // 所需輔助空間 ------ O(1) // 穩定性 ------------ 不穩定 void ShellSort(int A[], int n) { int h = 0; while (h <= n) // 生成初始增量 { h = 3 * h + 1; } while (h >= 1) { for (int i = h; i < n; i++) { int j = i - h; int get = A[i]; while (j >= 0 && A[j] > get) { A[j + h] = A[j]; j = j - h; } A[j + h] = get; } h = (h - 1) / 3; // 遞減增量 } } int main() { int A[] = { 5, 2, 9, 4, 7, 6, 1, 3, 8 };// 從小到大希爾排序 int n = sizeof(A) / sizeof(int); ShellSort(A, n); printf("希爾排序結果:"); for (int i = 0; i < n; i++) { printf("%d ", A[i]); } printf("\n"); return 0; }
以23, 10, 4, 1的步長序列進行希爾排序:
希爾排序是不穩定的排序演算法,雖然一次插入排序是穩定的,不會改變相同元素的相對順序,但在不同的插入排序過程中,相同的元素可能在各自的插入排序中移動,最後其穩定性就會被打亂。
比如序列:{ 3, 5, 10, 8, 7, 2, 8, 1, 20, 6 },h=2時分成兩個子序列 { 3, 10, 7, 8, 20 } 和 { 5, 8, 2, 1, 6 } ,未排序之前第二個子序列中的8在前面,現在對兩個子序列進行插入排序,得到 { 3, 7, 8, 10, 20 } 和 { 1, 2, 5, 6, 8 } ,即 { 3, 1, 7, 2, 8, 5, 10, 6, 20, 8 } ,兩個8的相對次序發生了改變。
歸併排序(Merge Sort)
歸併排序是建立在歸併操作上的一種有效的排序演算法,效率為O(nlogn),1945年由馮·諾伊曼首次提出。
歸併排序的實現分為遞迴實現與非遞迴(迭代)實現。遞迴實現的歸併排序是演算法設計中分治策略的典型應用,我們將一個大問題分割成小問題分別解決,然後用所有小問題的答案來解決整個大問題。非遞迴(迭代)實現的歸併排序首先進行是兩兩歸併,然後四四歸併,然後是八八歸併,一直下去直到歸併了整個陣列。
歸併排序演算法主要依賴歸併(Merge)操作。歸併操作指的是將兩個已經排序的序列合併成一個序列的操作,歸併操作步驟如下:
- 申請空間,使其大小為兩個已經排序序列之和,該空間用來存放合併後的序列
- 設定兩個指標,最初位置分別為兩個已經排序序列的起始位置
- 比較兩個指標所指向的元素,選擇相對小的元素放入到合併空間,並移動指標到下一位置
- 重複步驟3直到某一指標到達序列尾
- 將另一序列剩下的所有元素直接複製到合併序列尾
歸併排序的程式碼如下:
#include <stdio.h> #include <limits.h> // 分類 -------------- 內部比較排序 // 資料結構 ---------- 陣列 // 最差時間複雜度 ---- O(nlogn) // 最優時間複雜度 ---- O(nlogn) // 平均時間複雜度 ---- O(nlogn) // 所需輔助空間 ------ O(n) // 穩定性 ------------ 穩定 void Merge(int A[], int left, int mid, int right)// 合併兩個已排好序的陣列A[left...mid]和A[mid+1...right] { int len = right - left + 1; int *temp = new int[len]; // 輔助空間O(n) int index = 0; int i = left; // 前一陣列的起始元素 int j = mid + 1; // 後一陣列的起始元素 while (i <= mid && j <= right) { temp[index++] = A[i] <= A[j] ? A[i++] : A[j++]; // 帶等號保證歸併排序的穩定性 } while (i <= mid) { temp[index++] = A[i++]; } while (j <= right) { temp[index++] = A[j++]; } for (int k = 0; k < len; k++) { A[left++] = temp[k]; } } void MergeSortRecursion(int A[], int left, int right) // 遞迴實現的歸併排序(自頂向下) { if (left == right) // 當待排序的序列長度為1時,遞迴開始回溯,進行merge操作 return; int mid = (left + right) / 2; MergeSortRecursion(A, left, mid); MergeSortRecursion(A, mid + 1, right); Merge(A, left, mid, right); } void MergeSortIteration(int A[], int len) // 非遞迴(迭代)實現的歸併排序(自底向上) { int left, mid, right;// 子陣列索引,前一個為A[left...mid],後一個子陣列為A[mid+1...right] for (int i = 1; i < len; i *= 2) // 子陣列的大小i初始為1,每輪翻倍 { left = 0; while (left + i < len) // 後一個子陣列存在(需要歸併) { mid = left + i - 1; right = mid + i < len ? mid + i : len - 1;// 後一個子陣列大小可能不夠 Merge(A, left, mid, right); left = right + 1; // 前一個子陣列索引向後移動 } } } int main() { int A1[] = { 6, 5, 3, 1, 8, 7, 2, 4 }; // 從小到大歸併排序 int A2[] = { 6, 5, 3, 1, 8, 7, 2, 4 }; int n1 = sizeof(A1) / sizeof(int); int n2 = sizeof(A2) / sizeof(int); MergeSortRecursion(A1, 0, n1 - 1); // 遞迴實現 MergeSortIteration(A2, n2); // 非遞迴實現 printf("遞迴實現的歸併排序結果:"); for (int i = 0; i < n1; i++) { printf("%d ", A1[i]); } printf("\n"); printf("非遞迴實現的歸併排序結果:"); for (int i = 0; i < n2; i++) { printf("%d ", A2[i]); } printf("\n"); return 0; }
上述程式碼對序列{ 6, 5, 3, 1, 8, 7, 2, 4 }進行歸併排序的例項如下
使用歸併排序為一列數字進行排序的巨集觀過程:
歸併排序除了可以對陣列進行排序,還可以高效的求出陣列小和(即單調和)以及陣列中的逆序對,詳見這篇博文。
堆排序(Heap Sort)
堆排序是指利用堆這種資料結構所設計的一種選擇排序演算法。堆是一種近似完全二叉樹的結構(通常堆是通過一維陣列來實現的),並滿足性質:以最大堆(也叫大根堆、大頂堆)為例,其中父結點的值總是大於它的孩子節點。
我們可以很容易的定義堆排序的過程:
- 由輸入的無序陣列構造一個最大堆,作為初始的無序區
- 把堆頂元素(最大值)和堆尾元素互換
- 把堆(無序區)的尺寸縮小1,並呼叫heapify(A, 0)從新的堆頂元素開始進行堆調整
- 重複步驟2,直到堆的尺寸為1
堆排序的程式碼如下:
#include <stdio.h> // 分類 -------------- 內部比較排序 // 資料結構 ---------- 陣列 // 最差時間複雜度 ---- O(nlogn) // 最優時間複雜度 ---- O(nlogn) // 平均時間複雜度 ---- O(nlogn) // 所需輔助空間 ------ O(1) // 穩定性 ------------ 不穩定 void Swap(int A[], int i, int j) { int temp = A[i]; A[i] = A[j]; A[j] = temp; } void Heapify(int A[], int i, int size) // 從A[i]向下進行堆調整 { int left_child = 2 * i + 1; // 左孩子索引 int right_child = 2 * i + 2; // 右孩子索引 int max = i; // 選出當前結點與其左右孩子三者之中的最大值 if (left_child < size && A[left_child] > A[max]) max = left_child; if (right_child < size && A[right_child] > A[max]) max = right_child; if (max != i) { Swap(A, i, max); // 把當前結點和它的最大(直接)子節點進行交換 Heapify(A, max, size); // 遞迴呼叫,繼續從當前結點向下進行堆調整 } } int BuildHeap(int A[], int n) // 建堆,時間複雜度O(n) { int heap_size = n; for (int i = heap_size / 2 - 1; i >= 0; i--) // 從每一個非葉結點開始向下進行堆調整 Heapify(A, i, heap_size); return heap_size; } void HeapSort(int A[], int n) { int heap_size = BuildHeap(A, n); // 建立一個最大堆 while (heap_size > 1) // 堆(無序區)元素個數大於1,未完成排序 { // 將堆頂元素與堆的最後一個元素互換,並從堆中去掉最後一個元素 // 此處交換操作很有可能把後面元素的穩定性打亂,所以堆排序是不穩定的排序演算法 Swap(A, 0, --heap_size); Heapify(A, 0, heap_size); // 從新的堆頂元素開始向下進行堆調整,時間複雜度O(logn) } } int main() { int A[] = { 5, 2, 9, 4, 7, 6, 1, 3, 8 };// 從小到大堆排序 int n = sizeof(A) / sizeof(int); HeapSort(A, n); printf("堆排序結果:"); for (int i = 0; i < n; i++) { printf("%d ", A[i]); } printf("\n"); return 0; }
堆排序演算法的演示:
動畫中在排序過程之前簡單的表現了建立堆的過程以及堆的邏輯結構。
堆排序是不穩