10种常见排序算法原理详解以及Java代码的完全实现

  本文详细介绍了10种常见排序算法的原理,包括冒泡排序、选择排序、插入排序、希尔排序、堆排序、归并排序、快速排序、计数排序、桶排序、基数排序。并且每种排序都提供了Java代码的实现案例。
  本文内容较多,欢迎点赞收藏加关注,慢慢看!

1 排序概述

排序的概念:
  将输入的数据按照某种比较关系,从小到大或者从大到小顺序进行排列,这就是排序。
排序的稳定性:
  假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中,r[i]=r[j],且r[i]在r[j]之前,而在排序后的序列中,r[i]仍在r[j]之前,则称这种排序算法是稳定的;否则称为不稳定的。
内排序与外排序:
  根据在排序过程中待排序的记录是否全部被放置在内存中,排序分为:内排序和外排序。
内排序是在排序整个过程中,待排序的所有记录全部被放置在内存中。外排序是由于排序的记录个数太多,不能同时放置在内存,整个排序过程需要在内外存之间多次交换数据才能进行。这里主要就介绍内排序的方法。
  根据排序过程中借助的主要操作,我们将内排序分为:插入排序、交换排序、选择排序和归并排序四类,每一类方法都有自己的多种具体方法和不同的时间复杂度:

插入排序 交换排序 选择排序 归并排序
插入排序 冒泡排序 选择排序 归并排序
希尔排序 快速排序 堆排序

  上面的7种排序的算法,按照算法的复杂度分为两大类,冒泡排序、选择排序和插入排序属于简单算法,而希尔排序、堆排序、归并排序、快速排序属于改进算法。而上面7中算法统称为比较排序。
  所谓的比较排序,即在排序的最终结果里,元素之间的次序依赖于它们之间的比较。每个数都必须和其他数进行比较,才能确定自己的位置。不同的算法区别在于比较次数的多少,在冒泡排序之类的排序中,问题规模为n,又因为需要比较n次,所以平均时间复杂度为O(n²)。在归并排序、快速排序之类的排序中,问题规模通过分治法消减为logN次,所以时间复杂度平均O(nlogn)。比较排序的优势是,适用于各种规模的数据,也不在乎数据的分布,都能进行排序。可以说,比较排序适用于一切需要排序的情况。通过比较两个数大小来进行排序的算法时间复杂度至少是O(nlgn)。
  因为我们如果要对一个数组排序,肯定至少要考察每个元素,因此可以推断O(n)是所有排序算法的下界。那么到底有没有哪种排序算法的时间复杂度是线性的(O(n))呢?在一定条件下,其实是有的,计数排序、基数排序、桶排序就属于线性复杂度的排序,同时他们都有“一定要求”!
  计数排序、基数排序、桶排序则属于非比较排序。非比较排序要求排序的元素都是整数,而且都在明确的m-n范围内。针对数组arr,计算arr[i]之前有多少个元素,则唯一确定了arr[i]在排序后数组中的位置。非比较排序只要确定每个元素之前的已有的元素个数即可,所以一次遍历即可解决,算法时间复杂度O(n)。非比较排序时间复杂度比较低,但由于非比较排序需要占用空间来确定唯一位置。所以对数据规模和数据分布有一定的要求,即空间复杂度较高。

2 比较排序

2.1 冒泡排序(Bubble Sort)

2.1.1 冒泡排序的实现

  无论什么语言,冒泡排序作为最简单的排序算法之一,也是新手必会的排序算法之一。
  冒泡排序原理:将前一个数和后一个数进行比较,若前一个比后一个小则交换位置,一轮完成后将最大值排在最前方再开始第二轮,选出第二大的值,排在倒数第二的位置,直至排到顺数第二位置,完成排序。
  外层循环控制循环次数,内层循环控制比较的两个数。
  冒泡排序就是把小的元素往前调或者把大的元素往后调。比较是相邻的两个元素比较,交换也发生在这两个元素之间。所以,如果两个元素相等,是不会再交换的;如果两个相等的元素没有相邻,那么即使通过前面的两两交换把两个相邻起来,这时候也不会交换,所以相同元素的前后顺序并没有改变,所以冒泡排序是一种稳定排序算法。

public class BubbleSort {
    public static void main(String[] args) {
        int[] arr = new int[]{9, 8, 5, 3, 1, 7};
        //外层循环控制循环次数
        //第一次循环比较需要比较五次,然后选出最大值 9,排在末尾
        //第二次循环只需要比较四次,选出第二大的值 8,排在倒数第二的位置
        //第n次循环 需要比较 arr.length-n 次
        //最后一次循环 需要比较一次
        for (int i = 0; i < arr.length - 1; i++) {
            //内层循环控制比较的相邻的两个数
            for (int j = 0; j < arr.length - 1 - i; j++) {
                //如果前一个数大于后一个数,则交换位置
                if (arr[j] > arr[j + 1]) {
                    arr[j] = arr[j] ^ arr[j + 1];
                    arr[j + 1] = arr[j] ^ arr[j + 1];
                    arr[j] = arr[j] ^ arr[j + 1];
                }
            }
        }
        System.out.println(Arrays.toString(arr));
    }
}

2.1.1 冒泡排序的复杂度分析

  设有n个需要排序的数,在冒泡排序中,第1 轮需要比较n - 1 次,第2 轮需要比较n - 2 次……第n - 1 轮需要比较1 次。因此,总的比较次数为(n - 1) + (n - 2) + … + 1 ≈ n²/2。这个比较次数恒定为该数值,和输入数据的排列顺序无关。因此,冒泡排序的时间复杂度为O(n²)。
  不过,交换数字的次数和输入数据的排列顺序有关。假设出现某种极端情况,如输入数据正好以从小到大的顺序排列,那么便不需要任何交换操作;反过来,输入数据要是以从大到小的顺序排列,那么每次比较数字后便都要进行交换。即
交换元素次数的复杂度最多为O(n²)。

  冒泡排序并没有借助外部辅助变量或者数据结构,因此空间复杂度为O(1)。

2.2 选择排序(Selection Sort)

2.2.1 选择排序的实现

  选择排序原理:第一轮,使用第一个值,索引为i,依次与后面的值做比较,并使用临时变量min=i,记录比较后的相对较小的值的索引,内层循环完毕之后,判断如果min不等于第一个元素的下标i,就让第一个元素跟他交换一下值,这样就找到整个数组中最小的数了,一轮结束后,此时将最小的值排在了最前方;再循环拿第二个值与后面的值依次作比较,直至倒数第二个值完成比较,即完成排序。
  外层循环控制比较的第一个数,内层循环控制比较的第二个数。
  在一轮选择之后,如果一个元素比当前元素小,而该小的元素又出现在一个和当前元素相等的元素后面,那么交换后稳定性就被破坏了。举个例子,序列5 8 5 2 9,第一轮选择第1个元素5会和2交换,那么原序列中两个5的相对前后顺序就被破坏了,所以选择排序是一个不稳定的排序算法。

public class SelectionSort {
    public static void main(String[] args) {
        //int[] arr = new int[]{9, 8, 5, 3, 1, 7};
        int[] arr = new int[]{49, 38, 65, 97, 76, 13, 27, 49, 78};
        //外层循环控制第一个被比较的数索引   [0 - (arr.length - 1) -1]
        //第一次循环比较需要比较五次,然后选出最小值 9,排在首位
        //第二次循环只需要比较四次,选出第二小的值 8,排在第二位
        //第n次循环 需要比较 arr.length-n 次
        //最后一次循环 需要比较一次
        for (int i = 0; i < arr.length - 1; i++) {
            int min = i;
            //内层循环控制第二个被比较的数索引 [i+1 ~ arr.length-]
            for (int j = i + 1; j < arr.length; j++) {
                //记录该轮最小的数的索引min
                if (arr[min] > arr[j]) {
                    min = j;
                }
            }
            //内层循环结束之后,判断索引min是否还是和i相等,不相等则说明有比arr[i]还小的数arr[min],交换元素
            if (min != i) {
                arr[i] = arr[i] ^ arr[min];
                arr[min] = arr[i] ^ arr[min];
                arr[i] = arr[i] ^ arr[min];
            }
        }
        System.out.println(Arrays.toString(arr));
    }
}

2.2.2 选择排序的复杂度分析

  选择排序使用了线性查找来寻找最小值,因此在第1 轮中需要比较n-1 个数字,第2 轮需要比较n - 2 个数字……到第n-1 轮的时候就只需比较1 个数字了。因此,总的比较次数与冒泡排序的相同,都是(n-1) + (n-2) + … + 1 ≈ n²/2 次。选择排序的时间复杂度也和冒泡排序的一样,都为O(n²)。
  每轮中交换数字的次数最多为1 次。如果输入数据就是按从小到大的顺序排列的,便不需要进行任何交换。即交换元素次数的复杂度最多为O(n),如果交换元素的开销比较大,那么选择排序优于冒泡排序。
  选择排序仅仅借助1个外部辅助变量,相对于输入元素个数n来说是一个常量,并没借助其他数据结构,因此空间复杂度为O(1)。

2.3 插入排序(Insertion Sort)

2.3.1 插入排序的实现

  插入排序原理:在排序过程中,左侧的数据默认是排好序的数据,而右侧的是还未被排序的数据。插入排序的思路就是从右侧的未排序区域内取出第一个数据,然后将它插入到左侧已排序区域内合适的位置上。当右侧区域数据取出排序只剩左侧区域,那么排序完毕。
  外层循环控制右侧未被排序的数,内层循环控制左侧已被排序的数。

public class InsertionSort {
    public static void main(String[] args) {
        int[] arr = new int[]{9, 8, 5, 3, 1, 7};
        int j;
        //外层循环控制右侧未被排序的数 假设"第一个数"是"已经"排好序的,因此 未排序的数据从第二个数开始取
        for (int i = 1; i < arr.length; i++) {
            int norSort = arr[i];
            //内层循环控制左侧已被排序的数,从最大的已排序的数开始比较
            //如果未排序的数小于已排序的数arr[j],则将arr[j]像右移动一位
            for (j = i - 1; j >= 0 && norSort < arr[j]; j--) {
                arr[j + 1] = arr[j];
            }
            //如果未排序的数大于已排序的数arr[j],则将arr[j+1]赋值给norSort,这就是为排序的数需要插入的已排序数据中的位置
            arr[j + 1] = norSort;
        }
        System.out.println(Arrays.toString(arr));
    }
}

2.3.2 排序过程分析

  该算法可能比冒泡排序和选择排序稍微绕一点,下面来看看执行步骤,需要对数组{9, 8, 5, 3, 1, 7}进行插入排序。我们可以把对n个数的插入排序看成n-1轮排序组成。上面的数组中n=6,即需要5轮排序。
  未排序:未排序时,默认将最左侧的数当作已排序的数据区域。数组结构如下::
