快速排序(Quick Sort)
看到名字,就知道這種排序算法速度非常快。那到底有多快呢?在前面冒泡排序時,就有提到過這種排序算法,它的平均時間復雜度為O(nlogn),但看到其最壞時間復雜度為O(n^2),不過,雖然有最壞的情況,但是還是有辦法降低最壞情況出現(xiàn)的概率,所以總體來講,效率還是非常高的。但是在前面也介紹過,堆排序與歸并排序,其時間復雜度都是O(nlogn)級別的,但是這三個O(nlogn)級別的排序算法,哪個算法會更快呢?其實快速排序會更加快,也就是說,在本章節(jié)介紹的快速排序會比對排序與歸并排序速度更快。
快速排序簡介
快速排序是1960年由查爾斯·安東尼·理查德·霍爾(Charles Antony Richard Hoare),一般稱為東尼·霍爾(Tony Hoare)
其實結(jié)合前面的歸并排序介紹,可以發(fā)現(xiàn)一個特點,現(xiàn)在的排序算法都是幾十年前的結(jié)論,也就是說,幾十年過去了,到現(xiàn)在還沒有一個穩(wěn)定的,被大家公認的排序算法來替代這些優(yōu)秀的算法,還望大家來突破這些算法。
快速排序執(zhí)行流程
-
從序列中選擇一個軸點元素(pivot)
-
假設(shè)每次選擇0位置的元素為軸點元素,例如下圖的序列
現(xiàn)在選擇0位置的元素6作為軸點元素,一旦定義好了軸點元素后,就開始執(zhí)行步驟2
-
-
利用軸點元素將序列分割成2個子序列
- 將小于軸點元素的元素放在軸點元素的前面(左側(cè))
- 將大于軸點元素的元素放在軸點元素的后面(右側(cè))
- 等于軸點元素的元素放任意一邊都可以
所以利用上圖的序列,進行分割以后,就會變?yōu)橄聢D的這樣一個序列,通過這樣分割,相對于原來的序列,會變得更加有序一點
-
對子序列進行1,2操作
- 知道不能再分割(子序列中只剩下一個元素)
所以對上面的序列再次執(zhí)行1,2操作的話,最終得到的結(jié)果如下圖
再次執(zhí)行1,2操作,得到下圖的結(jié)果
最終,將所有元素一次排好序。
所以可以發(fā)現(xiàn),快速排序的本質(zhì)是
逐漸將每一個元素都轉(zhuǎn)換成軸點元素,當所有元素都變?yōu)檩S點元素時,就拍好序了
那這個流程應該怎樣去實現(xiàn)呢?
構(gòu)造軸點
假設(shè)現(xiàn)在有下列的序列,這個序列有點特殊,其中有兩個6,兩個8,為了表示區(qū)分,方別用6a/6b,8a/8b來表示,其中begin指向首元素,end指向末尾元素(這里的end和前面章節(jié)的end指向的位置有一點區(qū)別)
現(xiàn)在希望將6a變?yōu)檩S點,一般會先將該元素進行備份,利用一個臨時的變量,將該值保存起來。
由于現(xiàn)在是begin有空的位置,所以從end到begin+1開始掃描元素,在本序列中,首先掃描到的是7,由于7大于軸點元素,所以只需要將end--即可,繼續(xù)掃描
現(xiàn)在掃描到的是5,由于5是小于軸點元素,所以應該放到序列的左邊。然后begin++
現(xiàn)在空的位置變?yōu)閑nd方向,所以掃描的防線變?yōu)閺腷egin到end的方向。掃描到的第一個元素是8a,由于8a是比軸點元素大的,所以將8a的值直接覆蓋end指向的位置,然后將end--
由于現(xiàn)在空位置是由begin直接指向的,所以本次掃描方向由end方向到begin方向,繼續(xù)掃描,這次掃描發(fā)現(xiàn)元素為9,比軸點元素大,所以只需要將end--即可
由于空位置沒有發(fā)生變化,所以繼續(xù)從end掃描到begin,本次掃描到的元素為4,其值比軸點元素小,所以將元素4直接覆蓋掉begin指向的位置,然后begin++
現(xiàn)在空位置是右end指針指向,所以掃描方向發(fā)生變化,變?yōu)閺腷egin掃描到end,由于本次掃描到的元素為8b,值比軸點元素大,所以將8b的值覆蓋掉end指針指向的位置,然后進行end--操作
完成后,空的位置現(xiàn)在有begin指針指向,所以掃描方向變?yōu)閑nd到begin方向,本次掃描到的元素是6b,是等于軸點元素的,由于等于軸點元素的元素,放軸點元素任意一邊都可以,所以直接end--就行,不過這里執(zhí)行的操作是將相等的元素放到另外一邊,所以這里是將end蚊子相等的元素放在begin,begin++,最終的效果如下圖
現(xiàn)在空的位置由end指向,所以掃描方向由begin到end方向。不過這次掃描發(fā)現(xiàn)值比軸點元素小,所以直接begin++就可以了,begin++后得到的begin等于end,一旦發(fā)現(xiàn)begin和end重疊,就意味著軸點元素構(gòu)建完畢
由于之前備份了6a的值,所以現(xiàn)在要做的事情是將6a的值,覆蓋當前空出來的位置,這樣6a就歸位了
所以最終發(fā)現(xiàn),6a變?yōu)檩S點后,最終找到了自己的位置,并且軸點構(gòu)建完畢。
軸點構(gòu)建總結(jié):掃描方向主要看空出來的位置是由begin指向還是有end指向
- 如果end指向空出來的元素, 則從begin掃描到end
- 如果是begin指向空出來的元素,則從end掃描到begin
根據(jù)上面的分析,最終可以得到獲取軸點的實現(xiàn)代碼如下
/*
* 構(gòu)造出[begin,end)范圍內(nèi)的軸點元素
* @return 軸點元素的最終位置
* */
private int pivotIndex(int begin, int end) {
//備份軸點元素
E pivot = array[begin];
//end指向最后一個元素
end--;
while (begin < end) {
while (begin < end) {
if (cmp(pivot,array[end]) < 0) {//右邊元素大于軸點元素
end--;
} else {//右邊元素小于等于軸點元素
array[begin++] = array[end];
break;
}
}
while (begin < end) {
if (cmp(pivot,array[begin]) >0) {//左邊元素小于軸點
begin++;
} else {//左邊元素大于等于軸點元素
array[end--] = array[begin];
break;
}
}
}
//將軸點元素放入最終的位置
array[begin] = pivot;
//返回軸點元素的位置
return begin;
}
然后利用獲取到的軸點,對begin到end范圍內(nèi)的元素進行快速排序的代碼
/*
* 對[begin,end)范圍內(nèi)的元素進行快速排序
* */
private void quickSort(int begin, int end) {
if (end - begin < 2) return;
//確定軸點位置
int mid = pivotIndex(begin,end);
//對子序列也進行快速排序
quickSort(begin,mid);//左邊子序列快速排序
quickSort(mid + 1 ,end);//右邊子序列快速排序
}
利用上面代碼,結(jié)合前面的幾種排序算法,對20000條數(shù)據(jù)進行排序的結(jié)果如下圖
可以看到,快速排序與前面幾種排序結(jié)果排序后,得到的結(jié)果非常優(yōu)秀。如果再將數(shù)據(jù)進行增加到50000條數(shù)據(jù),得到的結(jié)果就更加明顯了,結(jié)果如下(這里的排序,對前面性能較差的幾種排序就不再做對比了)
時間復雜度分析
在軸點左右元素數(shù)量比較均勻的情況下,是快速排序性能最佳的時候。
在這個時候,可以得到所消耗時間的表達式為:T(n) = 2 * T(n/2) + O(n)
可以看到,這個表達式與歸并排序的表達式是一樣的,所以在最好的情況下, 快速排序的時間復雜度與歸并排序的時間復雜度相同,都是O(nlogn)
如果軸點左右兩邊元素數(shù)量及其不均勻,則是時間復雜度最壞的情況。
例如現(xiàn)在有如下的序列
將元素7作為軸點,軸點構(gòu)造完成后的結(jié)果為
并往復執(zhí)行構(gòu)造軸點,最終得到的每一步結(jié)果為
所以在這個時候,就是最壞時間復雜度的時候。在這種情況下,所消耗時間的表達式為:T(n) = T(n-1) + O(n) = O(n^2);
避免最壞情況
為了降低最壞情況的出現(xiàn)概率,一般采取的做法是
- 隨機選擇軸點元素
所以優(yōu)化后的代碼為
private int pivotIndex(int begin, int end) {
//隨機選擇一個元素跟begin位置進行交換
swap(begin,(int)(Math.random() * (end - begin)) + begin);
//備份軸點元素
E pivot = array[begin];
//end指向最后一個元素
end--;
while (begin < end) {
while (begin < end) {
if (cmp(pivot,array[end]) < 0) {//右邊元素大于軸點元素
end--;
} else {//右邊元素小于等于軸點元素
array[begin++] = array[end];
break;
}
}
while (begin < end) {
if (cmp(pivot,array[begin]) >0) {//左邊元素小于軸點
begin++;
} else {//左邊元素大于等于軸點元素
array[end--] = array[begin];
break;
}
}
}
//將軸點元素放入最終的位置
array[begin] = pivot;
//返回軸點元素的位置
return begin;
}
快速排序時間復雜度總結(jié):
最好,平均時間復雜度為:O(nlogn)
最壞時間復雜度:O(n^2)
空間復雜度:O(logn)
與軸點相等的元素
在前面,如果遇到軸點元素相等的元素,都是直接將該元素放到軸點元素的另一邊。具體是怎么做的呢?請看下圖,現(xiàn)在所有元素都是相等的
-
備份軸點元素
-
執(zhí)行第一次確定元素位置,發(fā)現(xiàn)值是相等的,這時,原來軸點右邊的元素,被放到了軸點的左邊
-
執(zhí)行第二次確定元素位置,發(fā)現(xiàn)值是相等的,這時,將原來軸點左邊的元素,被放到軸點的右邊
-
執(zhí)行第三次確定元素位置,發(fā)現(xiàn)值是相等的,這時,原來軸點右邊的元素,被放到了軸點的左邊
-
執(zhí)行第四次確定元素位置,發(fā)現(xiàn)值是相等的,這時,將原來軸點左邊的元素,被放到軸點的右邊
-
將備份的元素放到begin與end重合的位置
發(fā)現(xiàn),如果用原來的這種確定軸點的方式,有一個好處就是,軸點確定以后,依然可以平均分割原來的序列。
所以在pivot與end元素進行比較時,不是≤或者≥的原因是為了提高性能,避免出現(xiàn)最壞時間復雜度的情況。
如果將cmp位置的判斷分別改為≤,≥會起到什么效果呢?
這樣進行判斷,會導致軸點元素切割出來的序列,非常不均勻,可能會導致最壞時間復雜度O(n^2)
為了進行對比,現(xiàn)生成5萬條相等的數(shù)據(jù),利用原來的算法進行測試,得到的結(jié)果如下圖
現(xiàn)在將判斷條件分別改為≤,≥,得到的結(jié)果如下圖
最終控制臺打印出了非常多的這種信息,從提示信息可以看出,是發(fā)生了棧溢出,也就是說棧空間消耗完了,原因是現(xiàn)在的軸點切割非常不均勻,每次切割只會減少一個數(shù)據(jù)規(guī)模,也就意味著quickSort函數(shù)會遞歸調(diào)用n次,在當前程序中,是要遞歸調(diào)用5萬次,需要開辟5萬次棧空間,最終導致棧空間不夠用。
所以為了對比出最終的效果,現(xiàn)在將數(shù)據(jù)規(guī)模調(diào)整到1萬條。在不加=的情況下,得到的結(jié)果為
加上=后的結(jié)果為
可以看到,最終的比較結(jié)果,差距非常大。所以在進行比較判斷的時候,不要使用≥或者≤
完!