深入了解快速排序

概述

快速排序是面試中的常見(jiàn)題,每次簡(jiǎn)述一遍快速排序的原理便覺(jué)得仿佛已經(jīng)掌握了它。不是挺簡(jiǎn)單的嗎?然而實(shí)際實(shí)現(xiàn)的時(shí)候還是會(huì)遇到一些坑。于是,之前一次面試就跪了,很尷尬。所以,還是需要對(duì)它進(jìn)行深入理解!

基本步驟

  1. 檢查范圍,(以及終止條件)

注意遞歸終止條件

  1. 選擇基準(zhǔn)(pivot),分割序列

即篩選不大于pivot的元素和不小于pivot的元素),將pivot放至正確位置

  1. 對(duì)pivot的左半段和右半段序列分別進(jìn)行快速排序

參考[1], 我將快速排序的函數(shù)簽名定為
public <T extends Comparable<? super T>> void executeProcess(T[] sequ, int left, int right) { }

1.小數(shù)組優(yōu)化

遞歸的快速排序終會(huì)落入對(duì)于小數(shù)組的排序。而對(duì)于小數(shù)組的排序,快速排序不如插入排序。

quicksort insertsort
Size:10 Range:0~10 2222223 623112
Size:20 Range:0~20 1433779 487111
Size:30 Range:0~30 3454668 540889

單位:nanosecond
環(huán)境:CPU 4核8線(xiàn)程 2.30GHZ

2.檢查范圍,以及終止條件

// 檢查下標(biāo)是否越界
super.rangeCheck(sequ.length, left, right);

// 數(shù)組個(gè)數(shù)為0或1,已排序(終止條件)
int size = right - left + 1;
if (size < 2) {
    return;
}

3.選擇基準(zhǔn)(pivot),分割序列

3.1 選擇基準(zhǔn)

基準(zhǔn)選擇常見(jiàn)的有以下三種方法。

  1. 序列首/序列尾
    對(duì)于有序序列分割極不平衡
  2. 隨機(jī)選擇
    優(yōu)于序列首,但開(kāi)銷(xiāo)不小
  3. 三數(shù)中值分割
    它將考慮序列中left, right, (left + right) / 2這三個(gè)位置的元素值,選擇它們的中位數(shù)作為基準(zhǔn)

進(jìn)一步,可在三數(shù)中值分割的基礎(chǔ)上將三個(gè)位置上的較小值和較大值分別置于left位置、right位置。在使用下文的分割方式1時(shí)可保證兩個(gè)指針不越過(guò)序列端點(diǎn)。

// 三數(shù)排序決定基準(zhǔn),left/right/中位
int middle = (left + right) / 2;
if (sequ[left].compareTo(sequ[middle]) > 0) {
    swap(sequ, left, middle);
}
if (sequ[left].compareTo(sequ[right]) > 0) {
     swap(sequ, left, right);
}
if (sequ[middle].compareTo(sequ[right]) > 0) {
    swap(sequ, middle, right);
}
// 數(shù)組僅有2個(gè)或3個(gè)元素,此時(shí)已經(jīng)排好序
//(若對(duì)小數(shù)組使用插入排序,則該語(yǔ)句沒(méi)有必要)
if (!super.insertSortOptimized && middle == right - 1) {
    return super.InvalidPoint;
}
// 將基準(zhǔn)(三數(shù)中值)放至right-1位置
swap(sequ, middle, right - 1);
3.2 分割策略
3.2.1 分割方式1

forePoint從前往后找大于pivot的元素,backPoint從后往前找小于pivot的元素,并交換。
當(dāng)forePoint與backPoint相遇后,將pivot放至正確位置。


分割方式1

之后以此類(lèi)推
標(biāo)紅的元素為pivot

// 對(duì)left+1和right-2之間的范圍進(jìn)行分割
int forePoint = left;
int backPoint = right - 1;
T pivot = sequ[right - 1];

while (true) {
    while (sequ[++forePoint].compareTo(pivot) < 0) {
    }
    while (sequ[--backPoint].compareTo(pivot) > 0) {
    }

    if (forePoint >= backPoint) {
        // 將基準(zhǔn)放到合適位置
        swap(sequ, forePoint, right - 1);
        break;
    } else {
        swap(sequ, forePoint, backPoint);
    }
}

