面試 12:玩轉 Java 快速排序

終於輪到咱們排序算法中的王牌登場了。java

快速排序因爲排序效率在同爲 O(nlogn) 的幾種排序方法中效率最高,所以常常被採用。再加上快速排序思想——分治法也確實很是實用,因此 在各大廠的面試習題中,快排老是最耀眼的那個。要是你會的排序算法中沒有快速排序,我想你仍是偷偷去學好它,再去向大廠砸簡歷。面試

事實上,在咱們的諸多高級語言中,都能找到它的某種實現版本,那咱們 Java 天然不能在此缺席。算法

總的來講,默寫排序代碼是南塵很是不推薦的,撇開快排的代碼不是那麼容易默寫,即便你能默寫快排代碼,也總會由於面試官稍微的變種面試致使你驚慌失措。數組

因此咱們的面試系列天然不能少了這位王牌選手。安全

 
圖片來自於維基百科

基本思想

快速排序使用分治法策略來把一個序列分爲兩個子序列,基本步驟爲:網絡

  1. 先從序列中取出一個數做爲基準數;
  2. 分區過程:將把這個數大的數所有放到它的右邊,小於或者等於它的數全放到它的左邊;
  3. 遞歸地對左右子序列進行不走2,直到各區間只有一個數。
 
圖片來自於網絡

雖然快排算法的策略是分治法,但分治法這三個字顯然沒法很好的歸納快排的所有不走,所以借用 CSDN 神人 MoreWindows 的定義說明爲:挖坑填數 + 分治法。ui

彷佛仍是不太好理解,咱們這裏就直接借用 MoreWindows 大佬的例子說明。spa

以一個數組做爲示例,取區間第一個數爲基準數。code

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] = 72orm

因爲已經將 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 裏面一探究竟。

相關文章
相關標籤/搜索