Java SDK中的排序算法小議 - 01 開篇

在學習數據結構和算法的時候,很多書籍或資料會將每個知識點分開去講,這種方法可以幫助我們循序漸進地理解對應的知識點。

在排序算法裏邊,書本上常見的會有

  • 冒泡排序 (bubble sort) - 思想很簡單,但是實際當中基本不會使用
  • 插入排序 (insertion sort) - 同樣也很簡單,會經常在一些小數據量的時候使用
  • 選擇排序 (selection sort) - 不穩定
  • 快速排序 (quick sort) - 不穩定,理論上最快
  • 歸併排序 (merge sort) - 穩定,性能比較均衡

不太常見的還會有

  • 計數排序 (counting sort)
  • 桶排序 (bucket sort)
  • 基數排序 (radix sort)

最開始接觸的時候以爲在實際的使用當中,是相互獨立存在的。比如我預計到數據量比較小,那麼就使用插入排序,否則丟給快排去處理。

但是學習了JDK中的實現之後,發現真正應用在生產環境中的代碼其實是多種排序思想組合使用。這裏以Java中最常見的兩種排序 - Collections.sortArrays.sort,來粗略看看它是怎麼工作的,另外是它對優化的極致追求。

注意,這裏暫時不會cover更爲複雜的parallel sort,它的內容可能是另外一個專門的話題了。

兩種常見的sort

瞭解一下調用流程 - 從對外接口到真正實現

接下來分析的代碼基於JDK 1.8.1 u121

先看一下調用流程

Collections.sort

Y
N
Collections.sort
List.sort
Arrays.sort(T[])
is legacy?
-Arrays.mergeSort
~TimSort.sort

Arrays.sort

Y - primitive array
N - object array
Y
N
Arrays.sort
is primitive array?
~DualPrivotQuicksort.sort
is legacy?
-Arrays.mergeSort
~TimSort.sort

由於primitive有多個類型,所以Arrays.sort有多個針對primitive的實現,但是背後的處理方式是一致的。我們這裏以int類型爲例,來進行研究。

如圖所見,它們最後都會轉到3種排序方法

  • 針對對象的
    • -Arrays.mergeSort
    • ~TimSort.sort
  • 針對primitive的
    • ~DualPrivotQuicksort.sort

簡單說明一下,TimSort是一種改進的merge sort

爲什麼不都使用quick sort呢?

看到這裏,可能大家和我都有一個疑問:爲什麼針對對象的數組使用merge sort,而不是使用理論上更快(雖然他們都是O(nlogn)),並且佔用內存相對較小的merge sort呢?

這裏有一個看起來像是Josh Bloch的回答,可能沒有人比原作者更有發言權了吧。

Java裏大名鼎鼎的Collection Framework和排序算法的作者就是Josh Block,而且也是另外一本暢銷書書籍 - Effective Java 的作者。

I did write these methods, so I suppose I'm qualified to answer. It is true that there is no single best sorting algorithm. QuickSort has two major deficiencies when compared to mergesort:

It's not stable (as parsifal noted).

It doesn't guarantee n log n performance; it can degrade to quadratic performance on pathological inputs.

Stability is a non-issue for primitive types, as there is no notion of identity as distinct from (value) equality. And the possibility of quadratic behavior was deemed not to be a problem in practice for Bentely and McIlroy's implementation (or subsequently for Dual Pivot Quicksort), which is why these QuickSort variants were used for the primitive sorts.

Stability is a big deal when sorting arbitrary objects. For example, suppose you have objects representing email messages, and you sort them first by date, then by sender. You expect them to be sorted by date within each sender, but that will only be true if the sort is stable. That's why we elected to provide a stable sort (Merge Sort) to sort object references. (Techincally speaking, multiple sequential stable sorts result in a lexicographic ordering on the keys in the reverse order of the sorts: the final sort determines the most significant subkey.)

It's a nice side benefit that Merge Sort guarantees n log n (time) performance no matter what the input. Of course there is a down side: quick sort is an "in place" sort: it requies only log n external space (to maintain the call stack). Merge, sort, on the other hand, requires O(n) external space. The TimSort variant (introduced in Java SE 6) requires substantially less space (O(k)) if the input array is nearly sorted.

資料出處參見 - Why Collections.sort uses merge sort instead of quicksort? [closed]

