vlambda博客
学习文章列表

图解冒泡、选择和插入排序

    排序是我们开发中经常用到的操作,排序的算法也是多种多样,比如常用的有:冒泡排序、插入排序、选择排序、归并排序、快速排序、计数排序、基数排序、桶排序、堆排序等。
一、排序算法分析方法
    对于上述的多种排序算法,如何评价它们的优劣呢?下面通过几个方面来分析:
1、执行效率
对于执行效率的分析,一般从这几个方面来衡量:
    <1>最好、最好及平均情况时间复杂度
    在分析排序算法的时间复杂度时,要分别给出最好、最坏和平均情况下的时间复杂度。另外,还要知道最好、最坏时间复杂度对应的要排序的原始数据是什么样的。
    因为对于要排序的数据,有的接近有序,有的完全无序。有序度不同的数据,对于排序的执行时间肯定是有影响的,我们要知道排序算法在不同数据下的性能表现。
    <2>时间复杂度的系数、常数、低阶
    我们通常表示时间复杂度时,会忽略系数、常数、低阶。但是实际的开发中,我们排序的可能是 10 个、100 个、1000 个这样规模很小的数据,所以,在对同一阶时间复杂度的排序算法性能对比的时候,我们就要把系数、常数、低阶也考虑进来。
    <3>比较次数和交换(或移动)次数
    基于比较的排序算法的执行过程,会涉及两种操作,一种是元素比较大小,另一种是元素交换或移动。所以,如果我们在分析排序算法的执行效率的时候,应该把比较次数和交换(或移动)次数也考虑进去。
2、内存消耗
    算法的内存消耗可以通过空间复杂度来衡量,针对排序算法的空间复杂度,引入了一个新的概念,原地排序(Sorted in place)。原地排序算法,就是特指空间复杂度是 O(1) 的排序算法。
3、排序算法的稳定性
    针对排序算法,还有一个重要的度量指标,稳定性。这个概念是说,如果待排序的序列中存在值相等的元素,经过排序之后,相等元素之间原有的先后顺序不变。下面通过一个例子来解释一下。比如有一组数据 2,9,3,4,8,3,按照大小排序之后就是 2,3,3,4,8,9。这组数据里有两个 3。经过某种排序算法排序之后,如果两个 3 的前后顺序没有改变,那我们就把这种排序算法叫作稳定的排序算法;如果前后顺序发生变化,那对应的排序算法就叫作不稳定的排序算法。
为什么要比较排序算法是否稳定?
    在实际使用排序算法的时候,我们往往是对一组对象按照某几个属性进行排序,比如:
    现在要给电商交易系统中的“订单”排序。订单有两个属性,一个是下单时间,另一个是订单金额。如果我们现在有 10 万条订单数据,希望按照金额从小到大对订单数据排序。对于金额相同的订单,我们希望按照下单时间从早到晚有序。对于这样一个排序需求,我们怎么来做呢?
    最先想到的方法是:我们先按照金额对订单数据进行排序,然后,再遍历排序之后的订单数据,对于每个金额相同的小区间再按照下单时间排序。这种排序思路理解起来不难,但是实现起来会很复杂。
        借助稳定排序算法,这个问题可以非常简洁地解决。解决思路是这样的:我们先按照下单时间给订单排序,注意是按照下单时间,不是金额。排序完成之后,我们用稳定排序算法,按照订单金额重新排序。两遍排序之后,我们得到的订单数据就是按照金额从小到大排序,金额相同的订单按照下单时间从早到晚排序的。为什么呢?
    稳定排序算法可以保持金额相同的两个对象,在排序之后的前后顺序不变。第一次排序之后,所有的订单按照下单时间从早到晚有序了。在第二次排序中,我们用的是稳定的排序算法,所以经过第二次排序之后,相同金额的订单仍然保持下单时间从早到晚有序。
下面我们详细分析三种时间复杂度为O(n^2)的排序算法

二、冒泡排序
    冒泡排序只会操作相邻的两个数据。每次冒泡操作都会对相邻的两个元素进行比较,看是否满足大小关系要求,如果不满足就让它俩互换。一次冒泡会让至少一个元素移动到它应该在的位置,每次冒泡结束后,待排序的集合就少一个元素,重复 n 次后,就完成了 n 个数据的排序工作。
下面对一组数据 [3,1,7,4,9] 按升序进行排序,第一次的冒泡过程如下图:

    通过第一次冒泡,至少有一个数,即 "9" 移动到了它应该在的位置。整个冒泡的过程如下:

图解冒泡、选择和插入排序

    从上图可以看出,从第三次冒泡开始,就没有元素交换了,此时数据已经是有序的了。所以我们可以对冒泡排序进行优化,判断是否有元素交换,如果没有即退出,这样可以减少冒泡的次数,提高效率。

代码实现(以升序为例):

