常見8種演算法總結
目錄
我們通常所說的排序演算法往往指的是內部排序演算法,即資料記錄在記憶體中進行排序。
排序演算法大體可分為兩種:
一種是比較排序,時間複雜度最少可達到O(n log n),主要有:氣泡排序,選擇排序,插入排序,歸併排序,堆排序,快速排序等。
另一種是非比較排序,時間複雜度可以達到O(n),主要有:計數排序,基數排序,桶排序等。
這裡我們來探討一下常用的比較排序演算法,非比較排序演算法將在後續文章中介紹。下表給出了常見比較排序演算法的效能:
這裡有一點我們很容易忽略的是排序演算法的穩定性
排序演算法穩定性的簡單形式化定義為:如果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 exchange(int A[], int i, int j) // 交換A[i]和A[j] { int temp = A[i]; A[i] = A[j]; A[j] = temp; } int main() { int A[] = { 6, 5, 3, 1, 8, 7, 2, 4 }; // 從小到大氣泡排序 int n = sizeof(A) / sizeof(int); 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],則變為不穩定的排序演算法 { exchange(A, i, i + 1); } } } 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 exchange(int A[], int i, int j) // 交換A[i]和A[j] { int temp = A[i]; A[i] = A[j]; A[j] = temp; } int main() { int A[] = { 6, 5, 3, 1, 8, 7, 2, 4 }; // 從小到大定向氣泡排序 int n = sizeof(A) / sizeof(int); int left = 0; // 初始化邊界 int right = n - 1; while (left < right) { for (int i = left; i < right; i++) // 前半輪,將最大元素放到後面 if (A[i] > A[i + 1]) { exchange(A, i, i + 1); } right--; for (int i = right; i > left; i--) // 後半輪,將最小元素放到前面 if (A[i - 1] > A[i]) { exchange(A, i - 1, i); } left++; } 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 exchange(int A[], int i, int j) // 交換A[i]和A[j] { int temp = A[i]; A[i] = A[j]; A[j] = temp; } int main() { int A[] = { 8, 5, 2, 6, 9, 3, 1, 4, 0, 7 }; // 從小到大選擇排序 int n = sizeof(A) / sizeof(int); int i, j, min; for (i = 0; i <= n - 2; i++) // 已排序序列的末尾 { min = i; for (j = i + 1; j <= n - 1; j++) // 未排序序列 { if (A[j] < A[min])// 依次找出未排序序列中的最小值,存放到已排序序列的末尾 { min = j; } } if (min != i) { exchange(A, min, i); // 該操作很有可能把穩定性打亂,所以選擇排序是不穩定的排序演算法 } } printf("選擇排序結果:"); for (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) // 穩定性 ------------ 穩定 int main() { int A[] = { 6, 5, 3, 1, 8, 7, 2, 4 };// 從小到大插入排序 int n = sizeof(A) / sizeof(int); int i, j, get; for (i = 1; i < n; i++) // 類似抓撲克牌排序 { get = A[i]; // 右手抓到一張撲克牌 j = i - 1; // 拿在左手上的牌總是排序好的 while (j >= 0 && A[j] > get) // 將抓到的牌與手牌從右向左進行比較 { A[j + 1] = A[j]; // 如果該手牌比抓到的牌大,就將其右移 j--; } A[j + 1] = get;// 直到該手牌比抓到的牌小(或二者相等),將抓到的牌插入到該手牌右邊(相等元素的相對次序未變,所以插入排序是穩定的) } printf("插入排序結果:"); for (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) // 穩定性 ------------ 穩定 int main() { int A[] = { 5, 2, 9, 4, 7, 6, 1, 3, 8 };// 從小到大二分插入排序 int n = sizeof(A) / sizeof(int); int i, j, get, left, right, middle; for (i = 1; i < n; i++) // 類似抓撲克牌排序 { get = A[i]; // 右手抓到一張撲克牌 left = 0; // 拿在左手上的牌總是排序好的,所以可以用二分法 right = i - 1; // 手牌左右邊界進行初始化 while (left <= right) // 採用二分法定位新牌的位置 { middle = (left + right) / 2; if (A[middle] > get) right = middle - 1; else left = middle + 1; } for (j = i - 1; j >= left; j--) // 將欲插入新牌位置右邊的牌整體向右移動一個單位 { A[j + 1] = A[j]; } A[left] = get; // 將抓到的牌插入手牌 } printf("二分插入排序結果:"); for (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) // 穩定性 ------------ 不穩定 int main() { int A[] = { 5, 2, 9, 4, 7, 6, 1, 3, 8 };// 從小到大希爾排序 int n = sizeof(A) / sizeof(int); int i, j, get; int h = 0; while (h <= n) // 生成初始增量 { h = 3*h + 1; } while (h >= 1) { for (i = h; i < n; i++) { j = i - h; 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; // 遞減