排序指的是将一组对象按照特定的逻辑顺序重新排列的过程,排序的应用十分广泛,可以说是无处不在,它在商业数据处理和现代科学计算中发挥着举足轻重的作用,目前已知的应用最广泛的排序算法—快速排序,更是被誉为了 20 世纪科学和工程领域的十大算法之一。

排序算法有很多,有比较常见的有比如插入排序、归并排序、快速排序,就是我接下来会讲解的这几种;也有一些非常冷门的排序算法,有一些可能你连名字都没听过,例如鸡尾酒排序、侏儒排序、图书馆排序、耐心排序、臭皮匠排序等等……

这篇文章的篇幅较长,涉及到大量图例、证明、代码示例,一次性全部看完可能有点困难,可以分几次多看几遍,自己思考一下,结合一些经典书籍资料等等,相信看完之后你对排序算法的认识会有很大的提升。

在开始讲解之前,先定义一个游戏规则,为了复用部分代码,我写了一个排序的抽象类,当然你可以转换成任何你熟悉的编程语言,如下:

public abstract class AbstractSort {

    public abstract void sort(Object[] nums);

    /**
     * 比较两个数的大小
     */
    @SuppressWarnings({"unchecked", "rawtypes"})
    public boolean compare(Object v, Object w){
        return ((Comparable)v).compareTo(w) < 0;
    }

    /**
     * 交换两个数组元素
     */
    public void swap(Object[] nums, int i, int j){
        Object temp = nums[i];
        nums[i] = nums[j];
        nums[j] = temp;
    }

    /**
     * 输出数组内容
     */
    public void showArray(Object[] nums){
        for (Object num : nums) {
            System.out.print(num + " ");
        }
    }
}

这个类里面的方法也很简单,一个 sort 抽象方法,后续的排序实现类都会实现这个方法,来填充具体的排序逻辑;排序的操作基本上都会依赖比较和交换,因此写了一个 compare 比较方法,比较两个数字的大小,还有一个 swap 方法交换两个数组的元素;最后是一个 showArray 方法,打印出数组内容查看排序的结果是否符合预期。

然后再介绍两个基本的概念,在文章里接下来的分析中我会经常提到:

复杂度

按照惯例,对算法的分析需要关注其复杂度,包括时间复杂度和空间复杂度,排序算法当然也不例外。对于不占用额外的存储空间,空间复杂度是 O(1) 的排序,有一个专用的名词来称呼,叫做原地排序。

稳定性

稳定的排序指的是在排序前后,相等的值的先后顺序不会发生改变,反之即是不稳定的排序。举个例子,一组数据 3 4 4 2 1 5,排序之后是 1 2 3 4 4 5,数组中有两个 4,如果排序之后,两个 4 的先后顺序没有改变,则称排序算法是稳定的。稳定的排序算法在某些特定的场景中会非常有用。

一、基础排序

1. 冒泡排序

很容易想到的一种排序思路是,每次都遍历数组,查看相邻的两个数字的大小并交换位置,这样每次都能够将较大的那个元素移动至数组的另一端。每一次遍历,较大的元素都会像气泡一样慢慢的“浮动”至数组另一端,这便是这个算法被叫做冒泡排序的原因。

下面这张动图展示了冒泡排序的整个过程:

冒泡排序很容易理解,复杂度也很好分析,在最好的情况下,如果数组本来就是有序的,那么只需要遍历一次数组即可,时间复杂度是 O(n),在最坏的情况下,每排列一个数据,都需要遍历整个数据集,因此时间复杂度是 O(n2),平均情况下也接近 O(n2)。排序过程不会使用到额外的存储空间,因此空间复杂度是 O(1),是原地排序。

冒泡排序是稳定的吗?从上面的图中可以看到,每次比较都是交换的两个数值不相等的数据,而相等的数据要么不满足条件不移动,要么满足条件则全部移动,这保证了相等数据的前后顺序不发生改变,因此冒泡排序是稳定的。