在这里插入图片描述
  第1轮排序:外层循环i=1,取出未排序的元素8;进入内层循环,j=i-1=0,即0索引元素9,对它们进行比较发现8<9,此时将索引0+1=1的位置赋值为9,j–变成-1不满足大于等于0,内层循环结束;进入下一步,将-1+1=0索引的值赋值为未排序元素8,外层循环结束,第一次轮序结束。数组结构如下:
在这里插入图片描述

  第2轮排序:外层循环i=2,取出未排序的元素5;进入内层循环,j=i-1=1,即1索引元素9,对它们进行比较发现5<9,此时将索引1+1=2的位置赋值为9,j–变成0>=0,对索引0的值8和5进行比较,发现5<8,进入第二次内层循环,此时将索引0+1=1的位置赋值为8,j–变成-1不满足大于等于0,内层循环结束;进入下一步,将-1+1=0索引的值赋值为未排序元素5,外层循环结束,第二轮排序结束。数组结构如下:
在这里插入图片描述
  如此往复进行5轮排序,此时即可完成排序:
在这里插入图片描述

2.3.3 插入排序的复杂度分析

  在插入排序中,需要将取出的数据与其左边的数字进行比较。就跟前面讲的步骤一样,如果左边的数字更小,就不需要继续比较,本轮操作到此结束,自然也不需要交换数字的位置。如果要排序的表本身就是有序的,那么n轮只需要比较n次,时间复杂度为O(n)。
  然而,如果取出的数字比左边已排序的所有数字都要小,就必须不停地比较大小,交换数字,直到它到达整个序列的最左边为止。具体来说,就是外层第n轮需要内层循环比较n次。因此,在最糟糕的情况下,即输入数据按从大到小的顺序排列时,第1轮需要操作1 次,第2 轮操作2 次……第n 轮操作n次,所以时间复杂度和冒泡排序、选择排序的一样,都为O(n²)。
  当表数据基本是有序时,那么插入排序速度将比冒泡排序、选择排序更快。 当表数据基本是有序时,那么插入排序速度将比冒泡排序、选择排序更快。插入排序适用于已经有部分数据已经排好,并且排好的部分越大越好。一般在输入规模大于1000的场合下不建议使用插入排序。
  插入排序的空间复杂度为常数阶O(1)。
  到此,我们知道冒泡排序、选择排序、插入排序的时间复杂度都是O(n²),下面将介绍打破二次时间屏障的排序算法。

2.4 希尔排序(Shell Sort)

2.4.1 希尔排序的原理和实现

  在很长的时间里,众人发现尽管各种排序算法花样繁多(比如前面我们提到的三种不同的排序算法),但时间复杂度都是O(n²),似乎没法超越了。
  希尔排序是D.L.Shell于1959年提出来的一种排序算法,希尔排序算法是打破二次时间屏障的第一批排序算法之一。可惜的是它被发现若干年后才证明了它的亚二次时间界。
  希尔排序是基于插入排序的以下两点性质而提出改进方法的:

  1. 插入排序在对几乎已经排好序的数据操作时,效率高,即可以达到线性排序的效率。
  2. 但对于数据较多且基本无序的数据来说插入排序是低效的,因为插入排序每次只能将数据移动一位,并且插入排序的工作量和n的平方成正比,如果n比较小,那么排序的工作量自然要小得多。

  希尔排序算法先是把需要排序的记录按下标索引的一定增量d1<n分组,每组中记录的下标相差d1,分别对每组中全部元素进行排序,此时对于每个索引i都有arr[i]<arr[i+d],整个记录变成了“相对有序”;然后再用一个较小的增量d2<d1对它进行分组,在每组中再进行排序,整个记录变得更加“相对有序”;重复上述的分组和排序,当增量dn减到1时,即最终所有记录放在同一组中进行一次完整的插入排序为止,排序完成。
  由于增量序列d1,d2,……,dn(dn=1)的存在,希尔排序又被称为缩小增量排序。该方法实质上是一种分组插入方法。比较相隔较远距离(称为增量)的数,使得数移动时能跨过多个元素,实现跳跃式移动,则进行一次比较就可能消除多个元素交换,并且每一轮的分组数据较少,而且一轮排序之后数据变的“相对有序”,其总体效率相比直接插入排序更高。
  由于多次插入排序,我们知道一次插入排序是稳定的,不会改变相同元素的相对顺序,但在不同的插入排序过程中,相同的元素可能在各自的插入排序中移动,最后其稳定性就会被打乱,所以希尔排序是不稳定的。
  希尔排序的一种实现如下:

public class ShellSort {
    public static void main(String[] args) {
        int[] arr = new int[]{49, 38, 65, 97, 76, 13, 27, 49, 78, 34, 12, 64, 1};
        int j;
        //希尔增量初始gap=arr.length / 2   增量每次减半gap /= 2  直到等于0,结束希尔排序
        for (int gap = arr.length / 2; gap > 0; gap /= 2) {
            /*内部对每次分组的元素采用插入排序,因此,与传统插入排序不同的是,这里的插入排序实现了数据的跳跃式移动*/
            //外层循环控制某分组右侧未被排序的数,同样假设"第一个数"是"已经"排好序的,因此 未排序的数据从某分组第二个数即0+gap的索引处开始取
            for (int i = gap; i < arr.length; i++) {
                int norSort = arr[i];
                //内层循环控制某分组左侧已被排序的数,从最大的已排序的数开始比较,同样假设"第一个数"是"已经"排好序的,即0索引的数
                //如果未排序的数小于已排序的数arr[j],则将arr[j]像右移动j+gap位
                for (j = i - gap; j >= 0 && norSort < arr[j]; j -= gap) {
                    arr[j + gap] = arr[j];
                }
                //如果未排序的数大于已排序的数arr[j],则将arr[j+gap]赋值给norSort,这就是为排序的数需要插入的已排序数据中的位置
                arr[j + gap] = norSort;
            }
        }
        System.out.println(Arrays.toString(arr));
    }
}

2.4.2 排序过程分析

  希尔排序是插入排序的改进,这里详细说明一下排序过程。
  未分组的数据为:
在这里插入图片描述
  第一轮
  初始希尔增量为arr.length / 2=4。然后对以4进行索引分组的每组数据进行插入排序。如下图是分组之后的结构:
在这里插入图片描述
  对每一组插入排序之后的结构如下:
在这里插入图片描述
  可以看出来,这样分组之后每组数据量变少了,插入排序效率更高,虽然第一轮之后并没有元素完全有序,但是它将较小的元素,不是一步一步地往前挪动,而是跳跃式地往前移,相对于最开始的数据变得“更加有序”了,这有助于加快后续第二轮希尔排序的效率。
  第二轮
  希尔增量为4/2=2。然后对以2进行索引分组的每组数据进行插入排序。如下图是分组之后的结构:
在这里插入图片描述
  对每一组插入排序之后的结构如下:
在这里插入图片描述
  可以看出来,第二轮之后元素并没有完全有序,但是相对于第一轮的数据变得“更加有序”了,这有助于加快后续第三轮希尔排序的效率。
  第三轮
  希尔增量为2/2=1,这说明是最后一轮排序,该轮排序之后,元素将会变得有序。对以1进行索引分组的每组数据进行插入排序。如下图是分组之后的结构:
在这里插入图片描述
  可以看到,最终所有元素归为一组进行总的插入排序,排序之后的结构如下:
在这里插入图片描述
  可以看出来,对经过前两轮希尔排序之后的数据进行插入排序,两个数据只需要最多交换一次即可实现有序,这相比于对最开始的元素集合进行直接插入排序要快的多。实际上只需要内层交换4次即可:
在这里插入图片描述

2.4.3 希尔排序的复杂度分析

  从上面的案例可知,希尔排序的关键并不是随便分组后各自排序,而是将相隔某个“增量”的记录组成一个子序列,实现跳跃式的移动,使得排序的效率提高。这里“增量”的选取就非常关键了。
  常见的增量序列有两种,一种是Shell 增量序列,一种是Hibbard 增量序列。
  Shell 增量序列的递推公式为:
在这里插入图片描述
  Shell 增量序列的最坏时间复杂度为 Θ(N²)。
  Hibbard 增量序列的递推公式为:
在这里插入图片描述
   Hibbard 增量序列的最坏时间复杂度为 Θ(N (3/2) );平均时间复杂度约为 O(N(5/4)),要好于直接插入排序的O(n²)。 希尔排序突破了亚二次时间界。
  需要注意的是,增量序列的最后一个增量值必须等于1才行。另外由于记录是跳跃式的移动,希尔排序并不是一种稳定的排序算法。时间复杂度和增量序列直接相关,如果增量序列选择不好,那么基本上不能获得相比直接插入更高的效率。
  希尔排序并没有借助外部辅助变量或者数据结构,因此空间复杂度为O(1)。

2.5 堆排序(Heap Sort)

2.5.1 堆排序的原理和实现

  简单选择排序,它在待排序的n个记录中选择一个最小的记录需要比较n-1次。可惜的是,这样的操作并没有把每一轮的比较结果保存下来,在后一轮的比较中,有许多比较在前一轮已经做过了,但由于前一轮排序时未保存这些比较结果,所以后一轮排序时又重复执行了这些比较操作,因而记录的比较次数较多。
  如果可以做到每次在选择到最小记录的同时,记住比较的结果,并根据比较结果对其他记录做出相应的调整,那样排序的总体效率就会非常高了。而堆排序(HeapSort),就是对简单选择排序进行的一种改进,这种改进的效果是非常明显的。堆排序算法是Floyd和Williams在1964年共同发明的,同时,他们发明了“堆”这样的数据结构。 堆结构与树有关,因此需要一些基础知识,如果对二叉树不明白可以看看这个专栏:
  堆是具有下列性质的二叉树:

  1. 它是一颗完全二叉树,一个重要的性质就是完全二叉树的节点能够完美的映射到数组中
  2. 每个节点的值都大于或等于其左右孩子节点的值,称为大顶堆/最大堆,如下图左;或者每个节点的值都小于或等于其左右孩子节点的值,称为小顶堆/最小堆,如下图右。
    在这里插入图片描述

  如果按照层序遍历的方式给结点从1开始编号,由于完全二叉树节点之间的天然存在的关系(二叉树的性质),节点之间满足如下关系:

大顶堆:k[i] >= k[2i] && k[i] >= k[2i+1] (1<=i<=n/2)
小顶堆:k[i] <= k[2i] && k[i] <= k[2i+1] (1<=i<=n/2)

  如果从0开始编号,并把节点映射到数组中之后,则结点之间满足如下关系:

大顶堆:arr[i] >= arr[2i+1] && arr[i] >= arr[2i+2](0<=i<=n/2 -1)
小顶堆:arr[i] <= arr[2i+1] && arr[i] <= arr[2i+2](0<=i<=n/2 -1)

  大顶堆映射到数组的结构:
在这里插入图片描述
  小顶堆映射到数组的结构:
