11.排序(上)

11.排序(上):为什么插入排序比冒泡排序更受欢迎?

markdown文件已上传至github

按照时间复杂度将排序分三节课来讲。

img

带着问题去学习,是最有效的学习方法。

所以,先给出一个思考题:插入排序和冒泡排序的时间复杂度相同,都是 O ( n 2 ) O(n^2) ,在实际的软件开发里,为什么我们更倾向于使用插入排序,而不是冒泡排序?

1.如何分析一个排序算法?

从以下几个方面入手:

1.1 排序算法的执行效率。

对于排序算法的执行效率,我们一般从这几个方面来衡量:

1.1.1 最好情况、最坏情况、平均情况时间复杂度

在分析排序算法的时间复杂度,要分别给出上面几种情况的时间复杂度,因为排序算法在不同数据(接近有序、完全无序等)下的性能表现是不同的。

1.1.2 时间复杂度的系数、常数、低阶。

时间复杂度反映的是数据规模n很大的时候的一个增长趋势,所以它表示的时候会忽略系数、常数、低阶。在实际软件开发中,我们排序的数据可能是10个、100个、1000个这样规模很小的数据,所以在对痛一阶时间复杂的排序算法性能进行对比的时候,就要把系数、常数、低阶也考虑进来。

1.1.3 比较次数和交换(或移动)次数

这一节和下一节讲的都是基于比较的排序算法。基于比较的排序算法的执行过程中,会涉及两种操作,一种是元素比较大小,另一种是元素交换或移动。所以要把比较次数和交换(或移动)次数考虑进去。

1.2排序算法的内存消耗

算法的内存消耗可以通过空间复杂度来衡量。

原地排序:空间复杂度是O(1)的排序算法。

本节三种都是原地排序算法。

1.3.排序算法的稳定性

针对排序算法还有一个重要的度量指标:稳定性。如果待排序序列种存在值相等的元素,经过排序之后,相等元素之间原有的先后顺序不变,那么就称这种排序算法是稳定的排序算法。否则就称为不稳定的排序算法。

为什么要考虑排序算法的稳定性?

在真正的软件开发中,我们要排序的往往不是单纯的整数,而是一组对象,我们需要按照对象的某个key来排序。

例如:要给电商交易系统中的“订单”排序。订单有两个属性,一是下单时间。另一个是订单金额。如果我们现在有十万条订单数据,我们希望按照金额大小对订单数据排序,对于金额相同的订单,我们需要按照下单时间从早到晚有序。

借助稳定的排序算法就很容易实现。先按下单时间排序,再用稳定排序算法,按照订单金额排序。

img

2.冒泡排序(Bubble Sort)

每次比较相邻两个元素,如果大小关系不满足则交换,一次冒泡会让至少一个元素移动到它应该在的位置,重复n次就完成了n个数据的排序工作。

有时候并不需要重复n次就能完成排序,当某次冒泡操作已经没有数据交换时,说明已经完全有序,不用再继续执行后续操作了。

代码:


// 冒泡排序,a表示数组,n表示数组大小
public void bubbleSort(int[] a, int n) {
  if (n <= 1) return;
 
 for (int i = 0; i < n; ++i) {
    // 提前退出冒泡循环的标志位
    boolean flag = false;
    for (int j = 0; j < n - i - 1; ++j) {
      if (a[j] > a[j+1]) { // 交换
        int tmp = a[j];
        a[j] = a[j+1];
        a[j+1] = tmp;
        flag = true;  // 表示有数据交换      
      }
    }
    if (!flag) break;  // 没有数据交换,提前退出
  }
}

1.冒泡排序是原地排序算法。(只涉及相邻数据的交换操作)

2.冒泡排序是稳定的排序算法。(当相邻两个元素大小相等时,不做交换)

