插入排序算法原理、复杂度及python实现

插入排序算法原理

这个算法要解决的问题是,怎么对序列 A = ( a 1 , a 2 , a 3 , . . . a n ) A=(a_1,a_2,a_3,...a_n) A=(a1,a2,a3,...an)重新排序。
一种暴力排序的方法是,每一个循环中,通过两两比较,找到现有序列中最小值,依次放到一个新序列中(已经放到新序列中的数,下一个找最小值的循环就不再参与)。这样,为了排出有序列,需要比较 n ( n − 1 ) 2 \frac{n(n-1)}{2} 2n(n1)次,也就是说,其时间复杂度为 O ( n 2 ) O(n^2) O(n2),相对较高。并且,因为要形成一个新的容器存放排好序的序列,在大数据的情况下,很占内存,即该算法空间复杂度也较高。
如何改进这种暴力解法?首先,要降低空间复杂度,需要减少新容器中的元素;其次,要降低时间复杂度,要减少元素比较的次数。
从空间复杂度上优化该暴力算法的思路可以类比扑克牌抓牌时候的排序过程。每新抓一张牌,会把这张牌和已有的牌比较大小,依此选择插入点。因此,每次抓牌之前,手中的牌都是一个排好序的序列。归结到算法上,是每次将一个数插入到合适的位置中。
若要将序列从小到大排序,可以将这一过程抽象为:先在序列 A A A抽出第2个元素 a 2 a_2 a2,将其与 a 1 a_1 a1比较大小,若 a 2 < a 1 a_2<a_1 a2<a1,将 a 2 a_2 a2放在 a 1 a_1 a1左侧,若 a 2 > a 1 a_2>a_1 a2>a1,将 a 2 a_2 a2放在 a 1 a_1 a1右侧。这样,在 A A A中排好了两个元素的有序子列 ( a 1 ′ , a 2 ′ ) (a_1^{'},a_2^{'}) (a1,a2)本次排序结束再抽出 a 3 a_3 a3,将其与 a 2 ′ a_2^{'} a2比较,若大于,令 a 3 ′ = a 3 a_3^{'}=a_3 a3=a3,其他元素均不变,本次排序结束。若小于,先令 a 3 ′ = a 2 ′ a_3^{'}=a_2^{'} a3=a2再将 a 3 a_3 a3 a 1 ′ a_1^{'} a1比较。若大于,令 a 2 ′ = a 3 a_2^{'}=a_3 a2=a3本次排序结束。若小于 a 1 ′ a_1^{'} a1,因为 a 1 ′ a_1^{'} a1左边没有其他数了, a 3 a_3 a3只能放在 a 1 ′ a_1^{'} a1左边,也就是令 a 2 ′ = a 1 ′ a_2^{'}=a_1^{'} a2=a1 a 1 ′ = a 3 a_1^{'}=a_3 a1=a3本次排序结束。由特例可以发现,整个排序过程有两次循环。大循环是从序列A中依此抽元素 a k a_k ak,小循环是将 a k a_k ak A ′ A^{'} A中每个元素比较。小循环中止条件是 a k > a j ′ a_k>a_j^{'} ak>aj a k a_k ak插到 a j ′ a_j^{'} aj右侧, a j + 1 ′ a_{j+1}^{'} aj+1左侧),或是 j − 1 = 0 j-1=0 j1=0 a k a_k ak插到序列最左侧),循环继续条件则反过来。
可以算出,该方法时间复杂度仍是 O ( n 2 ) O(n^2) O(n2)。但因为该解法使用原地排序,只要建立一个存放待插入元素的容器,空间复杂度会比暴力解法低很多。
据此,可以写出该过程的代码。

伪代码及Python实现

伪代码


for j=2 to len(A)
//为了避免在赋值的过程中数字丢失,需要另设一个参数存放每次要插入的元素。可以试试如果不设key会怎样
//这里可看出,相比于暴力解法,该解法大大降低了空间复杂度。暴力解法需要建立一个和原序列一样长的容器,而该解法只要建立单元素容器。
  key = a[j]
  //默认条件是j之前的数已经排好序,因为代码正确执行后,这一条件一定成立。这是理解该代码的难点。
  //每次都是先和序列中前一个数比大小。这里的a[i]相当于A'序列中的数。
  i = j-1
  //小循环。这里用while书写,因为循环次数未知。根据本文第一部分的分析,可写出循环继续条件
  while key < a[i] & i>0
  //新元素小于A'第i个值,则A’第i个值往后移一位,即A'中第i+1个元素被赋值为原第i个元素的值
      a[i+1]=a[i]
      //注意while循环中要规定循环方向(for则不需要),否则会停住。此处是往A'的左侧走
      i=i-1
      //若新元素大于等于A'第i个值,则将该元素插到A'第i+1个位置,同时循环结束。新元素右侧的元素位置都已经更新过,左侧的则不用变。
      //若新元素小于A'所有值,即扫描到了i=0,则循环结束,该元素插到A’第1个位置,也即i+1。故两种情况可以统一表示。
  a[i+1]=key
    //一个新元素的插入完成。从原序列中抽下一个元素。