在这里插入图片描述
  n为数组长度,n/2 -1实际上表示数组从头到尾最后一个非叶子结点的索引位置。
  上面介绍完了堆,下面看看堆排序,堆排序(Heap Sort)就是利用堆进行排序的方法。
  堆排序的原理:先将给定n个值的无序序列构造成一个大/小顶堆(一般升序采用大顶堆,降序采用小顶堆)。然后将堆顶元素与堆尾元素进行交换,使堆尾元素最大/小,并将堆尾元素移除堆。然后继续调整剩余的n-1个堆元素成为大/小顶堆,再将堆顶元素与堆尾元素交换,并将堆尾元素移除堆,得到第二大/小元素。如此反复进行交换、重建、交换。直到堆剩下最后一个元素,此时完成堆排序,便能得到一个有序序列了。
  由于记录的比较与交换是跳跃式进行,因此堆排序也是一种不稳定的排序算法。另外,无论多少个数都需要构建堆,由于初始构建堆所需的比较次数较多,因此,它并不适合待排序序列个数较少的情况

  堆排序(大、小顶堆)的一种较好的实现如下:

public class HeapSort {
    public static void main(String[] args) {
        int[] arr = new int[]{49, 38, 65, 97, 76, 13, 27, 49, 78};
        //封装大顶堆排序算法的方法
        bigHeapSort(arr);
        System.out.println(Arrays.toString(arr));
        //封装小顶堆排序算法的方法
        //可以看出来,大顶堆和小顶堆排序算法差不多,只需理解其中一个,另外一个自然就理解了
        smallHeapSort(arr);
        System.out.println(Arrays.toString(arr));
    }


    /**
     * 大顶堆排序(顺序)
     *
     * @param arr 需要被排序的数据集合
     */
    private static void bigHeapSort(int[] arr) {
        /*1、构建大顶堆*/
        /*i从最后一个非叶子节点的索引开始,递减构建,直到i=-1结束循环
        这里元素的索引是从0开始的,所以最后一个非叶子节点array.length/2 - 1,这是利用了完全二叉树的性质*/
        for (int i = arr.length / 2 - 1; i >= 0; i--) {
            buildBigHeap(arr, i, arr.length);
        }
        /*2、开始堆排序,i = arr.length - 1,即从大顶堆尾部的数开始,直到i=0结束循环*/
        for (int i = arr.length - 1; i > 0; i--) {
            //交换堆顶与堆尾元素顺序
            swap(arr, 0, i);
            //重新构建大顶堆
            buildBigHeap(arr, 0, i);
        }
    }

    /**
     * 构建大顶堆
     *
     * @param arr    数组
     * @param i      非叶子节点的索引
     * @param length 堆长度
     */
    private static void buildBigHeap(int[] arr, int i, int length) {
        //先把当前非叶子节点元素取出来,因为当前元素可能要一直移动
        int temp;
        //节点的子节点的索引
        int childIndex;
        /*循环判断父节点是否大于两个子节点,如果左子节点索引大于等于堆长度 或者父节点大于两个子节点 则结束循环*/
        for (temp = arr[i]; (childIndex = 2 * i + 1) < length; i = childIndex) {
            //childIndex + 1 < length 说明该节点具有右子节点,并且如果如果右子节点的值大于左子节点,那么childIndex自增1,即childIndex指向右子节点索引
            if (childIndex + 1 < length && arr[childIndex] < arr[childIndex + 1]) {
                childIndex++;
            }
            //如果发现最大子节点(左、右子节点)大于根节点,为了满足大顶堆根节点的值大于子节点,需要进行值的交换
            //如果子节点更换了,那么,以子节点为根的子树会受到影响,所以,交换之后继续循环对子节点所在的树进行判断
            if (arr[childIndex] > temp) {
                swap(arr, i, childIndex);
            } else {
                //走到这里,说明父节点大于最大的子节点,满足最大堆的条件,直接终止循环
                break;
            }
        }
    }

    /**
     * 小顶堆排序(逆序)
     *
     * @param arr 需要被排序的数据集合
     */
    private static void smallHeapSort(int[] arr) {
        /*1、构建小顶堆*/
        /*i从最后一个非叶子节点的索引开始,递减构建,直到i=-1结束循环
        这里元素的索引是从0开始的,所以最后一个非叶子节点array.length/2 - 1,这是利用了完全二叉树的性质*/
        for (int i = arr.length / 2 - 1; i >= 0; i--) {
            buildSmallHeap(arr, i, arr.length);
        }
        /*2、开始堆排序,i = arr.length - 1,即从小顶堆尾部的数开始,直到i=0结束循环*/
        for (int i = arr.length - 1; i > 0; i--) {
            //交换堆顶与堆尾元素顺序
            swap(arr, 0, i);
            //重新构建小顶堆,此时堆的大小为交换前堆大小-1
            buildSmallHeap(arr, 0, i);
        }
    }

    /**
     * 构建小顶堆
     *
     * @param arr    数组
     * @param i      非叶子节点的索引
     * @param length 堆长度
     */
    private static void buildSmallHeap(int[] arr, int i, int length) {
        //先把当前非叶子节点元素取出来,因为当前元素可能要一直移动
        int temp;
        //节点的子节点的索引
        int childIndex;
        /*循环判断父节点是否大于两个子节点,如果左子节点索引大于等于堆长度 或者父节点大于两个子节点 则结束循环*/
        for (temp = arr[i]; (childIndex = 2 * i + 1) < length; i = childIndex) {
            //childIndex + 1 < length 说明该节点具有右子节点,并且如果如果右子节点的值小于左子节点,那么childIndex自增1,即childIndex指向右子节点索引
            if (childIndex + 1 < length && arr[childIndex] > arr[childIndex + 1]) {
                childIndex++;
            }
            //如果发现最小子节点(左、右子节点)小于根节点,为了满足小顶堆根节点的值小于子节点,需要进行值的交换
            //如果子节点更换了,那么,以子节点为根的子树会受到影响,所以,交换之后继续循环对子节点所在的树进行判断
            if (arr[childIndex] < temp) {
                swap(arr, i, childIndex);
            } else {
                //走到这里,说明父节点小于最小的子节点,满足最小堆的条件,直接终止循环
                break;
            }
        }
    }

    /**
     * 交换元素
     *
     * @param arr 数组
     * @param a   元素的下标
     * @param b   元素的下标
     */
    private static void swap(int[] arr, int a, int b) {
        arr[a] = arr[a] ^ arr[b];
        arr[b] = arr[a] ^ arr[b];
        arr[a] = arr[a] ^ arr[b];
    }
}

2.5.2 排序过程分析

2.5.2.1 构建堆

  该算法首先在第一个大循环中构建堆,其中i从arr.length/2–1=3的索引开始,3-2-1-0的变化,实际上这几个索引节点都是非叶子节点。如下图就是数组的元素映射到完全二叉树中的逻辑结构:
在这里插入图片描述
  我们所谓的将待排序的序列构建成为一个堆,其实就是从下往上、从右到左,将每个非叶子节点当作根结点,将其和其子树调整成堆。
  下面来看看第一步,如何构建堆,这里以大顶堆为例子。

for (int i = arr.length / 2 - 1; i >= 0; i--) {
buildBigHeap(arr, i, arr.length);
}
private static void buildBigHeap(int[] arr, int i, int length) {
    //先把当前非叶子节点元素取出来,因为当前元素可能要一直移动
    int temp;
    //节点的子节点的索引
    int childIndex;
    /*循环判断父节点是否大于两个子节点,如果左子节点索引大于等于堆长度 或者父节点大于两个子节点 则结束循环*/
    for (temp = arr[i]; (childIndex = 2 * i + 1) < length; i = childIndex) {
        //childIndex + 1 < length 说明该节点具有右子节点,并且如果如果右子节点的值大于左子节点,那么childIndex自增1,即childIndex指向右子节点索引
        if (childIndex + 1 < length && arr[childIndex] < arr[childIndex + 1]) {
            childIndex++;
        }
        //如果发现最大子节点(左、右子节点)大于根节点,为了满足大顶堆根节点的值大于子节点,需要进行值的交换
        //如果子节点更换了,那么,以子节点为根的子树会受到影响,所以,交换之后继续循环对子节点所在的树进行判断
        if (arr[childIndex] > temp) {
            swap(arr, i, childIndex);
        } else {
            //走到这里,说明父节点大于最大的子节点,满足最大堆的条件,直接终止循环
            break;
        }
    }
}

  第一次循环:
  第一次循环传入的i=3,然后获取左子结点索引childIndex = 2 * i + 1=7,然后判断是否具有右子节点8,明显存在,然后判断左子结点和右子节点最大的节点,明显是右节点,因此childIndex=7+1=8。
  然后判断父节点的值是否大于等于最大的子节点,如果不是,则为了满足大顶堆根节点的值大于子节点的要求,需要进行值的交换。这里arr[3]=97>arr[8]=78,因此不需要交换,此时直接break结束循环,进行下一次循环。此时数组并没有调整结构。
  第二次循环:
  第二次循环传入的i=2,然后获取左子结点索引childIndex = 2 * i + 1=5,然后判断是否具有右子节点6,明显存在,然后判断左子结点和右子节点最大的节点,明显是右节点,因此childIndex=5+1=6。
  然后判断父节点的值是否大于等于最大的子节点,如果不是,则为了满足大顶堆根节点的值大于子节点的要求,需要进行值的交换。这里arr[2]=65>arr[6]=27,因此不需要交换,此时直接break结束循环,进行下一次循环。此时数组并没有调整结构。
  第三次循环:
  第三次循环传入的i=1,然后获取左子结点索引childIndex = 2 * i + 1=3,然后判断是否具有右子节点4,明显存在,然后判断左子结点和右子节点最大的节点,明显是左节点,因此childIndex不需要自增。
  然后判断父节点的值是否大于等于最大的子节点,如果不是,则为了满足大顶堆根节点的值大于子节点的要求,需要进行值的交换。这里arr[1]=38<arr[3]=97,因此需要交换位置,此时数组边变成:
在这里插入图片描述
  然后由于子节点更换了,那么,以子节点为根的子树会受到影响,所以,交换之后继续循环对子节点所在的树进行判断。i=3,然后获取左子结点索引childIndex = 2 * i + 1=7,然后判断是否具有右子节点8,明显存在,然后判断左子结点和右子节点最大的节点,明显是右节点,因此childIndex=7+1=8。
  然后判断父节点的值是否大于等于最大的子节点,如果不是,则为了满足大顶堆根节点的值大于子节点的要求,需要进行值的交换。这里arr[3]=38<arr[8]=78,因此需要交换位置,此时数组边变成:
在这里插入图片描述
  然后由于子节点更换了,那么,以子节点为根的子树会受到影响,所以,交换之后继续循环对子节点所在的树进行判断。i=8,然后获取左子结点索引childIndex = 2 * i + 1=17,大于长度9,因此结束第三次循环。
  第四次循环:
  第四次循环传入的i=0,然后获取左子结点索引childIndex = 2 * i + 1=1,然后判断是否具有右子节点2,明显存在,然后判断左子结点和右子节点最大的节点,明显是左节点,因此childIndex不需要自增。
  然后判断父节点的值是否大于等于最大的子节点,如果不是,则为了满足大顶堆根节点的值大于子节点的要求,需要进行值的交换。这里arr[0]=47<arr[1]=97,因此需要交换位置,此时数组边变成:
在这里插入图片描述
  然后由于子节点更换了,那么,以子节点为根的子树会受到影响,所以,交换之后继续循环对子节点所在的树进行判断。i=1,然后获取左子结点索引childIndex = 2 * i + 1=3,然后判断是否具有右子节点4,明显存在,然后判断左子结点和右子节点最大的节点,明显是左节点,因此childIndex不需要自增。
  然后判断父节点的值是否大于等于最大的子节点,如果不是,则为了满足大顶堆根节点的值大于子节点的要求,需要进行值的交换。这里arr[1]=49<arr[3]=78,因此需要交换位置,此时数组边变成:
在这里插入图片描述
  然后由于子节点更换了,那么,以子节点为根的子树会受到影响,所以,交换之后继续循环对子节点所在的树进行判断。i=3,然后获取左子结点索引childIndex = 2 * i + 1=7,然后判断是否具有右子节点8,明显存在,然后判断左子结点和右子节点最大的节点,明显是左节点,因此childIndex不需要自增。
  然后判断父节点的值是否大于等于最大的子节点,如果不是,则为了满足大顶堆根节点的值大于子节点的要求,需要进行值的交换。这里arr[1]=49=arr[3]=49,因此不需要交换,此时直接break结束循环,进行下一次循环。此时数组并没有调整结构。
  下一次循环时i–= -1,小于i<0,此时第一个大循环结束,构建大顶堆结束,下面来看看构建大顶堆之后的数组结构:
在这里插入图片描述
  将其映射到平衡二叉树中,结构如下:
在这里插入图片描述
  可以看到,这颗平衡二叉树,完全符合大顶堆的特性,即父节点大于它的子节点。剩下的就是第二部分进行重复的交换-再构建。

2.5.2.1 交换-再构建

/*2、开始堆排序,i = arr.length - 1,即从大顶堆尾部的数开始,直到i=0结束循环*/
for (int i = arr.length - 1; i > 0; i--) {
    //交换堆顶与堆尾元素顺序
    swap(arr, 0, i);
    //重新构建大顶堆
    buildBigHeap(arr, 0, i);
}

  理解了第一部分,那么第二部分应该不难理解。首先将堆顶节点即0索引元素与堆尾节点即尾索引节点元素互换位置,然后将最后一个元素“踢出”堆,此时由于堆顶节点元素变了,因此需要重构堆,这个重构并不是像第一部分那样循环构建,而是构建可能会影响到的元素,由于堆顶节点元素改变,此时传入的i=0,有序需要踢出堆尾元素,因此传入的length变成arr.length – 1=8。此时堆在数组中的元素位置如下:
在这里插入图片描述
  其中红色部分表示堆元素,黑色表示被“踢出”堆的元素。然后开始进行一次构建堆。
  i=0,然后获取左子结点索引childIndex = 2 * i + 1=1,然后判断是否具有右子节点2,明显存在,然后判断左子结点和右子节点最大的节点,明显是左节点,因此childIndex不需要自增。
  然后判断父节点的值是否大于等于最大的子节点,如果不是,则为了满足大顶堆根节点的值大于子节点的要求,需要进行值的交换。这里arr[0]=38<arr[1]=78,因此需要交换位置,此时数组边变成:
在这里插入图片描述
  然后由于子节点更换了,那么,以子节点为根的子树会受到影响,所以,交换之后继续循环对子节点所在的树进行判断。i=1,然后获取左子结点索引childIndex = 2 * i + 1=3,然后判断是否具有右子节点4,明显存在,然后判断左子结点和右子节点最大的节点,明显是右节点,因此childIndex=3+1=4。
  然后判断父节点的值是否大于等于最大的子节点,如果不是,则为了满足大顶堆根节点的值大于子节点的要求,需要进行值的交换。这里arr[1]=38<arr[4]=76,因此需要交换位置,此时数组边变成:
在这里插入图片描述
  然后由于子节点更换了,那么,以子节点为根的子树会受到影响,所以,交换之后继续循环对子节点所在的树进行判断。i=4,然后获取左子结点索引childIndex = 2 * i + 1=9,大于长度8,因此结束该次构建。此时平衡二叉树即堆的逻辑结构如下:
在这里插入图片描述
  这里红色节点表示被“踢出”堆的节点,可以看到调整之后的平衡二叉树同样完全符合最大堆的特性。
  剩下的就是不断地循环了,直到最终i=0,即8个元素被“踢出”堆,剩下一个自然是最小的,结束循环,完成堆排序。此时数组结构如下:
在这里插入图片描述
  我们可以看到,当从堆中移除一个元素时,只需要对该元素所在的堆树进行重新构建以及堆该元素构建之后影响的子堆进行构建,而不需要影响到其他的数据,不会进行多余的比较,这是因为前面的构建堆操作,将所有元素的顺序都保存了下来。这也是堆排序更快的原因。

2.5.3 堆排序的复杂度分析

  堆排序的运行时间主要是消耗在初始构建堆和在排序重建堆时的反复筛选上。
  构建堆:
  假设高度为k,则从倒数第二层右边的节点开始,这一层的节点都要执行子节点比较然后交换(如果顺序  是对的就不用交换);倒数第三层呢,则会选择其子节点进行比较和交换,如果没交换就可以不用再执行下去了。如果交换了,那么又要选择一支子树进行比较和交换;
  那么总的时间计算为:s = 2^( i - 1 ) * ( k - i );其中 i 表示第几层,2^( i - 1) 表示该层上有多少个元素,( k - i) 表示子树上要比较的次数,如果在最差的条件下,就是比较次数后还要交换;因为这个是常数,所以提出来后可以忽略;
  S = 2^(k-2) * 1 + 2(k-3)*2…+2*(k-2)+2(0)*(k-1) ===> 因为叶子层不用交换,所以i从 k-1 开始到 1;
  S = 2^k -k -1;又因为k为完全二叉树的深度,而k=log(n) + 1,把此式带入;得到:S = 2n - logn -2,所以时间复杂度为:O(n)
  排序重建堆:
  每次重建意味着有一个节点出堆,所以需要将堆的容量减一。构建堆的函数的时间复杂度k=log(n),k为堆的层数。所以在每次重建时,随着堆的容量的减小,层数会下降,函数时间复杂度会变化。重建堆一共需要n-1次循环,每次循环的比较次数为log(i),则相加为:log2+log3+…+log(n-1)+log(n)≈log(n!)。log(n!)和nlog(n)是同阶函数,所以时间复杂度为O(nlogn)
  所以总体来说,堆排序的时间复杂度为O(n+nlogn)=O(nlogn)。由于堆排序对原始记录的排序状态并不敏感,因此它无论是最好、最坏和平均时间复杂度均为O(nlogn)。这在性能上显然要远远好过于冒泡、简单选择、直接插入的O(n2)的时间复杂度了。但由于要使用堆这个相对复杂的数据结构(上面的案例是使用的逻辑堆结构,物理结构还是数组),所以实现起来也较为困难。
  空间复杂度上,它并没有真正的借助堆的物理结构,还是在原数组上进行操作,其空间复杂度为常数阶O(1)。注意有些堆排序的实现借助了额外的数据结构,此时空间复杂度大大增加,因此本案例的算法算是一种比较好的算法。

2.6 归并排序(Merge Sort)

2.6.1 归并排序的原理和实现

  归并排序(Merge Sort)是建立在归并操作上的一种有效的排序算法,该算法是采用分治法(Divide and Conquer)的一个非常典型的应用。分(divide)阶段将问题分成一些小的问题然后递归求解,而治(conquer)的阶段则将分的阶段得到的各答案"合并"在一起,即分而治之)。
  归并排序原理:如果初始序列含有n个记录,先将总记录拆分成相同长度两个子序列,然后再对两个子序列继续拆分(利用了递归),最终拆分成n个有序的子序列,每个子序列的长度为1;然后两两归并,得到|n/2|(|x|表示不小于x的最小整数)个长度为2或1的有序子序列;再两两归并,如此重复(利用了递归),直至得到一个长度为n的有序序列为止,这种排序方法又被称为二路归并排序
  归并排序是一种稳定的排序算法,即相等的元素的顺序不会改变,速度仅次于快速排序。
  一种并归排序的实现如下:

public class MergeSort {

    public static void main(String[] args) {
        int[] arr = new int[]{49, 38, 65, 97, 76, 13, 27, 49, 78};
        mergeSort(arr, new int[arr.length], 0, arr.length - 1);
        System.out.println(Arrays.toString(arr));
    }

    /**
     * 并归排序
     *
     * @param arr      要排序的数组
     * @param aidedArr 辅助数组
     * @param l        拆分的左数组的起始索引
     * @param r        拆分的右数组的结束索引
     */
    private static void mergeSort(int[] arr, int[] aidedArr, int l, int r) {
        //如果数组大于等于两个元素,则进行拆分,否则就递归返回
        if (l < r) {
            /*1、拆分*/
            //算出拆分的中值,作为左数组的结束索引
            int mid = (r + l) / 2;
            //拆分为左子数组,然后将左子数组作为父数组继续递归拆分,直到拆分为只有一个元素的两个"有序"子数组
            mergeSort(arr, aidedArr, l, mid);
            //拆分为右子数组,然后继续递归拆分,直到拆分为只有一个元素的两个"有序"子数组
            mergeSort(arr, aidedArr, mid + 1, r);
            /*2、拆分到不能再拆了,即一个子数组只有一个元素,那么就算拆分完毕,开始两两排序合并*/
            merge(arr, aidedArr, l, mid, mid + 1, r);
        }
    }

    /**
     * 排序-合并
     *
     * @param arr        需要排序的数组
     * @param aidedArr   辅助数组
     * @param leftStart  左数组的起始索引
     * @param leftEnd    左数组的结束索引
     * @param rightStart 右数组的起始索引
     * @param rightEnd   右数组的结束索引
     */
    private static void merge(int[] arr, int[] aidedArr, int leftStart, int leftEnd, int rightStart, int rightEnd) {
        //备份获取起始索引m,在后面会用到;获取该两个相邻子数组的元素个数numElements,后面会用到
        int m = leftStart, numElements = rightEnd - leftStart + 1;
        //如果左数组起始位置小于等于左结束位置,并且右数组起始位置小于等于右结束位置,那么比较它们相同的相对位置的元素大小,并且将较小的元素加入到新的数组对应的索引位置(从左起始索引开始)中
        //然后被添加的元素位置相应的自增1,继续循环比较,直到其中一个条件不满足,结束循环
        while (leftStart <= leftEnd && rightStart <= rightEnd) {
            aidedArr[m++] = arr[leftStart] <= arr[rightStart] ? arr[leftStart++] : arr[rightStart++];
        }
        //如果左数组起始位置小于等于左结束位置,说明上面的循环并没有将左数组的元素添加完毕,继续添加
        while (leftStart <= leftEnd) {
            aidedArr[m++] = arr[leftStart++];
        }
        //如果右数组起始位置小于等于右结束位置,说明上面的循环并没有将右数组的元素添加完毕,继续添加
        while (rightStart <= rightEnd) {
            aidedArr[m++] = arr[rightStart++];
        }
        //然后再将新数组的元素拷贝到原数组对应索引处,这一步是需要的,这保证了后续排序合并元素的有序性
        for (int j = 0; j < numElements; j++, rightEnd--) {
            arr[rightEnd] = aidedArr[rightEnd];
        }
    }
}

