这节开始总结7种最常用的排序算法,先来看看最简单的冒泡算法。
所有完整源代码点击github 这里 查看。我们假设测试数据集为以下数组,要求按照从小到达的升序排列。
而排序的动图演示,可以参考 http://www.atool.org/sort.php
1 | public static void main(String[] args) { |
冒泡排序
思路
基于相邻比较和交换的方法。针对 n个数,一共会有 n-1 轮。每轮将确定一个值的最终位置,比如升序,那么每轮将会该轮中最大的数的位置确定。
Java 实现
先实现交换的辅助函数
1 | public void swap(int[] a, int indexI, int indexJ) { |
冒泡排序实现
1 | public void bubbleSort(int[] input) { |
你可以看到,当测试集为正序时候,仍然需要$O(n^2)$ 的复杂度,其实每一轮遍历中,两两比较,若整个序列没有发生交换,其实排序就已经可以停止了,比如正序情况下,第一轮两两比较时,由于都是正序,所以一边遍历后没有任何交换,此时就需要停止了。若发生交换,则记录最后一个交换的位置,则该位置为下次交换遍历的终点位置。减少不必要的比较。优化的代码如下
1 | public void bubbleSortOpt(int[] input) { |
这样复杂度,在最好的情况下(已经有序),可以变为$O(n)$
复杂度分析
间复杂度
排序算法的时间复杂度在于交换和比较的次数,因此交换和比较次数的总和越小,那么时间复杂度越低,冒泡排序一个有 n -1 轮的遍历,每轮遍历中,比较次数依次为 n-1,n-2,…1 。因此比较次数的总和为
$sum=\sum_{1}^{n-1}=1+2+…+n-1=\dfrac{n(n-1)}{2}$ 为 $O(n^2)$
最差情况下(逆序),那么比较一次,就要交换一次。因此总次数为 n(n-1) ,为 $O(n^2)$ ,而最好的情况下,已序的情况下,经过优化后的冒泡为 n-1 次完成,$O(n)$ ,因此平均复杂度为 $O(n^2)$ 。
空间复杂度
因为都是固定的几个局部变量,因此空间复杂度为 O(1)
稳定性
因为是基于比较,所以冒泡排序是稳定,所谓稳定性就是指相等的两个数 a,b,排序后,a,b的前后位置不变,若a在b前,则排序后仍是a在b前,否则称为不稳定。
选择排序
思路
选择排序也是生活中比较常见的一种思路。和冒泡相比,它减少了交换的次数,冒泡相邻的两个数比较,一旦符合交换条件,就会进行交换。选择排序则是一次性找到最后的index值,在一次性的交换。
Java 实现
1 | public void selectSort(int[] data) {//升序排序 |
复杂度分析
时间复杂度
由于是逐一确定每个索引位置处最合适的值,因此最好的情况和最差情况,都需要进行比较,
$Sum=\sum_{i=1}^{n-1}=1+2+3+..+n-1=\dfrac{n(n-1)}{2}$,因此复杂度均为为 $O(n^2)$
空间复杂度
因为都是固定的几个局部变量,因此空间复杂度为 O(1)
稳定性
不稳定。举个例子分析,3,1,3, 2 ,第一趟时 index=0为3,逐一确定最小值的索引,所以此时2为最小的值,因此第一个3要与2交换,这样第一个3就在第二个3后面,破换了相等两个值之前的顺序了。因此是不稳定的。
直接插入排序
思路
直接插入排序基本思想是将一个数插入一个已经有序的数组中,默认有序的为index=0的第一个数,然后逐渐将第i个数插入[0~i-1] 这样有序的数组中。因此可以看出直接插入排序,在已经基本有序的数组中,排序效率是很高的。
Java实现
1 | public void insertSort(int[] data) { |
复杂度分析
时间复杂度
最好情况下,每次插入前的比较都不满足,所以遍历一遍就结束 ,复杂度为 $O(n)$
最差情况下,从i=1处开始,每次都要发生数组移动,移动次数为 $1+2+3+…+n-1=\dfrac{n(n-1)}{2}$
比较次数也一样,因此复杂度为 $O(n^2)$
空间复杂度
几个局部变量,复杂度为 $O(1)$
稳定性
基于比较,因此能保持相等的两个数的相对位置,是稳定的。
希尔排序
思路
借助直接插入排序。基本思想是将整个数组分步逐渐变为基本有序,到全有序。首先确定step,就是将整个数组分组,比如 step=2。那么就是该数组 索引位置 [0,2,4,6,..] 的子序列做插入排序,这样子序列完成有序。接着在step=1.那么就是所有的数组在做插入排序。
Java实现
1 | public void shellSort(int[] data) { |
复杂度分析
时间复杂度
希尔排序是第一批突破 $O(n^2)$ 的算法,算法的复杂度跟 step 步长有关系。见维基百科
空间复杂度
几个局部变量,复杂度为 $O(1)$
稳定性
由于是跳跃式的移动,因此是不稳定的。
归并排序
思路
归并的意思包含两层,一个是分治,一个是合并。思想是将一个待排序的数组,分为两个有序的数组,然后将这两个有序数组合并为一个。
Java实现
1 | public void mergeSort(int[] data) { |
复杂度分析
时间复杂度
每次合并将两个有序的数组合并一起,需要遍历一次,因此复杂度为$O(n)$ ,而要合并多少次?由于二分,所以次数为 $log_2n$ 次,所以总的时间复杂度: $nlog_2n$ 。进一步可以到,不管是正序,逆序复杂度均一样,因此效率比较高。
空间复杂度
因为归并排序使用了一个同等规模的额外辅助数组,这个复杂度为 $O(n)$,采用递归调用的话,因为递归栈的深度为 $log_2n$,因此总的是$O(n+log_2n)$。若采用的是非递归,那么递归栈部分可以省略,效率更高,复杂度为 $O(n)$ 。因此优先使用的是非递归的实现。
稳定性
由于也是基本两两比较,因此也是稳定性的
堆排序
TODO
快速排序
思路
“分治”+”填坑“法。基本思路为,选取一个基准值,比如index=0的值,然后唯一确定这个基准的位置,使得该值前面的数都小于它,后面的数都大于它,这样就唯一确定了这个基准值的位置。然后在分别进行递归操作,逐一逼近单一数组值。而在确定基准值时候,是采用两头法,假设基准值为index=0的值,先保存好基准值,然后先从右游标向左找起,若是升序排序,则一直找到比基准值小的位置,停住。然后将该值填入基准值的坑,然后在从左往右找大于基准值的位置,找到后,将该值插入右边出现的坑。直到左右游标相遇,则该值就是基准值的位置填入,这样一轮下来,就唯一确定基准值了。然后在递归左序列和右序列。
Java 实现
1 | public void quickSort(int[] data, int start, int end) { |
上面的实现是快排的基本实现,但是可以看出,有许多地方可以进行优化。
基准值改进
首先基准值的选择比较重要,假设基准值每次都是选取了最大或是最小,其实一轮下来,数据量只是减少了一个,假设每次基准值都是选择比较平均,都是中间的值,那么每轮下来,数据量都会减小一半。可以采用取首位,中间值三者的中值来作为基准值。
尾递归改进
由于快排是基于递归实现,所以数据量大的时候,会造成调用栈深度过大,因此改为尾递归调用
小数量时,退化为直接插入排序
所以优化后的快排为
1 | public void quickSortOpt(int[] data, int start, int end) { |
复杂度分析
从实现来看,快排每次都需要遍历一次数组,$O(n)$ ,而最差的情况,每次分治后,都只减少一个数据,所以一共会有 n-1 次,所以此种情况下,时间复杂度为 $O(n^2)$ ,最好的情况下,是每次分治都减少一半的数据量,因此次数为 $O(logn)$ ,所以最好的情况是 $O(nlogn)$ ,空间复杂度为由于是递归调用,所以$O(logn)$
同时又由于是跳跃的比较,所以是不稳定的。
总结
时间复杂度(最好) | 时间复杂度(最差) | 时间平均复杂度 | 空间复杂度 | 稳定性 | |
---|---|---|---|---|---|
冒泡排序 | $O(n)$ | O(n^2) | $O(n^2)$ | O(1) | 稳定 |
选择排序 | $O(n^2)$ | $O(n^2)$ | $O(n^2)$ | O(1) | 不稳定 |
直接插入排序 | $O(n)$ | $O(n^2)$ | $O(n^2)$ | O(1) | 稳定 |
希尔插入排序 | $O(nlogn)$ | 依据step | 依据step | O(1) | 不稳定 |
归并排序 | $O(nlog_2n)$ | $O(nlog_2n)$ | $O(nlog_2n)$ | O(n) | 稳定 |
快速排序 | $O(nlogn)$ | $O(n^2)$ | $O(nlogn)$ | $O(logn)$ | 不稳定 |