相等元素的處理
當(dāng)遇到和基準(zhǔn)值相等的值時(shí),應(yīng)該如何處理?是往左半段移動(dòng)?還是往右半段移動(dòng)?
特別地,對(duì)于forePoint和backPoint同時(shí)分別遇到與基準(zhǔn)值相等的元素時(shí),應(yīng)該如何處理?
按照[1]中所說(shuō),forePoint和backPoint的地位應(yīng)是等價(jià)的,那么它們對(duì)于與基準(zhǔn)值相等的元素的處理方式也應(yīng)相同。否則,則會(huì)有左半段與右半段不均衡的情況出現(xiàn),降低快速排序的效率。
那么我們還剩下forePoint和backPoint均停止(進(jìn)行交換)和均不停止(不進(jìn)行交換)的選擇。 [1]中推薦前種做法。那么對(duì)于后種做法,可不可行呢?
針對(duì)上述的三種基準(zhǔn)選擇方法分別進(jìn)行分析:

  • 前兩種選擇的基準(zhǔn)均有可能是該序列中的最大值或者最小值,序列中可能存在其他與該值相同的元素,也可能不存在。因此,必須考慮forePoint和backPoint越過(guò)序列端點(diǎn)的情況,停止與不停止并沒(méi)有差別。
  • 而對(duì)于三數(shù)中值分割,它所選擇的基準(zhǔn),最大僅可能是該序列中的次大值(可能等于最大值),或者最小僅可能是次小值(可能等于最小值)。若在三數(shù)中值分割的基礎(chǔ)上將三個(gè)位置上的較小值和較大值分別置于left位置、right位置,那么,forePoint和backPoint則無(wú)法越過(guò)序列端點(diǎn)。但考慮到right位置與基準(zhǔn)值相等的情況,若采用不停止的方式,則需要再次考慮forePoint越過(guò)序列端點(diǎn)的情況,
    因此,遇到與基準(zhǔn)相等的元素,forePoint或者backPoint停止并且交換的做法相對(duì)更佳。

其實(shí)值等于pivot的元素在該次快速排序中,既可以隨便出現(xiàn)在左半段,也可以隨便出現(xiàn)在右半段,不用恰好緊挨在該次被作為pivot的元素周?chē)R驗(yàn)椋S著之后對(duì)于左半段和右半段調(diào)用的快速排序,它們會(huì)各自被放到正確的位置上,這并不屬于該次快速排序的職責(zé)。

3.2.2 分割方式2

curPoint從前往后遍歷序列,parPoint指向小于基準(zhǔn)與大于等于基準(zhǔn)的序列的分割位置 —— 大于等于基準(zhǔn)的序列的第一個(gè)元素。
當(dāng)curPoint遍歷結(jié)束,將pivot與parPoint位置的元素交換。

分割方式2

之后以此類(lèi)推
標(biāo)紅的元素為pivot

// 對(duì)left+1和right-2之間的范圍進(jìn)行分割
int curPoint = left + 1;
int parPoint = left + 1;
T pivot = sequ[right - 1];

while(curPoint < right - 1) {
    if(sequ[curPoint].compareTo(pivot) < 0) {
        swap(sequ, curPoint, parPoint);
        parPoint++;
    }
    curPoint++;
}
swap(sequ, parPoint, right - 1);

4. 對(duì)pivot的左半段和右半段序列分別進(jìn)行快速排序

4.1 遞歸
int partionPoint = partition(sequ, left, right);
if(partionPoint < 0) {
    return;
}
executeProcess(sequ, left, partionPoint - 1);
executeProcess(sequ, partionPoint + 1, right);
4.2 非遞歸

采用棧保存下次要進(jìn)行分割的序列首尾位置,深度優(yōu)先。

Stack<Integer> stack = new Stack<Integer>();

int partionPoint = partition(sequ, left, right);
if(partionPoint < 0) {
    return;
}

stack.push(partionPoint + 1);
stack.push(right);

stack.push(left);
stack.push(partionPoint - 1);

while(!stack.isEmpty()) {
    int sRight = stack.pop();
    int sLeft = stack.pop();

    partionPoint = partition(sequ, sLeft, sRight);
    if(partionPoint < 0) {
        continue;
    }

    stack.push(partionPoint + 1);
    stack.push(sRight);

    stack.push(sLeft);
    stack.push(partionPoint - 1);
}

About Error

  • 若產(chǎn)生無(wú)限循環(huán),則問(wèn)題可能出在兩個(gè)方面:一個(gè)是遞歸終止條件;另一個(gè)是分割序列處的循環(huán),尤其注意forePoint和backPoint同時(shí)分別遇到與基準(zhǔn)值相等的元素時(shí),forePoint和backPoint的移動(dòng)情況

  • 若沒(méi)有正確排序,由于結(jié)果基本有序,我們可以從錯(cuò)誤序列中看出端倪。如以下情況:

    Before:
    8 3 15 13 2 0 0 5 10 2 1 9 7 3 9 10 15 5 8 2 9 12 1 8 10
    After:
    0 0 1 1 2 2 2 3 3 5 5 7 8 8 9 8 9 9 10 10 10 12 13 15 15

共有25個(gè)元素,下標(biāo)14和15位置的元素沒(méi)有正確排序。25的分割沿著出錯(cuò)位置依次為
0~11 12 13~24; 13~17 18 19~24; 13~14 15 16~17。 即可知道是13~17這次快速排序發(fā)生差錯(cuò),從而進(jìn)行仔細(xì)調(diào)試。

More