3.冒泡排序的时间复杂度

  • 最好情况下,要排序的数据是有序的,这时只需要一次冒泡操作,时间复杂度 O ( n ) O(n)

  • 最坏情况下,要排序的数据刚好是倒序排列的,需要n次冒泡操作,时间复杂度为 O ( n 2 ) O(n^2)

  • 平均情况下,平均时间复杂度就是加权平均期望复杂度,分析时要结合概率论的知识。

    对于包含n个数据的数组,这n个数据就有 n ! n! 种排列方式。不同的排列方式,冒泡排序执行的时间肯定是不同的。这里用概率论方法定量分析平均时间复杂度,涉及的数学推理和计算会很复杂。

    这里可以采用另外一种思路,通过“有序度”和“逆序度”来分析。

    **有序度:**数组中具有有序关系的元素对的个数,用数学表达式表示为 a [ i ] < = a [ j ] , i < j a[i]<=a[j],如果i<j

img

​ 一个倒序排列的数组,有序度为0。一个完全有序的数组,有序度为 n ( n 1 ) 2 \frac{n*(n-1)}{2} ,称为满有序度。

逆序度:跟有序度相反(默认从小到大为有序),数学表达式为 a [ i ] > a [ j ] , i < j a[i]>a[j] ,如果 i<j。

逆序度 = 满有序度-有序度

​ 冒泡排序包含两个原子操作,比较和交换,每交换一次有序度就加1.不管算法怎么改进,交换次数总是确定的,即为逆 序度,即 n ( n 1 ) / 2 n*(n-1)/2-初始有序度 。最好情况下,不需要交换。最坏情况下,需要进行 n ( n 1 ) 2 \frac{n*(n-1)}{2} 次交换。我们取个中 间 值 n ( n 1 ) / 4 n*(n-1)/4 来表示初始有序度既不是很高也不是很低的平均情况。比较操作肯定要比交换操作多,而复杂度上 限是 O ( n 2 ) O(n^2) ,所以平均情况下的时间复杂度为 O ( n 2 ) O(n^2)

这个平均时间复杂度的推导过程并不严格,但是很多时候很实用,毕竟概率论的定量分析太复杂,不太好用。

3.插入排序(Insertion Sort)

将数组中的数据分为两个区间,已排序区间和未排序区间。初始已排序区间只有一个元素,就是数组的第一个元素。插入算法的核心思想是取未排序区间中的元素,在已排序区间中找到合适的插入位置将其插入,并保证已排序区间数据一直有序。重复这个过程,直到未排序区间中元素为空,算法结束。

对于不同的查找插入点的方法(从头到尾,从尾到头),元素的比较次数是有区别的。但对于一个给定的初始序列,移动操作的次数总是固定的,就等于逆序度。

代码:


// 插入排序,a表示数组,n表示数组大小
public void insertionSort(int[] a, int n) {
  if (n <= 1) return;

  for (int i = 1; i < n; ++i) {
    int value = a[i];
    int j = i - 1;
    // 查找插入的位置
    for (; j >= 0; --j) {
      if (a[j] > value) {
        a[j+1] = a[j];  // 数据移动
      } else {
        break;
      }
    }
    a[j+1] = value; // 插入数据
  }
}

1.插入排序是原地排序算法。空间复杂度 O ( 1 ) O(1)

2.插入排序是稳定的排序算法。(将后面出现的元素插入到前面出现元素的前面)

3.插入排序的时间复杂度

  • 最好情况下,要排序的数据已经有序,我们不需要搬移任何数据,每次只比较最后一个数据就知道插入位置为最后,所以时间复杂度为 O ( n ) O(n) .
  • 最坏情况下,数组是倒序的,每次插入都相当于再数组第一个位置插入数据,所以需要移动大量的数据,时间复杂度为 O ( n 2 ) O(n^2)
  • 之前的章节分析过,在数组中插入一个数据的平均时间复杂度为 O ( n ) O(n) .插入排序每次插入都相当于在数组中插入一个数据,循环n次插入操作,所以平均时间复杂度为 O ( n 2 ) O(n^2)

