快速排序(quick sort) 是算法題中常常遇到的工具類算法,所謂工具類算法就是存在不少的算法或者思考題是基於相同思想進行解答的,那麼這類算法被考察的機率很高,對於這類算法的思考和探究也就十分有意義!javascript
快排也是在實踐中應用很是普遍的一種排序算法,特別是在C++或對Java基本類型的排序中特別有效。java
爲何說是基本類型? 這是在對比歸併排序(merge sort)和快排時常常提的問題,緣由在於兩種排序方式各自的特色:
快速排序(quick sort)元素移動次數少,元素比較次數多;
歸併排序(merge sort)元素移到次數多,元素比較次數最少。
而算法的運行時間耗費在:
1)比較元素;
2)移動元素。
因此快排更加適用於比較成本較低的基本類型,而對於耗時較長的泛型比較,例如實現comparator接口,就該考慮使用比較次數較少的歸併排序了。算法
它的平均運行時間是 ,可是不穩定,它的最壞情形時間複雜度爲
,可是這種不穩地是能夠經過對算法的優化避免的,接下來就討論對算法的優化問題。數組
public static void QSort(int[] a, int left, int right) {
if(left >= right) {
return;
}
//選擇最左邊元素爲基線值
int base = a[left];
int i = left;
int j = right;
//移動元素使得基線值
while(i < j) {
//左移放前面
while(i < j && base <= a[j]) {
j--;
}
//右移
while(i < j && base >= a[i]) {
i++;
}
if(i < j) {
swap(a, i, j);
}
}
//交換base和比base小的最後一個元素的值
swap(a, left, i);
QSort(a, left, i-1);//左邊遞歸
QSort(a, i+1, right);
}
複製代碼
快排是一種分治的遞歸算法,描述這種最多見的快排實現方式, 對數組進行排序的基本算法由下面4步組成:
安全
快排的過程參考下面GIF:bash
前面提到快排存在不平衡的狀況,可是這種不平衡能夠經過對算法的優化來解決。那何時會引發不平衡的狀況?數據結構
快速排序的性能高度依賴於樞紐元的選擇,對於常見寫法中選擇第一個元素做爲樞紐元的策略是極其危險的,若是輸入的是預排序或者是反排序的,那麼樞紐元會產生極其不平衡的分割---元素全在集合或者元素全劃分到
集合。工具
而且這種糟糕的狀況會發生在全部的遞歸中,這種不平衡狀況時間耗費是,更爲尷尬的是若是選取第一個元素做爲樞紐元且輸入是預先排序,時間消耗是二次的,可是結果倒是什麼也沒作。性能
測試用例: Leetcode 217.存在重複元素測試
public boolean containsDuplicate(int[] nums) {
if (nums == null) {
return false;
}
QSort(nums, 0, nums.length - 1);
for (int i = 0; i < nums.length; i++) {
if (i + 1 < nums.length && nums[i] == nums[i + 1]) {
return true;
}
}
return false;
}
複製代碼
當按照常見寫法完成快排時,會發現這道題的最後一個測試用例是一個龐大的預先排序的整數數組,會致使超時,這就是因爲不平衡致使的二次時間引發的超時。
咱們再考慮由分割策略引發的不平衡,將快排步驟中的第三步元素的比較和交換的策略稱爲分割策略,能夠理解爲將數組按照與樞紐元大小關係分割成兩個不相交子數組的策略,以下圖:
圖中的樞紐元爲5,分割結果爲5的左邊爲小於5的數右邊爲大於5的數。須要注意到數組中全部的元素互異,而分割策略的優化着重考慮的是數組中出現重複元素該怎麼辦。
最優的分割策略,咱們期待是將數組分割爲元素個數相近的兩個子數組,而壞的分割策略則會產生不均衡的兩個子數組,即出現不平衡問題,極端狀況結果就和預先排序且選取第一個元素做爲樞紐元時的相同,時間複雜度。
咱們考慮一種極端狀況,當數組全部元素的值都相等的狀況,以常見寫法爲例,查看算法的分割策略:
//左移
while(i < j && base <= a[j]) {
j--;
}
//右移
while(i < j && base >= a[i]) {
i++;
}
複製代碼
首先R指針左移尋找到第一個小於樞紐值的元素,注意:對於和樞紐元相同的元素採用的策略是不停(遇到相等元素時繼續移動),因此右移會一直左移直到L == R
結束,以下圖所示:
結果很明顯,子數組爲空,
包含除了樞紐元外的其他5個元素,是極不均衡的分割策略。
從上面描述的算法來看,樞紐元存在多種選擇,不管選擇數組內的哪一個元素都能完成排序工做,可是前面也提到一些壞的選擇會致使不平衡的問題,接下來討論以下幾種選擇:
這種錯誤的選取方式就是把第一個元素或者最後一個元素用做樞紐元,若是輸入的數組是隨機的,那麼這是能夠接受的,可是若是輸入是預排序或者是反序的,則會產生不平衡的問題,時間複雜度上升到。
這種方法產生的糟糕結果在前面給出的Leetcode
算法題中已經體現,會產生算法超時,因此咱們應該避免這種方法。
一種安全的方針是隨機選取樞紐元,通常來講這種策略很是安全,除非隨機數發生器有問題,由於隨機的樞紐元不可能總在接二連三的產生劣質的分割。可是隨機數的生成通常開銷很大有點得不償失。
在綜合考慮後,提出一種中值估計的方法----三數中值分割法,基本思路是:使用左端,右端和中心位置上的三個元素的中值做爲樞紐元,實際上是對中值的估計,選取過程:
代碼實現以下,參數爲須要選取樞紐元的數組,返回樞紐元的值。
private int median3(int[] a,int i,int j) {
//對三個數組進行排序
int m = (i + j) >> 1;
if (a[m] < a[i]) {
swap(a, i, m);
}
if (a[j] < a[i]) {
swap(a, i, j);
}
if (a[j] < a[m]) {
swap(a, j, m);
}
//將樞紐值放在j - 1;
swap(a, m, j - 1);
return a[j - 1];
}
複製代碼
實現細節:
對左端a[left]
右端a[right]
和中心位置a[center]
的元素進行排序,而後將樞紐元放在a[right-1]
的位置。
好處一:a[left]
和a[right]
的位置是分割的正確的位置,因此在後序的須要分割的區間能夠縮小到[left+1,right-2]
。
好處二:樞紐元存儲在a[right-1]
能夠充當警惕標記,防止越界。
回顧前面由分割策略引發的不平衡,分割策略的細節在於如何處理那些等於樞紐元的元素,問題在於L
指針和R
指針在遇到等於樞紐元的元素是否中止,則存在以下三種策略:
L
指針和R
指針都不停;L
指針和R
指針都停;L
指針和R
指針其中一個停,一個不停。考慮元素全相等的極端狀況,顯然不停和其中一個停的策略其實結果都是產生不平衡狀況,分割結果爲極不平衡的兩個數組。(參考前面不平衡的分割策略的圖)
因此考慮到咱們追求的是平衡的一種策略,因此進行沒必要要的交換創建兩個平衡的子數組要比冒險獲得兩個極不均衡的子數組要好。所以在LR
遇到等於樞紐元的元素時,讓兩個指針都停下來,進而避免二次時間的出現。
public void QSort(int[] a, int left, int right) {
if(left >= right) {
return;
}
//三數中值分割法選取樞紐元
int base = median3(a, left, right);
int i = left;
int j = right - 1;
while(i < j) {
while(i < j && base > a[++i]) {}
while(i < j && base < a[--j]) {}
if(i < j) {
swap(a, i, j);
}
}
swap(a, i, right - 1);
QSort(a, left, i - 1);
QSort(a, i + 1, right);
}
private void swap(int[] a, int i, int j) {
int temp = a[i];
a[i] = a[j];
a[j] = temp;
}
//三數中值分割法
private int median3(int[] a,int i,int j) {
//對三個數進行排序
int m = (i + j) >> 1;
if (a[m] < a[i]) {
swap(a, i, m);
}
if (a[j] < a[i]) {
swap(a, i, j);
}
if (a[j] < a[m]) {
swap(a, j, m);
}
//將樞紐元放在j - 1;
swap(a, m, j - 1);
return a[j - 1];
}
複製代碼
小細節
對比常見寫法和優後的快排,發現除了前面提到的優化策略外還有一些更改:
例如常見寫法中是先R
指針左移,後L
指針右移,而優化後的是先L
指針右移,爲何?
緣由:
在常見寫法中樞紐元在最左邊,右指針左移確定停在一個小於或等於樞紐元的元素對應的位置(假設爲index),緊接着是左指針右移假設一直小於樞紐值,則會停在和右指針相同位置(index),分割的最後一步是將樞紐元和左指針交換,而左指針指向的是一個小於或等於樞紐元的值(即右指針左移停的index),則由於最終分割結果是小於樞紐元的值在左邊,因此徹底沒問題。
再考慮若是在常見寫法中,使用左指針先移的策略,那麼左指針停的位置是一個大於或等於樞紐元的位置,若是進行最後的左指針和樞紐元的交換,就將一個大於樞紐元的值移到了左邊,顯然是不可行的。
結論:
因此得出結論:左右指針的移動順序是由要交換的樞紐元位置決定的,若是樞紐元在左邊(常見寫法的樞紐元)那麼應該將一個小於或等於樞紐元的值和它交換,而右指針先左移確定獲得的是小於或等於樞紐元的值;
而樞紐元在右邊的(優化後的樞紐元)那麼應該將一個大於或等於樞紐元的值和它交換,因此採用先左移的方案!
能夠將優化後的算法再次嘗試解決存在不平衡的測試用例的例題:Leetcode 217.存在重複元素,執行結果。
這是在掘金的第一篇blog,寫的很差的地方但願你們指正!
References:
《數據結構與算法分析Java語言描述》---7.7 快速排序
圖片來源:
快排動態圖
不平衡的快排調用棧
分割策略