下面是一个简单的代码实现供参考:

public class BubbleSort extends AbstractSort {

    @Override
    public void sort(Object[] nums) {
        if (nums == null || nums.length <= 1){
            return;
        }

        int n = nums.length;
        boolean swap = true;
        for (int i = 0; swap && i < n; i++) {
            swap = false;
            for (int j = 0; j < n - i - 1; j++) {
                if (compare(nums[j + 1], nums[j])){
                    swap(nums, j, j + 1);
                    swap = true;
                }
            }
        }
    }
}

需要注意的是,代码中使用到了一个辅助变量 swap,它的作用是标识数据是否已经没有交换了,如果没有则说明数据已经有序了,直接跳出循环,这样可以避免多余的冒泡操作。例如数据 1 2 3 4 5 6,第一遍历之后,发现已经是有序的了,就没有必要再进行后续的遍历。

2. 选择排序

选择排序也很容易理解,对于一个要排序的数组,我们每次都从数组中寻找最小值,并把它和第一个元素交换,然后在剩下的数据中继续寻找最小值,然后将其与数组第二个元素交换,如此循环往复,直到整个数组有序。

下面这张动图可以帮助你理解选择排序的整个过程:

无论在最好还是平均情况下,选择排序的时间复杂度都是 O(n2),因为就算是要排序一个已经有序的数组,选择排序的逻辑还是要遍历数组寻找最小值。空间复杂度则和冒泡排序一样,是 O(1)。

需要注意的是,选择排序是不稳定的,这是由它的交换特性决定的,我举个例子就容易明白了,例如数据 3 3 2 0 1 5,第一次遍历,我们找到了最小值 0,并把它和第一个数据 3 交换位置,那么这两个 3 的前后顺序就被改变了,因此选择排序不能保证稳定性。

下面是一个简单的代码示例供参考:

public class SelectionSort extends AbstractSort {

    @Override
    public void sort(Object[] nums) {
        if (nums == null || nums.length <= 1){
            return;
        }

        int n = nums.length;
        for (int i = 0; i < n - 1; i++) {
            int min = i + 1;
            for (int j = i; j < n; j++) {
                if (compare(nums[j], nums[min])){
                    min = j;
                }
            }
            swap(nums, i, min);
        }
    }

}

3. 插入排序

插入排序的基本思路是:依次遍历每一个数字,并将其和前面已排序的数据进行对比,将其插入到合适的位置。生活中也有很多这样的例子,假如我们手中有一副扑克牌,当新来了一张扑克牌之后,我们便会依次查找,将新来的扑克牌放到合适的位置。

插入排序的过程如下图:

插入排序的时间复杂度和冒泡排序类似,这里不再赘述了,平均情况下的时间复杂度是 O(n2),空间复杂度是 O(1),是原地排序。并且插入排序也是稳定的,从上图也很容易明白,在每个数据的遍历交换过程中,相同数据的前后顺序肯定不会被改变。

下面是一个简单的代码示例供参考:

public class InsertionSort extends AbstractSort {
    
      @Override
    public void sort(Object[] nums) {
        if (nums == null || nums.length <= 1){
            return;
        }

        int n = nums.length;
        for (int i = 0; i < n - 1; i++) {
            int j = i + 1;
            Object k = nums[j];
            while (j > 0 && compare(k, nums[j - 1])){
                nums[j] = nums[--j];
            }
            nums[j] = k;
        }
    }
}

4. 希尔排序

希尔排序其实是插入排序的一个优化版本,如果你搞懂了插入排序,那么弄懂希尔排序就非常容易了。回想一下,插入排序的特点是:每次比较的时候交换相邻元素,因此数据每次只能移动一位。如果这样的话,对于一些大规模乱序的数据,例如最大值刚好在头部,那么将其移动至尾部,需要移动大概 n - 1 次。

而希尔排序则将数据分成了 n 个组,对每个组内的数据分别进行插入排序,这样就能够将较大的数据一次性移动很多位,为后续的比较交换提供了便利。文字描述起来可能比较的晦涩抽象,可以结合下面的图来看一下:

我们先定义一个增量 k,k 一般为数组长度的 1 / 2 ,上图中要排序的原始数据的个数是 8 ,因此 k = 8 / 2 = 4,所以第一次可以将数组分为 4 组,分别是 (9, 5), (2, 1), (4, 0), (3, 6),即上图中用直线连接起来的数据,然后将每个组内的数据分别进行插入排序,那么第一次排序之后的结果为:5 1 0 3 9 2 4 6

可以看到最开始 9 在数组最前面,但是经过第一次排序之后,9 已经向后移动了 4 位,相较于插入排序,这样就减少了移动的次数,希尔排序的性能提升就主要体现在这里。

然后继续进行分组,这时 k 缩小一半变为 2,因此我们将数组分为两组,然后在组内再进行插入排序,如下图:

此时分为了两组,分别是(5, 0, 9, 4), (1, 3, 2, 6),进行排序之后的结果是:0 1 4 2 5 3 9 6,可以看到此时数组已经基本上是有序的了。

然后 k 再次缩小为 1,我们将数组分为 1 组,其实就是原始的数据了,然后再进行一次插入排序,由于其实数据已经是基本上有序的了,因此只需要微调即可,一般不会进行大量的数据移动:

整个排序的过程当中,增量 k 是一步一步缩小的,正因此,希尔排序其实也叫做缩小增量排序。

下面是一个简单的希尔排序的代码示例:

public class ShellSort extends AbstractSort {

    @Override
    public void sort(Object[] nums) {
        if (nums == null || nums.length <= 1){
            return;
        }

        int n = nums.length;
        int k = n / 2;
        while (k >= 1){
            for (int i = 0; i < n - k; i++) {
                int j = i + k;
                Object v = nums[j];
                while (j > k - 1 && compare(v, nums[j - k])){
                    nums[j] = nums[j - k];
                    j = j - k;
                }
                nums[j] = v;
            }
            k = k / 2;
        }
    }
}

希尔排序的时间复杂度比较难分析,可以大致概括为 O(nlog2n),但实际上它和定义的增量、数据本身的排列特性等相关,有很多相关论文都进行了分析,但都无法得到具体选择哪个增量是最好的。已知的最好的增量是 Sedgewick 提出的 (1, 5, 19, 41, 109,……),感兴趣的可以看下 wikipedia 上关于步长序列的描述:维基百科—希尔排序

希尔排序的空间复杂度很好理解,是 O(1),并且希尔排序是不稳定的,因为它和选择排序类似,都是跳着进行数据的交换,这样就会破坏稳定性。

当数据量较小的时候,希尔排序是一个不错的选择,但是它并没有被广泛的应用,因为在数据量较大的时候,我们更倾向于使用复杂度更优的 O(nlogn) 的排序算法。

5. 总结

前面说到的这几种基础排序算法,在实际当中的使用并不是很多,因为它们的时间复杂度较高,应用在大规模数据中的话,效率将会非常低下,因此它们只适合小规模的数据排序。例如 Java 中的双轴快速排序,在数据量小于 47 时便使用了插入排序。

你也许会纳闷,为什么插入排序、冒泡排序、选择排序的平均时间复杂度都是 O(n2),但是在大多数情况下,针对数据量较少的情况,一般都会采用插入排序呢?

简单分析一下,选择排序就不用说了,因为它的性能并没有绝对优势,即便是在数组已经有序的情况下,它的时间复杂度仍然是 O(n2),并且它还不是稳定的,因此选择排序一般并不会考虑使用。

接下来再看看冒泡排序和插入排序,这两者虽然时间复杂度是一样的,但是在实际的排序过程中,冒泡排序的数据交换次数更多,你可以从上面的代码示例中看到,冒泡排序的一次交换涉及到三次操作,而插入排序只需要一次数据赋值,这样的话便使插入排序要稍微快一些。