2.6.2 排序过程分析

  从代码可以看出,实际归并排序不算太难,重要的是理解它的分治思想,并且是如何借助了递归的特性巧妙地实现该思想。

2.6.2.1 拆分

/*1、拆分*/
//算出拆分的中值,作为左数组的结束索引
int mid = (r + l) / 2;
//拆分为左子数组,然后将左子数组作为父数组继续递归拆分,直到拆分为只有一个元素的两个"有序"子数组
mergeSort(arr, aidedArr, l, mid);
//拆分为右子数组,然后继续递归拆分,直到拆分为只有一个元素的两个"有序"子数组
mergeSort(arr, aidedArr, mid + 1, r);

  拆分部分代码就只有上面几行,但是运用了递归的思想,只要数组元素超过两个,那么就是拆分为两个子数组,然后对左、右子数组继续递归拆分,当然递归的调用都是有返回条件的,这里的返回条件就是最开始的判断:l < r,如果l 不小于 r,说明该数组只有一个元素,那么不会执行if中的语句,此时递归方法返回,并且整个序列都被拆分为只有一个元素的子数组。递归返回后开始执行下面的merge方法,即排序—合并。
  下图表示拆分的过程,共4轮8次:
在这里插入图片描述

2.6.2.2 排序-合并

/**
 * 排序-合并
 *
 * @param arr        需要排序的数组
 * @param aidedArr   辅助数组
 * @param leftStart  左数组的起始索引
 * @param leftEnd    左数组的结束索引
 * @param rightStart 右数组的起始索引
 * @param rightEnd   右数组的结束索引
 */
private static void merge(int[] arr, int[] aidedArr, int leftStart, int leftEnd, int rightStart, int rightEnd) {
    //备份获取起始索引m,在后面会用到;获取该两个相邻子数组的元素个数numElements,后面会用到
    int m = leftStart, numElements = rightEnd - leftStart + 1;
    //如果左数组起始位置小于等于左结束位置,并且右数组起始位置小于等于右结束位置,那么比较它们相同的相对位置的元素大小,并且将较小的元素加入到新的数组对应的索引位置(从左起始索引开始)中
    //然后被添加的元素位置相应的自增1,继续循环比较,直到其中一个条件不满足,结束循环
    while (leftStart <= leftEnd && rightStart <= rightEnd) {
        aidedArr[m++] = arr[leftStart] <= arr[rightStart] ? arr[leftStart++] : arr[rightStart++];
    }
    //如果左数组起始位置小于等于左结束位置,说明上面的循环并没有将左数组的元素添加完毕,继续添加
    while (leftStart <= leftEnd) {
        aidedArr[m++] = arr[leftStart++];
    }
    //如果右数组起始位置小于等于右结束位置,说明上面的循环并没有将右数组的元素添加完毕,继续添加
    while (rightStart <= rightEnd) {
        aidedArr[m++] = arr[rightStart++];
    }
    //然后再将新数组的元素拷贝到原数组对应索引处,这一步是需要的,这保证了后续排序合并元素的有序性
    for (int j = 0; j < numElements; j++, rightEnd--) {
        arr[rightEnd] = aidedArr[rightEnd];
    }
}

  上面的代码也比较简单,就是对两个子数组比较大小,从小到大放入辅助数组对应的索引中,此时该段元素已经排好序了,最后再将辅助数组对应位置排好序的元素拷贝到原数组相同的索引处,即完成一次合并,然后开始下一次排序-合并,排序-合并是因为前的拆分造成的,这也是利用了递归的性质,在方法返回后,继续一层层的执行递归代码后面的代码。下面来一步步解析。
  由递归的特性可知,最先返回的方法,将最先执行下面的merge即排序-合并的过程。结合上面的拆分图和代码(先递归拆分左子数组,后递归拆分右子数组)可以看出来,轮次靠后的拆分的方法将最先返回、左子数组的拆分将最先返回,因此,排序-合并的过程和先后顺序如下图:
在这里插入图片描述

2.6.3 归并排序的复杂度分析

时间复杂度:
  具有n个元素的序列,每一轮并归排序需要将n个元素都扫描一次,时间复杂度为O(n),完全二叉树的深度为丨logn丨,因此整个并归排序需要进行logn轮,总的时间复杂度为O(nlogn)。归并排序需要元素两两比较,不存在跳跃,因此归并排序是一种稳定的排序算法,O(nlogn)是归并排序算法中最好、最坏、平均的时间性能。
空间复杂度:
  归并的空间复杂度就是那个临时的数组和递归时压入栈的数据占用的空间:n + logn;所以空间复杂度为O(n)。
  归并排序虽然比较稳定,在时间上也是非常有效的,但是这种算法比较消耗空间。

2.7 快速排序(Quick Sort)

2.7.1 快速排序的原理

  希尔排序相当于直接插入排序的升级,它们同属于插入排序类,堆排序相当于简单选择排序的升级,它们同属于选择排序类。而快速排序(Quicksort)其实就是我们前面认为最慢的冒泡排序的升级,它们都属于交换排序类。
  快速排序由C. A. R. Hoare在1960年提出,被称为。相对于冒泡排序,快速排序增大了记录的比较和移动的距离,将关键字较大的记录从前面直接移动到后面,关键字较小的记录从后面直接移动到前面,这样不会像冒泡一样每次都只交换相邻的两个数,因此总的比较和交换的此数都变少了,速度自然更高。
  Java源码中的基本类型排序使用的是快速排序,快速排序中比较和数据移动次数达到了平衡,对于基本类型来说更加合适。快速排序的具体实现有很多,简单分为单轴快排(如同上面的定义,具有一个中心点)、双轴快排(JDK1.8的快排是一种双轴快排,具有两个中心点)。
  JDK1.7开始,Java源码中的泛型(Comparator、Comparable)对象排序使用的是基于归并排序的TimSort排序,因为对于对象类型进行比较的开销会比较大,归并排序具有流行算法中最少的比较次数,相对较多的数据移动(Java中移动的是对象引用,并不是真正的对象,因此开销比较小)。
  而TimSort可以说是并归排序的又一次升级,并且融入了插入排序。TimeSort是一种稳定的、自适应的、迭代的并归排序,在部分已排序的数组上运行时,能够识别已排好序的部分,时间复杂度远小于O(nlogn),在随机数组上运行时性能开销和传统的归并排序相当。Java版的算法实现是基于原Tim Peters于2002年在Python版实现的改进:Python实现TimSort。TimSort不属于十大经典排序算法,但是速度相比经典的排序算法更快,在后续文章中,将会单独介绍一些现代的更快的算法。
  快速排序原理:通过一趟排序将要排序的数据分割成独立的两部分,其中一部分的所有数据都比另外一部分的所有数据都要小(找一个基准值,一部分数比基准值小、另一部分数比基准值大),这样就找到了基准值在数组中的正确位置;然后再按此方法对这两部分数据分别进行快速排序,以此达到整个数据变成有序序列。可以看到其中还是利用了分治法的思想,并且整个排序过程可以递归进行。
  快速排序是一种不稳定的排序算法,也就是说,多个相同的值的相对位置也许会在算法结束时产生变动。

2.7.2 快速排序的优化

  在提供实现之前,先说明几个问题,以及解决方法。

2.7.2.1 如何选取基准值

  每次快速排序都需要选择基准值,如果我们选取的基准值是处于整个序列的中间位置,那么就能能使得两个子序列的长度为原本的一半,那么快速排序的运行时间和归并排序的一样,都为O(nlogn)。
  常见的一种垃圾选择是将第一个元素作为基准值,如果输入是真的随机的,那么该方法是正确的,如果输入是正序或者反序的,那么每次划分只得到一个比上一次划分少一个记录的子序列,注意另一个为空。此时需要对所有元素进行递归调用,时间复杂度变成O(n²)。并且通常输入的元素并不是真正随机的,通常是部分有序的。因此冒然的取某个固定位置的元素作为基准值是不可取的。
  另一种选择是在序列中随机选择一个基准值,这种方法是完全正确的,在某种程度上,解决了对于基本有序的序列快速排序时的性能瓶颈。但是随机数的生成开销一般都很大(比如Java中的Random,并且还不是真随机数),因此这种方法是一种正确但是不实用的方法。
  常见的正确做法是三数取中(median-of-three)法。即取三个关键字先进行排序,将中间数作为枢轴,一般是取左端、右端和中间三个数,也可以随机选取。这样至少这个中间数一定不会是最小或者最大的数,从概率来说,取三个数均为最小或最大数的可能性是微乎其微的,因此中间数位于较为中间的值的可能性就大大提高了。由于整个序列是无序状态,随机选取三个数和从左中右端取三个数其实是一回事,而且随机数生成器本身还会带来时间上的开销,因此随机生成不予考虑。
  如果数据量非常大,还可以采用九数取中法

2.7.2.2 分割策略实现

  找到基准值之后,剩下的就是怎么来根据基准值进行数组对比分割了。分割策略也比较多,这里介绍一种正确且比较高效的方法。即双指针法,从数组的两端分别进行比对
  如数组:{38, 49, 65, 97, 49, 64, 27, 49, 78},对其进行并归排序。
  首先使用采用三数取中法选取基准值,然后一系列交换元素,使得基准值位置最左边的索引处:
在这里插入图片描述
  这样我们选择最左边的数49作为基准值,肯定比原来的38好多了。
  基准值选好之后,开始真正的分割,分割阶段就是把小于与基准值的元素移动到左边,而大于基准值的元素移动到右边。双指针法的原理如下:
  采用两个指针,指针i从基准值后一个元素开始,指针j从最后一个元素开始。将i右移,经过小于基准值的元素,直到找到大于基准值的元素或者一直找到j所在的位置则停止;将j左移,经过大于基准值的元素,直到找到小于基准值的元素则停止。如果指针都停止之后,如果还是i<j那么交换它们所在元素的位置,然后继续查找,如果停止后i>=j,那么分割完毕,此时进入下一步,寻找基准值的在数组的正确位置。
  寻找基准值的在数组的正确位置很简单,只需要将此时j所在位置的值与基准值所在位置的值互换即可,此时分割才算真正的完毕,基准值的排序后的位置已经被找到了,剩下的就是对基准值两边的数组,继续进行递归分割的操作了。而返回的条件是子数组只有一个元素。
  这里需要考虑的是,当某个元素与基准值相等时该怎么办,这里我们同样采取停止指针的策略,可以想象如果某个序列的元素全部相等,那么可能造成很多相同元素的交换,这看起来没有意义,但是好处是i和j最终会在中间交互,因此可以把数组分割为两个几乎相等的子数组。此时就如同并归排序,运行时间可达O(logn)。因此,快速排序是一种不稳定的排序算法,也就是说,多个相同的值的相对位置也许会在算法结束时产生变动。
