排序

问题思考

插入排序和冒泡排序的时间复杂度相同,都是O(n^2),在实际软件开发里,为什么更倾向于使用插入排序算法而不是冒泡排序算法呢?

如何分析一个排序算法

排序算法的执行效率

1 . 最好情况、最坏情况、平均情况时间复杂度
分析排序算法的时间复杂度是,要分别给出最好情况、最坏情况、平均情况下的时间复杂度。除此之外,还要说出最好、最坏时间复杂度对应的要排序的原始数据长什么样。

2 . 时间复杂度的系数、常数、低阶
尽管表示时间复杂度时,忽略了系数、常数、低阶。但实际软件开发中,排序的数据可能是10个、100个、1000个这样的小规模数据,因而对于同一阶时间复杂度的排序算法,在比较时,应该把系数、常数和低阶考虑进来。

3 . 比较次数和交换(移动)次数
基于比较的排序算法在执行过程中,会涉及到两种操作,一种是比较元素大小,另一种是元素交换或移动。

排序算法的内存消耗

内存消耗可以通过空间复杂度来衡量。针对排序算法的空间复杂度,引入一个新概念,原地排序(Sorted in place)。原地排序算法,就是特指空间复杂度是O(1)的排序算法。

插入排序、冒泡排序、选择排序都是原地排序算法。

排序算法的稳定性

稳定性指:如果待排序的序列中存在值相等的元素,经过排序后,相等元素之间原有的先后顺序不变。
相等元素之间原有的先后顺序没有变,这种排序算法叫作稳定的排序算法;否则,叫作不稳定的排序算法

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

场景举栗
比如说,我们现在要给电商交易系统中的“订单”排序。订单有两个属性,一个是下单时间,另一个是订单金额。
如果我们现在有 10 万条订单数据,我们希望按照金额从小到大对订单数据排序。对于金额相同的订单,我们希望按照下单时间从早到晚有序。对于这样一个排序需求,我们怎么来做呢?

思路分析

  • 最先想到的方法是:先按照金额对订单数据进行排序,然后,再遍历排序之后的订单数据,对于每个金额相同的小区间再按照下单时间排序。这种排序思路理解起来不难,但是实现起来会很复杂。

  • 更好的方法是:借助稳定排序算法。先按照下单时间给订单排序,注意是按照下单时间,不是金额。排序完成之后,再用稳定排序算法,按照订单金额重新排序。两遍排序之后,得到的订单数据就是按照金额从小到大排序,金额相同的订单按照下单时间从早到晚排序的。

  • 原因:稳定排序算法可以保持金额相同的两个对象,在排序之后的前后顺序不变。第一次排序之后,所有的订单按照下单时间从早到晚有序了。在第二次排序中,因为用的是稳定的排序算法,所以经过第二次排序之后,相同金额的订单仍然保持下单时间从早到晚有序。

冒泡排序

排序过程

冒泡排序只会操作相邻的两个数据。每次冒泡操作都会对相邻的两个元素进行比较,看是否满足大小关系要求。如果不满足就让它俩互换。一次冒泡会让至少一个元素移动到它应该在的位置,重复 n 次,就完成了 n 个数据的排序工作。

优化过程

当某次冒泡操作已经没有数据交换时,说明已经达到完全有序,就不用再继续执行后续的冒泡操作。

代码

def bubble_sort(lt):
    length = len(lt)
    flag = False
    for i in range(0,length):
        for j in range(i+1,length):
            if lt[i] > lt[j]:                
                lt[i], lt[j] = lt[j], lt[i] #数据交换
                flag = True
        if not flag:
            break
    return lt

分析

1 . 冒泡排序只涉及相邻数据的交换操作,只需常量级的临时空间,空间复杂度为O(1),所以是一个原地排序算法
2 . 冒泡排序中只有交换才改变两个元素的前后顺序。为了保证其稳定性,当相邻元素相等时,不做交换,那么相同大小的数据在排序前后不会改变顺序。所以冒泡排序是稳定的排序算法。
3 . 最好情况下,排序数据已经是有序的,只进行一次冒泡操作,所以时间复杂度是O(n);最坏情况是,排序的数据刚好是倒序排列的,需要进行n次冒泡操作,所以时间复杂度为O(n^2)
平均时间复杂度,采用一种不严格的方法,通过有序度逆序度两个概念来分析。

有序度:数组中具有有序关系的元素对的个数。(默认,从小到大是有序的)

例:2,4,3,5这组数据的有序度为:5,分别是(2,4),(2,3),(2,5),(4,5),(3,5);同理,对于一个倒序排列的数组,有序度为0;对于一个完全有序的数组,比如2,3,4,5,有序度就是n*(n-1)/2,也就是6。把这种完全有序的数组的有序度叫作满有序度
逆有序度:其定义跟有序度正好相反。

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