大意是:

  • 針對primitive類型,我們無需考慮穩定性。因爲primitive除了自身的值之外,沒有其它額外的數據。
  • 針對object類型,情況就複雜多了。其中穩定性是一個很重要的考量,你本次排序的input很有可能是之前已經按照其它條件排序過了的數據,爲了保持這種相對的順序,就有了穩定性的要求。
  • 另外可能的考慮因素是,相比merge sort, quick sort不能保證穩定的nlogn的時間複雜度,雖然其在空間佔用的內存更小。

所以,就像回答裏邊開始所說的,沒有一種可以包治百病的排序算法,在特定的場景下,有相對更適合的算法,否則也不會有這麼多排序算法需要我們去了解,學習了。(在DualPrivotQuicksort裏邊也不僅僅是純粹的quick sort)

具體是怎麼實現的

前邊我們通過調用流程圖發現,最終支撐其JDK排序功能的主要是3種算法

  • 針對對象的
    • -Arrays.mergeSort
    • ~TimSort.sort
  • 針對primitive的
    • ~DualPrivotQuicksort.sort

接下來,以最簡單的merge sort爲例,我們來分析下它是怎麼實現的。

Arrays.mergeSort

Y
N
Y
N
開始 - 計算排序區域length
length < 7 ?
E - 直接使用insertion sort進行排序
繼續merge sort
F - 計算中間點,分成左右兩半,分別進行遞歸調用
經過上邊排序後的左右兩半是否已經有序?
將src數組直接複製到dest數組,然後return
J - 將上述兩個已經有序的數組合並,然後return

這個算法可以幫助我們複習一下基本的知識點,如遞歸分治

首先它的僞遞推公式是

f(sorted) = merge(f(left sorted half), f(right sorted half))

遞歸的3個要點是

  • 問題可以拆解成幾個子問題的解,即數據規模更小的同類問題
  • 原問題與拆解之後的子問題,除了數據規模不同,求解思路完全一致
  • 存在遞歸終止條件

同時也體現了4種算法思想中的分治思想(divide and conquer),即

  • 分而治之
  • 將原問題劃分爲n個規模較小,並且結構與原問題相似的子問題
  • 遞歸地解決這些子問題
  • 然後再合併其結果,就可以得到原問題的解

另外,上述圖中有幾個點比較重要,我分別標記爲 E, F, J

E

這裏是一個優化,看起來原理簡單的insertion sort還是有它的用場的。至於閾值爲什麼是7?猜測是作者做了大量測試之後,選取的一個比較優的值吧。源碼如下,

        // Insertion sort on smallest arrays
        // INSERTIONSORT_THRESHOLD = 7
        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;
        }

F

這裏需要注意的是,對左右兩半遞歸調用的時候,參數src/dest的位置對調了一下。爲什麼要這樣呢?看了下邊J的分析就明白了。源碼如下,

        // 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);

J

將兩個已經排好序的數組合並。這裏有3個指針,其中pq分別指向左右兩個數組的起點,另外一個i則指向整體已經排序過的數組dest。一層for循環就可以達到目的,所以這裏的n就是整體O(nlogn)裏邊的n,而另外的logn是遞歸了logn次。

注意,這裏的src存儲的是前邊已經排好序的兩個數組,而dest則是合併之後的數組。所以前邊F裏纔要對調destsrc的位置。這個問題最初會讓人感覺比較繞,可以多體會一下。

這部分的源碼如下,

        // 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++];
        }

小結

總結一下,我們以實踐的角度,從JDK提供的兩種排序作爲入口,分析了他們的調用流程,最後發現隱藏在背後的主要有3種排序算法:

  • 針對對象的
    • -Arrays.mergeSort
    • ~TimSort.sort
  • 針對primitive的
    • ~DualPrivotQuicksort.sort

然後以merge sort爲例,從多個角度分析了它是如何實現的。比如,遞歸,分治,優化,代碼中一些細節的點等等。

另外兩種相對更爲複雜一些,我不想只是貼代碼然後草草而過,所以後邊有時間再來分別分析一下。

參考資料

  1. Why Collections.sort uses merge sort instead of quicksort? [closed]
  2. java.util.DualPivotQuickSort的實現
  3. Java中雙基準快速排序方法(DualPivotQuicksort.sort())的具體實-站長資訊中心
  4. 單軸快排(SinglePivotQuickSort)和雙軸快排(DualPivotQuickSort)及其JAVA實現
  5. 讀 Java TimSort算法 源碼 筆記
  6. Java DualPivotQuickSort 雙軸快速排序 源碼 筆記
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章