手记

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

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.

大意是:

  • 针对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个指针,其中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为例,从多个角度分析了它是如何实现的。比如,递归,分治,优化,代码中一些细节的点等等。

另外两种相对更为复杂一些,我不想只是贴代码然后草草而过,所以后边有时间再来分别分析一下。

参考资料

2人推荐
随时随地看视频
慕课网APP