[3] 中通過(guò)尾遞歸對(duì)快速排序C語(yǔ)言版優(yōu)化。關(guān)于尾遞歸,[4]講述得比較明了。然而Java沒(méi)有實(shí)現(xiàn)尾遞歸優(yōu)化。相對(duì)的,我們只能采取避免遞歸過(guò)深或者用迭代取代遞歸的方式。

It's important to note that this isn't a bug in the JVM. It's an optimization that can be implemented to help functional programmers who use recursion, which is much more common and normal in those languages. I recently spoke to Brian Goetz at Oracle about this optimization, and he said that it's on a list of things to be added to the JVM, but it's just not a high-priority item. For now, it's best to make this optimization yourself, if you can, by avoiding deeply recursive functions when coding a functional language on the JVM.

DualPivotQuicksort

Java中對(duì)于基本數(shù)據(jù)類(lèi)型的排序算法通過(guò)DualPivotQuicksort實(shí)現(xiàn)。它有如下特性:This algorithm offers O(n log(n)) performance on many data sets that cause other quicksorts to degrade to quadratic performance, and is typically faster than traditional (one-pivot) Quicksort implementations.


DualPivotQuicksort

排序方式具體如下:

  1. For small arrays (length < 17), use the Insertion sort algorithm.
  2. Choose two pivot elements P1 and P2. We can get, for example, the first element a[left] as P1 and the last element a[right] as P2.
  3. P1 must be less than P2, otherwise they are swapped. So, there are the following parts:
  • part I with indices from left+1 to L–1 with elements, which are less than P1,
  • part II with indices from L to K–1 with elements, which are greater or equal to P1 and less or equal to P2,
  • part III with indices from G+1 to right–1 with elements greater than P2,
  • part IV contains the rest of the elements to be examined with indices from K to G.
  1. The next element a[K] from the part IV is compared with two pivots P1 and P2, and placed to the corresponding part I, II, or III.
  2. The pointers L, K, and G are changed in the corresponding directions.
  3. The steps 4 - 5 are repeated while K ≤ G.
  4. The pivot element P1 is swapped with the last element from part I, the pivot element P2 is swapped with the first element from part III.
  5. The steps 1 - 7 are repeated recursively for every part I, part II, and part III.
性能比較
基準(zhǔn)選擇 分割策略 遞歸? 插排優(yōu)化?
ver1 三數(shù)中值分割 分割方式1 遞歸
ver2 三數(shù)中值分割 分割方式2 遞歸
ver3 三數(shù)中值分割 分割方式1 非遞歸
ver1 ver2 ver3
Round1 8283115 11229782 2312889
Round2 2574668 4175557 2521779
Round3 2995112 2246667 3599113

單位:nanosecond
環(huán)境:CPU 4核8線(xiàn)程 2.30GHZ
測(cè)試序列:長(zhǎng)度100范圍0~100的隨機(jī)數(shù)序列

代碼地址

參考文獻(xiàn)

1 Mark Allen Weiss[美]. 數(shù)據(jù)結(jié)構(gòu)與算法分析: Java語(yǔ)言描述:第2版[M]. 機(jī)械工業(yè)出版社, 2012.
2 ThomasH.Cormen…. 算法導(dǎo)論:第2版[M]. 機(jī)械工業(yè)出版社, 2007.
3 http://blog.csdn.net/insistgogo/article/details/7785038
4 http://www.ruanyifeng.com/blog/2015/04/tail-call.html
5 http://stackoverflow.com/questions/20917617/whats-the-difference-of-dual-pivot-quick-sort-and-quick-sort

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀(guān)點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

推薦閱讀更多精彩內(nèi)容

  • 背景 一年多以前我在知乎上答了有關(guān)LeetCode的問(wèn)題, 分享了一些自己做題目的經(jīng)驗(yàn)。 張土汪:刷leetcod...
    土汪閱讀 12,768評(píng)論 0 33
  • 概述 排序有內(nèi)部排序和外部排序,內(nèi)部排序是數(shù)據(jù)記錄在內(nèi)存中進(jìn)行排序,而外部排序是因排序的數(shù)據(jù)很大,一次不能容納全部...
    蟻前閱讀 5,215評(píng)論 0 52
  • 概述:排序有內(nèi)部排序和外部排序,內(nèi)部排序是數(shù)據(jù)記錄在內(nèi)存中進(jìn)行排序,而外部排序是因排序的數(shù)據(jù)很大,一次不能容納全部...
    每天刷兩次牙閱讀 3,742評(píng)論 0 15
  • 1.插入排序—直接插入排序(Straight Insertion Sort) 基本思想: 將一個(gè)記錄插入到已排序好...
    依依玖玥閱讀 1,271評(píng)論 0 2
  • 2017-04-07 在這樣的信息爆炸時(shí)代,我們無(wú)時(shí)無(wú)刻都在接受信息的沖刷洗禮。 只是一兩個(gè)小時(shí)沒(méi)有看手機(jī),微信上...
    一粟于海閱讀 663評(píng)論 4 3