面試中常用的排序演算法
排序演算法是演算法的入門知識,其經典思想可以用於很多演算法當中。因為其實現程式碼較短,應用較常見。所以在面試中經常會問到排序演算法及其相關的問題。但萬變不離其宗,只要熟悉了思想,靈活運用也不是難事。一般在面試中最常考的是快速排序和歸併排序,並且經常有面試官要求現場寫出這兩種排序的程式碼。對這兩種排序的程式碼一定要信手拈來才行。還有插入排序、氣泡排序、堆排序、選擇排序、基數排序、桶排序等。面試官對於這些排序可能會要求比較各自的優劣、各種演算法的思想及其使用場景。還有要會分析演算法的時間和空間複雜度。通常查詢和排序演算法的考察是面試的開始,如果這些問題回答不好,估計面試官都沒有繼續面試下去的興趣都沒了。所以想開個好頭就要把常見的排序演算法思想及其特點要熟練掌握,有必要熟練寫出程式碼[1]。
對排序演算法的分類方式也有很多種[2]:
1、計算的時間複雜度(最差、平均、和最好效能),依據列表(list)的大小(n)。一般而言,好的效能是O(n log n),壞的效能是O(n2)。對於一個排序理想的效能是O(n),但平均而言不可能達到。基於比較的排序演算法對大多數輸入而言至少需要O(n log n)。
2、空間複雜度。
3、穩定性:穩定排序演算法會讓原本有相等鍵值的紀錄維持相對次序。也就是如果一個排序演算法是穩定的,當有兩個相等鍵值的紀錄R和S,且在原本的列表中R出現在S之前,在排序過的列表中R也將會是在S之前。
4、排序的方法:交換、選擇、插入、合併等等。
首先給出一個對比的表格,以便從整體上理解排序演算法:
接下來我們按照
交換排序:氣泡排序、快速排序
選擇排序:選擇排序、堆排序
插入排序:插入排序
歸併排序:歸併排序
的順序、分析一下這六種常見的排序演算法及其使用場景。限於篇幅,某些演算法的詳細演示和圖示請在演算法導論中尋找詳細的參考。值得一提的是,本文中的程式碼思想都源自演算法導論,如果有不明白的程式碼請翻閱演算法導論一書。本文專為面試前突擊而作,套路和思想和演算法導論一模一樣,即以方便記憶為主。
交換排序
交換排序的基本方法是在待排序的元素中選擇兩個元素,將他們的值進行比較,如果反序則交換他們的位置,直到沒有反序的記錄為止。交換排序中常見的是氣泡排序和快速排序。
氣泡排序
氣泡排序演算法的虛擬碼如下:
function bubble_sort (array, length) {
var i, j;
for(i from 0 to length-1){
for(j from 0 to length-1-i){
if (array[j] > array[j+1])
swap(array[j], array[j+1])
}
}
}
參考虛擬碼不難寫出程式碼:
/**
* @param arr
* 1、氣泡排序
* 氣泡排序時間複雜度O(n^2),比較次數多,交換次數多。因此是效率極低的演算法。
* 氣泡排序是一種穩定的演算法。
*/
public static void bubbleSort(int[] arr){
int len = arr.length;
for(int i = 0; i < len - 1; i++){
for(int j = 0; j < len - 1 - i; j++){
if(arr[j] > arr[j + 1]){
int temp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = temp;
}
}
}
}
氣泡排序對n個專案需要O(n^2)的比較次數,且可以原地排序。儘管這個演算法是最簡單瞭解和實現的排序演算法之一,但氣泡排序的實現通常會對已經排序好的數列拙劣地執行O(n^2),它對於包含大量的元素的數列排序是很沒有效率的。
快速排序
快速排序是氣泡排序的一種改進,氣泡排序排完一趟是最大值冒出來了,那麼可不可以先選定一個值,然後掃描待排序序列,把小於該值的記錄和大於該值的記錄分成兩個單獨的序列,然後分別對這兩個序列進行上述操作。這就是快速排序,我們把選定的那個值稱為樞紐值,如果樞紐值為序列中的最大值,那麼一趟快速排序就變成了一趟氣泡排序。
快速排序是基於分治模式的[3]:
分解:陣列A【p..r】被劃分成兩個(可能空)子陣列A【p..q-1】和A【q+1..r】,使得A【p..q-1】中的每個元素都小於等於A(q),而且,小於等於A【q+1..r】中的元素。下 標q 也在返個劃分過程中迕行計算。
解決:通過遞迴呼叫快速排序,對子陣列A【p..q-1】和A【q+1..r】排序。
合併:因為兩個子陣列使就地排序的,將它們的合併不需要操作:整個陣列A【p..r】已排序。
/**
* @param arr
* @param p
* @param r
* 2、快排
* 快排最壞時間複雜度O(n^2),平均時間複雜度O(nlgn)。空間複雜度為O(nlgn)。
* 快排是一種不穩定的演算法。
*/
public static void quickSort(int[] arr, int p, int r){
if(p < r){
int q = partition(arr, p, r);
quickSort(arr, p, q - 1);
quickSort(arr, q + 1, r);
}
return;
}
public static int partition(int[] arr, int p, int r){
int x = arr[r];
int i = p - 1;
for(int j = p; j < r; j++){
if(arr[j] < x){
swap(arr, ++i, j);
}
}
swap(arr, ++i, r);
return i;
}
private static void swap(int[] arr, int i,int j){
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
快速排序是最常用的一種排序演算法,包括C的qsort,C++和Java的sort,都採用了快排(C++和Java的sort經過了優化,還混合了其他排序演算法)。
快排最壞情況O( n^2 ),但平均效率O(n lg n),而且這個O(n lg n)幾號中隱含的常數因子很小,快排可以說是最快的排序演算法,並非浪得虛名。另外它還是就地排序。
舉一個例子,java中arrays.sort()方法:
1)當待排序的陣列中的元素個數較少時,原始碼中的閥值為7,採用的是插入排序。儘管插入排序的時間複雜度為0(n^2),但是當陣列元素較少時,插入排序優於快速排序,因為這時快速排序的遞迴操作影響效能。
2)較好的選擇了劃分元(基準元素)。能夠將陣列分成大致兩個相等的部分,避免出現最壞的情況。例如當陣列有序的的情況下,選擇第一個元素作為劃分元,將使得演算法的時間複雜度達到O(n^2).
原始碼中選擇劃分元的方法:
當陣列大小為 size=7 時 ,取陣列中間元素作為劃分元。int n=m>>1;(此方法值得借鑑)
當陣列大小 7
選擇排序
選擇排序的基本思想是,每趟排序在待排序序列中,選擇值較小的元素,順序新增到有序序列的最後,直到全部記錄排序完畢。常用的有簡單選擇排序和堆排序。
簡單選擇排序
簡單選擇排序是一種簡單直觀的排序演算法。它的工作原理如下。首先在未排序序列中找到最小(大)元素,存放到排序序列的起始位置,然後,再從剩餘未排序元素中繼續尋找最小(大)元素,然後放到已排序序列的末尾。以此類推,直到所有元素均排序完畢。
/**
* @param arr
* 3、選擇排序
* 選擇排序時間複雜度O(n^2)。比較次數多,交換次數少。
* 選擇排序是一種不穩定的排序演算法。例如:(7) 2 5 9 3 4 [7] 1...
* 當我們利用直接選擇排序演算法進行排序時,(7)和1調換,(7)就在[7]的後面了,原來的次序改變,這樣就不穩定.
*/
public static void selectSort(int[] arr){
int len = arr.length;
for(int i = 0; i < len - 1; i++){
int min = i;
for(int j = i + 1; j < len; j ++){
if(arr[min] > arr[j]){
min = j;
}
}
swap(arr, i, min);
}
}
簡單選擇排序是移動次數最少的演算法。原始序列為正序時,比較次數O( n^2 ),移動次數為0;逆序時,比較次數O( n^2 ),移動次數O( n )。平均情況下時間複雜度O( n^2 ),空間複雜度O( 1 )。
另外簡單選擇排序是不穩定的;簡單選擇排序是原地排序。
堆排序
在堆的資料結構中,堆中的最大值總是位於根節點(在優先佇列中使用堆的話堆中的最小值位於根節點)。堆排序需要用到堆中定義以下三個種操作:
- 最大堆調整(Max_Heapify):將堆的末端子節點作調整,使得子節點永遠小於父節點。
- 建立最大堆(Build_Max_Heap):將堆所有資料重新排序。
- 堆排序(HeapSort):移除位在第一個資料的根節點,並做最大堆調整的遞迴運算。
/**
* @param arr
* 4、堆排序
* 堆排序有三個操作:1、維護堆性質;2、建堆;3、堆排序
* 1、維護堆性質,時間複雜度為O(lgn)
* 2、建堆,時間複雜度O(n)
* 3、堆排序,n-1次呼叫維護堆性質函式,每次時間複雜度為O(lgn),因此總體時間複雜度為O(nlgn)。
* 空間複雜度為O(lgn)
* 堆排序是一種不穩定的排序。
*/
public static void heapSort(int[] arr){
/*
* 第一步:將陣列堆化
* beginIndex = 第一個非葉子節點。
* 從第一個非葉子節點開始即可。無需從最後一個葉子節點開始。
* 葉子節點可以看作已符合堆要求的節點,根節點就是它自己且自己以下值為最大。
*/
int len = arr.length - 1;
int beginIndex = (len - 1) >> 1;
for(int i = beginIndex; i >= 0; i--){
maxHeapify(arr, i, len);
}
/*
* 第二步:對堆化資料排序
* 每次都是移出最頂層的根節點A[0],與最尾部節點位置調換,同時遍歷長度 - 1。
* 然後從新整理被換到根節點的末尾元素,使其符合堆的特性。
* 直至未排序的堆長度為 0。
*/
for(int i = len; i > 0; i--){
swap(arr, 0, i);
maxHeapify(arr, 0, i - 1);
}
}
/**
* 調整索引為 index 處的資料,使其符合堆的特性。
*
* @param index 需要堆化處理的資料的索引
* @param len 未排序的堆(陣列)的長度
*/
public static void maxHeapify(int[] arr, int i, int len){
int l = 2 * i + 1; // 左子節點索引
int r = l + 1; // 右子節點索引
int largest = i; // 預設父節點索引為最大值索引
if(l <= len && arr[l] > arr[i]){ //判斷左子節點是否比父節點大
largest = l;
}
if(r <= len && arr[r] > arr[largest]){ //判斷右子節點是否比父節點大
largest = r;
}
if(largest != i){
swap(arr, i, largest); // 如果父節點被子節點調換,
maxHeapify(arr, largest, len); // 則需要繼續判斷換下後的父節點是否符合堆的特性。
}
}
插入排序
插入排序不是通過交換位置,而是通過比較找到合適的位置插入元素。類似於打撲克牌,整牌的時候就是拿到一張牌,找到一個合適的位置插入。這個原理其實和插入排序是一樣的。
直接插入排序
/**
* @param arr
* 5、插入排序
* 插入排序平均時間複雜度為O(n^2),空間複雜度為O(1)。
* 直接插入排序是一種穩定的演算法,當陣列長度較小時,效果要比快排好。
*/
public static void insertSort(int[] arr){
int len = arr.length;
for(int i = 1; i < len; i++){
int temp = arr[i];
int j = i - 1;
// 找到合適的位置j來插入arr[i]
while(j >= 0 && arr[j] > temp){
arr[j + 1] = arr[j];
j--;
}
arr[j + 1] = temp;
}
}
合併排序
合併排序的基本方法是,將兩個或兩個以上的有序序列歸併成一個有序序列。常見的演算法有歸併排序。
歸併排序
/**
* @param arr
* 6、歸併排序
* 歸併排序平均時間複雜度為O(nlgn),空間複雜度為O(n)。
* 歸併排序是一種穩定的演算法。
*
*/
public static void mergeSort(int[] arr, int p, int r){
if (p < r){
int q = (p + r) / 2;
mergeSort(arr, p, q);
mergeSort(arr, q + 1, r);
merge(arr, p, q, r);
}
}
public static void merge(int[] arr, int p, int q, int r){
int len1 = q - p + 1;
int len2 = r - q;
// 建立長度為len1和len2的新陣列。
int[] arr1 = new int[len1 + 1];
int[] arr2 = new int[len2 + 1];
// 賦值,尾部值為無窮。
for(int i = 0; i < len1; i++){
arr1[i] = arr[p + i];
}
for(int i = 0; i < len2; i++){
arr2[i] = arr[q + 1 + i];
}
arr1[len1] = Integer.MAX_VALUE;
arr2[len2] = Integer.MAX_VALUE;
// 比較兩個新陣列的元素大小,將小的元素新增的arr,進行排序。
for(int k = 0, i = 0, j = 0; k < len1 + len2; k++){
if(arr1[i] < arr2[j]){
arr[p + k] = arr1[i++];
}else{
arr[p + k] = arr2[j++];
}
}
}
參考文獻
[3] 演算法導論