在这里插入图片描述
  第一次交换前后:
在这里插入图片描述
  可知arr[i]=arr[j]=49,因此交换结果是数组不发生改变,下面继续分割。
  第二次交换前后:
在这里插入图片描述
  继续查找,第三次交换前后:
在这里插入图片描述
  然后i和j各自继续查找,然后出现了如下的情况:
在这里插入图片描述
  根据上面的双指针法步骤,此时i>=j成立,算是分割完毕,开始查找基准值的位置,前面也说了,位置查找很简单,就是在最后一次分割之后的j的位置,让后将j和基准值的位置的元素交换就行了
在这里插入图片描述
  此时,实际上基准值49的排序后的位置j已经找到了,并且元素已归位。可看出来,分割后的左右子数组元素符合条件:左子数组的所有元素值小于等于基准值,右子数组的所有元素值大于等于基准值。因此,继续对两个子数组进行递归分割时,可以将该值排除:
在这里插入图片描述
  下面是左、右子数组的分割过程图,都比较简单:
在这里插入图片描述
  可以看到,右子数组分割完毕之后,左子数组有三个元素,因此还需要递归分割一次
在这里插入图片描述
  可以看到,最终全部数组递归分割完毕返回(返回的条件是子数组只有一个元素)之后,数组其实已经变得有序了:
在这里插入图片描述
  是不是和归并排序很相似?其实理解了它的思想特别是递归分割的思想,快速排序还是比较简单的!

2.7.2.3 小数组排序

  从快速排序的算法可以看出来,数组最终会被分割为一个元素长度。如果数组非常小,其实快速排序反而不如直接插入排序来得更好(直接插入是简单排序中性能最好的)。其原因在于快速排序用到了递归操作,在大量数据排序时,这点性能影响相对于它的整体算法优势而言是可以忽略的,但如果数组只有比较少的记录需要排序时,递归就不合适了
  因此可以加一个判断,当数组长度小于多小时就使用插入排序而不是快速排序了。这个值具体是多少,并没有特殊规定,在《数据结构与算法 Java语言描述》一书中该值建议取10,而在JDK1.8的Arrays.sort()方法中,该值为47。

2.7.3 单轴快排的实现案例

2.7.3.1 普通实现

  下面提供单轴快排的普通实现,即只使用快速排序:

public class QuickSort {
    public static void main(String[] args) {
        //int[] arr = new int[]{0, 1, 2, 3, 4, 5, 6, 7, 8,9};
        int[] arr = new int[]{5, 4, 3, 2, 1, 0, -1, -2, -3, -4};
        //int[] arr = new int[]{49, 38, 65, 97, 64, 49, 27, 49, 78};
        //int[] arr = new int[]{49, 38, 49, 97, 49, 49, 49, 49, 49, 49, 38, 49, 97, 49, 49, 49, 49, 49};
        //int[] arr = new int[]{49, 38, 65, 97, 65, 13, 27, 49, 78};
        //int[] arr = new int[]{1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1};
        //int[] arr = new int[]{1, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 1,1, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 1};
        //int[] arr = new int[]{49, 65, 38, 97, 49, 78, 27, 11, 49, 49, 65, 38, 97, 49, 78, 27, 11, 49};
        quickSort(arr);
        System.out.println(Arrays.toString(arr));
    }


    /**
     * 快速排序
     *
     * @param arr 要排序的数组
     */
    private static void quickSort(int[] arr) {

        if (arr == null || arr.length <= 1) {
            return;
        }
        quickSort(arr, 0, arr.length - 1);
    }


    /**
     * 快排核心算法,递归实现
     *
     * @param arr   要排序的数组
     * @param left  起始索引
     * @param right 结束索引
     */
    private static void quickSort(int[] arr, int left, int right) {
        /*长度大于4则走快速排序,否则走插入排序*/
        if (left < right) {
            /*1、分割 分割完成了将返回基准值的正确索引位置*/
            int baseIndex = partition(arr, left, right);
            /*2、递归对分割后的两个子数组继续执行排序,由于还是上面基准值的为止已经确定了,因此可以排除基准值索引*/
            quickSort(arr, left, baseIndex - 1);
            quickSort(arr, baseIndex + 1, right);
        }
    }

    /**
     * 分割数组  找一个基准值,将数组分成两部分,一部分比基准值小,另一部分比基准值大
     *
     * @param arr   需要分割的数组
     * @param left  左起始索引
     * @param right 右结束索引
     * @return 基准值在整个数组中的正确索引
     */
    private static int partition(int[] arr, int left, int right) {
        //基准值,这里取最左边的值,这是不合理的
        int base = arr[left];
        /*2、开始分割*/
        //记录前后索引初始值,后面会用到
        int i = left, j = right;
        while (true) {
            /*先从左向右找,然后从右向左找,顺序不能乱,如果乱了那么需要改变代码*/
            //先从左往右边找,直到找到大于等于base值的数
            //i一定小于等于j,等于j时说明left所在的数base就是最大数
            while (arr[++i] < base && i < j) {
            }
            //后从右边往左找,直到找到小于等于base值的数
            //j可能小于等于i
            //j也可能等于left,等于left时说明left所在的数base就是最小的数
            while (arr[j] > base) {
                --j;
            }
            //上面的循环结束表示找到了位置(i<j)或者(i>=j)了
            //如果是找到了位置,那么交换两个数在数组中的位置
            if (i < j) {
                swap(arr, i, j);
            } else {
                break;
            }
            //如果还要继续分割,那么j--,该操作可以让与base相同的j继续向中间靠拢而不是停在原地
            --j;
        }
        /*3、寻找基准值的在数组的正确位置,位置应该在j的值*/
        arr[left] = arr[j];
        arr[j] = base;
        /*4、返回基准值的正确索引*/
        return j;
    }

    /**
     * 交换元素
     *
     * @param arr 数组
     * @param a   元素的下标
     * @param b   元素的下标
     */
    private static void swap(int[] arr, int a, int b) {
        arr[a] = arr[a] ^ arr[b];
        arr[b] = arr[a] ^ arr[b];
        arr[a] = arr[a] ^ arr[b];
    }
}

2.7.3.2 优化实现

  采用三数取中法获取基准值,当数组元素个数少于某个值时采用插入排序。

public class QuickSort3 {
    public static void main(String[] args) {
        //int[] arr = new int[]{0, 1, 2, 3, 4, 5, 6, 7, 8};
        //int[] arr = new int[]{5, 4, 3, 2, 1, 0, -1, -2, -3};
        int[] arr = new int[]{38, 49, 65, 97, 49, 64, 27, 49, 78};
        //int[] arr = new int[]{49, 38, 49, 97, 49, 49, 49, 49, 49, 49, 38, 49, 97, 49, 49, 49, 49, 49};
        //int[] arr = new int[]{49, 38, 65, 97, 65, 13, 27, 49, 78};
        //int[] arr = new int[]{1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1};
        //int[] arr = new int[]{1, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 1,1, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 1};
        //int[] arr = new int[]{49, 65, 38, 97, 49, 78, 27, 11, 49, 49, 65, 38, 97, 49, 78, 27, 11, 49};
        quickSort(arr);
        System.out.println(Arrays.toString(arr));
    }


    /**
     * 快速排序
     *
     * @param arr 要排序的数组
     */
    private static void quickSort(int[] arr) {

        if (arr == null || arr.length <= 1) {
            return;
        }
        quickSort(arr, 0, arr.length - 1);
    }

    /**
     * 当数组长度小于等于4时,采用直接插入排序(这里为了演示,值取得比较小,实际值可以更大一些)
     */
    private static final int INSERTION_SORT_THRESHOLD = 4;

    /**
     * 快排核心算法,递归实现
     *
     * @param arr   要排序的数组
     * @param left  起始索引
     * @param right 结束索引
     */
    private static void quickSort(int[] arr, int left, int right) {
        /*长度大于4则走快速排序,否则走插入排序*/
        if (right - left + 1 > INSERTION_SORT_THRESHOLD) {
            /*1、分割 分割完成了将返回基准值的正确索引位置*/
            int baseIndex = partition(arr, left, right);
            /*2、递归对分割后的两个子数组继续执行排序,由于还是上面基准值的为止已经确定了,因此可以排除基准值索引*/
            quickSort(arr, left, baseIndex - 1);
            quickSort(arr, baseIndex + 1, right);
        } else {
            insertionSort(arr, left, right);
        }
    }

    /**
     * 分割数组  找一个基准值,将数组分成两部分,一部分比基准值小,另一部分比基准值大
     *
     * @param arr   需要分割的数组
     * @param left  左起始索引
     * @param right 右结束索引
     * @return 基准值在整个数组中的正确索引
     */
    private static int partition(int[] arr, int left, int right) {
        /*1、采用三数取中法选取基准值*/
        int base = median3(arr, left, right);
        /*2、开始分割*/
        //记录前后索引初始值,后面会用到
        int i = left, j = right;
        while (true) {
            /*先从左向右找,然后从右向左找,顺序不能乱,如果乱了那么需要改变代码*/
            //从左往右边找,直到找到大于等于base值的数
            //i一定小于等于j,等于j时说明left所在的数base就是最大数
            while (arr[++i] < base && i < j) {
            }
            //后从右边往左找,直到找到小于等于base值的数
            //j可能小于等于i
            //j也可能等于left,等于left时说明left所在的数base就是最小的数
            while (arr[j] > base) {
                --j;
            }
            //上面的循环结束表示找到了位置(i<j)或者(i=j)了
            //如果是找到了位置,那么交换两个数在数组中的位置
            if (i < j) {
                swap(arr, i, j);
            } else {
                break;
            }
            //如果还要继续分割,那么j--,该操作可以让与base相同的j继续向中间靠拢而不是停在原地
            --j;
        }
        /*3、寻找基准值的在数组的正确位置*/
        arr[left] = arr[j];
        arr[j] = base;
        /*4、返回基准值的正确索引*/
        return j;
    }

    /**
     * 数组小于等于4时,采用插入排序
     *
     * @param a     数组
     * @param left  元素起始索引
     * @param right 元素结束索引
     */
    private static void insertionSort(int[] a, int left, int right) {
        int j;
        for (int p = left + 1; p <= right; p++) {
            int noSort = a[p];
            for (j = p - 1; j >= left && noSort < a[j]; j--) {
                a[j + 1] = a[j];
            }
            a[j + 1] = noSort;
        }
    }

