在学习数据结构和算法的时候,很多书籍或资料会将每个知识点分开去讲,这种方法可以帮助我们循序渐进地理解对应的知识点。
在排序算法里边,书本上常见的会有
不太常见的还会有
最开始接触的时候以为在实际的使用当中,是相互独立存在的。比如我预计到数据量比较小,那么就使用插入排序,否则丢给快排去处理。
但是学习了JDK中的实现之后,发现真正应用在生产环境中的代码其实是多种排序思想组合使用。这里以Java中最常见的两种排序 - Collections.sort
和Arrays.sort
,来粗略看看它是怎么工作的,另外是它对优化的极致追求。
注意,这里暂时不会cover更为复杂的parallel sort
,它的内容可能是另外一个专门的话题了。
接下来分析的代码基于JDK 1.8.1 u121
先看一下调用流程
Collections.sort
Arrays.sort
由于primitive有多个类型,所以
Arrays.sort
有多个针对primitive的实现,但是背后的处理方式是一致的。我们这里以int类型为例,来进行研究。
如图所见,它们最后都会转到3种排序方法
-Arrays.mergeSort
~TimSort.sort
~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]
大意是:
merge sort
, quick sort
不能保证稳定的nlogn
的时间复杂度,虽然其在空间占用的内存更小。所以,就像回答里边开始所说的,没有一种可以包治百病
的排序算法,在特定的场景下,有相对更适合的算法,否则也不会有这么多排序算法需要我们去了解,学习了。(在DualPrivotQuicksort
里边也不仅仅是纯粹的quick sort
)
前边我们通过调用流程图发现,最终支撑其JDK排序功能的主要是3种算法
-Arrays.mergeSort
~TimSort.sort
~DualPrivotQuicksort.sort
接下来,以最简单的merge sort
为例,我们来分析下它是怎么实现的。
Arrays.mergeSort
这个算法可以帮助我们复习一下基本的知识点,如递归
与分治
。
首先它的伪递推公式是
f(sorted) = merge(f(left sorted half), f(right sorted half))
递归的3个要点是
同时也体现了4种算法思想中的分治思想
(divide and conquer),即
另外,上述图中有几个点比较重要,我分别标记为 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
~DualPrivotQuicksort.sort
然后以merge sort
为例,从多个角度分析了它是如何实现的。比如,递归,分治,优化,代码中一些细节的点等等。
另外两种相对更为复杂一些,我不想只是贴代码然后草草而过,所以后边有时间再来分别分析一下。