數(shù)據(jù)結構與算法之美筆記——排序優(yōu)化

摘要:

快速排序有普適性、原地算法節(jié)省空間的優(yōu)秀特質,當使用「三點取中」和「隨機法」降低快排時間復雜度的退化概率后,快排在底層的排序實現(xiàn)中成為廣泛應用的算法。

為何是快排

排序算法多種多樣,在之前章節(jié)中我們已經(jīng)學習了常見的幾種排序算法,各有特點,我們先對它們進行執(zhí)行效率的比較。

算法名稱 時間復雜度 是否原地 是否穩(wěn)定
冒泡排序 O(n^2) ?? ??
插入排序 O(n^2) ?? ??
選擇排序 O(n^2) ?? ?
歸并排序 O(n\log\left(n\right)) ? ??
快速排序 O(n\log\left(n\right)) ?? ?
桶排序 O(n) ? ??
計數(shù)排序 O(n) ? ??
基數(shù)排序 O(n) ? ??

從圖表中來看,桶排序、計數(shù)排序和基數(shù)排序無疑在時間復雜度上具有較大優(yōu)勢,但這三種算法對排序數(shù)據(jù)有特殊要求,對于底層排序實現(xiàn)這種需要對數(shù)據(jù)具有普適性的情況,明顯是不適合的。

退而求其次,時間復雜度次之的是歸并排序和快速排序,快速排序時間復雜度并不穩(wěn)定,有退化為 O(n^2) 的可能,而且也不是穩(wěn)定的排序算法,按理應該選擇歸并排序,但歸并排序有個劣勢——不是原地算法。歸并排序的空間復雜度是 O(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í)行效率。雖然 O(n\log\left(n\right)) 在時間復雜度的通常意義上來說比 O(n^2) 的更加優(yōu)秀,但這是忽略常量、系數(shù)等的理想情況下,當數(shù)據(jù)規(guī)模較小的時候,常量、系數(shù)及低階項都有影響時間復雜度的可能。

例如,100+10\log\left(10\right)>10。這樣情況肯定會存在,在工業(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ù)結構與算法之美筆記系列將會做為我對王爭老師此專欄的學習筆記,如想了解更多王爭老師專欄的詳情請到極客時間自行搜索。

?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容