概述
快速排序是面試中的常見(jiàn)題,每次簡(jiǎn)述一遍快速排序的原理便覺(jué)得仿佛已經(jīng)掌握了它。不是挺簡(jiǎn)單的嗎?然而實(shí)際實(shí)現(xiàn)的時(shí)候還是會(huì)遇到一些坑。于是,之前一次面試就跪了,很尷尬。所以,還是需要對(duì)它進(jìn)行深入理解!
基本步驟
- 檢查范圍,(以及終止條件)
注意遞歸終止條件
- 選擇基準(zhǔn)(pivot),分割序列
即篩選不大于pivot的元素和不小于pivot的元素),將pivot放至正確位置
- 對(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)的有以下三種方法。
- 序列首/序列尾
對(duì)于有序序列分割極不平衡 - 隨機(jī)選擇
優(yōu)于序列首,但開(kāi)銷(xiāo)不小 - 三數(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放至正確位置。
之后以此類(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位置的元素交換。
之后以此類(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.
排序方式具體如下:
- For small arrays (length < 17), use the Insertion sort algorithm.
- 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.
- 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.
- 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.
- The pointers L, K, and G are changed in the corresponding directions.
- The steps 4 - 5 are repeated while K ≤ G.
- 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.
- 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