python代码实现

构造一个实例:对序列A=[1,4,6,3,5,10,7,3,8]从小到大排序。
当然,用python内置的sorted函数可以一次性完成排序:

sorted(A, reverse=FALSE)

如果要自己编写一个排序函数,如何实现呢?可以根据上述伪代码写出python代码:

//升序排列
def sort(lista):
    for j in range(1,len(lista)):
        key = lista[j]
        i = j-1
        while key > lista[i]  and i>=0:
            lista[i+1]=lista[i]
            i = i-1
        lista[i+1]=key
        print(lista)
sort(A)

//降序排列只要把key > lista[i]换成key<lista[i]即可

算法分析(RAM模型)

在RAM模型中,假定:1、语句只能是真实的基本计算机指令,而不能是一个封装好的包。2、每一语句的执行时间是一个常量。3、不同语句不能并行计算。
虽然这些条件不一定成立,但在分析算法时间复杂度中有很大作用。
下图是插入排序算法每一步的执行时间与执行次数统计(图源自《算法导论》)。

在这里插入图片描述

为何第一句运行次数为n而非n-1?需要注意for,while循环语句执行测试次数比执行循环体次数多1。
t j t_j tj指第j个元素进行插入时,进行while循环测试的次数(注意比循环体执行次数多1)。循环体执行次数即待插入元素与A‘序列元素比较的次数,取决于是序列排序程度。最好情况是完全升序,这样即不用执行循环体, t j = 1 t_j=1 tj=1。最坏情况是完全降序,这样待插入元素需要和j之前的j-1个元素比较,则有 t j = j t_j=j tj=j。可以总结出技巧:同一级循环体内的语句执行次数应当是相同的。while下面的语句实际是和for循环一级的,因此执行次数也是n-1。
根据RAM的假设,若要知道该算法耗费总时间,求这些步的时间次数乘积和即可。
计算可得,在最好情况下, T ( n ) = a n + b T(n)=an+b T(n)=an+b
最坏情况下, T ( n ) = a n 2 + b n + c T(n)=an^2+bn+c T(n)=an2+bn+c
这里也可以看出,算法运行时间取决于很多因素:输入规模(n)、数据排序程度、单步运行时间……

时间复杂度

由上述部分可以看出,算法运行时间有最坏情况,也有最好情况。但算法分析一般只看最坏情况。1、最坏情况确定了算法运行时间的上界,在算法时间复杂度的比较上,如果可以证明A算法的最坏情况都比B算法的最好情况快,那A在这一层面上一定是优于B算法的。2、一般来说,平均情况和最坏情况一样坏。在插入排序中,平均情况是有一半数是升序排好的, t j = j / 2 t_j=j/2 tj=j/2,这样算出的T(n)仍然有二次项。
并且,我们更关注的是算法运行时间的增长率,或者说我们作不同算法的比较时,统一将n视为无穷大。此时,常数系数也可以被忽略。因此,我们定义时间复杂度 O ( n ) O(n) O(n)为当n趋向无穷大时,其运行时间增长率的决定因素,即T(n)最高项的非常数因子。因此,在暴力排序和插入排序中,都有 O ( n ) = n 2 O(n)=n^2 O(n)=n2
对于最高项阶数相同的算法,比较其复杂度则可以看其最高项系数。如A算法有 O ( n ) = 2 n O(n)=2n O(n)=2n,B算法有 O ( n ) = n O(n)=n O(n)=n,B时间复杂度更低。
当然,当输入规模较小时,常数系数和低阶项的影响可能会占上风,但总会存在临界规模,高于该规模,高阶项起决定作用。

猜你喜欢

转载自blog.csdn.net/zhy_is_pig/article/details/108691135
今日推荐