快速排序由於排序效率在同為O(N*logN)的幾種排序方法中效率較高,因此經常被采用,再加上快速排序思想----分治法也確實實用,因此很多軟件公司的筆試面試,常常出現快速排序的身影。總的說來,要直接默寫出快速排序還是有一定難度的,自己總結整理一下,希望對大家理解有幫助。
原理
快速排序的基本思想:通過一趟排序將待排記錄分隔成獨立的兩部分,其中一部分記錄的關鍵字均比另一部分的關鍵字小,則可分別對這兩部分記錄繼續進行排序,以達到整個序列有序。說白了就是給基准數據找其正確索引位置的過程。
快速排序是C.R.A.Hoare於1962年提出的一種划分交換排序。它采用了一種分治的策略,通常稱其為分治法(Divide-and-ConquerMethod)。
該方法的基本思想是:
- 1.先從數列中取出一個數作為基准數。
- 2.分區過程,將比這個數大的數全放到它的右邊,小於或等於它的數全放到它的左邊。
- 3.再對左右區間重復第二步,直到各區間只有一個數。
快速排序使用分治法來把一個數列分為兩個子數列。具體算法描述如下:
1)從數列中挑出一個元素,稱為 “基准”(pivot);
2)重新排序數列,所有比基准值小的元素放在基准前面,所有比基准值大的元素放在基准的后面(相同的數可以到任一邊),該基准就處於數列的中間位置。這稱為分區(partition)操作;
3)遞歸地(recursive)對小於基准值元素的子數列和大於基准值元素的子數列進行快速排序。


