摘要:歸併排序和快速排序是兩種稍微複雜的排序演演算法,它們用的都是分治的思想,程式碼都通過遞回來實現,過程非常相似。理解歸併排序的重點是理解遞推公式和 merge() 合併函數。
本文分享自華為雲社群《深入淺出八種排序演演算法》,作者:嵌入式視覺 。
歸併排序和快速排序是兩種稍微複雜的排序演演算法,它們用的都是分治的思想,程式碼都通過遞回來實現,過程非常相似。理解歸併排序的重點是理解遞推公式和 merge() 合併函數。
排序演演算法是程式設計師必須瞭解和熟悉的一類演演算法,排序演演算法有很多種,基礎的如:冒泡、插入、選擇、快速、歸併、計數、基數和桶排序等。
氣泡排序只會操作相鄰的兩個資料。每次冒泡操作都會對相鄰的兩個元素進行比較,看是否滿足大小關係要求,如果不滿足就讓它倆互換。一次冒泡會讓至少一個元素移動到它應該在的位置,重複 n 次,就完成了 n 個資料的排序工作。
總結:如果陣列有 n 個元素,最壞情況下,需要進行 n 次冒泡操作。
基礎的氣泡排序演演算法的 C++ 程式碼如下:
// 將資料從小到大排序 void bubbleSort(int array[], int n){ if (n<=1) return; for(int i=0; i<n; i++){ for(int j=0; j<n-i; j++){ if (temp > a[j+1]){ temp = array[j] a[j] = a[j+1]; a[j+1] = temp; } } } }
實際上,以上的氣泡排序演演算法還可以優化,當某次冒泡操作已經不再進行資料交換時,說明陣列已經達到有序,就不需要再繼續執行後續的冒泡操作了。優化後的程式碼如下:
// 將資料從小到大排序 void bubbleSort(int array[], int n){ if (n<=1) return; for(int i=0; i<n; i++){ // 提前退出冒泡迴圈發標志位 bool flag = False; for(int j=0; j<n-i; j++){ if (temp > a[j+1]){ temp = array[j] a[j] = a[j+1]; a[j+1] = temp; flag = True; // 表示本次冒泡操作存在資料交換 } } if(!flag) break; // 沒有資料交換,提交退出 } }
氣泡排序的特點:
插入排序和氣泡排序一樣,也包含兩種操作,一種是元素的比較,一種是元素的移動。
當我們需要將一個資料 a 插入到已排序區間時,需要拿 a 與已排序區間的元素依次比較大小,找到合適的插入位置。找到插入點之後,我們還需要將插入點之後的元素順序往後移動一位,這樣才能騰出位置給元素 a 插入。
插入排序的 C++ 程式碼實現如下:
void InsertSort(int a[], int n){ if (n <= 1) return; for (int i = 1; i < n; i++) // 未排序區間範圍 { key = a[i]; // 待排序第一個元素 int j = i - 1; // 已排序區間末尾元素 // 從尾到頭查詢插入點方法 while(key < a[j] && j >= 0){ // 元素比較 a[j+1] = a[j]; // 資料向後移動一位 j--; } a[j+1] = key; // 插入資料 } }
插入排序的特點:
選擇排序演演算法的實現思路有點類似插入排序,也分已排序區間和未排序區間。但是選擇排序每次會從未排序區間中找到最小的元素,將其放到已排序區間的末尾。
選擇排序的最好情況時間複雜度、最壞情況和平均情況時間複雜度都為 O(n2)O(n2),是原地排序演演算法,且是不穩定的排序演演算法。
選擇排序的 C++ 程式碼實現如下:
void SelectSort(int a[], int n){ for(int i=0; i<n; i++){ int minIndex = i; for(int j = i;j<n;j++){ if (a[j] < a[minIndex]) minIndex = j; } if (minIndex != i){ temp = a[i]; a[i] = a[minIndex]; a[minIndex] = temp; } } }
這三種排序演演算法,實現程式碼都非常簡單,對於小規模資料的排序,用起來非常高效。但是在大規模資料排序的時候,這個時間複雜度還是稍微有點高,所以更傾向於用時間複雜度為 O(nlogn)O(nlogn) 的排序演演算法。
特定演演算法是依賴特定的資料結構的。以上三種排序演演算法,都是基於陣列實現的。
歸併排序的核心思想比較簡單。如果要排序一個陣列,我們先把陣列從中間分成前後兩部分,然後對前後兩部分分別排序,再將排好序的兩部分合並在一起,這樣整個陣列就都有序了。
歸併排序使用的是分治思想。分治,顧名思義,就是分而治之,將一個大問題分解成小的子問題來解決。小的子問題解決了,大問題也就解決了。
分治思想和遞迴思想有些類似,分治演演算法一般用遞迴實現。分治是一種解決問題的處理思想,遞迴是一種程式設計技巧,這兩者並不衝突。
知道了歸併排序用的是分治思想,而分治思想一般用遞迴實現,接下來的重點就是如何用遞迴實現歸併排序。寫遞迴程式碼的技巧就是,分析問題得出遞推公式,然後找到終止條件,最後將遞推公式翻譯成遞迴程式碼。所以,要想寫出歸併排序的程式碼,得先寫出歸併排序的遞推公式。
遞推公式: merge_sort(p…r) = merge(merge_sort(p…q), merge_sort(q+1…r)) 終止條件: p >= r 不用再繼續分解,即區間陣列元素為 1
歸併排序的虛擬碼如下:
merge_sort(A, n){ merge_sort_c(A, 0, n-1) } merge_sort_c(A, p, r){ // 遞迴終止條件 if (p>=r) then return // 取 p、r 中間的位置為 q q = (p+r)/2 // 分治遞迴 merge_sort_c(A[p, q], p, q) merge_sort_c(A[q+1, r], q+1, r) // 將A[p...q]和A[q+1...r]合併為A[p...r] merge(A[p...r], A[p...q], A[q+1...r]) }
1,歸併排序是一個穩定的排序演演算法。分析:虛擬碼中 merge_sort_c() 函數只是分解問題並沒有涉及移動元素和比較大小,真正的元素比較和資料移動在 merge() 函數部分。在合併過程中保證值相同的元素合併前後的順序不變,歸併排序排序就是一個穩定的排序演演算法。
2,歸併排序的執行效率與要排序的原始陣列的有序程度無關,所以其時間複雜度是非常穩定的,不管是最好情況、最壞情況,還是平均情況,時間複雜度都是 O(nlogn)O(nlogn)。分析:不僅遞迴求解的問題可以寫成遞推公式,遞迴程式碼的時間複雜度也可以寫成遞推公式:
3,空間複雜度是 O(n)。分析:遞迴程式碼的空間複雜度並不能像時間複雜度那樣累加。儘管演演算法的每次合併操作都需要申請額外的記憶體空間,但在合併完成之後,臨時開闢的記憶體空間就被釋放掉了。在任意時刻,CPU 只會有一個函數在執行,也就只會有一個臨時的記憶體空間在使用。臨時記憶體空間最大也不會超過 n 個資料的大小,所以空間複雜度是 O(n)O(n)。
快排的思想是這樣的:如果要排序陣列中下標從 p 到 r 之間的一組資料,我們選擇 p 到 r 之間的任意一個資料作為 pivot(分割區點)。我們遍歷 p 到 r 之間的資料,將小於 pivot 的放到左邊,將大於 pivot 的放到右邊,將 pivot 放到中間。經過這一步驟之後,陣列 p 到 r 之間的資料就被分成了三個部分,前面 p 到 q-1 之間都是小於 pivot 的,中間是 pivot,後面的 q+1 到 r 之間是大於 pivot 的。
根據分治、遞迴的處理思想,我們可以用遞迴排序下標從 p 到 q-1 之間的資料和下標從 q+1 到 r 之間的資料,直到區間縮小為 1,就說明所有的資料都有序了。
遞推公式如下:
遞推公式: quick_sort(p,r) = quick_sort(p, q-1) + quick_sort(q, r) 終止條件: p >= r
歸併排序和快速排序是兩種稍微複雜的排序演演算法,它們用的都是分治的思想,程式碼都通過遞回來實現,過程非常相似。理解歸併排序的重點是理解遞推公式和 merge() 合併函數。同理,理解快排的重點也是理解遞推公式,還有 partition() 分割區函數。
除了以上 5 種排序演演算法,還有 3 種時間複雜度是 O(n)O(n) 的線性排序演演算法:桶排序、計數排序、基數排序。這八種排序演演算法效能總結如下圖: