摘要:
快速排序有普適性、原地算法節(jié)省空間的優(yōu)秀特質,當使用「三點取中」和「隨機法」降低快排時間復雜度的退化概率后,快排在底層的排序實現(xiàn)中成為廣泛應用的算法。
為何是快排
排序算法多種多樣,在之前章節(jié)中我們已經(jīng)學習了常見的幾種排序算法,各有特點,我們先對它們進行執(zhí)行效率的比較。
算法名稱 | 時間復雜度 | 是否原地 | 是否穩(wěn)定 |
---|---|---|---|
冒泡排序 | ?? | ?? | |
插入排序 | ?? | ?? | |
選擇排序 | ?? | ? | |
歸并排序 | ? | ?? | |
快速排序 | ?? | ? | |
桶排序 | ? | ?? | |
計數(shù)排序 | ? | ?? | |
基數(shù)排序 | ? | ?? |
從圖表中來看,桶排序、計數(shù)排序和基數(shù)排序無疑在時間復雜度上具有較大優(yōu)勢,但這三種算法對排序數(shù)據(jù)有特殊要求,對于底層排序實現(xiàn)這種需要對數(shù)據(jù)具有普適性的情況,明顯是不適合的。
退而求其次,時間復雜度次之的是歸并排序和快速排序,快速排序時間復雜度并不穩(wěn)定,有退化為 的可能,而且也不是穩(wěn)定的排序算法,按理應該選擇歸并排序,但歸并排序有個劣勢——不是原地算法。歸并排序的空間復雜度是
,也就是說當需要排序的數(shù)據(jù)大小為 100 M 時,還需要額外申請 100 M 的空間,這個缺點導致了快排走上 C 位。
快排的優(yōu)化
快排雖然走上 C 位,但想成為真正的明星還需要包裝一番。快排的時間復雜度并不那么穩(wěn)定,究其原因是在快排選取分區(qū)點時很難理想化地將排序數(shù)據(jù)均分,當分區(qū)極其不平衡時,時間復雜度的退化在所難免,所以要解決快排時間復雜度退化的問題就要解決如何取分區(qū)點的問題。
三點取中法
三點取中法就是從排序數(shù)據(jù)中取三個位置的數(shù)據(jù),一般來說是第一個、最后一個和中間一個數(shù)據(jù),然后比較三個數(shù)據(jù),使用中間的一個數(shù)作為分區(qū)點數(shù)據(jù)。這種方法實現(xiàn)簡單,但當數(shù)據(jù)規(guī)模較大時,三點可能已經(jīng)無法滿足使分區(qū)盡量平衡的要求,需要五點取中甚至十點取中,所以這種方法在遭遇大規(guī)模數(shù)據(jù)時有其弊端。
隨機數(shù)法
通過隨機的方式獲取分區(qū)點,雖然不能完全避免分區(qū)極度不平衡的情況,但能夠將這種概率降低,降低時間復雜度退化的可能。
因為算法的實現(xiàn)使用遞歸的方式,所以快排不僅存在時間復雜度退化的問題,還要避免遞歸導致的棧內存溢出問題。解決這個問題可以通過限制遞歸深度,也可以自己在堆上實現(xiàn)方法棧以突破內存限制。
合適的數(shù)據(jù)遇到合適的算法
雖然使用快排可以實現(xiàn)底層的排序方法,但當數(shù)據(jù)規(guī)模較小時使用快排可能會降低執(zhí)行效率。雖然 在時間復雜度的通常意義上來說比
的更加優(yōu)秀,但這是忽略常量、系數(shù)等的理想情況下,當數(shù)據(jù)規(guī)模較小的時候,常量、系數(shù)及低階項都有影響時間復雜度的可能。
例如,。這樣情況肯定會存在,在工業(yè)上實現(xiàn)排序方法時,也會對不同的數(shù)據(jù)規(guī)模應用不同類型的排序算法,這樣可以提高排序方法的執(zhí)行效率。
Java 中的應用
Java 中的 Collection 子類就實現(xiàn)了排序方法,我們一起探尋一番平時常用的 List 排序方法的秘密,需要注明此處分析的是 JDK 1.8 源碼。
因為 JDK 1.8 引入了 default 關鍵字,可以允許在 interface 中定義默認的方法實現(xiàn)(感覺 Abstract Class 的功能被削弱),在 List 中有 sort 方法的默認實現(xiàn)。
default void sort(Comparator<? super E> c) {
Object[] a = this.toArray();
Arrays.sort(a, (Comparator) c);
ListIterator<E> i = this.listIterator();
for (Object e : a) {
i.next();
i.set((E) e);
}
}
Arrays.sort
暴露了其實 List 使用的是 Arrays 的排序方法,繼續(xù)跟進。
public static <T> void sort(T[] a, Comparator<? super T> c) {
if (c == null) {
sort(a);
} else {
if (LegacyMergeSort.userRequested)
legacyMergeSort(a, c);
else
TimSort.sort(a, 0, a.length, c, null, 0, 0);
}
}
LegacyMergeSort.userRequested
來自啟動參數(shù) java.util.Arrays.useLegacyMergeSort
,可以讓用戶通過啟動參數(shù)使用舊的排序方式。默認情況下是使用 TimSort,也是一種排序算法。接著分析一下 legacyMergeSort
方法,畢竟一看名字就知道是我們熟悉的歸并排序。
private static void legacyMergeSort(Object[] a) {
Object[] aux = a.clone();
mergeSort(aux, a, 0, a.length, 0);
}
private static void mergeSort(Object[] src,
Object[] dest,
int low,
int high,
int off) {
int length = high - low;
// Insertion sort on smallest arrays
if (length < INSERTIONSORT_THRESHOLD) {
for (int i=low; i<high; i++)
for (int j=i; j>low &&
((Comparable) dest[j-1]).compareTo(dest[j])>0; j--)
swap(dest, j, j-1);
return;
}
// Recursively sort halves of dest into src
int destLow = low;
int destHigh = high;
low += off;
high += off;
int mid = (low + high) >>> 1;
mergeSort(dest, src, low, mid, -off);
mergeSort(dest, src, mid, high, -off);
// If list is already sorted, just copy from src to dest. This is an
// optimization that results in faster sorts for nearly ordered lists.
if (((Comparable)src[mid-1]).compareTo(src[mid]) <= 0) {
System.arraycopy(src, low, dest, destLow, length);
return;
}
// Merge sorted halves (now in src) into dest
for(int i = destLow, p = low, q = mid; i < destHigh; i++) {
if (q >= high || p < mid && ((Comparable)src[p]).compareTo(src[q])<=0)
dest[i] = src[p++];
else
dest[i] = src[q++];
}
}
legacyMergeSort
對排序數(shù)組克隆之后調用 mergeSort
方法,mergeSort
方法的主體部分和常規(guī)的歸并排序一樣,也是獲取中間點,然后分區(qū),分區(qū)再進行歸并排序,對分區(qū)數(shù)組合并時進行排序。
int destLow = low;
int destHigh = high;
low += off;
high += off;
int mid = (low + high) >>> 1;
mergeSort(dest, src, low, mid, -off);
mergeSort(dest, src, mid, high, -off);
// Merge sorted halves (now in src) into dest
for(int i = destLow, p = low, q = mid; i < destHigh; i++) {
if (q >= high || p < mid && ((Comparable)src[p]).compareTo(src[q])<=0)
dest[i] = src[p++];
else
dest[i] = src[q++];
}
不過在合并排序的時候增加了一步判斷,當前一個分區(qū)的最大數(shù)不大于后一個分區(qū)最小數(shù)時兩個分區(qū)已經(jīng)自然有序,所以直接按前后順序拼接兩個數(shù)組即可,避免了循環(huán)比較合并數(shù)組。
// If list is already sorted, just copy from src to dest. This is an
// optimization that results in faster sorts for nearly ordered lists.
if (((Comparable)src[mid-1]).compareTo(src[mid]) <= 0) {
System.arraycopy(src, low, dest, destLow, length);
return;
}
對于遞歸的終止條件也進行了不同的處理,當分區(qū)數(shù)據(jù)規(guī)模小于 INSERTIONSORT_THRESHOLD
(這個常量表示啟用插入排序的閥值)時,使用插入排序對數(shù)據(jù)排序處理并終止遞歸。
int length = high - low;
// Insertion sort on smallest arrays
if (length < INSERTIONSORT_THRESHOLD) {
for (int i=low; i<high; i++)
for (int j=i; j>low &&
((Comparable) dest[j-1]).compareTo(dest[j])>0; j--)
swap(dest, j, j-1);
return;
}
總結
快排雖然存在缺點,但使用三點取中法、隨機法解決分區(qū)不平衡問題,使用限制遞歸深度、自定義實現(xiàn)方法棧解決遞歸過深問題后,在實際生產(chǎn)中被廣泛應用,不過為了提高排序方法的執(zhí)行效率一般會根據(jù)數(shù)據(jù)規(guī)模的變化采用適合的排序算法。
文章中如有問題歡迎留言指正
數(shù)據(jù)結構與算法之美筆記系列將會做為我對王爭老師此專欄的學習筆記,如想了解更多王爭老師專欄的詳情請到極客時間自行搜索。