public static int[] bubbling(int[] arr) { int len = arr.length; if(len<=1){ return arr; }
    boolean flag=false;//标记是否有元素交换,默认为false int tmp = 0;    for (int i = 0; i < len - 1; i++) { for (int j = 0; j < len - i - 1; j++) { if (arr[j] > arr[j + 1]) { flag=true; tmp = arr[j]; arr[j] = arr[j + 1]; arr[j + 1] = tmp; } } //如果内层循环未进行交换,即已排好序,直接结束;否则重置flag,继续循环。 if (!flag) { break; }else { flag=false; } } return arr;}

下面用开始讲的分析方法分析一下冒泡排序的好坏:

(1)冒泡排序是否是原地排序算法

    冒泡的过程只涉及相邻数据的交换操作,只需要常量级的临时空间,所以它的空间复杂度为 O(1),是一个原地排序算法。

(2)冒泡排序是否是稳定排序算法

    冒泡排序中,只有交换才可以改变两个元素的前后顺序。为了保证冒泡排序算法的稳定性,当有相邻的两个元素大小相等的时候,我们不做交换,相同大小的数据在排序前后不会改变顺序,所以冒泡排序是稳定的排序算法。

(3)冒泡排序的时间复杂度是多少

    最好情况下,要排序的数据已经是有序的了,我们只需要进行一次冒泡操作(即遍历n个元素),就可以结束了,所以最好情况时间复杂度是 O(n)。而最坏的情况是,要排序的数据刚好是倒序排列的,我们需要进行 n 次冒泡操作,所以最坏情况时间复杂度为 O(n^2)。

平均时间复杂度:

    这里分析平均时间复杂度,通过“有序度”和“逆序度”这两个概念来进行分析。有序度是数组中具有有序关系的元素对的个数。有序元素对用数学表达式表示就是这样:

有序元素对:a[i] <= a[j], 如果i < j。

    按升序排列来讲,就是一对元素,下标大的元素数据值也大,那么这两个元素就是有序元素对。如:

[3,7,1,4,9] 这组数据的有序度为:7其有序元素对有7对,分别为:(3,7),(3,4),(3,9),(7,9),(1,4),(1,9),(4,9)

    同理,对于一个倒序排列的数组,比如 6,5,4,3,2,1,有序度是 0;对于一个完全有序的数组,比如 1,2,3,4,5,6,有序度就是 n*(n-1)/2,也就是 15。我们把这种完全有序的数组的有序度叫作满有序度。

逆序度的定义正好跟有序度相反(默认从小到大为有序)

[3,7,1,4,9] 这组数据的逆序度为:3其逆序元素对有3对,分别为:(3,1),(7,1),(7,4)

    根据上面的分析,可以得到一个公式:逆序度 = 满有序度 - 有序度。我们排序的过程就是一种增加有序度,减少逆序度的过程,最后达到满有序度,就说明排序完成了。

    冒泡排序包含两个原子操作,比较和交换。每交换一次,有序度就加 1。不管算法怎么改进,交换次数总是确定的,即为逆序度,也就是n*(n-1)/2——初始有序度。此例中就是 10–7=3,要进行 3次交换操作。

    对于包含 n 个数据的数组进行冒泡排序,平均交换次数是多少呢?最坏情况下,初始状态的有序度是 0,所以要进行 n*(n-1)/2 次交换。最好情况下,初始状态的有序度是 n*(n-1)/2,就不需要进行交换。我们可以取个中间值 n*(n-1)/4,来表示初始有序度既不是很高也不是很低的平均情况。

    换句话说,平均情况下,需要 n*(n-1)/4 次交换操作,比较操作肯定要比交换操作多,而复杂度的上限是 O(n^2),所以平均情况下的时间复杂度就是 O(n^2)。这个平均时间复杂度推导过程并不严格,但是很多时候很实用。


三、插入排序

    顾名思义,插入排序是对于欲排序的元素以插入的方式找寻该元素的适当位置,以达到排序的目的。插入排序是一种内部排序。

思路分析:

    把n个待排序的元素看成为一个有序和一个无序表,开始时有序表中只包含一个元素,无序表中包含n-1个元素,排序过程中每次从无序表中取出第一个元素,把它的排序码依次与有序表元素的排序码进行比较,将它插入到有序表中的适当位置,使之成为新的有序表。

图解:

    对于一组元素[34,101,231,10,78,99],其中有序列表为[34,101,231],无序列表为[10,78,99]。假设现在要插入的数据为10,下面演示一下插入的过程:

①先把10保存到insertVal,然后前一个位置为insertIndex=2

图解冒泡、选择和插入排序

②由于arr[insertIndex](231) > insertVal(10),且insertIndex=2>=0,把231赋值给后一个位置,insertIndex-1=1,指向101

图解冒泡、选择和插入排序

由于arr[insertIndex](101) > insertVal(10),且insertIndex=1>=0,把101赋值给后一个位置,insertIndex-1=0,指向34

图解冒泡、选择和插入排序

由于arr[insertIndex](34) > insertVal(10),且insertIndex=0=0,把34赋值给后一个位置,insertIndex-1=-1

图解冒泡、选择和插入排序

⑤由于insertIndex<0,所以找到了插入位置,即insertVal的位置为insertIndex+1=-1+1=0,然后将insertVal赋值给下标为0的位置

图解冒泡、选择和插入排序

至此,一个待插入的数就完成了 插入,然后依次插入后续的数即可

代码实现:

public static int[] insertSort(int[] arr) { for (int i =1;i<arr.length;i++) { //定义插入的数,从数组第2个数开始 int insertVal = arr[i]; //定义插入位置,从插入数的前一个位置坐标开始依次往前判断 int insertIndex = i - 1; //给insertVal找到插入的位置 //说明: //1.insertIndex>=0保证插入位置下标不越界 //2.arr[insertIndex] > insertVal 待插入的数小于前面的数,说明还没找到插入位置 //(因为是升序排,所以待插入值前面的值都是升序排好的,所以只要找到前面数小于待插入数时即找到位置) //3.需要将arr[insertIndex]后移:arr[insertIndex + 1] = arr[insertIndex] //4.insertIndex前移继续找位置 while (insertIndex >= 0 && arr[insertIndex] > insertVal) { arr[insertIndex + 1] = arr[insertIndex]; insertIndex--; } //当退出while循环时,说明insertVal的位置已经找到,就是insertIndex+1 arr[insertIndex + 1] = insertVal; } return arr;}

    在上面的例子中,我们采用的是从尾到头查找插入点的方法,当然也可以用从头到尾查找的方法,对于这两种方法,元素的比较次数是有区别的。但对于一个给定的初始序列,移动操作的次数总是固定的,就等于逆序度。

    拿我们上面的一组元素 [34,101,231,10,78,99] 来说,满有序度为n*(n-1)/2=15,初始序列的有序度是 8,所以逆序度是 7。插入排序中,数据移动的个数总和也等于 7=3+2+2。

下面继续分析一下插入排序的好坏:
(1)插入排序是否是原地排序算法
    插入排序算法只需要常量级的临时空间,所以空间复杂度是 O(1),也就是说,这是一个原地排序算法。
(2)插入排序是否是稳定排序算法
    在插入排序中,对于值相同的元素,我们可以选择将后面出现的元素,插入到前面出现元素的后面(上面arr[insertIndex] > insertVal条件决定了值相同的元素,后面出现的会插入前面出现的元素后面),这样就可以保持原有的前后顺序不变,所以插入排序是稳定的排序算法。
(3)插入排序的时间复杂度是多少
最好时间复杂度:
    如果要排序的数据已经是有序的,不需要搬移任何数据。如果我们从尾到头在有序数据组里面查找插入位置,每次只需要比较一个数据就能确定插入的位置。所以这种情况下,最好是时间复杂度为 O(n)。注意,这里是从尾到头遍历已经有序的数据。
最坏时间复杂度:
    如果数组是倒序的,每次插入都相当于在数组的第一个位置插入新的数据,所以需要移动大量的数据,所以最坏情况时间复杂度为 O(n^2)。
平均时间复杂度:
    在数组中插入一个数据的平均时间复杂度是 O(n)。所以,对于插入排序来说,每次插入操作都相当于在数组中插入一个数据,循环执行 n 次插入操作,所以平均时间复杂度为 O(n^2)。

四、选择排序

思路分析:

    选择排序算法的实现思路有点类似插入排序,也分已排序区间和未排序区间。但是选择排序每次会从未排序区间中找到最小的元素,将其放到已排序区间的末尾。直到未排序区间中只剩一个为止,这个元素就是这组元素的最大值,这时完成排序。

图解:

代码实现:
public static int[] selectSort(int[] arr) { for (int i = 0; i < arr.length - 1; i++) {        int minIndex = i;//最小值的下标 int min = arr[i];//最小值 for (int j = i + 1; j < arr.length; j++) { if (min > arr[j]) { min = arr[j]; minIndex = j; } } if (minIndex != i) {//如果最小值下标发生了改变,需要交换arr[i]和arr[minIndex]的值 arr[minIndex] = arr[i]; arr[i] = min; } } return arr;}
下面继续分析一下选择排序的好坏:
(1)选择排序是否是原地排序算法
    选择排序算法只需要常量级的临时空间,所以空间复杂度是 O(1),也就是说,这是一个原地排序算法。
(2)选择排序是否是稳定排序算法
    选择排序是一种不稳定的排序算法。选择排序每次都要找剩余未排序元素中的最小值,并和前面的元素交换位置,这样破坏了稳定性。比如有这样一组元素:[3,5,3,1,7],第一次遍历时,要将最小元素1和第一个元素3交换位置,那么第一个3就到了第二个3的后面,顺序发生了改变,所以选择排序是一种不稳定的排序算法。 
(3)选择排序的时间复杂度是多少
     选择 排序中,无论待排序的 元素是否有序 ,每次都需要 遍历 所有 的未排序区间 ,一共遍历n次,所以无论是最好、最坏还是平均时间复杂度,都是 O( n^ 2 )。

总结:

原地排序
稳定性
最好时间复杂度
最差时间复杂度
平均时间复杂度
冒泡排序

稳定 O(n)
O(n^2) O(n^2)
插入排序
稳定
O(n) O(n^2) O(n^2)
选择排序

不稳定
O(n^2) O(n^2) O(n^2)

参考链接: https://time.geekbang.org/column/article/41802