tip:這個算法還是有點難度的,而且有些地方只可意會不可言傳,很難用文字表述出來,也可能我語言表達能力不行😂,反正讀者要多動腦筋。而且我也可能有寫錯想錯的地方,畢竟我也是剛學,如果有大佬看出問題了,請務必跟我說,我好改正,謝謝了。
簡單介紹
快速排序(Quicksort) 是對 冒泡排序的一種改進。
基本思想
快速排序算法通過多次比較和交換來實現排序,其排序流程如下:
(1)首先設定一個分界值(基准值),通過該分界值將數組分成左右兩部分。
(2)將大於或等於分界值的數據集中到數組右邊,小於分界值的數據集中到數組的左邊。此時,左邊部分中各元素都小於或等於分界值,而右邊部分中各元素都大於或等於分界值。
(3)然后,左邊和右邊的數據可以獨立排序。對於左側的數組數據,又可以取一個分界值,將該部分數據分成左右兩部分,同樣在左邊放置較小值,右邊放置較大值。右側的數組數據也可以做類似處理。
(4)重復上述過程,可以看出,這是一個遞歸定義。通過遞歸將左側部分排好序后,再遞歸排好右側部分的順序。當左、右兩個部分各數據排序完成后,整個數組的排序也就完成了。
該思想可以概括為:挖坑填數 + 分治法。
比如如下的示意圖:
- 上圖以 最后一個元素的值 作為基准
- 比基准值小的,排在左側,比基准值大的排在右側
- 然后再以分好的部分重復以上操作,直到每個部分中只有一個數據時,就排好序了
思路分析
基本思想如上,但是實現思路有多種,一般取基准值都是以首尾或者中間,這里使用 數組中間 的作為基准值,進行講解。
-
原始數組:
arr = [-9,78,0,23,-567-70]
-
設置兩個變量,左下標
L = 0
,右下標R = 數組大小 - 1
,選數組中間的值為基准值,pivot = arr[(L + R )/ 2]
,基准值為 0。再用兩個變量保存 左下標 和 右下標,left = L
和right = R
,用於后面的排序做准備。可以看到,pivot把數組分成了兩組
-
左邊一組,從左到右,挨個與 基准值 比較,找出比基准值大的值,跳出查找循環
-
右邊一組,從右到左,挨個與 基准值 比較,找出比基准值小的值,跳出查找循環
-
可以看到左右兩組各找到一個對應的值,那么就讓他們進行交換
-
然后繼續找,直到左右兩邊碰到,
可以看到左邊的組已經找完了,
L
指向了基准值,那就跳出查找循環,右邊組開始查找,但是我們可以看到右邊的數也都符合規則,所以
R
也循環遍歷到了基准值的位置,此時L
和R
已經碰到一起了,這一輪就結束。這一輪就稱為快速排序。繼續對分出來的小組,進行上述的快速排序操作,直到組內只剩下一個數時,則排序完成。
-
將
L
和R
分別前進一步和后退一步,如圖,就可以再次利用這兩個變量,對兩組進行快速排序了。
-
先對左邊的組進行快速排序,同樣進行上面的操作,
這里就用到了上一次快速循環所保存的
left
和right(上圖沒有畫出來)
了。 -
因為這里
left
直接就指向了pivot
,所以不用進行移動查找了。R
跟pivot
進行比較 ,-567 < -9 ,R
和left
進行交換,得到如下圖,注:pivot
是一個值,不是引用類型因為
R
和left
沒有碰頭,所以還得進行一次循環比較。因為R
就在基准點這,所以不移動,R
和pivot
比較,-9 > -567 , 所以left
前進一步,此時
R
和left
已經碰到一起了,這一輪就結束了。 -
右邊的組也是同樣的道理,這里就不作過多的解析了
l ------------ pivot --------------- r
一組從左往右找 一組從右往左找
可以看到,分組后,可以使用遞歸,對這一組繼續分組,然后對他們進行快速排序。
代碼實現
推導實現
推導法先實現第一輪
@Test
public void processDemo() {
int arr[] = {-9, 78, 0, 23, -567, 70};
System.out.println("原始數組:" + Arrays.toString(arr));
processQuickSort(arr, 0, arr.length - 1);
}
/**
* @param arr
* @param left 左邊這一組的下標起始點,到中間值,則為一組
* @param right 右邊這一組的下標結束點,到中間值,則為一組
*/
public void processQuickSort(int[] arr, int left, int right) {
/*
基本思想:選擇一個基准值,將基准值小分成一組,比基准值大的分成一組。
這里的實現思路:
1. 挑選的基准值為數組中間的值
2. 中間值就把數組分成了兩組
3. 左邊一組,從左到右,挨個與 基准值 比較,找出比基准值大的值
4. 右邊一組,從右到左,挨個與 基准值 比較,找出比基准值小的值
5. 左右兩邊各找到一個值,那么就讓這兩個值進行交換
6. 然后繼續找,直到左右兩邊碰到,這一輪就結束。這一輪就稱為快速排序
7. 繼續對分出來的小組,進行上述的快速排序操作,直到組內只剩下一個數時,則排序完成
l ------------ pivot --------------- r
一組從左往右找 一組從右往左找
*/
int l = left;
int r = right;
// 中心點,讓這個點作為基准值
int pivot = arr[(left + right) / 2];
// 當他們沒有碰到的時候,說明還這一輪還可以繼續找
while (l < r) {
// 左邊組:當找到大於基准值時,退出循環,則表示該值需要交換到右側去: arr[l] > pivot
// 也就是說,如果 arr[l] < pivot,則表示還沒有找到比基准值大的數
// 注意:不能等於 pivort,因為最差的情況沒有找到,則最后 arr[l] 就是 pivot 這個值,也同樣退出循環
while (arr[l] < pivot) {
l++; // 所以讓左邊這一組繼續找
}
// 右邊組:當找到小於基准值時,退出循環,則表示該值需要交換到左側去:arr[r] < pivot
// 也就是說,如果 arr[l] > pivot,則表示還沒有找到比基准值小的數
//注意:這里也同樣跟上面一樣,不能等於 pivort
while (arr[r] > pivot) {
r--;//讓右邊組繼續找
}
// 當左側與右側相碰時,說明兩邊都沒有找到,這一輪不用進行交換
// 等於表示,找到了中間的下標
if (l >= r) {
break;
}
// 當找到時,則兩數進行交換。
//注意:這里可能會出現有一組已經找完了,或者沒有找到,但是另一組找到了,所以一個是指向 pivot 的,
//另一個則是指向要交換的數,交換后 pivot的值在數組中的位置會發生改變,下次的交換方式就會發生變化。這個地方要動腦筋想想。
int temp = arr[l];
arr[l] = arr[r];
arr[r] = temp;
// 當交換后,
// 當數組是: {-9, 78, 0, -23, 0, 70} 時(pivot的值在數組中有多個),就可以驗證這里的邏輯
// 如果沒有這個判定,將會導致,l 永遠 小於 r。循環不能退出來的情況,就出現死循環了
if (arr[l] == pivot) {
/*
l 自己不能往前移動 一步,因為當交換完成后為:{-9, 0, 0, -23, 78, 70}
l = 1,arr[l] = 0
r = 4,arr[r] = 78
再經過一次循環后
l = 1,arr[l] = 0
r = 3,arr[r] = -23
交換后數組為:{-9,-23,0,0,78,70}
此時 l = 1,arr[l] = -23;r = 3,arr[r] = 0
又經過一次循環后
l = 2,arr[l] = 0
r = 3,arr[r] = 0
數組為:{-9,-23,0,0,78,70}
進入了死循環
這里好好動腦子想想
這里為什么是用r-=1呢?是因為if里面的條件是arr[l] == pivot,如果要排序的數組中不存在多個和基准值相等的值,
那么用l+=1的話,l就會跑過分界線(基准值),跑到另一組去,這個算法也就失敗了,
還有一個原因是,r 是剛剛交換過的,一定比 基准值大,所以沒有必要再和基准值比較了
*/
r -= 1;
}
// 這里和上面一致,如果說,先走了上面的 r-=1
// 這里也滿足(也就是說有多個值和基准值相等),那么說明,下一次是相同的兩個值,一個是 r == 一個是基准值
// 但是他們是相同的值,r后退一步 l前進一步,不影響。但是再走這完這里邏輯時,就會導致 l > r,退出整個循環
if (arr[r] == pivot) {
l += 1;
}
}
System.out.println("第 1 輪排序后:" + Arrays.toString(arr));
}
注意:上述的算法特別是邊界判定,就是上面「當交換后」對 r-=1
的這個邊界判定時,有點難以理解,但是一定要理解為什么要這樣寫。
測試信息輸出
原始數組:[-9, 78, 0, 23, -567, 70]
第 1 輪排序后:[-9, -567, 0, 23, 78, 70]
那么如何向左遞歸和右遞歸呢?在上面的代碼后面接着實現如下
System.out.println("第 1 輪排序后:" + Arrays.toString(arr));
/*
if (l >= r) {
break;
}
循環從這條代碼中跳出就會l = r
*/
// 如果 l = r,會出現死循環,出現棧溢出
//這里也要動腦子想想
if (l == r) {
l++;
r--;
}
// 開始左遞歸
// 上面算法是 r--,l++ ,往兩組的中間走,當 left < r 時,表示還可以繼續分組
if (left < r) {
processQuickSort(arr, left, r);
}
if (right > l) {
processQuickSort(arr, l, right);
}
完整實現
完整實現和推導實現其實差不多了,為了加深記憶,自己按照基本思想和思路分析,默寫。
/**
* 快速排序默寫實現
*
* 基本思想:通過一趟將要排序的數據,分隔成獨立的兩個部分,一部分的所有數據都比另一部分的所有數據要小。
* 思路分析:
* {-9, 78, 0, 23, -567, 70}; length=6
* 1. 挑選中間的值作為 基准值:(0 + (6 -1))/2= [2] = 0
* 2. 左側 left 部分,從 0 開始到中間值 -1: 0,1: -9, 78,找出一個比基准值大的數
* 3. 右側 right 部分,從中間值 + 1 到數組大小-1:3,5:23,-567, 70,找出一個比基准值小的數
* 4. 如果找到,則將他們進行交換,這樣一輪下來,就完成了一次快速排序:一部分的所有數據都比另一部分的所有數據要小。
* 4. 如果左側部分還可以分組,則進行左側遞歸調用
* 5. 如果右側部分還可以分組,則進行右側遞歸調用
*
* 簡單說:一輪快速排序示意圖如下:
* 中間的基准值
* l ------------ pivot --------------- r
* 一組從左往右找 一組從右往左找
* 找到比基准值大的數 找出一個比基准值小的數
* 然后進行交換
*
*/
@Test
public void quickSortTest() {
int arr[] = {-9, 78, 0, 23, -567, 70};
// int arr[] = {-9, 78, 0, -23, 0, 70}; // 在推導過程中,將會導致交換異常的數組,在這里不會出現那種情況
int left = 0;
int right = arr.length - 1;
System.out.println("原始數組:" + Arrays.toString(arr));
quickSort(arr, left, right);
System.out.println("排序后:" + Arrays.toString(arr));
}
public void quickSort(int[] arr, int left, int right) {
// 找到中間值
int pivotIndex = (left + right) / 2;
int pivot = arr[pivotIndex];
int l = left;
int r = right;
while (l < r) {
// 從左往右找,直到找到一個數,比基准值大的數
while (arr[l] < pivot) {
l++;
}
// 從右往左找,知道找到一個數,比基准值小的數
while (arr[r] > pivot) {
r--;
}
// 表示未找到
if (l >= r) {
break;
}
// 進行交換
int temp = arr[l];
arr[l] = arr[r];
arr[r] = temp;
// 那么下一輪,左側的這個值將不再參與排序,因為剛交換過,一定比基准值小
// 那么下一輪,右側的這個值將不再參與排序,因為剛交換過,一定比基准值大
r--;
l++;
}
// 當一輪找完后,沒有找到,則是中間值時,
// 需要讓他們擦肩而過,也就是重新分組,中間值不再參與分組
// 否則,在某些情況下,會進入死循環
if (l == r) {
l++;
r--;
}
// 如果左側還可以繼續分組,則繼續快排
// 由於擦肩而過了,那么左側的組值,則是最初的開始與中間值的前一個,也就是這里得到的 r
if (left < r) {
quickSort(arr, left, r);
}
if (right > l) {
quickSort(arr, l, right);
}
}
另外,在實現的過程中,將某些代碼為什么要那樣判斷邊界,進行了梳理。你會發現上述代碼和推導的代碼有一個地方不一樣。這個是我自己按邏輯進行的改進,更容易看明白一些。目前未發現 bug,如果有錯請評論指出,畢竟這個算法還是有點難的。
大數據量耗時測試
/**
* 大量數據排序時間測試
*/
@Test
public void bulkDataSort() {
int max = 80000;
// int max = 8;
int[] arr = new int[max];
for (int i = 0; i < max; i++) {
arr[i] = (int) (Math.random() * 80000);
}
if (arr.length < 10) {
System.out.println("原始數組:" + Arrays.toString(arr));
}
Instant startTime = Instant.now();
// processQuickSort(arr, 0, arr.length - 1); // 和老師的原版代碼對比,結果是一樣的
quickSort(arr, 0, arr.length - 1);
if (arr.length < 10) {
System.out.println("排序后:" + Arrays.toString(arr));
}
Instant endTime = Instant.now();
System.out.println("共耗時:" + Duration.between(startTime, endTime).toMillis() + " 毫秒");
}
多次運行輸出
共耗時:40 毫秒
共耗時:52 毫秒
共耗時:36 毫秒
共耗時:31 毫秒
性能分析
快速排序的一次划分算法從兩頭交替搜索,直到low和hight重合,因此其時間復雜度是O(n);而整個快速排序算法的時間復雜度與划分的趟數有關。
理想的情況是,每次划分所選擇的中間數恰好將當前序列幾乎等分,經過log2n趟划分,便可得到長度為1的子表。這樣,整個算法的時間復雜度為O(nlog2n)。
最壞的情況是,每次所選的中間數是當前序列中的最大或最小元素,這使得每次划分所得的子表中一個為空表,另一子表的長度為原表的長度-1。這樣,長度為n的數據表的快速排序需要經過n趟划分,使得整個排序算法的時間復雜度為O(n2)。
為改善最壞情況下的時間性能,可采用其他方法選取中間數。通常采用“三者值取中”方法,即比較H->r[low].key、H->r[high].key與H->r[(low+high)/2].key,取三者中關鍵字為中值的元素為中間數。
可以證明,快速排序的平均時間復雜度也是O(nlog2n)。因此,該排序方法被認為是目前最好的一種內部排序方法。
從空間性能上看,盡管快速排序只需要一個元素的輔助空間,但快速排序需要一個棧空間來實現遞歸。最好的情況下,即快速排序的每一趟排序都將元素序列均勻地分割成長度相近的兩個子表,所需棧的最大深度為log2(n+1);但最壞的情況下,棧的最大深度為n。這樣,快速排序的空間復雜度為O(log2n)。