Java SDK中的sort算法小議
在學習數據結構和算法的時候,很多書籍或資料會將每個知識點分開去講,這種方法可以幫助我們循序漸進地理解對應的知識點。
在排序算法裏邊,書本上常見的會有
- 冒泡排序 (bubble sort) - 思想很簡單,但是實際當中基本不會使用
- 插入排序 (insertion sort) - 同樣也很簡單,會經常在一些小數據量的時候使用
- 選擇排序 (selection sort) - 不穩定
- 快速排序 (quick sort) - 不穩定,理論上最快
- 歸併排序 (merge sort) - 穩定,性能比較均衡
不太常見的還會有
- 計數排序 (counting sort)
- 桶排序 (bucket sort)
- 基數排序 (radix sort)
最開始接觸的時候以爲在實際的使用當中,是相互獨立存在的。比如我預計到數據量比較小,那麼就使用插入排序,否則丟給快排去處理。
但是學習了JDK中的實現之後,發現真正應用在生產環境中的代碼其實是多種排序思想組合使用。這裏以Java中最常見的兩種排序 - Collections.sort
和Arrays.sort
,來粗略看看它是怎麼工作的,另外是它對優化的極致追求。
注意,這裏暫時不會cover更爲複雜的parallel sort
,它的內容可能是另外一個專門的話題了。
兩種常見的sort
瞭解一下調用流程 - 從對外接口到真正實現
接下來分析的代碼基於JDK 1.8.1 u121
先看一下調用流程
Collections.sort
Arrays.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
這個算法可以幫助我們複習一下基本的知識點,如遞歸
與分治
。
首先它的僞遞推公式是
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個指針,其中p
和q
分別指向左右兩個數組的起點,另外一個i
則指向整體已經排序過的數組dest
。一層for
循環就可以達到目的,所以這裏的n就是整體O(nlogn)
裏邊的n
,而另外的logn
是遞歸了logn
次。
注意,這裏的src存儲的是前邊已經排好序的兩個數組,而dest則是合併之後的數組。所以前邊F裏纔要對調dest
和src
的位置。這個問題最初會讓人感覺比較繞,可以多體會一下。
這部分的源碼如下,
// 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
爲例,從多個角度分析了它是如何實現的。比如,遞歸,分治,優化,代碼中一些細節的點等等。
另外兩種相對更爲複雜一些,我不想只是貼代碼然後草草而過,所以後邊有時間再來分別分析一下。