快速排序由於排序效率在同為 O(nlogn) 的幾種排序方法中效率最高,因此經常被采用。再加上快速排序思想——分治法也確實非常實用,所以 在各大廠的面試習題中,快排總是最耀眼的那個。要是你會的排序算法中沒有快速排序,我想你還是偷偷去學好它,再去向大廠砸簡歷。
事實上,在我們的諸多高級語言中,都能找到它的某種實現版本,那我們 Java 自然不能在此缺席。
總的來說,默寫排序代碼是南塵非常不推薦的,撇開快排的代碼不是那么容易默寫,即使你能默寫快排代碼,也總會因為面試官稍微的變種面試導致你惶恐不安。
基本思想
快速排序使用分治法策略來把一個序列分為兩個子序列,基本步驟為:
- 先從序列中取出一個數作為基准數;
- 分區過程:將把這個數大的數全部放到它的右邊,小於或者等於它的數全放到它的左邊;
- 遞歸地對左右子序列進行不走2,直到各區間只有一個數。

雖然快排算法的策略是分治法,但分治法這三個字顯然無法很好的概括快排的全部不走,因此借用 CSDN 神人 MoreWindows 的定義說明為:挖坑填數 + 分治法。
似乎還是不太好理解,我們這里就直接借用 MoreWindows 大佬的例子說明。
以一個數組作為示例,取區間第一個數為基准數。
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
---|---|---|---|---|---|---|---|---|---|
72 | 6 | 57 | 88 | 60 | 42 | 83 | 73 | 48 | 85 |
初始時,i = 0; j = 9; temp = a[i] = 72
由於已經將 a[0] 中的數保存到 temp 中,可以理解成在數組 a[0] 上挖了個坑,可以將其它數據填充到這來。
從 j 開始向前找一個比 temp 小或等於 temp 的數。當 j = 8,符合條件,將 a[8] 挖出再填到上一個坑 a[0] 中。
a[0] = a[8]; i++; 這樣一個坑 a[0] 就被搞定了,但又形成了一個新坑 a[8],這怎么辦了?簡單,再找數字來填 a[8] 這個坑。這次從i開始向后找一個大於 temp 的數,當 i = 3,符合條件,將 a[3] 挖出再填到上一個坑中 a[8] = a[3]; j--;
數組變為:
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
---|---|---|---|---|---|---|---|---|---|
48 | 6 | 57 | 88 | 60 | 42 | 83 | 73 | 88 | 85 |
i = 3; j = 7; temp = 72
再重復上面的步驟,先從后向前找,再從前向后找。
從 j 開始向前找,當 j = 5,符合條件,將 a[5] 挖出填到上一個坑中,a[3] = a[5]; i++;
從i開始向后找,當 i = 5 時,由於 i==j 退出。
此時,i = j = 5,而a[5]剛好又是上次挖的坑,因此將 temp 填入 a[5]。
數組變為:
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
---|---|---|---|---|---|---|---|---|---|
48 | 6 | 57 | 42 | 60 | 72 | 83 | 73 | 88 | 85 |
可以看出 a[5] 前面的數字都小於它,a[5] 后面的數字都大於它。因此再對 a[0…4] 和 a[6…9] 這二個子區間重復上述步驟就可以了。
對挖坑填數進行總結
1.i = L; j = R; 將基准數挖出形成第一個坑 a[i]。
2.j-- 由后向前找比它小的數,找到后挖出此數填前一個坑 a[i] 中。
3.i++ 由前向后找比它大的數,找到后也挖出此數填到前一個坑 a[j] 中。
4.再重復執行 2,3 二步,直到 i==j,將基准數填入 a[i] 中。
有了這樣的分析,我們明顯能寫出下面的代碼:
public class Test09 { private static void printArr(int[] arr) { for (int anArr : arr) { System.out.print(anArr + " "); } } private static int partition(int[] arr, int left, int right) { int temp = arr[left]; while (right > left) { // 先判斷基准數和后面的數依次比較 while (temp <= arr[right] && left < right) { --right; } // 當基准數大於了 arr[right],則填坑 if (left < right) { arr[left] = arr[right]; ++left; } // 現在是 arr[right] 需要填坑了 while (temp >= arr[left] && left < right) { ++left; } if (left < right) { arr[right] = arr[left]; --right; } } arr[left] = temp; return left; } private static void quickSort(int[] arr, int left, int right) { if (arr == null || left >= right || arr.length <= 1) return; int mid = partition(arr, left, right); quickSort(arr, left, mid); quickSort(arr, mid + 1, right); } public static void main(String[] args) { int[] arr = {6, 4, 3, 2, 7, 9, 1, 8, 5}; quickSort(arr, 0, arr.length - 1); printArr(arr); } }
我們不妨嘗試來對這個算法進行一下時間復雜度的分析:
-
最好情況
在最好的情況下,每次我們進行一次分區,我們會把一個序列剛好分為幾近相等的兩個子序列,這個情況也我們每次遞歸調用的是時候也就剛好處理一半大小的子序列。這看起來其實就是一個完全二叉樹,樹的深度為 O(logn),所以我們需要做 O(logn) 次嵌套調用。但是在同一層次結構的兩個程序調用中,不會處理為原來數列的相同部分。因此,程序調用的每一層次結構總共全部需要 O(n) 的時間。所以這個算法在最好情況下的時間復雜度為 O(nlogn)。
事實上,我們並不需要如此精確的分區:即使我們每個基准值把元素分開為 99% 在一邊和 1% 在另一邊。調用的深度仍然限制在 100logn,所以全部運行時間依然是 O(nlogn)。
-
最壞情況
事實上,我們總不能保證上面的理想情況。試想一下,假設每次分區后都出現子序列的長度一個為 1 一個為 n-1,那真是糟糕透頂。這一定會導致我們的表達式變成:
T(n) = O(n) + T(1) + T(n-1) = O(n) + T(n-1)
這和插入排序和選擇排序的關系式真是如出一轍,所以我們的最壞情況是 O(n²)。
找到更好的基准數
上面對時間復雜度進行了簡要分析,可見我們的時間復雜度和我們的基准數的選擇密不可分。基准數選好了,把序列每次都能分為幾近相等的兩份,我們的快排就跟着吃香喝辣;但一旦選擇的基准數很差,那我們的快排也就跟着窮困潦倒。
所以大家就各顯神通,出現了各種選擇基准數的方式。
-
固定基准數
上面的那種算法,就是一種固定基准數的方式。如果輸入的序列是隨機的,處理時間還相對比較能接受。但如果數組已經有序,用上面的方式顯然非常不好,因為每次划分都只能使待排序序列長度減一。這真是糟糕透了,快排淪為冒泡排序,時間復雜度為 O(n²)。因此,使用第一個元素作為基准數是非常糟糕的,我們應該立即放棄這種想法。
-
隨機基准數
這是一種相對安全的策略。由於基准數的位置是隨機的,那么產生的分割也不會總是出現劣質的分割。但在數組所有數字完全相等的時候,仍然會是最壞情況。實際上,隨機化快速排序得到理論最壞情況的可能性僅為1/(2^n)。所以隨機化快速排序可以對於絕大多數輸入數據達到 O(nlogn) 的期望時間復雜度。
-
三數取中
雖然隨機基准數方法選取方式減少了出現不好分割的幾率,但是最壞情況下還是 O(n²)。為了緩解這個尷尬的氣氛,就引入了「三數取中」這樣的基准數選取方式。
三數取中法實現
我們不妨來分析一下「三數取中」這個方式。我們最佳的划分是將待排序的序列氛圍等長的子序列,最佳的狀態我們可以使用序列中間的值,也就是第 n/2 個數。可是,這很難算出來,並且會明顯減慢快速排序的速度。這樣的中值的估計可以通過隨機選取三個元素並用它們的中值作為基准元而得到。事實上,隨機性並沒有多大的幫助,因此一般的做法是使用左端、右端和中心位置上的三個元素的中值作為基准元。顯然使用三數中值分割法消除了預排序輸入的不好情形,並且減少快排大約 5% 的比較次數。
我們來看看代碼是怎么實現的。
public class Test09 { private static void swap(int[] arr, int i, int j) { int temp = arr[i]; arr[i] = arr[j]; arr[j] = temp; } private static void printArr(int[] arr) { for (int anArr : arr) { System.out.print(anArr + " "); } } private static int partition(int[] arr, int left, int right) { // 采用三數中值分割法 int mid = left + (right - left) / 2; // 保證左端較小 if (arr[left] > arr[right]) swap(arr, left, right); // 保證中間較小 if (arr[mid] > arr[right]) swap(arr, mid, right); // 保證中間最小,左右最大 if (arr[mid] > arr[left]) swap(arr, left, mid); int pivot = arr[left]; while (right > left) { // 先判斷基准數和后面的數依次比較 while (pivot <= arr[right] && left < right) { --right; } // 當基准數大於了 arr[right],則填坑 if (left < right) { arr[left] = arr[right]; ++left; } // 現在是 arr[right] 需要填坑了 while (pivot >= arr[left] && left < right) { ++left; } if (left < right) { arr[right] = arr[left]; --right; } } arr[left] = pivot; return left; } private static void quickSort(int[] arr, int left, int right) { if (arr == null || left >= right || arr.length <= 1) return; int mid = partition(arr, left, right); quickSort(arr, left, mid); quickSort(arr, mid + 1, right); } public static void main(String[] args) { int[] arr = {6, 4, 3, 2, 7, 9, 1, 8, 5}; quickSort(arr, 0, arr.length - 1); printArr(arr); } }
由於篇幅關系,今天我們的講解暫且就到這里。
話說 Java 官方是怎么實現的呢?我們明天不妨直接到 JDK 里面一探究竟。