你可以构造一组随机数据来测试这两种排序算法,例如我在本机测试了一组 5 万个数据,冒泡排序的耗时是 8 秒多一点,而插入排序只使用了不到 3 秒。

//冒泡排序,swap 函数涉及到三次赋值操作
for (int j = 0; j < n - i - 1; j++) {
   if (compare(nums[j + 1], nums[j])){
     swap(nums, j, j + 1);
     swap = true;
   }
}

//插入排序,交换时只需要一次数据赋值
while (j > 0 && compare(k, nums[j - 1])){
   nums[j] = nums[j - 1];
   j--;
}

最后再来看看希尔排序,希尔排序发明于 1959 年,它是最早的时间复杂度突破 O(n2) 的排序算法之一,但是它的应用并不是很广泛,原因在于它遇到了更强劲的“对手”,相较于归并排序,它并不是稳定的,而相较于快速排序,它的性能又没有任何优势可言,因此归并排序和快速排序更受青睐。

二、归并排序

在介绍归并排序之前,先简单说下分治思想。分治,顾名思义就是分而治之,它是一种解决问题的思路,将原始问题分解为多个相同或相似的子问题,然后将子问题解决,并将子问题的求得的解进行合并,这样原问题就能够得到解决了。分治思想是很多复杂算法的基础,例如归并排序、快速排序、二分查找等等。

言归正传,再来看归并排序,它的概念理解起来非常简单,如果我们要对一组数据进行排序,我们可以将这个数组分为两个子数组,子数组再进行分组,这样子数组排序之后,将结果合并起来,就能够得到原始数据排序的结果,相信你已经发现了,这就是典型的利用分治思想解决问题的思路。

下面这张图展示了将一个问题分解为多个子问题的过程:

子问题得到解决之后,需要将结果合并,合并的过程如下图:

下面的动图比较直观的展示了归并排序的整个过程,可以对照起来理解一下:

如果使用一个简单的公式来表示归并排序的话,那么可以写成这样:

 merge_sort(data, p, r) = merge(merge_sort(data, p, q), merge_sort(data, q + 1, r));

我来简单解释一下这个公式,我们将排序的数组叫做 data,p 和 r 分别表示其起始和终止下标,因为要进行分组,因此取 p 和 r 的中间值 q,然后对 p 到 q 和 q + 1 到 r 的数据分别再进行排序。

排序之后的结果需要用一个合并的函数来将结果合并,这个函数可以叫做 merge,merge 函数的功能很简单,对于两个已排序的数据区间,假设下标是 p ~ r,我们可以新建一个临时数组,然后依次比较两个已排序的数据区间的值,并将其一 一复制到临时数组中,你可以结合下图来理解一下:

理解了这些之后,再来看归并排序的代码实现就会非常简单了,下面是一个代码示例:

public class MergeSort extends AbstractSort {

    private Object[] temp;

    @Override
    public void sort(Object[] nums) {
        if (nums == null || nums.length <= 1){
            return;
        }

        int n = nums.length;
        temp = new Object[n];
        sortHelper(nums, 0, n - 1);
    }

    private void sortHelper(Object[] data, int p, int r){
        if (p >= r){
            return;
        }

        int q = p + (r - p) / 2;
        sortHelper(data, p, q);
        sortHelper(data, q + 1, r);
        merge(data, p, q, r);
    }

    private void merge(Object[] data, int p, int q, int r){
        int i = p, j = q + 1;
        System.arraycopy(data, p, temp, p, r - p + 1);

        for (int k = p; k <= r; k++) {
            if (i > q) data[k] = temp[j++];
            else if (j > r) data[k] = temp[i++];
            else if (compare(temp[j], temp[i])) data[k] = temp[j++];
            else data[k] = temp[i++];
        }
    }
}

2.1 算法分析

归并排序的时间复杂度,一般可以使用两种方式来进行推导,假如将要排序的数组的长度记为 n,排序一个长度为 n 的数组的时间记为 T(n),由于归并排序需要将原问题分解成子问题,因此 T(n) 又可以写成 T(n) = 2 * T(n/2) + n,其中 n 表示合并两个子数组所需要的时间。

然后继续进行分解,这个公式可以表示成这样:

T(n) = 2 * T(n/2) + n
     = 2 * ( 2 * T(n/4) + n/2) + n                = 4 * T(n/4) + 2n
     = 2 * ( 2 * (2 * T(n/8) + n/4) + n/2) + n    = 8 * T(n/8) + 3n
     … … … 
     = 2^k * T(n/2^k) + k*n

可以看到,时间计算公式可以记为:T(n) = 2k T(n/2k) + k n,当 n / 2k = 1 的时候,可以得到 k = logn,然后再将 k 代入到公式中,可以得到 T(n) = Cn + nlogn (C 为常数),使用大 O 表示法,那么归并排序的时间复杂度可以大致记为 O(nlogn)。

当然这种分析方式较为复杂抽象,还有一种更加简单直观的方式,我们可以将归并排序的过程使用树的方式,形象的展示出来,大致就是这样的:

可以看到,归并排序的时间复杂度可以使用树结构分级展示,每一层涉及到的操作是分组和合并,所消耗的时间都是一样的 n,因此算法的总体时间复杂度就是 n * 树的高度,可以很明显的看出,这是一颗满二叉树,满二叉树的高度一般是 logn,因此归并排序的时间复杂度就是 O(nlogn)

归并排序的空间复杂度比较简单,从代码中可以看到,当进行子数组合并的时候,需要一个临时数组 temp,数组的长度等于要排序的数组长度,因此空间复杂度可以表示为 O(n)。

其实归并排序并没有像快速排序那样应用广泛,其中很重要的原因便是它并不是原地排序算法,这是它的一个致命弱点。

最后思索一下归并排序是稳定的吗?这个问题比较好理解,数组的拆分并不会涉及到数据交换,只有合并才会,因此归并排序是否稳定主要取决于 merge 的过程。

假设我们需要合并 1 3 3 5 2 3 4 6,可以看到在合并的过程当中,我们的判断条件是单一的,如果第一个数字被放到的了临时数组中,那么后续相同的数字也会依次被放到临时数组中,前后顺序不会被改变,因此归并排序是稳定的。

2.2 自底向上

上面讲到的归并排序是最常见的一种类型,它的特点是自顶向下进行数据拆分,你可以从最开始的分解图清晰的看出来,其实还有另外一种归并排序,它的顺序恰恰相反,是自底向上的,只不过这种方式理解起来并不如上面的直观,你可以简单做个了解。

其思路是这样的:首先进行两两归并(你可以将数组想象成多个只有一个元素的子数组),然后是四四归并(将长度为 2 的子数组合并为长度为 4 的子数组),然后是八八归并,一直进行下去。

下面是一个简单的代码示例:

public class MergeSortBottomUp extends AbstractSort {

    private Object[] temp;

    @Override
    public void sort(Object[] nums) {
        if (nums == null || nums.length <= 1){
            return;
        }

        int n = nums.length;
        temp = new Object[n];
        for (int i = 1; i < n; i = 2 * i) {
            for (int j = 0; j < n - i; j += (2 * i)){
                merge(nums, j, j + i - 1, Math.min(j + 2 * i - 1, n - 1));
            }
        }
    }

    private void merge(Object[] data, int p, int q, int r){
        if (compare(data[q], data[q + 1])){
            return;
        }
        int i = p, j = q + 1;
        System.arraycopy(data, p, temp, p, r - p + 1);

        for (int k = p; k <= r; k++) {
            if (i > q) data[k] = temp[j++];
            else if (j > r) data[k] = temp[i++];
            else if (compare(temp[j], temp[i])) data[k] = temp[j++];
            else data[k] = temp[i++];
        }
    }
}

三、快速排序

快速排序通常叫做“快排”,它应该是应用最广泛的一个排序算法了,很多编程语言内置的排序工具,都或多或少使用到了快速排序,因为快速排序的时间复杂度可以达到 O(nlogn),并且是原地排序,前面介绍的几种排序算法都无法将这两个优点结合起来。

快排和归并排序类似,都采用了分治思想,但是它的解决思路却和归并排序不太一样。如果要排序一个数组,我们可以从数组中选择一个数据,做为分区点(pivot),然后将小于分区点的放到分区点的左侧,大于分区点的放到其右侧,然后对于分区点左右两边的数据,继续采用这种分区的方式,直到数组完全有序。

概念读起来可能有点抽象,这里我画了一张图来帮助你理解整个排序的过程:

上图展示了第一次分区的过程,假设要排序的数组的下标是 p ~ r,我们取数组的最后一个元素 5 做为分区点,然后比 5 小的数字 0 3 1 2 移动到 5 的左边,比 5 大的数字 9 6 8 7 移动到 5 的右边。

然后以数字 5 为分界点,其左边的数字(下标为 p ~ q - 1),以及右边的数字(下标为 q + 1 ~ r),分别再进行同样的分区操作,一直分下去,直到数组完全有序,如下图:

下面的动图展示了快速排序的完整过程(注意动图中是选择第一个元素做为分区点的):

如果使用一个简单的公式来表示快速排序,可以写成这样:

int q = partition(data, p, r);
quick_sort(data, p, r) = quick_sort(data, p, q - 1) + quick_sort(data, q + 1, r);

这里有一个 partition 分区函数,它的作用是选择一个分区点,并且将小于分区点的数据放到其左边,大于分区点的放到其右边,然后返回分区点的下标。其实这个 partition 分区函数是快速排序实现的关键,那究竟怎么实现这个函数呢?很容易想到的一种方式是:直接遍历一次原数组,依次取出小于和大于分区点的数据,将其各自存放到临时数组中,然后再依次拷贝回原数组中,过程如下图:

相信你也看出来了,这样做虽然简单,但是存在一个缺陷,那就是每次分区都会使用额外的存储空间,这会导致快速排序的空间复杂度为 O(n),那么就不是原地排序了。所以快速排序使用了另一种方式来实现分区,并且没有借助额外的存储空间,它是怎么实现的呢?我还是画了一张图来帮助你理解:

这个分区的过程可能不太好理解,你可以多看几遍上面的图,我们声明了两个指针 i 和 j,从数组的最开始处向后移动,这里的移动规则有两个:一是如果 j 所在元素大于分区点,那么 j 向后移动一位,i 不变;二是如果 j 所在元素小于分区点,那么交换 i 和 j 所在元素,然后 i 和 将 j 同时向后移动一位。

终止的条件是 j 移动至数组末尾,然后交换分区点和 i 所在的元素,i 就是分区点的下标。

理解了这个过程之后,再来看快速排序的代码实现,就会非常的简单了,下面是一个示例:

public class QuickSort extends AbstractSort {

    @Override
    public void sort(Object[] nums) {
        if (nums == null || nums.length <= 1){
            return;
        }
        sortHelper(nums, 0, nums.length - 1);
    }

    private void sortHelper(Object[] data, int p, int r){
        if (p >= r){
            return;
        }

        int q = partition(data, p, r);
        sortHelper(data, p, q - 1);
        sortHelper(data, q + 1, r);
    }

    private int partition(Object[] data, int p, int r){
        Object pivot = data[r];
        int i = p, j = p;
        while (j < r){
            if (compare(data[j], pivot)) {
                swap(data, i, j);
                i++;
            }
            j++;
        }
        swap(data, r, i);
        return i;
    }
}

3.1 算法分析

快速排序的时间复杂度的表示方法其实和归并排序类似,在最好的情况下,每次分区都能够均匀的将数组一分为二,那么时间复杂度可表示为 T(n) = 2 * T(n/2) + n,由上面的归并排序公式分析可以得出,最后的时间复杂度可以表示为 O(nlogn),尽管每次分区并不总会是最好情况,但是平均情况下,其时间复杂度还是在 O(nlogn) 左右的。

仔细分析一下,其实快速排序的简洁快速主要体现在:每次分区的时候,数组中的元素其实都在和一个定值比较并判断是否交换,扫描一遍数组之后,便不会再有数据交换了,而归并排序则是在交换比较元素之后,会重新把数组拷贝回原数组,所以可以得出结论,在多数情况下,快速排序其实比归并排序更快,尽管它们的时间复杂度都可以表示为 O(nlogn)。

快速排序另一个重要的特性是原地排序,因为分区的过程中并没有使用到额外的存储空间,空间复杂度是 O(1),但很遗憾的是,快速排序是不稳定的,因为在分区的过程当中,数据会跳着进行交换,你可以结合我上面的图理解一下。

3.2 算法优化

快速排序作为一种在编程语言中应用很多的算法,很多程序语言设计专家都对其进行了大量的优化,接下来我们可以了解几种比较常用的优化策略。

使用插入排序

在数据量较少的情况下,可以使用插入排序,很多编程语言内置的排序算法都使用到了这个优化办法。

分区点选择

在上面的讲解中,我直接是以数组最后一个元素做为快速排序的分区点,这样做虽然简单,但是可能存在效率低下的情况,例如要排序的数据本来就是或接近有序的,那么每次分区数据的分布都极不均匀,时间复杂度就会退化。

快速排序最理想的情况是,每次分区都能够均匀的将数据一分为二,为了达到或者接近这种情况,分区点的选择其实可以更讲究一点,常用的方法有:随机法,我们可以随机的选择数组中一个数字做为分区点;三数取中,每次都随机的从数组中取出三个数,然后取三个数中间的那一个做为分区点。

三向切分

如果数组中存在大量重复的元素,那么重复的元素其实并不用排序了,但是上面的分区方法还是会把重复的元素切分为更小的数组。针对这种情况,可以使用三向切分的办法,即把数据切分为三份,分别是小于分区点、等于分区点、大于分区点的元素,而等于分区点的元素则不用继续切分。

下面是三向切分的一个代码示例,你可以结合代码来理解一下:

public class QuickSort3Way extends AbstractSort{

    @Override
    public void sort(Object[] nums) {
        if (nums == null || nums.length <= 1){
            return;
        }
        sortHelper(nums, 0, nums.length - 1);
    }

    @SuppressWarnings({"unchecked", "rawtypes"})
    private void sortHelper(Object[] data, int p, int r){
        if (p >= r){
            return;
        }

        int lt = p, i = p + 1, gt = r;
        Comparable pivot = (Comparable) data[p];
        while (i <= gt){
            int cmp = pivot.compareTo(data[i]);
            if (cmp > 0){
                swap(data, lt++, i++);
            } else if (cmp < 0){
                swap(data, i, gt--);
            } else{
                i++;
            }
        }
        sortHelper(data, p, lt - 1);
        sortHelper(data, gt + 1, r);
    }
}

四、堆排序

要理解堆排序,必须得先明白什么是二叉堆。二叉堆(以下简称堆)是一种很优雅的数据结构,它是一种特殊的二叉树,满足二叉树的两个特性便可以叫做堆:

  • 是一个完全二叉树
  • 堆中任意一个节点的值都必须大于等于(或者小于等于)其子树中的所有节点值

对于节点大于等于子树中节点值的堆,叫做大顶堆,反之则叫做小顶堆,以下是几个堆的例子:

从定义和上图中可以看到,堆的一个特点便是,堆顶元素就是堆中最大(或最小)的元素。堆其实可以使用数组来存储,堆顶元素就是数组的第一个元素,并且对于任意下标为 i 的节点,其左子节点是 2 * i + 1,右子节点是 2 * i + 2,有了这个对应关系,堆在数组中的存储就是这样的:

理解了什么是堆之后,接下来进入正题,看看如何基于堆实现排序。堆排序的步骤一般有两个,分别是构造堆和排序,下面依次介绍。

4.1 构造堆

构造堆指的是将无序的数组构造成大顶堆,使其符合大顶堆的特征,举一个例子,对于一个完全无序的数组,其原始的排列顺序就像下图这样:

要使其变成大顶堆,我们可以这样做:从第一个非叶子节点(叶子节点指的是树中没有子节点的节点)开始,依次将其和子节点的值进行比较,根据大顶堆的特性,如果节点的值小于子节点的值,则将其和较大的子节点的值进行交换,然后再依次向下比较,直到叶子节点。

例如上图中的数据,第一个非叶子节点是 3,所以就从 3 开始进行比较,整个过程如下图:

4.2 排序

构造堆完成之后,接下来便是排序了,前面已经提到过,大顶堆有一个重要的特性是其堆顶元素是堆中的最大元素,因此我们可以每次取堆顶的元素,将其放到数组的末尾,然后将堆重新组织,继续取堆顶元素,这样将堆中的元素依次取出之后,整个数组便是有序的了,你可以参考下图理解整个过程:

你也可以结合代码来看一下,下面是一个示例:

public class HeapSort extends AbstractSort{

    @Override
    public void sort(Object[] nums) {
        if (nums == null || nums.length <= 1){
            return;
        }

        buildHeap(nums, nums.length);
        for (int i = nums.length - 1; i > 0; i--) {
            swap(nums, 0, i);
            heapify(nums, 0, i);
        }
    }

    private void buildHeap(Object[] data, int n){
        for (int i = (n - 1) / 2; i >= 0; i--) {
            heapify(data, i, n);
        }
    }

    private void heapify(Object[] data, int i, int n){
        while (true){
            int max = i;
            if (2 * i + 1 <= n - 1 && compare(data[max], data[2 * i + 1])) {
                max = 2 * i + 1;
            }
            if (2 * i + 2 <= n - 1 && compare(data[max], data[2 * i + 2])) {
                max = 2 * i + 2;
            }
            if (i == max) break;
            swap(data, max, i);
            i = max;
        }
    }
}

4.3 算法分析

堆排序的时间复杂度比较好分析,堆是一个完全二叉树,完全二叉树和满二叉树的高度都是 logn,每次重新构建堆的时间消耗是 logn,排序至少需要遍历数组每个元素,时间消耗是 n,因此堆排序的总体时间复杂度便是 O(nlogn)。排序的过程中没有使用到额外的存储空间,空间复杂度是 O(1),是原地排序。

堆排序并不是稳定的,你可以结合上面的图理解一下,数据在交换的过程当中是像选择排序一样跳着交换的,因此相同数据的前后顺序可能发生变化。

相信你已经注意到了,堆排序和快速排序一样,其时间复杂度是 O(nlogn),并且它非常稳定(这里的稳定指的是算法的性能,而不是上面说到的算法的稳定特性),在各种数据规模、数据排列特征下,它都能够保持 O(nlogn) 的运行时间,并且它还是原地排序。

但其实堆排序还是没有快速排序应用广泛,其中一个很重要的原因是堆排序无法利用 CPU 缓存,为什么这么说呢?

从上面堆排序的过程中,我们可以发现,其实堆排序在进行数据的构建堆操作时,一般不会顺序的读取数据,因为前面提到的那个公式,对于任意节点 i,其左子节点是 2 * i + 1,右子节点是 2 * i + 2。例如在数组下标为 3 处的节点,进行建堆时,会访问它的左右子节点,分别是 7 和 8,这对 CPU 缓存来说是不友好的。

后记

我本想将 Java 中内置的排序算法放到文章最后进行分析,但是 Java 里面的 DualPivotQuickSort 和 TimeSort 实在是有点复杂,并且加上这个的话文章的篇幅将会更长,所以以后可以针对这个单独写一篇文章,这次就暂时不介绍了。


roseduan
170 声望43 粉丝