本文爲轉載技術博客
作者:iTimeTraveler
原文鏈接:八大排序算法總結與java實現
概述
因爲健忘,加上對各種排序算法理解不深刻,過段時間面對排序就蒙了。所以決定對我們常見的這幾種排序算法進行統一總結,強行學習。首先羅列一下常見的十大排序算法:
- 直接插入排序
- 希爾排序
- 簡單選擇排序
- 堆排序
- 冒泡排序
- 快速排序
- 歸併排序
- 基數排序
我們討論的這八大排序算法的實現可以參考我的Github:SortAlgorithms,其中也包括了排序測試模塊[Test.java]和排序算法對比模塊[Bench.java],大家可以試運行。
他們都屬於內部排序,也就是隻考慮數據量較小僅需要使用內存的排序算法,他們之間關係如下:
一、直接插入排序(Insertion Sort)
插入排序的設計初衷是往有序的數組中快速插入一個新的元素。它的算法思想是:把要排序的數組分爲了兩個部分, 一部分是數組的全部元素(除去待插入的元素), 另一部分是待插入的元素; 先將第一部分排序完成, 然後再插入這個元素. 其中第一部分的排序也是通過再次拆分爲兩部分來進行的.
插入排序由於操作不盡相同, 可分爲 直接插入排序 、 折半插入排序(又稱二分插入排序)、 鏈表插入排序 、 希爾排序 。我們先來看下直接插入排序。
1.基本思想
直接插入排序的基本思想是:將數組中的所有元素依次跟前面已經排好的元素相比較,如果選擇的元素比已排序的元素小,則交換,直到全部元素都比較過爲止。
使用插入排序爲一列數字進行排序的過程
2.算法描述
一般來說,插入排序都採用in-place在數組上實現。具體算法描述如下:
①. 從第一個元素開始,該元素可以認爲已經被排序
②. 取出下一個元素,在已經排序的元素序列中從後向前掃描
③. 如果該元素(已排序)大於新元素,將該元素移到下一位置
④. 重複步驟3,直到找到已排序的元素小於或者等於新元素的位置
⑤. 將新元素插入到該位置後
⑥. 重複步驟②~⑤
直接插入排序演示
如果比較操作的代價比交換操作大的話,可以採用二分查找法來減少比較操作的數目。該算法可以認爲是插入排序的一個變種,稱爲二分查找插入排序。
3.代碼實現
/**
* 插入排序
*
* 1. 從第一個元素開始,該元素可以認爲已經被排序
* 2. 取出下一個元素,在已經排序的元素序列中從後向前掃描
* 3. 如果該元素(已排序)大於新元素,將該元素移到下一位置
* 4. 重複步驟3,直到找到已排序的元素小於或者等於新元素的位置
* 5. 將新元素插入到該位置後
* 6. 重複步驟2~5
* @param arr 待排序數組
*/
public static void insertionSort(int[] arr){
for( int i=0; i<arr.length-1; i++ ) {
for( int j=i+1; j>0; j-- ) {
if( arr[j-1] <= arr[j] )
break;
int temp = arr[j]; //交換操作
arr[j] = arr[j-1];
arr[j-1] = temp;
System.out.println("Sorting: " + Arrays.toString(arr));
}
}
}
直接插入排序複雜度如下:
平均時間複雜度 | 最好情況 | 最壞情況 | 空間複雜度 |
---|---|---|---|
O(n²) | O(n²) | O(n²) | O(1) |
Tips: 由於直接插入排序每次只移動一個元素的位, 並不會改變值相同的元素之間的排序, 因此它是一種穩定排序。
二、希爾排序(Shell Sort)
希爾排序,也稱遞減增量排序算法,1959年Shell發明。是插入排序的一種高速而穩定的改進版本。
第一個突破O(n^2)的排序算法;是簡單插入排序的改進版;它與插入排序的不同之處在於,它會優先比較距離較遠的元素。
希爾排序是先將整個待排序的記錄序列分割成爲若干子序列分別進行直接插入排序,待整個序列中的記錄“基本有序”時,再對全體記錄進行依次直接插入排序。
1.基本思想
將待排序數組按照步長gap進行分組,然後將每組的元素利用直接插入排序的方法進行排序;每次再將gap折半減小,循環上述操作;當gap=1時,利用直接插入,完成排序。
可以看到步長的選擇是希爾排序的重要部分。只要最終步長爲1任何步長序列都可以工作。一般來說最簡單的步長取值是初次取數組長度的一半爲增量,之後每次再減半,直到增量爲1。更好的步長序列取值可以參考維基百科。
2.算法描述
①. 選擇一個增量序列t1,t2,…,tk,其中ti>tj,tk=1;(一般初次取數組半長,之後每次再減半,直到增量爲1)
②. 按增量序列個數k,對序列進行k 趟排序;
③. 每趟排序,根據對應的增量ti,將待排序列分割成若干長度爲m 的子序列,分別對各子表進行直接插入排序。僅增量因子爲1 時,整個序列作爲一個表來處理,表長度即爲整個序列的長度。
3.代碼實現
以下是我自己的實現,可以看到實現很幼稚,但是好處是理解起來很簡單。因爲沒有經過任何的優化,所以不建議大家直接使用。建議對比下方的維基百科官方實現代碼,特別是步長取值策略部分。
/**
* 希爾排序
*
* 1. 選擇一個增量序列t1,t2,…,tk,其中ti>tj,tk=1;(一般初次取數組半長,之後每次再減半,直到增量爲1)
* 2. 按增量序列個數k,對序列進行k 趟排序;
* 3. 每趟排序,根據對應的增量ti,將待排序列分割成若干長度爲m 的子序列,分別對各子表進行直接插入排序。
* 僅增量因子爲1 時,整個序列作爲一個表來處理,表長度即爲整個序列的長度。
* @param arr 待排序數組
*/
public static void shellSort(int[] arr){
int gap = arr.length / 2;
for (; gap > 0; gap /= 2) { //不斷縮小gap,直到1爲止
for (int j = 0; (j+gap) < arr.length; j++){ //使用當前gap進行組內插入排序
for(int k = 0; (k+gap)< arr.length; k += gap){
if(arr[k] > arr[k+gap]) {
int temp = arr[k+gap]; //交換操作
arr[k+gap] = arr[k];
arr[k] = temp;
System.out.println(" Sorting: " + Arrays.toString(arr));
}
}
}
}
}
注意:
①. 第一層for循環表示一共有多少個增量。增量的序列的個數,就是希爾排序的趟數。上面的增量序列爲: arr.length/2, arr.length/2/2, arr.length/2/2/2, …. 2, 1
②. 裏層的兩個for循環,實際上就是以一個gap拆分爲一組的組內插入排序。
下面是維基百科官方實現,大家注意gap步長取值部分:
/**
* 希爾排序(Wiki官方版)
*
* 1. 選擇一個增量序列t1,t2,…,tk,其中ti>tj,tk=1;(注意此算法的gap取值)
* 2. 按增量序列個數k,對序列進行k 趟排序;
* 3. 每趟排序,根據對應的增量ti,將待排序列分割成若干長度爲m 的子序列,分別對各子表進行直接插入排序。
* 僅增量因子爲1 時,整個序列作爲一個表來處理,表長度即爲整個序列的長度。
* @param arr 待排序數組
*/
public static void shell_sort(int[] arr) {
int gap = 1, i, j, len = arr.length;
int temp;
while (gap < len / 3)
gap = gap * 3 + 1; // <O(n^(3/2)) by Knuth,1973>: 1, 4, 13, 40, 121, ...
for (; gap > 0; gap /= 3) {
for (i = gap; i < len; i++) {
temp = arr[i];
for (j = i - gap; j >= 0 && arr[j] > temp; j -= gap)
arr[j + gap] = arr[j];
arr[j + gap] = temp;
}
}
}
希爾排序複雜度:
平均時間複雜度 | 最好情況 | 最壞情況 | 空間複雜度 |
---|---|---|---|
O(nlog2 n) | O(nlog2 n) | O(nlog2 n) | O(1) |
三、選擇排序(Selection Sort)
從算法邏輯上看,選擇排序是一種簡單直觀的排序算法,在簡單選擇排序過程中,所需移動記錄的次數比較少。
1.基本思想
選擇排序的基本思想:比較 + 交換。
在未排序序列中找到最小(大)元素,存放到未排序序列的起始位置。在所有的完全依靠交換去移動元素的排序方法中,選擇排序屬於非常好的一種。
選擇排序的示例動畫。紅色表示當前最小值,黃色表示已排序序列,藍色表示當前位置。
2.算法描述
①. 從待排序序列中,找到關鍵字最小的元素;
②. 如果最小元素不是待排序序列的第一個元素,將其和第一個元素互換;
③. 從餘下的 N - 1 個元素中,找出關鍵字最小的元素,重複①、②步,直到排序結束。
3.代碼實現
選擇排序比較簡單,以下是我自己的實現,跟官方版差不多,所以完全可以參考。
/**
* 選擇排序
*
* 1. 從待排序序列中,找到關鍵字最小的元素;
* 2. 如果最小元素不是待排序序列的第一個元素,將其和第一個元素互換;
* 3. 從餘下的 N - 1 個元素中,找出關鍵字最小的元素,重複①、②步,直到排序結束。
* 僅增量因子爲1 時,整個序列作爲一個表來處理,表長度即爲整個序列的長度。
* @param arr 待排序數組
*/
public static void selectionSort(int[] arr){
for(int i = 0; i < arr.length-1; i++){
int min = i;
for(int j = i+1; j < arr.length; j++){ //選出之後待排序中值最小的位置
if(arr[j] < arr[min]){
min = j;
}
}
if(min != i){
int temp = arr[min]; //交換操作
arr[min] = arr[i];
arr[i] = temp;
System.out.println("Sorting: " + Arrays.toString(arr));
}
}
}
選擇排序複雜度:
平均時間複雜度 | 最好情況 | 最壞情況 | 空間複雜度 |
---|---|---|---|
O(n²) | O(n²) | O(n²) | O(1) |
選擇排序的簡單和直觀名副其實,這也造就了它”出了名的慢性子”,無論是哪種情況,哪怕原數組已排序完成,它也將花費將近n²/2次遍歷來確認一遍。即便是這樣,它的排序結果也還是不穩定的。 唯一值得高興的是,它並不耗費額外的內存空間。
四、堆排序(Heap Sort)
1991年的計算機先驅獎獲得者、斯坦福大學計算機科學系教授羅伯特·弗洛伊德(Robert W.Floyd) 和威廉姆斯(J.Williams) 在1964年共同發明了著名的堆排序算法(Heap Sort).
堆的定義如下:nn個元素的序列 {k1,k2,⋅⋅⋅,kn}{k1,k2,···,kn} 當且僅當滿足下關係時,稱之爲堆。
把此序列對應的二維數組看成一個完全二叉樹。那麼堆的含義就是:完全二叉樹中任何一個非葉子節點的值均不大於(或不小於)其左,右孩子節點的值。由上述性質可知大頂堆的堆頂的關鍵字肯定是所有關鍵字中最大的,小頂堆的堆頂的關鍵字是所有關鍵字中最小的。因此我們可使用大頂堆進行升序排序, 使用小頂堆進行降序排序。
1.基本思想
此處以大頂堆爲例,堆排序的過程就是將待排序的序列構造成一個堆,選出堆中最大的移走,再把剩餘的元素調整成堆,找出最大的再移走,重複直至有序。
2.算法描述
①. 先將初始序列K[1..n]K[1..n]建成一個大頂堆, 那麼此時第一個元素K1K1最大, 此堆爲初始的無序區.
②. 再將關鍵字最大的記錄K1K1 (即堆頂, 第一個元素)和無序區的最後一個記錄 KnKn 交換, 由此得到新的無序區K[1..n−1]K[1..n−1]和有序區K[n]K[n], 且滿足K[1..n−1].keys⩽K[n].keyK[1..n−1].keys⩽K[n].key
③. 交換K1K1 和 KnKn 後, 堆頂可能違反堆性質, 因此需將K[1..n−1]K[1..n−1]調整爲堆. 然後重複步驟②, 直到無序區只有一個元素時停止.
動圖效果如下所示:
堆排序過程
堆排序算法的演示。首先,將元素進行重排,以匹配堆的條件。圖中排序過程之前簡單的繪出了堆樹的結構。
3.代碼實現
從算法描述來看,堆排序需要兩個過程,一是建立堆,二是堆頂與堆的最後一個元素交換位置。所以堆排序有兩個函數組成。一是建堆函數,二是反覆調用建堆函數以選擇出剩餘未排元素中最大的數來實現排序的函數。
總結起來就是定義了以下幾種操作:
- 最大堆調整(Max_Heapify):將堆的末端子節點作調整,使得子節點永遠小於父節點;
- 創建最大堆(Build_Max_Heap):將堆所有數據重新排序;
- 堆排序(HeapSort):移除位在第一個數據的根節點,並做最大堆調整的遞歸運算。
對於堆節點的訪問:
- 父節點i的左子節點在位置:(2*i+1);
- 父節點i的右子節點在位置:(2*i+2);
- 子節點i的父節點在位置:floor((i-1)/2);
/**
* 堆排序
*
* 1. 先將初始序列K[1..n]建成一個大頂堆, 那麼此時第一個元素K1最大, 此堆爲初始的無序區.
* 2. 再將關鍵字最大的記錄K1 (即堆頂, 第一個元素)和無序區的最後一個記錄 Kn 交換, 由此得到新的無序區K[1..n−1]和有序區K[n], 且滿足K[1..n−1].keys⩽K[n].key
* 3. 交換K1 和 Kn 後, 堆頂可能違反堆性質, 因此需將K[1..n−1]調整爲堆. 然後重複步驟②, 直到無序區只有一個元素時停止.
* @param arr 待排序數組
*/
public static void heapSort(int[] arr){
for(int i = arr.length; i > 0; i--){
max_heapify(arr, i);
int temp = arr[0]; //堆頂元素(第一個元素)與Kn交換
arr[0] = arr[i-1];
arr[i-1] = temp;
}
}
private static void max_heapify(int[] arr, int limit){
if(arr.length <= 0 || arr.length < limit) return;
int parentIdx = limit / 2;
for(; parentIdx >= 0; parentIdx--){
if(parentIdx * 2 >= limit){
continue;
}
int left = parentIdx * 2; //左子節點位置
int right = (left + 1) >= limit ? left : (left + 1); //右子節點位置,如果沒有右節點,默認爲左節點位置
int maxChildId = arr[left] >= arr[right] ? left : right;
if(arr[maxChildId] > arr[parentIdx]){ //交換父節點與左右子節點中的最大值
int temp = arr[parentIdx];
arr[parentIdx] = arr[maxChildId];
arr[maxChildId] = temp;
}
}
System.out.println("Max_Heapify: " + Arrays.toString(arr));
}
注: x>>1 是位運算中的右移運算, 表示右移一位, 等同於x除以2再取整, 即 x>>1 == Math.floor(x/2) .
以上,
①. 建立堆的過程, 從length/2 一直處理到0, 時間複雜度爲O(n);
②. 調整堆的過程是沿着堆的父子節點進行調整, 執行次數爲堆的深度, 時間複雜度爲O(lgn);
③. 堆排序的過程由n次第②步完成, 時間複雜度爲O(nlgn)。
平均時間複雜度 | 最好情況 | 最壞情況 | 空間複雜度 |
---|---|---|---|
O(nlog2n)O(nlog2n) | O(nlog2n)O(nlog2n) | O(nlog2n)O(nlog2n) | O(1) |
Tips: 由於堆排序中初始化堆的過程比較次數較多, 因此它不太適用於小序列。 同時由於多次任意下標相互交換位置, 相同元素之間原本相對的順序被破壞了, 因此, 它是不穩定的排序.
五、冒泡排序(Bubble Sort)
冒泡排序的思想
我想對於它每個學過C語言的都會了解,這可能是很多人接觸的第一個排序算法。
1.基本思想
冒泡排序(Bubble Sort)是一種簡單的排序算法。它重複地走訪過要排序的數列,一次比較兩個元素,如果他們的順序錯誤就把他們交換過來。走訪數列的工作是重複地進行直到沒有再需要交換,也就是說該數列已經排序完成。這個算法的名字由來是因爲越小的元素會經由交換慢慢“浮”到數列的頂端。
冒泡排序演示
2.算法描述
冒泡排序算法的運作如下:
①. 比較相鄰的元素。如果第一個比第二個大,就交換他們兩個。
②. 對每一對相鄰元素作同樣的工作,從開始第一對到結尾的最後一對。這步做完後,最後的元素會是最大的數。
③. 針對所有的元素重複以上的步驟,除了最後一個。
④. 持續每次對越來越少的元素重複上面的步驟①~③,直到沒有任何一對數字需要比較。
3.代碼實現
冒泡排序需要兩個嵌套的循環. 其中, 外層循環移動遊標; 內層循環遍歷遊標及之後(或之前)的元素, 通過兩兩交換的方式, 每次只確保該內循環結束位置排序正確, 然後內層循環週期結束, 交由外層循環往後(或前)移動遊標, 隨即開始下一輪內層循環, 以此類推, 直至循環結束。
/**
* 冒泡排序
*
* ①. 比較相鄰的元素。如果第一個比第二個大,就交換他們兩個。
* ②. 對每一對相鄰元素作同樣的工作,從開始第一對到結尾的最後一對。這步做完後,最後的元素會是最大的數。
* ③. 針對所有的元素重複以上的步驟,除了最後一個。
* ④. 持續每次對越來越少的元素重複上面的步驟①~③,直到沒有任何一對數字需要比較。
* @param arr 待排序數組
*/
public static void bubbleSort(int[] arr){
for (int i = arr.length; i > 0; i--) { //外層循環移動遊標
for(int j = 0; j < i && (j+1) < i; j++){ //內層循環遍歷遊標及之後(或之前)的元素
if(arr[j] > arr[j+1]){
int temp = arr[j];
arr[j] = arr[j+1];
arr[j+1] = temp;
System.out.println("Sorting: " + Arrays.toString(arr));
}
}
}
}
冒泡排序算法複雜度:
平均時間複雜度 | 最好情況 | 最壞情況 | 空間複雜度 |
---|---|---|---|
O(n²) | O(n) | O(n²) | O(1) |
冒泡排序是最容易實現的排序, 最壞的情況是每次都需要交換, 共需遍歷並交換將近n²/2次, 時間複雜度爲O(n²). 最佳的情況是內循環遍歷一次後發現排序是對的, 因此退出循環, 時間複雜度爲O(n). 平均來講, 時間複雜度爲O(n²). 由於冒泡排序中只有緩存的temp變量需要內存空間, 因此空間複雜度爲常量O(1).
Tips: 由於冒泡排序只在相鄰元素大小不符合要求時才調換他們的位置, 它並不改變相同元素之間的相對順序, 因此它是穩定的排序算法.
六、快速排序(Quick Sort)
快速排序(Quicksort)是對冒泡排序的一種改進,借用了分治的思想,由C. A. R. Hoare在1962年提出。
1.基本思想
快速排序的基本思想:挖坑填數+分治法。
首先選一個軸值(pivot,也有叫基準的),通過一趟排序將待排記錄分隔成獨立的兩部分,其中一部分記錄的關鍵字均比另一部分的關鍵字小,則可分別對這兩部分記錄繼續進行排序,以達到整個序列有序。
使用快速排序法對一列數字進行排序的過程
2.算法描述
快速排序使用分治策略來把一個序列(list)分爲兩個子序列(sub-lists)。步驟爲:
①. 從數列中挑出一個元素,稱爲”基準”(pivot)。
②. 重新排序數列,所有比基準值小的元素擺放在基準前面,所有比基準值大的元素擺在基準後面(相同的數可以到任一邊)。在這個分區結束之後,該基準就處於數列的中間位置。這個稱爲分區(partition)操作。
③. 遞歸地(recursively)把小於基準值元素的子數列和大於基準值元素的子數列排序。
遞歸到最底部時,數列的大小是零或一,也就是已經排序好了。這個算法一定會結束,因爲在每次的迭代(iteration)中,它至少會把一個元素擺到它最後的位置去。
快速排序演示
3.代碼實現
用僞代碼描述如下:
①. i = L; j = R; 將基準數挖出形成第一個坑a[i]。
②.j–,由後向前找比它小的數,找到後挖出此數填前一個坑a[i]中。
③.i++,由前向後找比它大的數,找到後也挖出此數填到前一個坑a[j]中。
④.再重複執行②,③二步,直到i==j,將基準數填入a[i]中
快速排序採用“分而治之、各個擊破”的觀念,此爲原地(In-place)分區版本。
/**
* 快速排序(遞歸)
*
* ①. 從數列中挑出一個元素,稱爲"基準"(pivot)。
* ②. 重新排序數列,所有比基準值小的元素擺放在基準前面,所有比基準值大的元素擺在基準後面(相同的數可以到任一邊)。在這個分區結束之後,該基準就處於數列的中間位置。這個稱爲分區(partition)操作。
* ③. 遞歸地(recursively)把小於基準值元素的子數列和大於基準值元素的子數列排序。
* @param arr 待排序數組
* @param low 左邊界
* @param high 右邊界
*/
public static void quickSort(int[] arr, int low, int high){
if(arr.length <= 0) return;
if(low >= high) return;
int left = low;
int right = high;
int temp = arr[left]; //挖坑1:保存基準的值
while (left < right){
while(left < right && arr[right] >= temp){ //坑2:從後向前找到比基準小的元素,插入到基準位置坑1中
right--;
}
arr[left] = arr[right];
while(left < right && arr[left] <= temp){ //坑3:從前往後找到比基準大的元素,放到剛纔挖的坑2中
left++;
}
arr[right] = arr[left];
}
arr[left] = temp; //基準值填補到坑3中,準備分治遞歸快排
System.out.println("Sorting: " + Arrays.toString(arr));
quickSort(arr, low, left-1);
quickSort(arr, left+1, high);
}
上面是遞歸版的快速排序:通過把基準temp插入到合適的位置來實現分治,並遞歸地對分治後的兩個劃分繼續快排。那麼非遞歸版的快排如何實現呢?
因爲遞歸的本質是棧,所以我們非遞歸實現的過程中,可以藉助棧來保存中間變量就可以實現非遞歸了。在這裏中間變量也就是通過Pritation函數劃分區間之後分成左右兩部分的首尾指針,只需要保存這兩部分的首尾指針即可。
/**
* 快速排序(非遞歸)
*
* ①. 從數列中挑出一個元素,稱爲"基準"(pivot)。
* ②. 重新排序數列,所有比基準值小的元素擺放在基準前面,所有比基準值大的元素擺在基準後面(相同的數可以到任一邊)。在這個分區結束之後,該基準就處於數列的中間位置。這個稱爲分區(partition)操作。
* ③. 把分區之後兩個區間的邊界(low和high)壓入棧保存,並循環①、②步驟
* @param arr 待排序數組
*/
public static void quickSortByStack(int[] arr){
if(arr.length <= 0) return;
Stack<Integer> stack = new Stack<Integer>();
//初始狀態的左右指針入棧
stack.push(0);
stack.push(arr.length - 1);
while(!stack.isEmpty()){
int high = stack.pop(); //出棧進行劃分
int low = stack.pop();
int pivotIdx = partition(arr, low, high);
//保存中間變量
if(pivotIdx > low) {
stack.push(low);
stack.push(pivotIdx - 1);
}
if(pivotIdx < high && pivotIdx >= 0){
stack.push(pivotIdx + 1);
stack.push(high);
}
}
}
private static int partition(int[] arr, int low, int high){
if(arr.length <= 0) return -1;
if(low >= high) return -1;
int l = low;
int r = high;
int pivot = arr[l]; //挖坑1:保存基準的值
while(l < r){
while(l < r && arr[r] >= pivot){ //坑2:從後向前找到比基準小的元素,插入到基準位置坑1中
r--;
}
arr[l] = arr[r];
while(l < r && arr[l] <= pivot){ //坑3:從前往後找到比基準大的元素,放到剛纔挖的坑2中
l++;
}
arr[r] = arr[l];
}
arr[l] = pivot; //基準值填補到坑3中,準備分治遞歸快排
return l;
}
快速排序是通常被認爲在同數量級(O(nlog2n))的排序方法中平均性能最好的。但若初始序列按關鍵碼有序或基本有序時,快排序反而蛻化爲冒泡排序。爲改進之,通常以“三者取中法”來選取基準記錄,即將排序區間的兩個端點與中點三個記錄關鍵碼居中的調整爲支點記錄。快速排序是一個不穩定的排序方法。
快速排序算法複雜度:
平均時間複雜度 | 最好情況 | 最壞情況 | 空間複雜度 |
---|---|---|---|
O(nlog₂n) | O(nlog₂n) | O(n²) | O(1)(原地分區遞歸版) |
快速排序排序效率非常高。 雖然它運行最糟糕時將達到O(n²)的時間複雜度, 但通常平均來看, 它的時間複雜爲O(nlogn), 比同樣爲O(nlogn)時間複雜度的歸併排序還要快. 快速排序似乎更偏愛亂序的數列, 越是亂序的數列, 它相比其他排序而言, 相對效率更高.
Tips: 同選擇排序相似, 快速排序每次交換的元素都有可能不是相鄰的, 因此它有可能打破原來值爲相同的元素之間的順序. 因此, 快速排序並不穩定.
七、歸併排序(Merging Sort)
歸併排序是建立在歸併操作上的一種有效的排序算法,1945年由約翰·馮·諾伊曼首次提出。該算法是採用分治法(Divide and Conquer)的一個非常典型的應用,且各層分治遞歸可以同時進行。
1.基本思想
歸併排序算法是將兩個(或兩個以上)有序表合併成一個新的有序表,即把待排序序列分爲若干個子序列,每個子序列是有序的。然後再把有序子序列合併爲整體有序序列。
這個圖很有概括性,來自維基
2.算法描述
歸併排序可通過兩種方式實現:
- 自上而下的遞歸
- 自下而上的迭代
一、遞歸法(假設序列共有n個元素):
①. 將序列每相鄰兩個數字進行歸併操作,形成 floor(n/2)個序列,排序後每個序列包含兩個元素;
②. 將上述序列再次歸併,形成 floor(n/4)個序列,每個序列包含四個元素;
③. 重複步驟②,直到所有元素排序完畢。
二、迭代法:
①. 申請空間,使其大小爲兩個已經排序序列之和,該空間用來存放合併後的序列
②. 設定兩個指針,最初位置分別爲兩個已經排序序列的起始位置
③. 比較兩個指針所指向的元素,選擇相對小的元素放入到合併空間,並移動指針到下一位置
④. 重複步驟③直到某一指針到達序列尾
⑤. 將另一序列剩下的所有元素直接複製到合併序列尾
3.代碼實現
歸併排序其實要做兩件事:
- 分解:將序列每次折半拆分
- 合併:將劃分後的序列段兩兩排序合併
因此,歸併排序實際上就是兩個操作,拆分+合併
如何合併?
L[first…mid]爲第一段,L[mid+1…last]爲第二段,並且兩端已經有序,現在我們要將兩端合成達到L[first…last]並且也有序。
首先依次從第一段與第二段中取出元素比較,將較小的元素賦值給temp[]
重複執行上一步,當某一段賦值結束,則將另一段剩下的元素賦值給temp[]
此時將temp[]中的元素複製給L[],則得到的L[first…last]有序
如何分解?
在這裏,我們採用遞歸的方法,首先將待排序列分成A,B兩組;然後重複對A、B序列
分組;直到分組後組內只有一個元素,此時我們認爲組內所有元素有序,則分組結束。
這裏我寫了遞歸算法如下:
/**
* 歸併排序(遞歸)
*
* ①. 將序列每相鄰兩個數字進行歸併操作,形成 floor(n/2)個序列,排序後每個序列包含兩個元素;
* ②. 將上述序列再次歸併,形成 floor(n/4)個序列,每個序列包含四個元素;
* ③. 重複步驟②,直到所有元素排序完畢。
* @param arr 待排序數組
*/
public static int[] mergingSort(int[] arr){
if(arr.length <= 1) return arr;
int num = arr.length >> 1;
int[] leftArr = Arrays.copyOfRange(arr, 0, num);
int[] rightArr = Arrays.copyOfRange(arr, num, arr.length);
System.out.println("split two array: " + Arrays.toString(leftArr) + " And " + Arrays.toString(rightArr));
return mergeTwoArray(mergingSort(leftArr), mergingSort(rightArr)); //不斷拆分爲最小單元,再排序合併
}
private static int[] mergeTwoArray(int[] arr1, int[] arr2){
int i = 0, j = 0, k = 0;
int[] result = new int[arr1.length + arr2.length]; //申請額外的空間存儲合併之後的數組
while(i < arr1.length && j < arr2.length){ //選取兩個序列中的較小值放入新數組
if(arr1[i] <= arr2[j]){
result[k++] = arr1[i++];
}else{
result[k++] = arr2[j++];
}
}
while(i < arr1.length){ //序列1中多餘的元素移入新數組
result[k++] = arr1[i++];
}
while(j < arr2.length){ //序列2中多餘的元素移入新數組
result[k++] = arr2[j++];
}
System.out.println("Merging: " + Arrays.toString(result));
return result;
}
由上, 長度爲n的數組, 最終會調用mergeSort函數2n-1次。通過自上而下的遞歸實現的歸併排序, 將存在堆棧溢出的風險。
歸併排序算法複雜度:
平均時間複雜度 | 最好情況 | 最壞情況 | 空間複雜度 |
---|---|---|---|
O(nlog₂n) | O(nlog₂n) | O(nlog₂n) | O(n) |
從效率上看,歸併排序可算是排序算法中的”佼佼者”. 假設數組長度爲n,那麼拆分數組共需logn,, 又每步都是一個普通的合併子數組的過程, 時間複雜度爲O(n), 故其綜合時間複雜度爲O(nlogn)。另一方面, 歸併排序多次遞歸過程中拆分的子數組需要保存在內存空間, 其空間複雜度爲O(n)。
和選擇排序一樣,歸併排序的性能不受輸入數據的影響,但表現比選擇排序好的多,因爲始終都是O(n log n)的時間複雜度。代價是需要額外的內存空間。
八、基數排序(Radix Sort)
基數排序的發明可以追溯到1887年赫爾曼·何樂禮在打孔卡片製表機(Tabulation Machine), 排序器每次只能看到一個列。它是基於元素值的每個位上的字符來排序的。 對於數字而言就是分別基於個位,十位, 百位或千位等等數字來排序。
基數排序(Radix sort)是一種非比較型整數排序算法,其原理是將整數按位數切割成不同的數字,然後按每個位數分別比較。由於整數也可以表達字符串(比如名字或日期)和特定格式的浮點數,所以基數排序也不是隻能使用於整數。
1.基本思想
它是這樣實現的:將所有待比較數值(正整數)統一爲同樣的數位長度,數位較短的數前面補零。然後,從最低位開始,依次進行一次排序。這樣從最低位排序一直到最高位排序完成以後,數列就變成一個有序序列。
基數排序按照優先從高位或低位來排序有兩種實現方案:
- MSD(Most significant digital) 從最左側高位開始進行排序。先按k1排序分組, 同一組中記錄, 關鍵碼k1相等,再對各組按k2排序分成子組, 之後, 對後面的關鍵碼繼續這樣的排序分組, 直到按最次位關鍵碼kd對各子組排序後. 再將各組連接起來,便得到一個有序序列。MSD方式適用於位數多的序列。
- LSD (Least significant digital)從最右側低位開始進行排序。先從kd開始排序,再對kd-1進行排序,依次重複,直到對k1排序後便得到一個有序序列。LSD方式適用於位數少的序列。
基數排序LSD動圖演示
2.算法描述
我們以LSD爲例,從最低位開始,具體算法描述如下:
①. 取得數組中的最大數,並取得位數;
②. arr爲原始數組,從最低位開始取每個位組成radix數組;
③. 對radix進行計數排序(利用計數排序適用於小範圍數的特點);
3.代碼實現
基數排序:通過序列中各個元素的值,對排序的N個元素進行若干趟的“分配”與“收集”來實現排序。
- 分配:我們將L[i]中的元素取出,首先確定其個位上的數字,根據該數字分配到與之序號相同的桶中
- 收集:當序列中所有的元素都分配到對應的桶中,再按照順序依次將桶中的元素收集形成新的一個待排序列L[]。對新形成的序列L[]重複執行分配和收集元素中的十位、百位…直到分配完該序列中的最高位,則排序結束
/**
* 基數排序(LSD 從低位開始)
*
* 基數排序適用於:
* (1)數據範圍較小,建議在小於1000
* (2)每個數值都要大於等於0
*
* ①. 取得數組中的最大數,並取得位數;
* ②. arr爲原始數組,從最低位開始取每個位組成radix數組;
* ③. 對radix進行計數排序(利用計數排序適用於小範圍數的特點);
* @param arr 待排序數組
*/
public static void radixSort(int[] arr){
if(arr.length <= 1) return;
//取得數組中的最大數,並取得位數
int max = 0;
for(int i = 0; i < arr.length; i++){
if(max < arr[i]){
max = arr[i];
}
}
int maxDigit = 1;
while(max / 10 > 0){
maxDigit++;
max = max / 10;
}
System.out.println("maxDigit: " + maxDigit);
//申請一個桶空間
int[][] buckets = new int[10][arr.length];
int base = 10;
//從低位到高位,對每一位遍歷,將所有元素分配到桶中
for(int i = 0; i < maxDigit; i++){
int[] bktLen = new int[10]; //存儲各個桶中存儲元素的數量
//分配:將所有元素分配到桶中
for(int j = 0; j < arr.length; j++){
int whichBucket = (arr[j] % base) / (base / 10);
buckets[whichBucket][bktLen[whichBucket]] = arr[j];
bktLen[whichBucket]++;
}
//收集:將不同桶裏數據挨個撈出來,爲下一輪高位排序做準備,由於靠近桶底的元素排名靠前,因此從桶底先撈
int k = 0;
for(int b = 0; b < buckets.length; b++){
for(int p = 0; p < bktLen[b]; p++){
arr[k++] = buckets[b][p];
}
}
System.out.println("Sorting: " + Arrays.toString(arr));
base *= 10;
}
}
基數排序算法複雜度,其中k爲最大數的位數:
平均時間複雜度 | 最好情況 | 最壞情況 | 空間複雜度 |
---|---|---|---|
O(d*(n+r)) | O(d*(n+r)) | O(d*(n+r)) | O(n+r) |
其中,d 爲位數,r 爲基數,n 爲原數組個數。在基數排序中,因爲沒有比較操作,所以在複雜上,最好的情況與最壞的情況在時間上是一致的,均爲 O(d*(n + r))。
基數排序更適合用於對時間, 字符串等這些整體權值未知的數據進行排序。
Tips: 基數排序不改變相同元素之間的相對順序,因此它是穩定的排序算法。
基數排序 vs 計數排序 vs 桶排序
這三種排序算法都利用了桶的概念,但對桶的使用方法上有明顯差異:
- 基數排序:根據鍵值的每位數字來分配桶
- 計數排序:每個桶只存儲單一鍵值
- 桶排序:每個桶存儲一定範圍的數值
總結
各種排序性能對比如下,有些排序未詳細介紹,暫且放到這裏。實例測試結果可以看這裏:八大排序算法耗時對比 。
排序類型 | 平均情況 | 最好情況 | 最壞情況 | 輔助空間 | 穩定性 |
---|---|---|---|---|---|
冒泡排序 | O(n²) | O(n) | O(n²) | O(1) | 穩定 |
選擇排序 | O(n²) | O(n²) | O(n²) | O(1) | 不穩定 |
直接插入排序 | O(n²) | O(n) | O(n²) | O(1) | 穩定 |
折半插入排序 | O(n²) | O(n) | O(n²) | O(1) | 穩定 |
希爾排序 | O(n^1.3) | O(nlogn) | O(n²) | O(1) | 不穩定 |
歸併排序 | O(nlog₂n) | O(nlog₂n) | O(nlog₂n) | O(n) | 穩定 |
快速排序 | O(nlog₂n) | O(nlog₂n) | O(n²) | O(nlog₂n) | 不穩定 |
堆排序 | O(nlog₂n) | O(nlog₂n) | O(nlog₂n) | O(1) | 不穩定 |
計數排序 | O(n+k) | O(n+k) | O(n+k) | O(k) | 穩定 |
桶排序 | O(n+k) | O(n+k) | O(n²) | O(n+k) | (不)穩定 |
基數排序 | O(d(n+k)) | O(d(n+k)) | O(d(n+kd)) | O(n+kd) | 穩定 |
從時間複雜度來說:
(1). 平方階O(n²)排序:各類簡單排序:直接插入、直接選擇和冒泡排序;
(2). 線性對數階O(nlog₂n)排序:快速排序、堆排序和歸併排序;
(3). O(n1+§))排序,§是介於0和1之間的常數:希爾排序
(4). 線性階O(n)排序:基數排序,此外還有桶、箱排序。
到此,很多人會注意到基數排序的時間複雜度是最小的,那麼爲什麼卻沒有快排、堆排序流行呢?我們看看下圖算法導論的相關說明:
基數排序只適用於有基數的情況,而基於比較的排序適用範圍就廣得多。另一方面是內存上的考慮。作爲一種通用的排序方法,最好不要帶來意料之外的內存開銷,所以各語言的默認實現都沒有用基數排序,但是不能否認基數排序在各領域的應用。
時間複雜度極限
當被排序的數有一些性質的時候(比如是整數,比如有一定的範圍),排序算法的複雜度是可以小於O(nlgn)的。比如:
- 計數排序 複雜度O( k+n) 要求:被排序的數是0~k範圍內的整數
- 基數排序 複雜度O( d(k+n) ) 要求:d位數,每個數位有k個取值
- 桶排序 複雜度 O( n ) (平均) 要求:被排序數在某個範圍內,並且服從均勻分佈
但是,當被排序的數不具有任何性質的時候,一般使用基於比較的排序算法,而基於比較的排序算法時間複雜度的下限必須是O( nlgn) 。參考很多高效排序算法的代價是 nlogn,難道這是排序算法的極限了嗎?
說明
- 當原表有序或基本有序時,直接插入排序和冒泡排序將大大減少比較次數和移動記錄的次數,時間複雜度可降至O(n);
- 而快速排序則相反,當原表基本有序時,將蛻化爲冒泡排序,時間複雜度提高爲O(n2);
- 原表是否有序,對簡單選擇排序、堆排序、歸併排序和基數排序的時間複雜度影響不大。