排序的过程就是一种增加有序度,减少逆序度的过程,最后达到满有序度,说明排序完成。
冒泡排序中,包含两个操作,比较和交换。每交换一次,有序度就加1。不管算法怎么改进,交换次数总是确定的,即为逆序度,也就是等于n*(n-1)/2 - 初始有序度。对于2,4,3,5这组数据来说,6-5=1,只需进行1次交换操作。

那么对于包含n个数据的数组进行冒泡排序,平均交换次数是多少呢?

  • 最坏情况:初始有序度为0,需要进行n*(n-1)/2次交换
  • 最好情况:初始有序度为 n*(n-1)/2,需要进行0次交换
  • 平均情况:取一个中间值 n*(n-1)/4,来表示初始有序度既不是很高也不是很低的平均情况。换句话说,平均情况下,需要 n*(n-1)/4 次交换操作,比较操作肯定比交换操作更多,而时间复杂度的上限是 O(n^2),所以平均情况下的时间复杂度就是 O(n^2)

插入排序

对于一个原本有序的数组,往里面加一个新数据后,如何继续保持数据的有序呢?很简单,只需要遍历数组,找到数据应该插入的位置将其插入即可。

插入排序就是借助这个思想来实现排序的。

排序过程

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

两种操作

一种是元素的比较,另一种是元素的移动。对于不同的查找插入点方法(从头到尾,从尾到头),元素的比较次数是有区别的。但对于一个给定的初始序列,移动操作的次数总是固定的,即为逆序度。(为什么移动次数=逆序度,可以拿个实例画一个图,很容易明白)

代码

def insertion_sort(lt):
    length = len(lt)
    for i in range(1, length):
        value = lt[i]
        for j in range(i-1,-1,-1):
            if lt[j] > value:
                lt[j+1] = lt[j] #数据移动
            else:
                break #位置确定
        lt[j+1] = value  #插入数据
    return lt

分析

1 . 插入排序算法的运行并不需要额外的存储空间,空间复杂度为 O(1),所以是一个原地排序算法
2 . 插入排序中,对于值相同的元素,可以选择将后面出现的元素,插入到前面出现元素后面,保持原有前后顺序不变。所以插入排序是稳定的排序算法。
3 . 时间复杂度分析。

  • 最好情况:数据已经有序,不需要搬移任何数据。如果从尾到头在有序数组里查找插入位置,每次只需比较一个数据就能确定插入位置。时间复杂度为 O(n)
  • 最坏情况:数组是倒序的,每次插入都相当于在数组的第一个位置插入新数据,所有需要移动大量数据。时间复杂度为 O(n^2)
  • 平均情况:在数组中插入一个数据的平均时间复杂度为 O(n),对于插入排序来说,每次插入操作就相当于在数组中插入一个数据,循环执行 n 次插入操作。所以平均时间复杂度为 O(n^2)

选择排序

思路与插入排序类似,也分已排序区间和未排序区间。但是选择排序每次会从未排序区间中找到最小的元素,将其放到已排序区间末尾。最开始没有已排好的区间,找到数组中最小元素,将其与第一个元素交换,然后再执行以上过程。

代码

def selection_sort(lt):
    length = len(lt)
    for i in range(0,length-1):
        smallest_index = i
        for j in range(i+1, length):
            if lt[j] < lt[smallest_index]:
                lt[j], lt[smallest_index] = lt[smallest_index], lt[j]
    return lt

分析

1 . 选择排序空间复杂度为O(1),是一种原地排序算法
2 . 时间复杂度分析。最好情况、最坏情况和平均时间复杂度都为 O(n^2)
3 . 选择排序是一种不稳定的排序算法。因为其每次都要找剩余未排序元素中的最小值,并和前面的元素交换位置,这样就破坏了稳定性。也因为此,相比于冒泡排序和插入排序,选择排序没那么好。

解答开篇

冒泡排序和插入排序的时间复杂度都是 O(n2),都是原地排序算法,为什么插入排序要比冒泡排序更受欢迎呢?
前面说过,冒泡排序不管怎么优化,元素交换的次数是一个固定值,是原始数据的逆序度。插入排序是同样的,不管怎么优化,元素移动的次数也等于原始数据的逆序度。
但是,从代码实现上来看,冒泡排序的数据交换要比插入排序的数据移动更复杂,冒泡排序需要 3 个赋值操作,而插入排序只需要 1 个。所以在对相同数组进行排序时,冒泡排序的运行时间理论上要长于插入排序。

-------------完-------------