    /**
     * 三数取中法选取基准值,相比于每次选取某一个固定位置的值更加的容易取到较好的基准值
     *
     * @param arr   数组
     * @param left  左边索引
     * @param right 右边索引
     * @return 三数取中法选取的基准值
     */
    private static int median3(int[] arr, int left, int right) {
        // 计算数组中间的元素的下标
        int center = (left + right) / 2;
        // 交换左端与右端数据,保证left小于等于right
        if (arr[right] < arr[left]) {
            swap(arr, left, right);
        }
        // 交换中间与右端数据,保证中间小于等于right
        if (arr[right] < arr[center]) {
            swap(arr, center, right);
        }
        // 交换中间与左端数据,保证中间小于等于left
        if (arr[left] < arr[center]) {
            swap(arr, left, center);
        }
        //经过交换,此时left为返回的最终的基准值,即三数的中间值,并且一定能有如下规则center<=left<=right
        // 为此我们可以进一步交换,让数组尽量变得"更加有序",注意该步骤是可以省略的
        swap(arr, center, left + 1);
        return arr[left];
    }

    /**
     * 交换元素,这里一定要判断下表是否不等,否则如果下标相等则^会返回0
     *
     * @param arr 数组
     * @param a   元素的下标
     * @param b   元素的下标
     */
    private static void swap(int[] arr, int a, int b) {
        if (a != b) {
            arr[a] = arr[a] ^ arr[b];
            arr[b] = arr[a] ^ arr[b];
            arr[a] = arr[a] ^ arr[b];
        }
    }
}

2.7.4 快速排序的复杂度分析

  分割子序列时需要选择基准值,如果每次选择的基准值都能使得两个子序列的长度为原本的一半,那么快速排序的运行时间和归并排序的一样,都为O(nlogn)。和归并排序类似,将序列对半分割logn次之后,子序列里便只剩下一个数据,这时子序列的排序也就完成了,总排序也就完成了。
  在最坏的情况下,待排序的序列为正序或者逆序,每次划分只得到一个比上一次划分少一个记录的子序列,注意另一个为空。如果递归树画出来,它就是一棵斜树。此时需要执行n-1次递归调用,且第i次划分需要经过n-i次关键字的比较才能找到第i个记录,也就是基准值的位置,这个操作也就和选择排序一样了。因此比较次数为sigma(i=1, n-1, n-i)=(n-1)+(n-2)+…+1=n(n-1)/2,最终其时间复杂度为O(n²)。
  如果数据中的每个数字被选为基准值的概率都相等,那么需要的平均运行时间为O(nlogn)。但是很难做到真随机的选取,即真随机数的生成比较困难,而且会耗费更多时间。
  快速排序使用到了递归,参考时间复杂度的最好、最坏的情况,空间复杂度范围是O(logn)~O(n)

2.8 总结

  7种算法的各种指标进行对比如下:
在这里插入图片描述
  从算法的简单性来看,我们将7种算法分为两类:

  1. 简单算法:冒泡、简单选择、直接插入。
  2. 改进算法:希尔、堆、归并、快速。

  从平均情况来看,显然最后3种改进算法要胜过希尔排序,并远远胜过前3种简单算法。
  从最好情况看,反而冒泡和直接插入排序要更胜一筹,也就是说,如果你的待排序序列总是基本有序,反而不应该考虑4种复杂的改进算法。
  从最坏情况看,堆排序与归并排序又强过快速排序以及其他简单排序。
  从待排序记录的个数上来说,待排序的个数n越小,采用简单排序方法越合适。反之,n越大,采用改进排序方法越合适。这也就是我们为什么对快速排序优化时,增加了一个阀值,低于阀值时换作直接插入排序的原因。
  3种简单排序算法的移动次数比较图如下:
在这里插入图片描述
  如果移动数据比较耗时的话,那么简单选择排序不失为一种好的选择!

3 非比较排序

3.1 计数排序(Counting Sort)

3.1.1 计数排序的原理和实现

  计数排序(Counting sort)是一种稳定的线性时间排序算法。计数排序要求输入的数据必须是有确定范围的整数。排序前和排序后相同的数字相对位置保持不变。
  计数排序仅适用于最大最小值相差差不是很大的情况。
  计数排序原理如下:

  1. 准备待排序的数组中最大-max和最小-min的元素,准备一个长度为(max-min+1)辅助计数数组help。
  2. 分配:统计原数组中值为arr[i]的元素出现的次数,存入辅助计数数组对应arr[i]-min的索引位置处。
  3. 收集: 收集:根据辅助数组的反向从index=0开始填充目标数组。循环判断辅助数组索引为i的元素值help[i]是否>0,如果是,则原数组arr[index++]=i + min
public class CountingSort {
    public static void main(String[] args) {
        //要求数组元素为整数
        //int[] arr = new int[]{3, 3, 5, 7, 2, 4, 10, 1, 13, 15, 3, 5, 6};
        int[] arr = new int[]{49, 38, 65, 97, 65, 13, 27, 49, 78};
        //int[] arr = new int[]{1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1};
        //int[] arr = new int[]{5, 4, 3, 2, 1, 0, -1, -2, -3};
        //int[] arr = new int[]{1, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 1};
        /*1、获取最大值和最小值  这一步应该是预先知道的*/
        int max = Arrays.stream(arr).max().getAsInt();
        int min = Arrays.stream(arr).min().getAsInt();
        /*2、准备辅助计数数组 大小为max-min+1*/
        int[] help = new int[max - min + 1];
        /*2、分配:辅助计数数组填充*/
        //找出每个元素value出现的次数,找到一次就让辅助数组的value-min索引处的值自增1
        for (int value : arr) {
            help[value - min]++;
        }
        System.out.println("help填充后" + Arrays.toString(help));
        /*3、收集:根据辅助数组从index=0开始填充目标数组
        循环判断辅助数组索引为i的元素值help[i]--是否>0,如果是,则原数组arr[index++]=i + min */
        int index = 0;
        for (int i = 0; i < help.length; i++) {
            while (help[i]-- > 0) {
                arr[index++] = i + min;
            }
        }
        System.out.println("arr排序后" + Arrays.toString(arr));
        System.out.println("help使用后" + Arrays.toString(help));
    }
}

  从实现可以看出来,计数排序仅适用于最大最小值相差差不是很大的情况,如果相差不大,就算待排序的元素再多,辅助数组也比较短。否则,辅助数组将会变得很长,造成空间复杂度提高。
  经计数排序,输出序列中值相同的元素之间的相对次序与他们在输入序列中的相对次序相同,换句话说,计数排序算法是一个稳定的排序算法。但是上面的实现并不是稳定算法的实现,上面的实现只是针对基本类型而言的,因为对于基本类型两个数都是一致的,这里提供稳定版本的实现,但是要求所有元素大于等于0:

public class CountingSortStable {
    public static void main(String[] args) {
        //计数排序稳定版本要求数组元素为整数  且必须大于等于0
        //int[] arr = new int[]{3, 3, 5, 7, 2, 4, 10, 1, 13, 15, 3, 5, 6};
        //int[] arr = new int[]{49, 38, 65, 97, 65, 13, 27, 49, 78};
        //int[] arr = new int[]{1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1};
        //int[] arr = new int[]{5, 4, 3, 2, 1, 0, -1, -2, -3}; 元素小于0报错
        int[] arr = new int[]{1, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 1};
        /*1、获取最大值和最小值  这一步应该是预先知道的*/
        int max = Arrays.stream(arr).max().getAsInt();
        int min = Arrays.stream(arr).min().getAsInt();
        /*2、准备辅助计数数组 大小为max-min+1*/
        int[] help = new int[max+1];
        //找出每个元素value出现的次数,找到一次就让辅助数组的value-min索引处的值自增1
        for (int value : arr) {
            help[value]++;
        }
        // 计算数组中小于等于每个元素的个数,即从tmp中的第一个元素开始,每一项和前一项相加
        for (int j = 1; j < help.length; j++) {
            help[j] += help[j - 1];
        }

        /*倒序遍历很重要,这一步保证了计数排序的稳定性*/
        // result数组用来临时存放排序结果
        int[] result = new int[arr.length];
        for (int i = arr.length - 1; i >= 0; i--) {
            result[help[arr[i]] - 1] = arr[i];
            //桶数值自减1 因为下一个相同的数来找索引需要放在上一个数的前面,从而保证稳定性;
            help[arr[i]]--;
        }
        //将临时数组的数据取出来,赋值给arr
        for (int i = 0, j = 0; i < arr.length; i++, j++) {
            arr[i] = result[j];
        }
        System.out.println("arr排序后" + Arrays.toString(arr));

    }
}

  另外,计数排序本质上是一种特殊的桶排序,当桶的个数最大(max-min+1)的时候,就是计数排序。这里的桶,就是辅助数组的一个位置。下面来看桶排序。

3.1.2. 计数排序的复杂度分析

  计数排序算法没有用到元素间的比较,它利用元素的实际值来确定它们在输出数组中的位置。因此,计数排序算法不是一个基于比较的排序算法,从而它的计算时间下界不再是O(nlogn)。
  非稳定版本的复杂度如下:

平均时间复杂度 O(n + (max - min))
最佳时间复杂度 O(n + (max - min))
最差时间复杂度 O(n + (max - min))
空间复杂度 O(max - min)

  Max表示最大值,min表示最小值,如果max-min=O(n),那么时间复杂度就是O(n)。
  由于计数排序需要额外的(max – min) + 1 长度的辅助数组,没有递归,因此空间复杂度为O(max - min),同样如果max-min=O(n),那么空间复杂度也是O(n)。
  稳定版本的复杂度如下:
  平均、最佳、最差时间复杂度均为O(n+k),k为待排序列最大值,它需要两个辅助数组,空间复杂度为O(n+k)。

3.2 桶排序(Bucket Sort)

3.2.1 桶排序的原理和实现

  桶排序(Bucket sort)又被称为箱排序。前面讲的计数排序,最大值和最小值相差多少就准备多少个桶,试想如果最大值和最小值相差过大的话,会造成桶的数量过多,空间复杂度大幅度提升。计数排序不再适用,此时可以采用桶排序。
  实际上这种情况下,一个桶里并非总放一个元素,很多时候一个桶里放多个元素。其实真正的桶排序和散列表有一样的原理。同理,桶排序需要事先知道元素范围,即最小值和最大值。
  桶排序可用于最大最小值相差较大的数据情况,但桶排序要求数据的分布必须均匀,否则可能导致数据都集中到一个桶中,导致桶排序失效。
  稳定性:桶排序是否稳定取决于每个桶采用的排序算法,因为桶排序可以做到稳定,所以桶排序是稳定的排序算法。
  桶排序详细过程如下:

  1. 准备待排序的数组中最大-max和最小-min的元素,准备一个桶容器,每个桶里放的元素用list存储,因为每个桶存放元素的数量是不固定的。桶的数量为k=(max-min)/arr.length+1。
  2. 遍历数组 arr,按一定的映射规则(这类似于哈希表)计算每个元素放的桶位置,并将待排序元素划分到不同的桶。
  3. 每个桶各自排序,一般采用快速排序。
  4. 顺序遍历桶集合,把排序好的元素回写进原数组。