2 首先從后半部分開始,如果掃描到的值大於基准數據就讓high減1,如果發現有元素比該基准數據的值小(如上圖中18<=tmp),就將high位置的值賦值給low位置 ,結果如下:
3 然后開始從前往后掃描,如果掃描到的值小於基准數據就讓low加1,如果發現有元素大於基准數據的值(如上圖46=>tmp),就再將low位置的值賦值給high位置的值,指針移動並且數據交換后的結果如下:
4 然后再開始從后向前掃描,原理同上,發現上圖11<=tmp,則將high位置的值賦值給low位置的值,結果如下:
5 然后再開始從前往后遍歷,直到low=high結束循環,此時low或high的下標就是基准數據23在該數組中的正確索引位置.如下圖所示.
6 這樣一遍走下來,可以很清楚的知道,其實快速排序的本質就是把基准數大的都放在基准數的右邊,把比基准數小的放在基准數的左邊,這樣就找到了該數據在數組中的正確位置.
以后采用遞歸的方式分別對前半部分和后半部分排序,當前半部分和后半部分均有序時該數組就自然有序了。
從上面的過程中可以看到:
①先從隊尾開始向前掃描且當low < high時,如果a[high] > tmp,則high–,但如果a[high] < tmp,則將high的值賦值給low,即arr[low] = a[high],同時要轉換數組掃描的方式,即需要從隊首開始向隊尾進行掃描了
②同理,當從隊首開始向隊尾進行掃描時,如果a[low] < tmp,則low++,但如果a[low] > tmp了,則就需要將low位置的值賦值給high位置,即arr[low] = arr[high],同時將數組掃描方式換為由隊尾向隊首進行掃描.
③不斷重復①和②,知道low>=high時(其實是low=high),low或high的位置就是該基准數據在數組中的正確索引位置.
按照上訴理論代碼如下:
public class QuickSort { public static void main(String[] args) { int[] arr = { 49, 38, 65, 97, 23, 22, 76, 1, 5, 8, 2, 0, -1, 22 }; quickSort(arr, 0, arr.length - 1); System.out.println("排序后:"); for (int i : arr) { System.out.println(i); } } private static void quickSort(int[] arr, int low, int high) { if (low < high) { // 找尋基准數據的正確索引 int index = getIndex(arr, low, high); // 進行迭代對index之前和之后的數組進行相同的操作使整個數組變成有序 quickSort(arr, low, index - 1); quickSort(arr, index + 1, high); } } private static int getIndex(int[] arr, int low, int high) { // 基准數據 int tmp = arr[low]; while (low < high) { // 當隊尾的元素大於等於基准數據時,向前挪動high指針 while (low < high && arr[high] >= tmp) { high--; } // 如果隊尾元素小於tmp了,需要將其賦值給low arr[low] = arr[high]; // 當隊首元素小於等於tmp時,向前挪動low指針 while (low < high && arr[low] <= tmp) { low++; } // 當隊首元素大於tmp時,需要將其賦值給high arr[high] = arr[low]; } // 跳出循環時low和high相等,此時的low或high就是tmp的正確索引位置 // 由原理部分可以很清楚的知道low位置的值並不是tmp,所以需要將tmp賦值給arr[low] arr[low] = tmp; return low; // 返回tmp的正確位置 } }
三、代碼實現
快速排序最核心的步驟就是partition操作,即從待排序的數列中選出一個數作為基准,將所有比基准值小的元素放在基准前面,所有比基准值大的元素放在基准的后面(相同的數可以到任一邊),該基准就處於數列的中間位置。partition函數返回基准的位置,然后就可以對基准位置的左右子序列遞歸地進行同樣的快排操作,從而使整個序列有序。
下面我們來介紹partition操作的兩種實現方法:左右指針法 和 挖坑法。
方法一:左右指針法
基本思路:
1.將數組的最后一個數 right 作為基准數 key。
2.分區過程:從數組的首元素 begin 開始向后找比 key 大的數(begin 找大);end 開始向前找比 key 小的數(end 找小);找到后交換兩者(swap),直到 begin >= end 終止遍歷。最后將 begin(此時begin == end)和最后一個數交換( 這個時候 end 不是最后一個位置),即 key 作為中間數(左區間都是比key小的數,右區間都是比key大的數)
3.再對左右區間重復第二步,直到各區間只有一個數。

/** * partition操作 * @param array * @param left 數列左邊界 * @param right 數列右邊界 * @return */ public static int partition(int[] array,int left,int right) { int begin = left; int end = right; int key = right; while( begin < end ) { //begin找大 while(begin < end && array[begin] <= array[key]) begin++; //end找小 while(begin < end && array[end] >= array[key]) end--; swap(array,begin,end); } swap(array,begin,right); return begin; //返回基准位置 } /** * 交換數組內兩個元素 * @param array * @param i * @param j */ public static void swap(int[] array, int i, int j) { int temp = array[i]; array[i] = array[j]; array[j] = temp; }
方法二:挖坑法
基本思路:
1.定義兩個指針 left 指向起始位置,right 指向最后一個元素的位置,然后指定一個基准 key(right),作為坑。
2.left 尋找比基准(key)大的數字,找到后將 left 的數據賦給 right,left 成為一個坑,然后 right 尋找比基數(key)小的數字,找到將 right 的數據賦給 left,right 成為一個新坑,循環這個過程,直到 begin 指針與 end指針相遇,然后將 key 填入那個坑(最終:key的左邊都是比key小的數,key的右邊都是比key大的數),然后進行遞歸操作。

/** * partition操作 * @param array * @param left 數列左邊界 * @param right 數列右邊界 * @return */ public static int partition(int[] array,int left,int right) { int key = array[right];//初始坑 while(left < right) { //left找大 while(left < right && array[left] <= key ) left++; array[right] = array[left];//賦值,然后left作為新坑 //right找小 while(left <right && array[right] >= key) right--; array[left] = array[right];//right作為新坑 } array[left] = key; /*將key賦值給left和right的相遇點, 保持key的左邊都是比key小的數,key的右邊都是比key大的數*/ return left;//最終返回基准 }
實現了partition操作,我們就可以遞歸地進行快速排序了
/** * 快速排序方法 * @param array * @param left 數列左邊界 * @param right 數列右邊界 * @return */ public static void Quicksort(int array[], int left, int right) { if(left < right){ int pos = partition(array, left, right); Quicksort(array, left, pos - 1); Quicksort(array, pos + 1, right); } }
四、代碼優化
我們之前選擇基准的策略都是固定基准,即固定地選擇序列的右邊界值作為基准,但如果在待排序列幾乎有序的情況下,選擇的固定基准將是序列的最大(小)值,快排的性能不好(因為每趟排序后,左右兩個子序列規模相差懸殊,大的那部分最后時間復雜度很可能會達到O(n2))。
下面提供幾種常用的快排優化:
優化一:隨機基准
每次隨機選取基准值,而不是固定選取左或右邊界值。將隨機選取的基准值和右邊界值進行交換,然后就回到了之前的解法。
只需要在 partition 函數前增加如下操作即可:
int random = (int) (left + Math.random() * (right - left + 1)); //隨機選擇 left ~ right 之間的一個位置作為基准 swap(array, random, right); //把基准值交換到右邊界
基本思想:
取第一個數,最后一個數,第(N/2)個數即中間數,三個數中數值中間的那個數作為基准值。
舉個例子,對於int[] array = { 2,5,4,9,3,6,8,7,1,0},2、3、0分別是第一個數,第(N/2)個是數以及最后一個數,三個數中3最大,0最小,2在中間,所以取2為基准值。
實現getMid函數即可:
/** * 三數取中,返回array[left]、array[mid]、array[right]三者的中間者下標作為基准 * @param array * @param left * @param right * @return */ public static int getMid(int[] array,int left,int right) { int mid = left + ((right - left) >> 1); int a = array[left]; int b = array[mid]; int c = array[right]; if ((b <= a && a <= c) || (c <= a && a <= b)) { //a為中間值 return left; } if ((a <= b && b <= c) || (c <= b && b <= a)) { //b為中間值 return mid; } if ((a <= c && c <= b) || (b <= c && c <= a)) { //c為中間值 return right; } return left; }
在子序列比較小的時候,直接插入排序性能較好,因為對於有序的序列,插排可以達到O(n)的復雜度,如果序列比較小,使用插排效率要比快排高。
實現方式也很簡單,快排是在子序列元素個數為 1 時才停止遞歸,我們可以設置一個閾值n,假設為5,則大於5個元素,子序列繼續遞歸,否則選用插排。
此時QuickSort()函數如下:
public static void Quicksort(int array[], int left, int right) { if(right - left > 5){ int pos = partition(array, left, right); Quicksort(array, left, pos - 1); Quicksort(array, pos + 1, right); }else{ insertionSort(array); } }
這種優化非常實用。
實測發現當待排序列為 [100000,99999,99998,...,3,2,1] 時,不加插入優化的快排由於遞歸次數過多甚至拋出了 java.lang.StackOverflowError!

而加入了插入優化並選擇閾值為 12500 時,排序用時如下:

實驗發現閾值的選擇也很關鍵,選擇閾值為 5 ,排序用時如下:

優化四:三路划分
如果待排序列中重復元素過多,也會大大影響排序的性能,這是因為大量相同元素參與快排時,左右序列規模相差極大,快排將退化為冒泡排序,時間復雜度接近O(n2)。這時候,如果采用三路划分,則會很好的避免這個問題。
三路划分的思想是利用 partition 函數將待排序列划分為三部分:第一部分小於基准v,第二部分等於基准v,第三部分大於基准v。這樣在遞歸排序區間的時候,我們就不必再對第二部分元素均相等的區間進行快排了,這在待排序列存在大量相同元素的情況下能大大提高快排效率。
來看下面的三路划分示意圖:

說明:紅色部分為小於基准v的序列,綠色部分為等於基准v的序列,白色部分由於還未被 cur 指針遍歷到,屬於大小未知的部分,藍色部分為大於基准v的序列。
left 指針為整個待排區間的左邊界,right 指針為整個待排區間的右邊界。less 指針指向紅色部分的最后一個數(即小於v的最右位置),more 指針指向藍色部分的第一個數(即大於v的最左位置)。cur 指針指向白色部分(未知部分)的第一個數,即下一個要判斷大小的位置。
算法思路:
1)由於最初紅色和藍色區域沒有元素,初始化 less = left - 1,more = right + 1,cur = left。整個區間為未知部分(白色)。
2)如果當前 array[cur] < v,則 swap(array,++less,cur++),即把紅色區域向右擴大一格(less指針后移),把 array[cur] 交換到該位置,cur 指針前移判斷下一個數。
3)如果當前 array[cur] = v,則不必交換,直接 cur++
4)如果當前 array[cur] > v,則 swap(array,--more,cur),即把藍色區域向左擴大一格(more指針前移),把 array[cur] 交換到該位置。特別注意!此時cur指針不能前移,這是因為交換到cur位置的元素來自未知區域,還需要進一步判斷array[cur]。
利用三路划分,我們就可以遞歸地進行三路快排了!並且可以愉快地避開所有重復元素區間。
代碼如下:
public static int[] partition(int[] array,int left,int right){ int v = array[right]; //選擇右邊界為基准 int less = left - 1; // < v 部分的最后一個數 int more = right + 1; // > v 部分的第一個數 int cur = left; while(cur < more){ if(array[cur] < v){ swap(array,++less,cur++); }else if(array[cur] > v){ swap(array,--more,cur); }else{ cur++; } } return new int[]{less + 1,more - 1}; //返回的是 = v 區域的左右下標 } public static void Quicksort(int array[], int left, int right) { if (left < right) { int[] p = partition(array,left,right); Quicksort(array,left,p[0] - 1); //避開重復元素區間 Quicksort(array,p[1] + 1,right); } }
三路划分可以解決經典的荷蘭國旗問題,具體見 leetcode 75
解法如下:
class Solution { // 方法一:使用計數排序解決,但需要兩趟掃描,不符合要求 /*public void sortColors(int[] nums) { int[] count = new int[3]; for(int i = 0; i < nums.length; i++) count[nums[i]]++; int k = 0; for(int i = 0; i < 3; i++){ for(int j = 0; j < count[i]; j++){ nums[k++] = i; } } }*/ // 方法二:使用快速排序的三路划分,時間復雜度為O(n),空間復雜度為O(1) public void sortColors(int[] nums) { int len = nums.length; if(len == 0) return; int less = -1; int more = len; int cur = 0; while(cur < more){ if(nums[cur] == 0){ swap(nums,++less,cur++); }else if(nums[cur] == 2){ swap(nums,--more,cur); }else{ cur++; } } } public static void swap(int[] array,int i,int j){ int temp = array[i]; array[i] = array[j]; array[j] = temp; } }
拓展:快速選擇算法
快速選擇算法用於求解 Kth Element 問題(無序數組第K大元素),使用快速排序的 partition() 進行實現。
快速排序的 partition() 方法會返回一個整數 j 使得 a[left..j-1] 小於等於 a[j],且 a[j+1..right] 大於等於 a[j]。
此時 a[j] 就是數組的第 j 小的元素,我們可以轉換一下題意,第 k 大的元素就是第 nums.size() - k 小的元素。
找到 Kth Element 之后,再遍歷一次數組,所有大於等於 Kth Element 的元素都是 TopK Elements。
時間復雜度 O(N),空間復雜度 O(1)。
還可以使用小根堆求解此問題,時間復雜度 O(NlogK),空間復雜度 O(K)。具體見:leetcode 215
解法如下:
class Solution { private int partition(vector<int>& array,int left,int right) { int key = array[right]; //初始坑 while(left < right) { //left找大 while(left < right && array[left] <= key ) left++; array[right] = array[left]; //賦值,然后left作為新坑 //right找小 while(left <right && array[right] >= key) right--; array[left] = array[right]; //right作為新坑 } array[left] = key; /*將key賦值給left和right的相遇點, 保持key的左邊都是比key小的數,key的右邊都是比key大的數*/ return left;//最終返回基准 } /*方法1:堆。用於求解 TopK Elements 問題,通過維護一個大小為 K 的小根堆, 堆中的元素就是TopK Elements。堆頂元素就是 Kth Element。如果是第K小的元素 就建立大根堆。時間復雜度 O(NlogK),空間復雜度 O(K)。*/ /* int findKthLargest(vector<int>& nums, int k) { int n = nums.size(); priority_queue<int,vector<int>,greater<int>> q; for(int i = 0;i < n;i++){ q.push(nums[i]); if(q.size() > k) q.pop(); } return q.top(); }*/ /*方法2:快速選擇。用於求解 Kth Element 問題,使用快速排序的 partition() 進行實現。 快速排序的 partition() 方法會返回一個整數 j 使得 a[left..j-1] 小於等於 a[j], 且 a[j+1..right] 大於等於 a[j],此時 a[j] 就是數組的第 j 小的元素, 我們可以轉換一下題意,第 k 大的元素就是第 nums.size() - k 小的元素。 找到 Kth Element 之后,再遍歷一次數組,所有大於等於 Kth Element 的元素都是 TopK Elements。時間復雜度 O(N),空間復雜度 O(1)*/ public int findKthLargest(vector<int>& nums, int k) { k = nums.size() - k; int left = 0, right = nums.size() - 1; while (left < right) { int j = partition(nums, left, right); if (j == k) { //選擇的基准等於目標,跳出循環 break; } else if (j < k) { //選擇的基准小於目標,在右側子序列中繼續選擇 left = j + 1; } else { //選擇的基准大於目標,在左側子序列中繼續選擇 right = j - 1; } } return nums[k]; } }
拓展:Arrays.sort() 和 Collections.sort() 原理,Collections.sort() 底層調用的是 Arrays.sort(),元素少於47用插入排序,至於大過INSERTION_SORT_THRESHOLD(47)的,少於閥值QUICKSORT_THRESHOLD(286)的用快速排序,至於大於286的,它會進入歸並排序(Merge Sort)。Arrays.sort() 原理見 剖析JDK8中Arrays.sort底層原理及其排序算法的選擇 。
總結
最壞情況 :選擇了最大或者最小數字作為基准,每次划分只能將序列分為一個元素與其他元素兩部分,此時快速排序退化為冒泡排序,如果用樹畫出來,得到的將會是一棵單斜樹,即所有的結點只有左(右)結點的樹,樹的深度為 n,時間復雜度為O(n 2)。
在最好情況下,即partition函數每次恰好能均分序列,空間復雜度為O(logn);在最壞情況下,即退化為冒泡排序,空間復雜度為O(n)。平均空間復雜度為O(logn)。
參考地址:
https://blog.csdn.net/nrsc272420199/article/details/82587933