暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

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

大数据记事本 2020-07-25
339
    排序是我们开发中经常用到的操作,排序的算法也是多种多样,比如常用的有:冒泡排序、插入排序、选择排序、归并排序、快速排序、计数排序、基数排序、桶排序、堆排序等。
一、排序算法分析方法
    对于上述的多种排序算法,如何评价它们的优劣呢?下面通过几个方面来分析:
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
              文章转载自大数据记事本,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

              评论