演算法是面試考察的重點,基礎演算法更是基礎,只有打好了基礎才可能在此之上深入學習。這裡總結了最常見的排序演算法,每個都進行了詳細分析,大家可以好好研究吸收。
1.排序
演算法的穩定性:通俗地講就是能保證排序前2個相等的數其在序列的前後位置順序和排序後它們兩個的前後位置順序相同。在簡單形式化一下,如果Ai = Aj, Ai原來在位置前,排序後Ai還是要在Aj位置前。
1.1 插入排序
插入排序是一種最簡單直觀的排序演算法,它的工作原理是透過構建有序序列,對於未排序資料,在已排序序列中從後向前掃描,找到相應位置並插入。
演算法步驟:
- 將第一待排序序列第一個元素看做一個有序序列,把第二個元素到最後一個元素當成是未排序序列。
- 從頭到尾依次掃描未排序序列,將掃描到的每個元素插入有序序列的適當位置。(如果待插入的元素與有序序列中的某個元素相等,則將待插入元素插入到相等元素的後面。)
複雜度: 時間複雜度O(n^2), 空間複雜度O(1);排序時間與元素已排序的程度有關。最佳情況,輸入陣列是已經排好序的陣列,執行時間是O(n); 最壞情況,輸入陣列是逆序,執行時間是O(n^2)。
穩定性: 相等元素的前後順序沒有改變,從原無序序列出去的順序仍是排好序後的順序,所以插入排序是穩定的。
// insertion sort
/* 如果要改成範型,可以將函式宣告改成如下:
public <T extends Comparable<? super T>> void insertionSort(T [] a)
對於函式中出現的比較等操作,可以替換為a.compareTo(b), 如果a.compareTo(b) < 0 則代表a < b
*/
public void insertionSort(int [] a) {
int j;
for (int i = 1; i < a.length; i++) {
int temp = a[i];
for (j = i; j > 0 && temp < a[j-1]; j--) {
a[j] = a[j-1];
}
a[j] = temp;
}
}
1.2 希爾排序
希爾排序,也稱遞減增量排序演算法,是插入排序的一種更高效的改進版本。但希爾排序是非穩定排序演算法。
希爾排序是基於插入排序的以下兩點性質而提出改進方法的:插入排序在對幾乎已經排好序的資料操作時, 效率高, 即可以達到線性排序的效率;但插入排序一般來說是低效的, 因為插入排序每次只能將資料移動一位
演算法步驟:
- 選擇一個增量序列t1,t2,…,tk,其中ti>tj,tk=1;
- 按增量序列個數k,對序列進行k 趟排序;
- 每趟排序,根據對應的增量ti,將待排序列分割成若干長度為m 的子序列,分別對各子表進行直接插入排序。僅增量因子為1 時,整個序列作為一個表來處理,表長度即為整個序列的長度。
複雜度: 最壞情形:O(N2),選擇N是2的冪,這使得除最後一個增量是1以外的增量都是偶數。此時如果陣列的偶數位置上有N/2個同是最大的數,而在基數位置上有N/2個同為最小的數,由於除最後一個增量外所有的增量都是偶數,因此當我們在最後一趟排序之前,N/2個最大的元素仍在偶數位置上,而N/2個最小的元素也還是在奇數位置上。於是,在最後一趟排序開始之前第i個最小的數(i<=N/2)在位置2i-1上,將第i個元素恢復到其正確的位置需要在陣列中移動i-1個間隔,這樣僅僅將N/2個元素放在正確的位置上至少需要O(N2)的工作。
穩定性: 由於多次插入排序,我們知道一次插入排序是穩定的,不會改變相同元素的相對順序,但在不同的插入排序過程中,相同的元素可能在各自的插入排序中移動,最後其穩定性就會被打亂。所以shell排序是不穩定的排序演算法。
// shell sort
public static void shellSort(int [] a) {
int j;
for (int gap = a.length / 2; gap > 0; gap /= 2) {
for (int i = gap; i < a.length; i++) {
int temp = a[i];
for (j = i; j >= gap && temp < a[j - gap]; j -= gap) {
a[j] = a[j-gap];
}
a[j] = temp;
}
}
}
1.3 選擇排序與氣泡排序
1.3.1 選擇排序
演算法步驟:
- 首先在未排序序列中找到最小元素,存放到排序序列的起始位置
- 再從剩餘未排序元素中繼續尋找最小元素,然後放到已排序序列的末尾。
- 重複第二步,直到所有元素均排序完畢。
複雜度: 時間複雜度O(n^2), 空間複雜度O(1)
穩定性: 排序時間與輸入無關,最佳情況,最壞情況都是如此, 不穩定
1.3.2 氣泡排序
可以看成是選擇排序的相反過程,每次找到最大的元素放到陣列的末尾
演算法步驟:
- 比較相鄰的元素。如果第一個比第二個大,就交換他們兩個。
- 對每一對相鄰元素作同樣的工作,從開始第一對到結尾的最後一對。這步做完後,最後的元素會是最大的數。
- 針對所有的元素重複以上的步驟,除了最後一個。
- 持續每次對越來越少的元素重複上面的步驟,直到沒有任何一對數字需要比較。
複雜度: 時間複雜度O(n^2), 空間複雜度O(1),排序時間與輸入無關,最好,最差,平均都是O(n^2)
穩定性: 穩定,相同元素經過排序後順序並沒有改變,所以氣泡排序是一種穩定排序演算法。
改進:在排序過程中,執行完當前的第i趟排序後,可能資料已全部排序完備,但是程式無法判斷是否完成排序,會繼續執行剩下的(n-1-i)趟排序。
解決方法:設定一個flag位, 如果一趟無元素交換,則 flag = 0; 以後再也不進入第二層迴圈。
// select sort
public static void selectSort(int [] a) {
int i, j, min;
int temp;
for (i = 0; i < a.length; i++) {
min = i;
for (j = i+1; j < a.length; j++) {
if (a[j] < a[min]) {
min = j;
}
}
temp = a[i];
a[i] = a[min];
a[min] = temp;
}
}
// bubble sort O(N*N)
public static void bubbleSort(int [] a) {
int i, j;
int temp;
for (i = 0; i < a.length; i++) {
for (j = 1; j < a.length - i; j++) {
if (a[j-1] > a[j]) {
temp = a[j-1];
a[j-1] = a[j];
a[j] = temp;
}
}
}
}
1.4 歸併排序
歸併排序(Merge sort)是建立在歸併操作上的一種有效的排序演算法。該演算法是採用分治法(Divide and Conquer)的一個非常典型的應用。
演算法步驟:
- 申請空間,使其大小為兩個已經排序序列之和,該空間用來存放合併後的序列
- 設定兩個指標,最初位置分別為兩個已經排序序列的起始位置
- 比較兩個指標所指向的元素,選擇相對小的元素放入到合併空間,並移動指標到下一位置
- 重複步驟3直到某一指標達到序列尾
- 將另一序列剩下的所有元素直接複製到合併序列尾
複雜度: 時間複雜度 O(nlogn), 空間複雜度O(n) +O(logn)
穩定性: 穩定,合併過程中我們可以保證如果兩個當前元素相等時,我們把處在前面的序列的元素儲存在結果序列的前面,這樣就保證了穩定性。
// Merger sort
public static void mergeSort(int [] a) {
int [] tempArray = new int [a.length];
mergeSort(a, tempArray, 0, a.length-1);
}
public static void mergeSort(int [] a, int [] tempArray, int left, int right) {
if (left < right) {
int center = (left + right) / 2;
mergeSort(a, tempArray, left, center);
mergeSort(a, tempArray, center+1, right);
merge(a, tempArray, left, center+1, right);
}
}
public static void merge(int [] a, int [] tempArray, int leftPos, int rightPos, int rightEnd) {
int leftEnd = rightPos - 1;
int tempPos = leftPos;
int elementNum = rightEnd - leftPos + 1;
// Main loop
while (leftPos <= leftEnd && rightPos <= rightEnd) {
if (a[leftPos] < a[rightPos])
tempArray[tempPos++] = a[leftPos++];
else
tempArray[tempPos++] = a[rightPos++];
}
while (leftPos <= leftEnd)
tempArray[tempPos++] = a[leftPos++];
while (rightPos <= rightEnd)
tempArray[tempPos++] = a[rightPos++];
// Copy tempArray back
for (int i = 0; i < elementNum; i++, rightEnd--)
a[rightEnd] = tempArray[rightEnd];
}
1.5 快速排序
在平均狀況下,排序 n 個專案要Ο(n log n)次比較。在最壞狀況下則需要Ο(n2)次比較,但這種狀況並不常見。事實上,快速排序通常明顯比其他Ο(n log n) 演算法更快,因為它的內部迴圈(inner loop)可以在大部分的架構上很有效率地被實現出來。快速排序使用分治法(Divide and conquer)策略來把一個序列(list)分為兩個子序列(sub-lists)。
演算法步驟:
- 從數列中挑出一個元素,稱為 “基準”(pivot).
- 重新排序數列,所有元素比基準值小的擺放在基準前面,所有元素比基準值大的擺在基準的後面(相同的數可以到任一邊)。在這個分割槽退出之後,該基準就處於數列的中間位置。這個稱為分割槽(partition)操作。
- 遞迴地(recursive)把小於基準值元素的子數列和大於基準值元素的子數列排序。
複雜度: 時間複雜度 O(nlogn) 空間複雜度O(logn)
穩定性: 快速排序是一個不穩定的排序演算法,不穩定發生在中樞元素和a[j]交換的時刻,可能會打亂穩定性
public static void quickSort(int [] a) {
quickSort(a, 0, a.length-1);
}
private static int median3(int [] a, int left, int right) {
int center = (left + right) / 2;
if (a[center] < a[left])
swap(a, left, center);
if (a[right] < a[left])
swap(a, left, right);
if (a[right] < a[center])
swap(a, center, right);
// Place pivot at position right-1
swap(a, center, right-1);
return a[right-1];
}
public static void swap(int [] a, int i, int j) {
int temp = a[i];
a[i] = a[j];
a[j] = temp;
}
private static void quickSort(int [] a, int left, int right) {
if (left < right) {
int pivot = median3(a, left, right);
int i = left, j = right - 1;
while (i < j) {
while (a[++i] < pivot);
while (a[--j] > pivot);
if (i < j)
swap(a, i, j);
else
break;
}
swap(a, i, right-1);
quickSort(a, left, i-1);
quickSort(a, i+1, right);
}
}
1.6 堆排序
堆排序(Heapsort)是指利用堆這種資料結構所設計的一種排序演算法。堆積是一個近似完全二叉樹的結構,並同時滿足堆積的性質:即子結點的鍵值或索引總是小於(或者大於)它的父節點。堆排序的平均時間複雜度為Ο(nlogn)。
演算法過程分為兩個步驟:
第一步,以線性時間建立一個Max堆,時間花費O(N);
第二步,透過將堆中的最後一個元素與第一個元素交換,縮減堆的大小並進行下濾,來執行N-1次DeleteMax操作,當演算法終止時,陣列則以所排的順序包含這些元素。由於每個DeleteMin花費時間O(logN),因此總的執行時間是O(NlogN)。
複雜度: 時間複雜度 O(nlogn) 空間複雜度O(1)
穩定性: 不穩定,發生在下濾的過程中
private static int leftChild(int i) {
return 2*i + 1;
}
private static void percDown(int [] a, int i, int n) {
int child;
int temp;
for (temp = a[i]; leftChild(i) < n; i = child) {
child = leftChild(i);
// Find the larger child
if (child+1 < n && a[child] < a[child+1])
child++;
// if temp is less than the child, then the percolate will be done
if (temp < a[child])
a[i] = a[child];
else
break;
}
a[i] = temp;
}
private static void heapSort(int [] a) {
// 從最後一個有葉子結點的結點開始下濾調整,建立一個Max堆
for (int i = a.length/2 - 1; i >= 0; i--)
percDown(a, i, a.length);
for (int i = a.length-1; i > 0; i--) {
// 將最大元素放到最後
swap(a, 0, i);
// 調整root結點的位置,進行下濾
percDown(a, 0, i);
}
}
1.7 桶排序
演算法思想: 是將陣列分到有限數量的桶子裡。每個桶子再個別排序(有可能再使用別的排序演算法或是以遞迴方式繼續使用桶排序進行排序)。
- 桶排序假設待排序的一組數均勻獨立的分佈在一個範圍中,並將這一範圍劃分成幾個子範圍(桶)。
- 然後基於某種對映函式F, 將待排序列的關鍵字 k 對映到第i個桶中 (即桶陣列B 的下標i) ,那麼該關鍵字k就作為 B[i]中的元素 (每個桶B[i]都是一組大小為N/M 的序列 )。
- 接著將各個桶中的資料有序的合併起來 : 對每個桶B[i] 中的所有元素進行比較排序 (可以使用快排)。然後依次列舉輸出 B[0]....B[M] 中的全部內容即是一個有序序列。
效能分析: 平均時間複雜度為線性的 O(n+C) 最優情形下,桶排序的時間複雜度為O(n)。桶排序的空間複雜度通常是比較高的,額外開銷為O(n+m)(因為要維護 M 個陣列的引用)。
對 N 個關鍵字進行桶排序的時間複雜度分為兩個部分:
(1) 迴圈計算每個關鍵字的桶對映函式,這個時間複雜度是 O(n)。
(2) 利用先進的比較排序演算法對每個桶內的所有資料進行排序,其時間複雜度為 ∑ O(ni*logni) 。其中 ni 為第 i個桶的資料量。
很顯然,第 (2) 部分是桶排序效能好壞的決定因素。這就是一個時間代價和空間代價的權衡問題了。
1.8 基數排序
假設我們有一些二元組(a,b),要對它們進行以a 為首要關鍵字,b的次要關鍵字的排序。我們可以先把它們先按照首要關鍵字排序,分成首要關鍵字相同的若干堆。然後,在按照次要關鍵值分別對每一堆進行單獨排序。最後再把這些堆串連到一起,使首要關鍵字較小的一堆排在上面。按這種方式的基數排序稱為 MSD(Most Significant Dight) 排序。
第二種方式是從最低有效關鍵字開始排序,稱為 LSD(Least Significant Dight)排序 。首先對所有的資料按照次要關鍵字排序,然後對所有的資料按照首要關鍵字排序。要注意的是,使用的排序演算法必須是穩定的,否則就會取消前一次排序的結果。由於不需要分堆對每堆單獨排序,LSD 方法往往比 MSD 簡單而開銷小。下文介紹的方法全部是基於 LSD 的。
通常,基數排序要用到計數排序或者桶排序。使用計數排序時,需要的是Order陣列。使用桶排序時,可以用連結串列的方法直接求出排序後的順序。
複雜度: 時間複雜度O(n)(實際上是O(d(n+k)) d是位數,O(n+k)為每次桶排序的複雜度)
1.8 總結
各種排序的穩定性,時間複雜度、空間複雜度、穩定性總結如下圖:
關於時間複雜度:
- 平方階(O(n^2))排序
各類簡單排序:直接插入、直接選擇和氣泡排序; - 線性對數階(O(nlog2n))排序
快速排序、堆排序和歸併排序; - O(n^(1+§))排序,§是介於0和1之間的常數。
希爾排序 - 線性階(O(n))排序
基數排序,此外還有桶、箱排序。 - 關於穩定性:
穩定的排序演算法:氣泡排序、插入排序、歸併排序和基數排序
不是穩定的排序演算法:選擇排序、快速排序、希爾排序、堆排序
更多面試技巧,深度好文,歡迎關注『後端精進之路』,立刻獲取最新文章和麵試合集資料。