【資料結構與演算法】之排序全家桶(十大排序詳解及其Java實現)---第七篇
本篇文章彙總了10種場常見的排序演算法,篇幅較長,可以通過下面的索引目錄進行定位查閱:
一、排序的基本概念
1、排序的定義
排序:就是使一串記錄,按照其中的某個或者某些關鍵字的大小,遞增或遞減的排列起來的操作。排序演算法,就是如何使得記錄按照要求排列的方法。排序演算法在很多領域都得到很大的重視,尤其在大量資料的處理方面。一個優秀的演算法可以節省大量的資源。
其它相關概念:
穩定:如果A=B,且A原本在B的前面,排序之後A仍然在B的前面,則說明這種排序演算法是穩定的;
不穩定:如果A=B,且A原本在B的前面,排序之後A排在了B的後面,則說明這種排序演算法是穩定的。
2、排序的分類
因為排序演算法應用非常廣泛,所以對應得排序演算法非常之多,這裡我們只挑其中常用的八大經典排序演算法【氣泡排序、插入排序、選擇排序、快速排序、歸併排序、桶排、計數排序以及基數排序】,下面對它們進行分類:
(1)按時間複雜度分類:
排序演算法 | 時間複雜度 | 是否基於比較 |
---|---|---|
氣泡排序、插入排序、選擇排序 | O() | 是 |
快速排序、歸併排序 | O() | 是 |
桶排序、計數排序、基數排序 | O(n) | 否 |
(2)按線性時間非比較類排序和非線性時間比較類排序分類:
非線性時間比較排序:通過比較來決定元素間的相對次序,由於其時間複雜度不能突破O(
線性時間非比較類排序:不通過比較來決定元素間的相對次序,它可以突破基於比較排序的時間下界,以線性時間執行,因此稱為線性時間非比較類排序。
3、排序的時間複雜度和空間複雜度
排序方法 | 平均時間複雜度 | 最壞時間複雜度 | 最好時間複雜度 | 空間複雜度 | 穩定性 |
---|---|---|---|---|---|
桶排序 | O(n+k) | O() | O(n+k) | O(n+k) | 穩定 |
計數排序 | O(n+k) | O(n+k) | O(n+k) | O(n+k) | 穩定 |
基數排序 | O(n) | O(n * k) | O(n * k) |
O(n * k) | 穩定 |
插入排序 | O() | O(n) | O() | O(1) | 穩定 |
希爾排序 | O(nlogn) | O() | O() | O(1) | 不穩定 |
選擇排序 | O() | O() | O() | O(1) | 不穩定 |
氣泡排序 | O() | O(n) | O() | O(1) | 穩定 |
快速排序 | O(nlogn) | O() | O(nlogn) | O(logn) | 不穩定 |
堆排序 | O(nlogn) | O(nlogn) | O(nlogn) | O(1) | 不穩定 |
歸併排序 | O(nlogn) | O(nlogn) | O(nlogn) | O(n) | 穩定 |
4、如何衡量排序演算法的執行效率?
對於排序演算法,我們一般會從以下幾個方面衡量它們的執行效率:
(1)最好情況、最壞情況以及平均情況時間複雜度
(2)時間複雜度的係數、常數、低階
(3)比較次數和交換(或移動)次數
二、十大經典排序演算法
1、氣泡排序
【ps:本部分內容參考及推薦閱讀:氣泡排序演算法詳解】
1.1 定義
氣泡排序(Bubble Sort):是一種典型的交換排序演算法,通過交換資料元素的位置進行排序。
關鍵的兩步操作:比較和交換
1.2 基本思想
氣泡排序的基本思想就是:從無序序列頭開始,進行兩兩比較,根據大小交換位置,直到最後將最大(或最小)的資料元素交換到了無序佇列的隊尾,從而成為有序序列的一部分;然後依次進行這個過程,直到所有資料元素都排好序為止。
1.3 演算法描述
(1)比較相鄰的元素,如果第一個比第二個大,就交換它們的位置;
(2)對每一對相鄰元素作同樣的工作,從開始第一對進行到結尾的最後一對,這樣在最後的元素將是最大的數;
(3)針對所有位置的元素重複以上的步驟,除了最後一個;
(4)持續每次對越來越少的元素(無序元素)重複上面的步驟,直到沒有任何一對相鄰的陣列需要比較,則序列最終有序。
1.4 圖示例
如下圖所示:對[3, 6, 4, 2, 11, 10, 5]這個陣列進行排序的過程:每次都從頭開始
1.5 程式碼實現
public class BubbleSort {
public static int[] bubbleSort(int[] arr){
// i用來控制需要排序多少趟,j用來控制當前元素需要比較的次數
int i, j, temp, len = arr.length;
// 共計多少趟排序,最後一個元素不用,前面的排好了,最後一個自動有序
for(i = 0; i < len - 1; i++){
// 比較次數,對剩下的無序元素進行排序
for(j = 0; j < len - i - 1; j++){
// 如果arr[j] > arr[j + 1],就交換它們的位置
if(arr[j] > arr[j + 1]){
temp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = temp;
}
}
}
return arr;
}
// 測試案例
public static void main(String[] args) {
int[] arr = {3, 6, 4, 2, 11, 10, 5};
int[] bubbleArray = bubbleSort(arr);
for(int i = 0; i < bubbleArray.length; i++){
System.out.print(bubbleArray[i] + ", ");
}
}
}
1.6 氣泡排序的改進
改進一:
如果你手寫過上面氣泡排序的程式碼,你會發現,它的實現過程優點傻傻的感覺,它的想法就是:不管你本身有序還是無序,每個位置上的元素都會和相鄰元素相互比較一次,再決定要不要進行位置交換。很明顯這樣的一個排序過程中有很多次的比較都是多餘的,比如陣列A = {4 ,3,5,7,8,9},很明顯只需要將4和3的位置交換一次,再用4和後面的元素進行比較,就會發現這個陣列已經有序了,就沒有必要再進行剩下的4趟冒泡了。
針對上面的問題,我們可以在外層迴圈裡面加入一個標誌變數change,如果在一次冒泡過程中發現沒有傳送資料交換,則認為當前陣列已經有序,則改變change變數的值,迴圈終止。實現程式碼如下:
public class BubbleSortImproved {
public static int[] bubbleSort(int[] arr) {
int i, j, temp, len = arr.length, changed = 1, count = 0;
for (i = 0; i < len - 1 && changed != 0; i++) {
count++; // 記錄冒泡的次數
changed = 0;
for (j = 0; j < len - i - 1; j++) {
if (arr[j] > arr[j + 1]) {
temp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = temp;
changed = 1; // 發生位置交換,說明當前陣列在本次位置交換前還是無序的
}
}
}
System.out.println(count); // 4,如果是未改進的氣泡排序則需要6次冒泡(7-1)
return arr;
}
// 測試案例
public static void main(String[] args) {
int[] arr = { 3, 6, 4, 2, 9, 10, 11 };
int[] bubbleArray = bubbleSort(arr);
for (int i = 0; i < bubbleArray.length; i++) {
System.out.print(bubbleArray[i] + ", ");
}
}
}
可以看到上面程式碼種的測試案例,只是陣列種的後三個元素有序,改進後的氣泡排序演算法,只需要4次冒泡就可以完成整個陣列的排序過程,而未改進的氣泡排序則需要6次冒泡才能完成最終的陣列排序,實際上最後兩次的氣泡排序都是多餘的,增加了3次比較操作。
改進二:雞尾酒排序/攪拌排序/來回排序
傳統的氣泡排序中的每一趟排序操作都只能找到一個最大值或者最小值,第一次冒泡都是首先從左邊開始一直比較到陣列的最右邊,然後再從最左邊開始比較到無序陣列的最右邊(右邊的最大有序陣列不再參與比較)。考慮這個過程,我們可以在冒泡到無序陣列中的最右邊時,再進行一次往左邊的冒泡操作,這樣就可以在左邊形成一個最小的有序陣列。
簡言之,在每趟排序過程中進行正向和反向兩次冒泡的方法,這樣一次排序可以得到兩個最終值(最大值和最小值),從而使排序趟數幾乎減少了一半。但是實際上,如果陣列在亂序的狀態下,雞尾酒排序和傳統的氣泡排序效率都很差勁。
public class BubbleSortImpoved2 {
public static void cocktailSort(int arr[]){
int i, tpme, left = 0, right = arr.length - 1;
while(left < right){
// 正向冒泡:從左往右找,找到最大的
for(i = left; i < right; i++){
if(arr[i] > arr[i + 1]){
tpme = arr[i];
arr[i] = arr[i + 1];
arr[i + 1] = tpme; // 把大的值給arr[i + 1]
}
}
right--; // 向左移動一位
// 反向冒泡:從右往左找,找到最小的
for(i = right; i > left; i--){
if(arr[i - 1] > arr[i]){
tpme = arr[i];
arr[i] = arr[i - 1];
arr[i - 1] = tpme; // 把小的值給arr[i - 1]
}
}
left++; // 向右移動一位
}
}
// 測試案例
public static void main(String[] args) {
int[] arr = {2, 3, 4, 5, 1};
cocktailSort(arr);
}
}
1.7 氣泡排序的效能分析
(1) 時間複雜度:(設定標誌變數之後)
當原始資料元素正序排列時,氣泡排序的比較次數為n-1,移動次數為0,即最好情況時間複雜度為:O(n)
當原始資料元素逆序排列時,氣泡排序的比較次數為n(n-1)/2,移動次數為3n(n-1)/2,所以最壞時間複雜度為:O()
當原始資料元素雜亂無序時,氣泡排序的平均時間複雜度為:O()
(2) 空間複雜度
氣泡排序過程中只需要一個臨時變數進行兩兩交換,所需要的額外空間為1,所以空間複雜度為O(1)
(3)穩定性
氣泡排序過程中,元素兩兩交換時,相同元素的前後順序並沒有發生改變,所以氣泡排序是一種穩定排序演算法
2、插入排序
2.1 定義
插入排序(Insertion Sort):是一種簡單直觀的排序演算法。它的工作原理是通過構件有序序列,對於未排序的資料元素,在已排序序列中從後向前掃描,找到相應的位置並插入。
2.2 基本思想
順序地把待排序的序列中的各個元素按照其關鍵字的大小,插入到已排序的序列中的適當位置。
2.3 演算法描述
整個排序演算法的執行過程建議檢視:十大經典排序演算法中插入排序的動態圖,能夠更加直觀的理解。
(1)從第一個元素開始,該元素可以認為已經是被排序的了;
(2)取出下一個新元素,在已經排序的元素序列中從後向前掃描;
(3)如果該元素(已排序的)大於新元素,將該元素移動到下一個位置;
(4)重複步驟(3),直到找到已排序中小於等於新元素的位置;
(5)將新元素插入到該位置;
(6)重複步驟(2)~(5)。
2.4 圖示例
2.5 程式碼實現
public class InsertionSort {
public static int[] insertionSort(int[] arr){
int i, j, temp, len = arr.length;
// 從第2個元素開始,和前面有序的數列中的元素依次進行比較,直到找到小於它的位置
for(i = 1; i < len; i++){
temp = arr[i];
// 最多和前面的i-1個數進行比較
for(j = i - 1; j >= 0 && arr[j] > temp; j--){
arr[j + 1] = arr[j]; // 如果arr[j]比arr[i]大的話,則後移一個位置
}
// 如果arr[j] <= arr[i]的話,則將arr[i]插入到j+1的位置,當前這個位置正好有空位,因為後面比arr[i]大的元素都後移了一個位置
arr[j + 1] = temp;
}
return arr;
}
// 測試
public static void main(String[] args) {
int[] arr = {3, 6, 4, 2, 9, 10, 11 };
int[] insertionArray = insertionSort(arr);
for(int i = 0; i < insertionArray.length; i++){
System.out.print(insertionArray[i] + ", ");
}
}
}
2.6 插入排序的效能分析
(1)時間複雜度
當原始序列正序時,直接插入排序效果最好,所有元素只需要進行一次比較(不包含第一個元素),所以共計n-1次比較,並且無需進行位置交換操作,所以直接插入排序最好情況複雜度為O(n);
當原始序列逆序時,直接插入排序效果最差,所以需要進行1+2+3+...+(n-1)次比較以及n-1次位置交換,所以最壞情況時間複雜度為O();
當原始資料元素雜亂無序時,相當於在陣列中插入一個數據(時間複雜度為O(n)),迴圈執行n次操作,所以直接插入排序的平均時間複雜度為:O()
(2)空間複雜度
插入排序過程中只需要一個臨時變數進行兩兩交換,所需要的額外空間為1,所以空間複雜度為O(1)
(3)穩定性
插入排序過程中,元素兩兩交換時,相同元素的前後順序並沒有發生改變,所以氣泡排序是一種穩定排序演算法
3、希爾排序
3.1 定義
希爾排序是簡單插入排序的改進版。它與插入排序的不同之處在於,他會優先比較距離較遠的元素。希爾排序又叫縮小增量排序。
3.2 基本思想
插入排序每次只能將資料移動一位,效率是比較低的,那麼希爾排序其實改進了插入排序的這個缺點。
希爾排序的基本思想:先將整個待排序的資料元素分割成若干子序列分別進行直接插入排序,待將序列中的元素基本有序時,再進行依次的插入排序。
3.3 演算法描述
(1)選擇一個增量序列T1,T2,.....Tk,其中對於Ti > Tj (i > j),Tk = 1;增量因子有很多種取法:最簡單的就是T(i + 1) = Ti / 2;
(2)按增量序列個數k,對序列進行k趟排序;
(3)每趟排序,根據對應的增量Ti,將待排序列分割為若干長度為m的子序列,分別對各子序列進行插入排序。僅當增量因子為1時,整個序列作為一整個序列來處理。
3.4 圖示例
3.5 程式碼實現
public class ShellSort {
public static void shellSort(int[] arr){
int incrementNum = arr.length / 3;
while(incrementNum >= 1){
for(int i = 0; i < arr.length; i++){
// 進行插入排序
for(int j = i; j < arr.length - incrementNum; j = j + incrementNum){
if(arr[j] > arr[j + incrementNum]){
int temp = arr[j];
arr[j] = arr[j + incrementNum];
arr[j + incrementNum] = temp;
}
}
}
// 設定新的增量
incrementNum /= 3;
}
}
// 測試
public static void main(String[] args) {
int[] arr = {70, 30, 40, 10, 80, 20, 90, 100, 75, 60, 45};
shellSort(arr);
System.out.println(Arrays.toString(arr));
}
}
3.6 希爾排序的效能分析
(1)時間複雜度分析:
最好情況時間複雜度:O()
最壞情況時間複雜度:O()
平均時間複雜度為:O(nlog2n)
(2)空間複雜度分析:
空間複雜度為:O(1)
(3)穩定性分析:
插入排序過程中,元素兩兩交換時,相同元素的前後順序發生了改變,所以氣泡排序是一種非穩定排序演算法。
4、選擇排序
4.1 定義
選擇排序(Select Sort):是一種簡單直觀的排序演算法,通過不斷選擇序列中最大(或最小)的元素完成排序。
4.2 基本思想
在要排序的一組數中,選出最小(或者最大)的一個數與第1個位置上的元素交換位置,然後再在剩下的數中找最小(或者最大)的數與第2個位置上的元素交換位置,依次類推,直到第n-1個元素(倒數第二個元素)和第n個元素(最後一個元素)比較為止。
4.3 演算法描述
(1)在原始序列中找到最小(或最大)元素,將其和原始序列的第一個位置上的元素進行位置交換;
(2)再從剩下的未排序元素中找到最小(或最大)元素,然後放到已排序序列的末尾(即第二個元素位置);
(3)重複步驟(2),直到所有元素均有序為止。
4.4 圖示例
4.5 程式碼實現
public class SelectionSort {
public static int[] selectionSort(int[] arr){
int i, j, temp, min, len = arr.length;
// 共進行i-1次大迴圈,最後一個元素自動有序
for(i = 0; i < len - 1; i++){
// 在剩下的無序序列中,找出最小的元素放在位置i處
min = i;
for(j = i + 1; j < len; j++){
if(arr[min] > arr[j]){
min = j; // 從剩下的無序序列中找到最小的元素位置
}
}
temp = arr[min];
arr[min] = arr[i];
arr[i] = temp;
}
return arr;
}
// 測試
public static void main(String[] args) {
int[] arr = {49, 38, 65, 97, 76, 13, 27, 49};
int[] selectionArray = selectionSort(arr);
for(int i = 0; i < selectionArray.length; i++){
System.out.print(selectionArray[i] + ", ");
}
}
}
4.6 選擇排序的效能分析
(1)時間複雜度分析:
當原始序列正序時,也需要進行:(n-1) + (n-2) + 2 + 1次比較和0次位置交換,所以最好情況複雜度為:(O())
當原始序列逆序時,需要進行:(n-1) + (n-2) + 2 + 1次比較和n-1次位置交換,所以最壞情況時間複雜度為:(O())
當原始序列無序時,其平均時間複雜度為:(O())
(2)空間複雜度分析:
選擇排序過程中只需要兩個個臨時變數temp和min,所需要的額外空間為2,所以空間複雜度為O(1)
(3)穩定性分析:
排序過程中,元素兩兩交換時,相同元素的前後順序發生了改變,所以氣泡排序是一種非穩定排序演算法。
因為選擇排序會每次在無序序列裡面選擇最大或者最小的元素放到已經有序的序列最後,如果出現值相等的兩個元素,選擇排序會將後面的元素拿走排到有序序列的最後面,這樣兩個相同元素的位置就發生了改變。例如下面這個序列:
原始序列:【49, 38, 65, 97, 76, 13, 27, 49】
排完序後的序列:【13,27,38,49,49,65,76,97】
可以發現兩個49的前後位置發生了變化,所以選擇排序不是穩定排序演算法。
5、歸併排序
5.1 定義
歸併排序(MERGE-SORT):是建立在歸併操作上的一種有效的排序演算法,該演算法是採用分治思想(Divided and Conquer)的一個非常典型的應用。將已經有序的子序列合併,得到完全有序的序列;即先使每個子序列有序,再使子序列段間有序。若將兩個有序表合併成一個有序表,稱為二路歸併。
5.2 基本思想
把待排序列分為若干個子序列,先使每個子序列是有序的,然後再把有序子序列合併為整體有序序列。
歸併排序使用的就是分治思想。分治,顧名思義就是分而治之,將一個大問題分解成小的問題來解決,小的問題解決了,大的問題自然也就解決了。分治思想和遞迴思想很像,實際上分治演算法一般都是用遞迴來實現的。分治是一種解決問題的處理思想,遞迴是一種程式設計技巧。歸併排序採用的就是分治思想,可以用遞迴程式碼實現。
遞推公式:mergeSort(p...r) = merge(mergeSort(p...q), mergeSort(q...r))
終止條件:p >= r 時不用再繼續分解
5.3 演算法描述
(1)把長度為n的輸入序列分為兩個長度為n/2的子序列;
(2)對這兩個子序列分別採用歸併排序;
(2.1)申請空間,使其大小為兩個已經排序序列之和,該空間用來存放合併後的序列;
(2.2)設定兩個指標,最初位置分別為兩個已經排序序列的起始位置;
(2.3)比較兩個指標所指向的元素,選擇相對小的元素放入合併空間,並移動指標到下一個位置;
(2.4)重複步驟(3),直到某一指標超出序列尾;
(2.5)將另一序列剩下的所有元素直接複製到合併序列尾部。
(3)將排序好的序列再拷貝回原陣列。
5.4 圖示例
5.5 程式碼實現
public class MergeSort {
public static int[] mergeSort(int[] array, int low, int high){
int mid = low + (high - low) / 2; // 將當前序列分為兩個子序列
if(low < high){
// 左邊
mergeSort(array, low, mid);
// 右邊
mergeSort(array, mid + 1, high);
// 左右歸併排序
merge(array, low, mid, high);
}
return array;
}
public static void merge(int[] array, int low, int mid, int high) {
int[] temp = new int[high - low + 1]; // 臨時陣列
int i = low; // 左指標
int j = mid + 1; // 右指標
int k = 0; // 臨時陣列中的指標
// 把較小的數放到臨時陣列中存放
while(i <= mid && j <= high){
if(array[i] <= array[j]){ // 注意:這裡必須是<=,如果不加=,則不能保證穩定性,左邊的值小,相同時,應該是左邊的元素先插入
temp[k++] = array[i++]; // 將array[i]放到temp[k]處
}else{
temp[k++] = array[j++]; // 將array[j]放到temp[k]處
}
}
// 當i <= mid時,說明左邊序列中元素有剩餘,則把剩餘的全部元素移動到臨時陣列
while(i <= mid){
temp[k++] = array[i++];
}
// 當j <= high時,說明右邊序列中元素有剩餘,則把剩餘的全部元素移動到臨時陣列
while(j <= high){
temp[k++] = array[j++];
}
// 將temp陣列覆蓋掉array陣列,m + low是原array陣列的開始下標即為low
for(int m = 0; m < temp.length; m++){
array[m + low] = temp[m];
}
}
// 測試
public static void main(String[] args) {
int[] array = {32, 12, 56, 78, 76, 45, 36};
mergeSort(array, 0, array.length - 1);
System.out.println(Arrays.toString(array));
}
}
5.6 歸併排序的效能分析
(1)時間複雜度分析:
因為歸併排序採用的是遞迴的實現方式,所以時間複雜度分析起來相對來說複雜一些,為了搞清楚整個過程,在這裡做下詳細的分析。
首先回想下遞迴的適用場景,一個問題A可以分解為多個子問題B和C,那麼求解問題A就可以分解成求解問題B和問題C,待問題B和C解決後,我們再將B和C的求解結果合併起來,就是A的結果了。
那麼下面我們定義求解問題A的時間函式是:T(A),求解問題B和C的時間函式分別為:T(B)和T(C),那麼就有:
T(A) = T(B) + T(C) + K (K:將B和C合併成問題A的結果時所消耗的時間)
由上面的分析我們可以得出這樣的一個結論:不僅遞迴求解的問題可以寫成遞推公式,遞迴程式碼的複雜度也可以寫成遞推公式。
那麼下面我們就用這個公式來分析下歸併排序的時間複雜度。我們假設對n個元素進行歸併排序所需要的時間是T(n),那麼拆解為兩個子陣列排序的時間都是T(n/2)。而merge()函式合併兩個子陣列的時間複雜度為O(n)【n次插入和n/2次比較】,所以歸併排序的時間複雜度的計算公式為:
T(1) = C; n = 1時,只需要常量級的執行時間
T(n) = 2 * T(n / 2) + n; n > 1
下面分解下這個過程:
T(n) = 2 * T(n / 2) + n
= 2 * (2 * T(n / 4) + n / 2) + n = 4 * T(n / 4) + 2 * n
= 4 * (2 * T(n / 8)) + n / 4) + 2 * n = 8 * T(n / 8) + 3 * n
.......................
= 2^k * T(n / 2^k) + k * n
則:T(n) = 2^k * T(n / 2^k) + k * n;當T(n / 2^k) = T(1)的時候,即:n / 2^k = 1,所以k = log2n,再將k代入T(n)中,可以得到:T(n) = Cn + nlog2n。則規定排序的時間複雜度為:O(nlog2n)。
從整個分析過程可以看出來,歸併排序的執行效率與要排序的原始陣列中元素的有序程度無關,所以其時間複雜度是非常穩定的,則其:
最好情況時間複雜度為:O(nlog2n);
最壞情況時間複雜度為:O(nlog2n)
平均時間複雜度為:O(nlog2n)
(2)空間複雜度分析:
從分析過程可以看出來,歸併排序不是原地排序。再合併兩個有序子陣列時,需要藉助額外的儲存空間。但是需要注意的是儘管每次合併操作都需要申請額外的記憶體空間,但在合併完成之後,臨時開闢的記憶體空間就被釋放掉了,在任意時刻,CPU只會有一個函式在執行,也只會有一個臨時的記憶體空間在使用,臨時記憶體空間最大也不會超過n個數據的大小,所以:
歸併排序的空間複雜度為:O(n)
(3)穩定性分析:
排序過程中,元素兩兩交換時,相同元素的前後順序沒有發生改變,所以歸併排序是一種穩定排序演算法。
6、快速排序
6.1 定義
快速排序(Quick Sort):是一種典型的交換排序演算法,其通過不斷的比較和移動來實現排序。其排序過程速度快、效率高。
6.2 基本思想
快速排序將關鍵字大的元素從前面移動到後面,關鍵字小的元素從後面直接移動到前面,從而減少了總的比較次數和移動次數,同時採用”分而治之“的思想,把大的拆分為小的,小的再拆分為更小的,其原理如下:
對於給定的一組元素,選擇一個基準元素,通常選擇第一個或者最後一個,通過一趟掃描,將序列分為兩個部分,一部分比基準元素小,一部分比基準元素大,此時基準元素的的位置就是在整個序列有序後它的位置,然後用同樣的方法遞迴地將劃分的兩個部分再進行上面的操作進行排序,直到序列中所有的元素都有序了為止。
快速排序的虛擬碼:
// 快速排序,A是陣列,n表示陣列的大小
quickSort(A, 0, n-1){
// 快速排序遞迴函式,p,r為下標
if p >= r return;
q = partition(A, p, r) ; // 獲取基準點
quickSort(A, p, q-1); // 前半部分排序
quickSort(A, q+1, r); // 後半部分排序
}
6.3 演算法描述
(1)選擇一個基準點元素,通常選擇第一個或者最後一個;
(2)然後分別從陣列的兩端掃描陣列,設兩個指示標誌(low指向起始為止,high指向末尾),首先從後半部分開始掃描,掃描時high指標跟著移動,如果發現有元素比該基準點的值小,則就交換low和high位置上的元素。然後再從前半部分開始掃描,掃描時low指標跟著移動,如果發現有元素比基準點的值大時,則交換high和low位置上的元素
(3)迴圈(2),直到lo >= hi,然後把基準點的值放到high這個位置,一次排序就完成了;
(4)採用遞迴的方式分別對前半部分和後半部分進行排序,當前半部分和後半部分均有序時,整個陣列自然也就有序了。
6.4 圖示例
6.5 程式碼實現
public class QuickSort {
public static void quickSort(int[] arr, int low, int high){
if(low >= high){
return;
}
// 進行第一輪排序獲取分割點
int index = partition(arr, low, high);
// 排序前半部分
quickSort(arr, low, index - 1);
// 排序後半部分
quickSort(arr, index + 1, high);
}
/**
* 一次快速排序
* @param arr 陣列
* @param low 陣列的前下標
* @param high 陣列的後下標
* @return key的下標index,也就是分片的間隔點
*/
public static int partition(int[] arr, int low, int high){
// 固定的切分方式
int key = arr[low]; // 選區陣列的前下標上的元素為基準點
while(low < high){
// 從後半部分往前掃描
while(high > low && arr[high] >= key){
high--;
}
arr[low] = arr[high]; // 交換位置,把後半部分比基準點位置元素值小的元素交換到前半部分的low位置處
// 從前半部分往後掃描
while(high > low && arr[low] < key){
low++;
}
arr[high] = arr[low]; // 交換位置,把前半部分比基準點位置元素值大的元素交換到後半部分的high位置處
}
arr[high] = key; // 最後把基準存入
return high;
}
// 測試案例
public static void main(String[] args) {
int[] arr = {49, 38, 65, 97, 76, 13, 27, 49};
quickSort(arr, 0, arr.length-1);
for(int i:arr){
System.out.print(i+",");
}
}
}
6.6 快速排序的效能分析
(1)時間複雜度分析:
快速排序採用的也是遞迴的方式,所有也可以使用時間複雜度的遞推公式:
T(1) = C; n = 1時,只需要常量級的執行時間
T(n) = 2 * T(n / 2) + n; n > 1
但是,公式成立的前提是:每次分割槽操作,我們選擇的分割槽基準點pivot都很合適,正好能將大區間對等地一分為二,所以快速排序的最好情況時間複雜度為:O(nlogn)
然而實際上,每次分割槽都能將其一分為二是很難實現的。如果陣列中的原始資料已經有序了,比如陣列A = {1,3,5,7,9},如果我們每次選擇第一個元素或者最後一個元素作為pivot,那麼每次分割槽得到的兩個區間都是不平等的。我們需要進行大約n次分割槽操作,才能完成整個快排過程。每次分割槽我們平均要掃描大約n/2個元素,所以快速排序的最壞情況時間複雜度為:O(),其實這個時候快速排序就退化成了氣泡排序;
我們可以利用遞迴樹求解出:快速排序的平均時間複雜度為:O(nlogn),只有在極端情況下會退化到O()
(2)空間複雜度分析:
空間複雜度為:O(n)
(3)穩定性分析:
排序過程中,元素兩兩交換時,相同元素的前後順序發生了改變,所以歸併排序是一種非穩定排序演算法。
6.7 快速排序的改進
綜合上面的分析,我們總結下快速排序的優缺點:
優點:(1)對於當資料量很大的時候,快速排序很容易將某個元素放到對應的位置;
缺點:(1)如果原始陣列就是有序的,那麼快速排序過程中對序列的劃分會十分的不均勻,將序列劃分為:1和n-1大小(時間複雜度為:O()),但是我們理想狀態下是二分(時間複雜度為:O(nlogn))
(2)對於小陣列進行排序時,也需要遞迴好幾次才能將資料放到正確的位置上;
(3)快速排序不是穩定的排序演算法,當重複資料比較多時,效率比較低。
那麼下面就分別針對上面所列的三個缺點提出三種改進版的快速排序演算法:
【1】三數取中法:優化分割槽時選取基準點pivot
由於快速排序在原始資料有序時,將退化為氣泡排序,其事件複雜度為O()。解決的辦法就是找一個可能在檔案的中間位置的元素作為pivot。則可以選取陣列中最左邊元素、最右邊元素以及中間元素中中間大小的元素作為pivot,這樣使得最壞情況幾乎不可能再發生,其次它減少了觀察哨的需要。
// 三數取中
// 下面兩步保證了array[high]是最大的
int mid = low + (high - low) / 2;
if(array[mid] > array[hi]){
swap(array[mid], array[high]);
}
if(array[low] > array[high]){
swap(array[low], array[high])
}
// 下面這一步只用比較array[low]和array[mid],讓兩者較大的在array[low]位置上
if(array[mid] > array[low]){
swap(array[mid], array[low]);
}
int key = array[low];
public void swap(arr[a], arr[b]){
int t;
t = arr[a];
arr[a] = arr[b];
arr[b] = t;
}
【2】序列較小時,使用插入排序代替快速排序
快速排序在針對大檔案(陣列length比較大的)有很大的優勢,但是對於小檔案其優勢將被削弱。對於基本的快速排序中,當遞迴到後面時【分割槽越來越小】,程式會呼叫自身的許多小檔案,需要遞迴好幾次才能將資料放入到正確的位置,因而在遇到子檔案時需要我們對傳統的快速排序演算法進行改進。一種方法就是:每次遞迴開始之前對檔案的大小進行測試,如果小於設定值,則將呼叫插入排序【插入排序對小檔案的排序比較好】
private static final int M = 10;
public void quickSort(int[] arr, int low, int high){
if(low >= high) return;
if(high - low <= M) return; // 小陣列不用排序
int i = partition(arr, low, high);
quickSort(arr, low, i - 1); // 左邊排序
quickSort(arr, i + 1, high); // 右邊排序
}
public void sort(int[] arr, int low, int high){
quickSort(arr, low, high);
insertionSort(arr, low, high); // 小資料時使用插入排序
}
public void partition(int[] arr, int low, int high){
// ...省略
}
【3】重複元素較多時,使用三分割槽法
通過劃分讓相等的元素連續地擺放,然後只對左側小於V的序列和右側大於V的序列進行排序。
如上圖所示,從左至右掃描陣列,維護一個指標lt使得[ lo...lt - 1]中的元素都比V小,一個指標gt使得所有[ gt + 1 ... hi ]的元素都大於V,以及一個指標i,使得所有[ lt ... i - 1 ]的元素都和V相等。元素[i...gt]之間是還沒處理到的元素,從lo開始,從左至右開始掃描:
1、如果a[ i ] < V:交換a[ lt ]和a[ i ],lt和i自增;
2、如果a[ i ] > V:交換a[ i ]和a[ gt ],gt自減;
3、如果a[ i ] = V:i自增
下面的7、8、9三個小節分別對同桶排序、計數排序以及基數排序進行講解。這些排序演算法的時間複雜度都是線性的,所以把這類排序演算法叫做線性排序(Linear Sort)。之所以能夠做到線性的時間複雜度,主要原因是,這三個排序演算法是非基於比較的排序演算法,都不涉及到元素之間的比較操作。
這幾種排序演算法理解起來不難,時間和空間複雜度也很簡單,但是對要排序的資料要求很苛刻,所以重點學習這些排序演算法的適用場景。
7、桶排序
7.1 定義
桶排序(Bucket Sort):又被稱為:箱排序,是非基於比較的排序演算法,因此其時間複雜度是線性的,為O(n)。
7.2 基本思想
桶排序的基本思想:將要排序的資料分到幾個有序的桶裡,每個桶裡的資料再單獨進行排序,有可能再使用其他排序演算法或是遞迴的方式繼續使用桶排序進行排序。桶內排完序後,再把每個桶裡的資料按照順序依次取出,組成的序列就是有序的了。當要被排序的陣列內數值是均勻分配的時候,桶排序使用線性時間O(n)。
7.3 演算法描述
(1)設定一個定量的陣列作為空桶;
(2)遍歷數列,並且把資料元素挨個放到對應的桶中;
(3)對每個不是空的桶子進行排序;
(4)從不是空的桶子裡把專案再放回原來的序列裡。
7.4 圖示例
7.5 程式碼實現
public class BucketSort {
/**
* @param arr:待排序陣列
* @param max:陣列中的最大值的範圍
*/
public static void bucketSort(int[] arr, int max) {
int[] buckets;
if (arr.length == 0) {
return;
}
// 建立一個容量為max的陣列buckets,並且將buckets中的所有元素初始化為0
buckets = new int[max];
// 1.計數 統計陣列中相同值大小的個數,並將這個數放入到對應的桶中,值是多少,就放入到幾號桶中
for (int i = 0; i < arr.length; i++) {
buckets[arr[i]]++;
}
// 2.排序,這裡是每個桶僅僅對應一個數,所以無需在單個桶中進行排序了,但是如果是一個桶中分了多個數據,那麼還要繼續在單個桶中進行排序
for (int i = 0, j = 0; i < arr.length; i++) {
while ((buckets[i]--) > 0) {
arr[j++] = i;
}
}
buckets = null;
}
public static void main(String[] args) {
int arr[] = { 8, 2, 3, 4, 3, 6, 6, 3, 9 };
bucketSort(arr, 10); // 桶排序
System.out.