常見排序算法
寫在前面
寫作本文的目的,是想對各種經典排序算法做一個自己的歸納總結,與大家一起分享。
先來看一張圖,對各類排序算法有個大致的瞭解。圖是從網上借鑑的:
穩定性是指這麼一種情況:
對於元素a和b,排序前:a等於b,並且a在b的前面。排序後:
如果a還在b的前面,該算法就是穩定的;
如果b有可能在a的前面,該算法就是不穩定的。
我個人認爲算法穩定性不具有普遍意義,與算法的具體實現有密切的關係,不好統一歸納。
下面來看各算法的具體分析吧。
基於比較的排序算法
給定一個亂序元素集合,通過元素間的各種比較策略,最終得到有序的元素序列,這樣的算法就稱之爲“基於比較的排序算法”。
選擇排序
這是最簡單的排序算法,也是最好理解的算法,就是一遍遍的從亂序元素集合中挑出最值元素,依次排好即可。
算法實際效率比較低,一般不常用,時間複雜度是O(N2)。
算法描述
- 遍歷N個元素集合,找出最值元素,與第一個元素交換位置
- 遍歷剩下N-1個元素,找出最值元素,與數組第二個元素交換位置
- 重複上述過程N-1次,完成排序
- 優化點:如果某次遍歷過後,發現最值元素的位置就是交換位置,則無需交換
動圖演示
代碼實現
public static void sort(int[] arr) {
int len = arr.length;
for (int i = 0; i < len - 1; i++) {
int minIndex = i;
for (int j = i + 1; j < len; j++) {
if (arr[j] < arr[minIndex]) minIndex = j;
}
if (minIndex == i) continue;
int temp = arr[i];
arr[i] = arr[minIndex];
arr[minIndex] = temp;
}
}
冒泡排序
這種排序算法簡單,也不難理解,可以聯想體育老師根據學生身高調整隊列的場景。
算法實際效率比較低,不常用,時間複雜度是O(N2)。
算法描述
- 從左到右,依次比較相鄰兩元素,如果左元素比右元素大,就交換位置,然後繼續向後比較。一輪比較下來,最大的元素就會被移動到最右端
- 最大元素位置確定後,在前N-1個元素中重複上述步驟
- 經過N-1輪比較調整後,完成排序
動圖演示
代碼實現
public static void sort(int[] arr) {
int len = arr.length;
for (int i = 0; i < len - 1; i++) {
for (int j = 0; j < len - i - 1; j++) {
if (arr[j] > arr[j + 1]) {
int temp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = temp;
}
}
}
}
插入排序
也是很簡單很好理解的排序算法,可以聯想玩撲克牌的場景。對於新來的元素,要與已經排好序的元素隊列挨個進行比較,找到自己的位置後插入隊列。
最好時間複雜度O(N),最壞時間複雜度O(N2)。
算法描述
- 假設第一個元素是已經排好序的元素
- 取出下一個元素,空出一個位置,向前依次與有序元素進行比較,如果有序元素大於當前元素,則有序元素後移一位,直到有序元素不大於當前元素時,將當前元素插入空位
- 重複上述過程,完成排序
動圖演示
代碼實現
public static void sort(int[] arr) {
int len = arr.length;
for (int i = 1; i < len; i++) {
int temp = arr[i];
int j = i - 1;
for (; j >= 0 && arr[j] > temp; j--) {
arr[j + 1] = arr[j];
}
arr[j + 1] = temp;
}
}
希爾排序
希爾排序是對插入排序的一種改進優化,是第一個突破O(n2)的排序算法。
希爾排序的解題思想是把序列按一定的間隔分組,對每組使用插入排序,然後不斷的減小間隔,直到間隔值等於0的時候,整個序列就是有序的。
語言說不太清楚,看圖更直觀,結合動態圖去理解吧。
算法描述
- 初始化gap間隔值,通常取集合長度length的一半,也就是gap=length/2,gap值的含義既是分組數量,也是每組元素的間隔長度
- 對所有分組進行插入排序
- gap=gap/2,得到新的分組,再對新分組進行插入排序,重複該過程,直到gap等於0
- gap等於0時,排序也就完成了
動圖演示
代碼實現
public static void sort(int[] arr) {
for (int gap = arr.length / 2; gap > 0; gap /= 2) { // gap 步長
// 代碼實現是多個分組交替執行,動態圖是按組依次執行,這就是 i++ 的原因
for (int i = gap; i < arr.length; i++) {
if (arr[i] >= arr[i - gap]) continue;// 本次沒有調整的必要
// 當前元素排序不正確,對該分組進行插入排序
int temp = arr[i];
int j = i - gap;
while (j >= 0 && arr[j] > temp) {
arr[j + gap] = arr[j];
j -= gap;
}
arr[j + gap] = temp;
}
}
}
歸併排序 (非遞歸實現)
歸併排序的思路是分治思想,將大問題分解了多個相似的小問題,通過求解多個小問題,最終合併出大問題的解。
歸併排序的時間複雜度是O(nlogn)。
算法描述
- 將N個元素分散爲N個區間,每個區間只有1個元素,所以每個區間的元素是有序的,這裏稱之爲N個“1元素區間”
- 對兩個相鄰的“1元素區間”進行合併排序,得到N/2個有序的“2元素區間”
- 對兩個相鄰的“2元素區間”進行合併排序,得到N/4個有序的“4元素區間”
- 繼續合併相鄰區間,直到N個元素處於同一區間,完成排序
動圖演示
代碼實現
網上很多人的代碼都是採用遞歸實現,數據量大的時候,遞歸就會有棧溢出的問題。
我這裏不寫遞歸了,採用非遞歸思路來實現歸併排序算法:
public static void sort(int[] arr) {
int len = arr.length, space = 1;
while (space < len) {
space *= 2; // 2,4,8,16 ... 每次歸併區間的數組長度
for (int low = 0; low < len; low += space) {
int high = low + space - 1, mid = (low + high) / 2;
if (mid + 1 >= len) continue;
merge(arr, low, mid, high < len ? high : len - 1);
}
}
}
public static void merge(int arr[], int low, int mid, int high) {
// 數組 arr[low..mid], arr[mid+1..high] 都是排好序的
int len = high - low + 1;
int[] temp = new int[len];// 用了一個額外數組空間,可優化掉
int left = low, right = mid + 1, index = 0;
while (left <= mid && right <= high) // 歸併
temp[index++] = (arr[left] <= arr[right]) ? arr[left++] : arr[right++];
while (left <= mid) temp[index++] = arr[left++];
while (right <= high) temp[index++] = arr[right++];
for (int k = 0; k < len; k++) arr[low + k] = temp[k];
}
快速排序 (遞歸實現)
快速排序算法也是分治思想的一種實踐,將大問題化解爲小問題求解。它的思路是將亂序元素集合一分爲二,兩個子集合整體有序,再將子集合一份爲二,保證更小的子集合也是整體有序的,就這麼一直分下去,直到所有的子集合的元素只有一個的時候,整體就有序了。一分爲二的代碼實現就可以藉助遞歸思路了。
快速排序、歸併排序,這兩種算法都是分治思想典型的實踐。但是它們的實現思路卻是截然相反的:快速排序是先對整體排序再對局部排序,從大到小的解決問題,歸併排序是先對局部排序再對整體排序,從小到大的解決問題。
快速排序是個很常用的算法,在實際應用中,爲了達到更好的效果,在各種細節上的會有進一步優化。
這裏只講快速排序的算法理論模型。
算法描述
- 挑選一個元素,作爲劃分兩個子區間的基準元素,通常取第一個元素
- 將基準元素作爲左區間唯一的元素,也當作是左區間最大的元素,剩下的元素先暫時歸入右區間
- 遍歷右區間元素,與基準元素比較,將小於基準值的元素交換到右區間的左側,同時在概念上將它們歸入左區間
- 右區間遍歷完成以後,將基準元素與左區間最右端元素交換位置,也就是左區間兩端元素互換
- 此時,基準元素就位於左區間最右側,所以,基準元素左側都是比它小的元素,右側都是比它大的元素
- 以基準元素作爲分界線,對其左右兩個區間的元素分別重複上述過程,直到每個區間只含有一個元素的時候,排序完成
動圖演示
代碼實現
public static void sort(int[] arr) {
quickSort(arr, 0, arr.length - 1);
}
public static void quickSort(int[] arr, int left, int right) {
if (left >= right) {
return;
}
int key = arr[left], empty = left;
int[] index = new int[]{left, right};
while (left < right) {
if (empty == left) {
if (arr[right] < key) {
arr[left++] = arr[right];
empty = right;
} else right--;
} else {
if (arr[left] > key) {
arr[right--] = arr[left];
empty = left;
} else left++;
}
}
arr[empty] = key;
quickSort(arr, index[0], empty - 1);
quickSort(arr, empty + 1, index[1]);
}
不基於比較的排序算法
從這裏開始,接下來的排序思想就不再是基於元素的比較了,而是藉助於數學上的關係映射思想。
實現這些算法前,需要找到某種映射關係,可以將亂序元素映射到另一種狀態,並且,另一種狀態當中隱藏着有序的關係,藉助這種有序的關係,可以反推出亂序元素的排序。
計數排序
計數排序算法,是將亂序元素映射成鍵值關係,鍵是元素本身,值是元素出現的次數。把鍵當成數組下標,它就是有序的,也就是說,亂序元素可以存儲在下標有序的數組裏面,通過數組下標的有序性可以推導出亂序元素的有序性。
當亂序元素取值範圍比較集中,且左右差值不大的時候,該算法纔會有比較良好的表現。
算法描述
- 遍歷亂序元素,找到最小值元素和最大值元素,確定亂序元素取值區間
- 根據取值區間,構建出合適的數組存儲結構
- 將亂序元素映射成數組下標,存入數組空間
- 根據排序規則,順序訪問數組並取出元素,完成排序
動圖演示
代碼實現
public static void sort(int[] arr) {
int len = arr.length, min = arr[0], max = arr[0];
// 確定取值範圍
for (int i = 1; i < len; i++) {
if (arr[i] < min) min = arr[i];
if (arr[i] > max) max = arr[i];
}
// 用數組構建哈希存儲結構,數組下標 index + min 就是取值範圍,數組值就是排序元素出現的次數
int[] temp = new int[max - min + 1];
for (int i = 0; i < len; i++) temp[arr[i] - min]++;
int index = 0;
for (int i = 0; i < temp.length; i++) {
while (temp[i]-- > 0) arr[index++] = min + i;
}
}
桶排序
很形象的算法名稱,就是準備多個裝元素的桶,並且,桶要有順序性,然後根據某種映射關係,將亂序元素裝入桶中,此時,每個桶中的元素是亂序的,但是桶區間整體是有序的,最後,對每個桶中的元素再進行排序,從而達到整體的有序性。
對單個桶的元素再排序,可以選擇繼續用桶排序算法,但是通常會選擇其它更簡單的算法排序,因爲映射關係通常是不好找的,並且也沒有一種放眼四海而皆準的映射關係。
仔細想想,其實桶排序和快速排序很像,都是將大區間分爲小區間,在小區間裏對元素進行排序,都有點分治思想。但是它們有本質區別:快速排序是一種原地排序算法,亂序元素之間存在相互作用關係(元素比較),桶排序不是原地排序算法,亂序元素在映射的過程中也不存在相互作用關係。
好的映射關係能夠有效提高算法效率。
算法描述
桶是一種解決問題的思路,桶排序算法沒有一個固定的編程範式,爲了理解,這裏假設一種排序場景:對3位數以內的非負整數集合進行排序,映射關係是相同位數的數字映射到一個桶內。
- 準備三個桶,分別接收1位數字、2位數字、3位數字
- 根據映射關係,將集合元素映射到三個桶中
- 對每個桶內的元素子集合進行排序
- 按順序從桶中取出元素,完成排序
動圖演示
代碼實現
代碼不具備通用性,只針對上述假設的排序場景:
public static void sort(int[] arr) {
// 1位數桶,2位數桶,3位數桶
List<Integer>[] buckets = new ArrayList[3];
// 初始化桶
for (int i = 0; i < buckets.length; i++) buckets[i] = new ArrayList<>();
// 元素根據映射關係入桶
for (int i = 0; i < arr.length; i++) {
int temp = arr[i];
if (temp / 10 == 0) buckets[0].add(temp);
else if (temp / 100 == 0) buckets[1].add(temp);
else buckets[2].add(temp);
}
// 桶排序
for (int i = 0; i < buckets.length; i++) Collections.sort(buckets[i]);
// 元素歸位
int index = 0;
for (int i = 0; i < buckets.length; i++) {
List<Integer> bucket = buckets[i];
for (Integer integer : bucket) arr[index++] = integer;
}
}
基數排序
基數排序算法依賴於元素自身的特性,適用於非負整數這一類的元素排序,用非負整數來理解該算法也更容易一些。
每個整數都可以拆分爲個位、十位、百位等等,對非負整數排序,可以先根據個位數字排序一次,再根據十位數字排序一次,再根據百位數字排序一次,以此類推,從低位到高位都排經過一次排序後,整體元素就是有序的。
算法描述
- 找出所有元素中最大的數,也就是位數最多的元素,有幾位就需要進行幾次基數排序
- 從最低位(個位)開始進行基數排序,這個過程中,所有元素會進入基數桶,然後按排序規則從桶中取出元素順序排列
- 從低位到高位,重複第二個過程,完成排序
動圖演示
代碼實現
public static void sort(int[] arr) {
// 找出最大數
int max = -1;
for (int num : arr) if (num > max) max = num;
int count = String.valueOf(max).length();// 需要進行幾次基數排序
int auxiliary = 1;// 輔助數,可用它求解每一位的數字
List<Integer>[] buckets = new ArrayList[10];// 0-10的基數桶
for (int i = 0; i < buckets.length; i++) buckets[i] = new ArrayList<>();// 初始化桶元素
while (count-- > 0) {
// 這個for循環是讓排序元素進入基數桶
auxiliary *= 10;
for (int i = 0; i < arr.length; i++) {
int temp = arr[i];
int num = (temp % auxiliary) / (auxiliary / 10);// 個位、十位、百位...數字
buckets[num].add(temp);
}
// 這個for循環是按排序規則,把基數桶中的元素放回原始數組
int index = 0;
for (int i = 0; i < buckets.length; i++) {
List<Integer> bucket = buckets[i];
for (Integer integer : bucket) arr[index++] = integer;
bucket.clear();
}
}
}
總結
常用經典排序算法應該總結的差不多了,還有一個堆排序沒寫,因爲沒有找到非常合適的動態圖,以後有空再補上。
代碼實現都是自己寫的,細節之處還有改進的地方。
感謝VisuAlgo網站提供的部分動態圖,希爾排序和桶排序的動態圖是我自己畫的。
文章較長,寫作不易,看完點個讚唄。