public class BucketSort {
    public static void main(String[] args) {
        //要求数组元素为整数
        //int[] arr = new int[]{3, 3, 5, 7, 2, 4, 10, 1, 13, 15, 3, 5, 6};
        //int[] arr = new int[]{1, 2, 3, 4, 5, 6, 7, 8, 10, 11, 12, 13, 14, 15};
        int[] arr = new int[]{49, 38, 65, 97, 65, 13, 27, 49, 78, 49, 38, 65, 97, 65, 65, 13, 27, 49, 78, 100};
        //int[] arr = new int[]{1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1};
        //int[] arr = new int[]{5, 4, 3, 2, 1, 0, -1, -2, -3};
        //int[] arr = new int[]{1, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 1};


        /*1、获取最大值和最小值  这一步应该是预先知道的*/
        int max = Arrays.stream(arr).max().getAsInt();
        int min = Arrays.stream(arr).min().getAsInt();


        /*2、准备桶容器*/
        //桶数量计算公式
        int bucketsLength = getBucketsLength(arr, max, min);
        List<Integer>[] buckets = new List[bucketsLength];
        for (int i = 0; i < bucketsLength; i++) {
            buckets[i] = new ArrayList<>();
        }


        /*3、计算arr每个元素应该存放的桶位置,并将每个元素放入对应的桶*/
        //每个桶的范围大小计算公式
        int bucketRange = getBucketRange(max, min, bucketsLength);
        //将每个元素放入桶
        for (int value : arr) {
            //每个元素映射到某个桶索引的函数f(key)
            int bucketIndex = getBucketIndex(min, bucketRange, value);
            buckets[bucketIndex].add(value);
        }
        System.out.println("buckets填充后:" + Arrays.toString(buckets));


        /*4、对每个桶进行排序*/
        for (List<Integer> list : buckets) {
            //这里由于底层是Object,实际上sort对每个桶的元素采用ComparableTimSort排序,这是一种基于归并排序而又优于归并排序的新的排序手段
            Collections.sort(list);
        }
        System.out.println("buckets排序后:" + Arrays.toString(buckets));


        /*5、将桶里面不为null的元素顺序回写到原数组,即完成排序*/
        int index = 0;
        for (List<Integer> bucket : buckets) {
            for (Integer integer : bucket) {
                arr[index++] = integer;
            }
        }
        System.out.println("arr排序后:" + Arrays.toString(arr));
    }

    /**
     * 获取桶数量
     *
     * @param arr 需要排序的数组
     * @param max 数组最大值
     * @param min 数组最小值
     * @return 桶数量
     */
    private static int getBucketsLength(int[] arr, int max, int min) {
        return (max - min) / arr.length + 1;
    }

    /**
     * 获取每个桶范围
     *
     * @param max           数组最大值
     * @param min           数组最小值
     * @param bucketsLength 桶数量
     * @return 桶范围 该范围是值直接相减的范围即8-1=7
     */
    private static int getBucketRange(int max, int min, int bucketsLength) {
        return (max - min + 1) / bucketsLength;
    }

    /**
     * 获取元素对应桶索引
     *
     * @param min         数组最小值
     * @param bucketRange 桶范围
     * @param value       元素值
     * @return 桶索引
     */
    private static int getBucketIndex(int min, int bucketRange, int value) {
        return (value - min) / (bucketRange + 1);
    }
}

3.2.2 桶排序的复杂度分析

  假设数据是均匀分布的,则每个桶的元素平均个数为 n/k,k表示桶的个数。假设选择用快速排序对每个桶内的元素进行排序,那么每次排序的时间复杂度为 O(n/klog(n/k))。总的时间复杂度为O(n)+O(m)O(n/klog(n/k)) = O(n+nlog(n/k)) = O(n+nlogn-nlogk) 。当 k 接近于 n 时,桶排序的时间复杂度就可以认为是 O(n) 的。即桶越多,时间效率就越高,而桶越多,空间复杂度就越大。桶数量达到(max-min+1)时,便成为了计数排序。
  原始数组的元素也会影响桶中的元素是否均匀分布,如果原始的元素就是分不极度不均匀的那么就不适宜采用桶排序。从代码实现可以看出来,桶的数量和映射函数f(key)的选取也会影响元素是否均匀分布分布,为了使桶排序更加高效,我们需要做到这两点:

  1. 在空间充足的情况下,尽量增大桶的数量;
  2. 映射函数f(key)需要能够将输入的 N 个数据尽量均匀的分配到 K 个桶中。

3.3 基数排序(Radix Sort)

3.3.1 基数排序的实现

  基数排序是一种对于非负整数的非比较排序方法,同样必须知道最大值。基数排序不是直接根据元素整体的大小进行元素比较,而是将原始列表元素分成多个部分,对每一部分按一定的规则进行排序,进而形成最终的有序列表。即基数排序必须依赖于另外的排序方法。实际上基数排序就是一种多关键字排序。注意基数排序的元素必须是非负整数。
  基数排序也是一种桶排序。桶排序是按值区间划分桶,基数排序是按数位来划分;基数排序可以看做是多轮桶排序,每个数位上都进行一轮桶排序。
  基数排序法是一种稳定的排序算法。
  具体思路如下

  1. 将所有待排序非负整数统一为位数相同的整数,位数较少的前面补零。一般用10进制,也可以用16进制甚至2进制,所有的前提是能够找到最大值,得到最长的位数,设k进制下最长为位数为d。实际上相同位数上的值就是要比较的多个键。
  2. 对相同的位数上的数值按照大小进行稳定排序(因为稳定排序能够将上一次排序的成果保留下来。例如十位数的排序过程能保留个位数的排序成果,百位数的排序过程能保留十位数的排序成果。)。基数排序的方式可以采用LSD(Least significant digital)或MSD(Most significant digital),LSD的排序方式由键值的最右边开始,而MSD则相反,由键值的最左边开始。
    a) MSD:先从高位开始进行排序,在每个关键字上,可采用计数排序。
    b) LSD:先从低位开始进行排序,在每个关键字上,可采用桶排序。

  如下图演示了先补齐长度然后从最低位开始进行的排序。当从最低位一直到最高位排序完成以后,整个序列就变成了一个有序序列:
在这里插入图片描述
  LSD的代码实现如下:

public class RadixSort {
    public static void main(String[] args) {
        //要求数组元素为非负整数
        //int[] arr = new int[]{12, 3, 55, 78, 102, 0, 88, 61, 30, 12, 3, 55, 78, 102, 0, 88, 61, 30};
        //int[] arr = new int[]{1, 2, 3, 4, 5, 6, 7, 8, 10, 11, 12, 13, 14, 15};
        //int[] arr = new int[]{11, 12, 13, 14, 15, 16, 17, 18, 20, 21, 22, 23, 24, 25};
        //int[] arr = new int[]{49, 38, 65, 97, 65, 13, 27, 49, 78, 49, 38, 65, 97, 65, 65, 13, 27, 49, 78, 100};
        //int[] arr = new int[]{1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1};
        //int[] arr = new int[]{5, 4, 3, 2, 1, 0, -1, -2, -3};  小于0报异常
        int[] arr = new int[]{1, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 1};

        /*获取最大元素的有几位*/
        int max = Arrays.stream(arr).max().getAsInt();
        int num = 0;
        while (max != 0) {
            max = max / 10;
            num++;
        }

        //辅助计数数组 桶
        int[] help = new int[10];

        //临时数组,放数据,取数据
        int[] bucket = new int[arr.length];

        //k表示第几位,1代表个位,2代表十位,3代表百位
        for (int k = 1; k <= num; k++) {
            //把count置空,防止上次循环的数据影响
            for (int i = 0; i < 10; i++) {
                help[i] = 0;
            }

            //分别统计第k位是0,1,2,3,4,5,6,7,8,9的数量,统计每个桶中的数据的数量
            /*遍历数组将每个数(对应位的数字)放进与之对应的桶里 并且计算桶里数的个数。*/
            for (int value : arr) {
                help[getFigure(value, k)]++;
            }

            /*将桶里数的个数变为前面桶的个数加上自己桶里的个数 (bucket[i] += bucket[i-1])
            实际上最后的桶里面的最大值就是元素个数,
            这一步完成之后 我们就可以知道help[j]对应的位数元素索引位置就是在(help[j-1])~(help[j]-1)之间
            这一步完成之后 我们就可以知道arr[i]的值的对应位数j排序后的位置索引就是help[j]-1、help[j]-1-1……。*/
            for (int i = 1; i < 10; i++) {
                help[i] += help[i - 1];
            }

            /*利用循环把数据装入临时数组中,注意是从后面遍历,因为是从后面的索引开始装入的
            因为如果从前遍历该数组 当碰到相同的数时 相同数的相对位置就变了 原来在后面的数就会排到前面去 这个排序就不稳定了,并且不会按照顺序排序*/
            for (int i = arr.length - 1; i >= 0; i--) {
                //获取arr[i]对应位数的值
                int j = getFigure(arr[i], k);
                //arr[i]的排序后的索引就是help[j]-1
                bucket[help[j] - 1] = arr[i];
                //桶数值自减1 因为下一个相同的数来找索引需要放在上一个数的前面;
                help[j]--;
            }

            //将桶中的数据取出来,赋值给arr
            for (int i = 0, j = 0; i < arr.length; i++, j++) {
                arr[i] = bucket[j];
            }

        }

        System.out.println("arr排序后" + Arrays.toString(arr));

    }

    /**
     * 获取整型数i的第k位是什么
     *
     * @param i 整型数i
     * @param k 第k位
     * @return 第k位的值
     */
    private static int getFigure(int i, int k) {
        int[] a = {1, 10, 100};
        return (i / a[k - 1]) % 10;
    }
}

3.3.2. 基数排序的复杂度分析

  设待排序元素个数为n,最大的数是d位数,基数为k(如基数为10,即10进制,最大有10种可能,即最多需要10个桶来映射数组元素)。
  处理器中一个位数时,需要遍历原数组和计数器数组,时间复杂度为O(n+k),总时间复杂度为O(d*(n+k))。
  基数排序过程中,用到一个计数器数组,长度为r,还用到一个长为n第的临时数组来存放元素,所以空间复杂度为O(k+n)。
  基数排序基于分别排序,分别收集,所以是稳定的。
  当使用2进制时, k=2 最小,那么位数 d 最大,时间复杂度 O((d*(n+k)) 会变大,空间复杂度 O(n+k) 会变小。

3.4 总结

  基数排序与计数排序、桶排序这三种排序算法都利用了桶的概念,但对桶的使用方法上有明显差异:

  1. 计数排序:每个桶只存储单一键值;
  2. 桶排序:每个桶存储一定范围的数值;
  3. 基数排序:根据键值的每一位的数字来分配桶;

参考
  《算法》
  《数据结构与算法》
  《大话数据结构》
  《算法图解》
  《我的第一本算法书》

如果有什么不懂或者需要交流,可以留言。另外希望点赞、收藏、关注,我将不间断更新各种Java学习博客!

猜你喜欢

转载自blog.csdn.net/weixin_43767015/article/details/106035215
今日推荐