**插入排序的优化:希尔排序

4.选择排序(Selection Sort)

将数组分为已排序区间和未排序区间,选择排序从未排序区间中找到最小的元素,将其放到已排序区间的末尾。

1.选择排序的空间复杂度是 O ( 1 ) O(1) ,是原地排序算法。

2.选择排序是不稳定的。(每次找到未排序区间最小的元素与未排序区间第一个元素交换作为已排序区间的末尾元素。)

3.选择排序的时间复杂度

  • 选择排序最好、最坏、平均时间复杂度都为 O ( n 2 ) O(n^2) 。(每选出一个未排序的元素,时间复杂度 O ( n ) O(n) 。)

5.解答开篇

冒泡排序和插入排序的时间复杂度都是 O(n2),都是原地排序算法,为什么插入排序要比冒泡排序更受欢迎呢?

冒泡排序不管怎么优化,元素交换的次数是一个固定值,是原始数据的逆序度。插入排序是同样的,不管怎么优化,元素移动的次数也等于原始数据的逆序度。

从代码实现上来看,冒泡排序的数据交换要比插入排序的数据移动要复杂,冒泡排序需要 3 个赋值操作,而插入排序只需要 1 个。


冒泡排序中数据的交换操作:
if (a[j] > a[j+1]) { // 交换
   int tmp = a[j];
   a[j] = a[j+1];
   a[j+1] = tmp;
   flag = true;
}

插入排序中数据的移动操作:
if (a[j] > value) {
  a[j+1] = a[j];  // 数据移动
} else {
  break;
}

我们把执行一个赋值语句的时间粗略地计为单位时间(unit_time),然后分别用冒泡排序和插入排序对同一个逆序度是 K 的数组进行排序。用冒泡排序,需要 K 次交换操作,每次需要 3 个赋值语句,所以交换操作总耗时就是 3*K 单位时间。而插入排序中数据移动操作只需要 K 个单位时间。

虽然冒泡排序和插入排序在时间复杂度上是一样的,都是 O ( n 2 ) O(n^2) ,但是如果我们希望把性能优化做到极致,那肯定首选插入排序。插入排序的算法思路也有很大的优化空间,我们只是讲了最基础的一种。

6.思考题

特定算法是依赖特定的数据结构的。我们今天讲的几种排序算法,都是基于数组实现的。如果数据存储在链表中,这三种排序算法还能工作吗?如果能,那相应的时间、空间复杂度又是多少呢?

对于老师所提课后题,觉得应该有个前提,是否允许修改链表的节点value值,还是只能改变节点的位置。一般而言,考虑只能改变节点位置,冒泡排序相比于数组实现,比较次数一致,但交换时操作更复杂;插入排序,比较次数一致,不需要再有后移操作,找到位置后可以直接插入,但排序完毕后可能需要倒置链表;选择排序比较次数一致,交换操作同样比较麻烦。综上,时间复杂度和空间复杂度并无明显变化,若追求极致性能,冒泡排序的时间复杂度系数会变大,插入排序系数会减小,选择排序无明显变化。

7.参考

这个是我学习王争老师的《数据结构与算法之美》所做的笔记,王争老师是前谷歌工程师,该课程截止到目前已有87244人付费学习,质量不用多说。

在这里插入图片描述

截取了课程部分目录,课程结合实际应用场景,从概念开始层层剖析,由浅入深进行讲解。本人之前也学过许多数据结构与算法的课程,唯独王争老师的课给我一种茅塞顿开的感觉,强烈推荐大家购买学习。课程二维码我已放置在下方,大家想买的话可以扫码购买。

在这里插入图片描述

本人做的笔记并不全面,推荐大家扫码购买课程进行学习,而且课程非常便宜,学完后必有很大提高。

在这里插入图片描述

猜你喜欢

转载自blog.csdn.net/supreme_1/article/details/107747254