技术

python冒泡排序

OnlyCEC · 3月16日 · 2020年 46次已读

简单讲讲: 我估计能打开我这篇文章的朋友都应该知道冒泡排序吧。我就不那么系统官方的定义了。我喜欢用实例说明,比如给你一个列表L,如下:

[2, 4, 3, 9, 7, 5, 1, 8, 6]

首先我们将2和4比较,4比较大,就保持不变;4和3比较,4比较大,4和3交换位置如下:

[2, 3, 4, 9, 7, 5, 1, 8, 6]

然后4和9比较,保持不变;9和7比较,9大,9和7交换位置,如下:

[2, 3, 4, 7, 9, 5, 1, 8, 6]

然后9和5比较,9大,9和5交换位置,如下:

[2, 3, 4, 7, 5, 9, 1, 8, 6]

9和1比较,9大,9和1交换位置,如下:

[2, 3, 4, 7, 5, 1, 9, 8, 6]

9和8比较,9大,9和8交换位置,如下:

[2, 3, 4, 7, 5, 1, 8, 9, 6]

9和6比较,9大,9和6交换位置,如下:

[2, 3, 4, 7, 5, 1, 8, 6, 9]

至此,经过第1轮“冒泡”之后,列表中最大的数字“9”到了它该到的位置。

以此类推,那么要经过多少轮才能把这个列表中的元素全都排完呢?

如果你不知道,我们可以用一个长度为3的列表来看看:

[3, 2, 1]

① 3 > 2 => [2, 3, 1]; 3 > 1 => [2, 1, 3]; 3到此排完了

② 1 < 2 => [1, 2, 3];2到此排完了,剩下一个1,没有元素和它比较了,于是排序结束。

观察上面这个例子,3个元素列表,只需要经过2轮就能排完了,因为剩最后2个元素的时候,它们互相比较一下,这2个元素的顺序就都好了,所以当一个列表的长度为len(L)时,只需要经过len(L)-1轮,排序就结束了,于是冒泡法的第一行代码就出来了:

for i in range(len(L)-1):  #只需要经过len(L)-1轮,排序即结束,i代表每一轮比较

下面再看看在每轮比较中,一共比较了多少次呢?拿刚开始的列表举例,

[2, 4, 3, 9, 7, 5, 1, 8, 6] ,经过第1轮后,这个列表变为了

[2, 3, 4, 7, 5, 1, 8, 6, 9],下一轮再比较,比到6就好了,不需要再和9比较了,对吧!所以我们发现这个规律:

第1轮,比较了len(L) – 1 = 8次;

第2轮,比较了len(L) – 2 = 7次;

第N轮,比较了len(L) – N次,于是代码又来一行:

    for j in range(1, len(L)-i): #这里面的i就是当前所在的轮数,j表示每一轮要遍历的元素

#这里面range(1, len(L)-i)也可以写成range(0,len(L)-i-1),反正表示的长度是一样的,区别在于
#j的第一个值是0还是1。是0时,先得到第一个元素;是1时,先得到第二个元素。

得到遍历的元素之后,我们就要比较了,相邻的两个元素,如果前面的大于后面的,就让它们的值交换,让大的往后走,一直到这个列表中最大的元素走到列表的最后面,于是:

        if L[j-1] > L[j]:   # 假设当j=1时,这里是第一个元素和第二个元素比较 
            L[j-1], L[j] = L[j], L[j-1]  # Python常用的交换变量写法

好了,我们的冒泡排序就写完了。觉得很短吗?没错,实际逻辑代码就4行。

标配代码

L = [2, 4, 3, 9, 7, 5, 1, 8, 6]

for i in range(len(L)-1):       #只需要经过len(L)-1轮,排序即结束,i代表每一轮比较
    for j in range(1, len(L)-i):#这里面的i就是当前所在的轮数,j表示每一轮要遍历的元素
        if L[j-1] > L[j]:                # 假设当j=1时,这里是第一个元素和第二个比较
            L[j-1], L[j] = L[j], L[j-1]  # Python常用的交换变量写法 

print(L)

还可以写成如下形式,但后续的代码都以上面的为主:

L = [2, 4, 3, 9, 7, 5, 1, 8, 6]
for i in range(len(L)-1):
    for j in range(0, len(L)-i-1): #和上面一样的,只是起始索引变了
        if L[j] > L[j+1]:          #当j=0时,这里是第一个元素和第二个比较
            L[j], L[j+1] = L[j+1], L[j]
print(L)

以上就是冒泡排序的最基础的代码。

完了?还有没有可优化的地方?面前的面试官皱着眉头对你说。你必须回答:有!

想象有这么几种特殊情况情况:

第一种:当遍历一轮发现没有元素交换时,排序即可结束。

[2, 1, 4, 3, 6, 5, 8, 7, 9] 当待排序列表为这个时,经过第1轮排序,就变成了

[1, 2, 3, 4, 5, 6, 7, 8, 9] 此时列表已经完全排好序了,但是呢,程序不知道,还在准备进入第2轮排序,前后比较,1和2比,2和3比,一直到7和8比完,一个也没交换,然后紧接着又要进入第3轮了!!停,在这块是不是可以优化下,我们发现在第2轮比较一遍后,没有任何值发生交换,那是不是可以说明这轮比较的所有元素全都是有序的了,所以才不需要交换。那我们在程序中可以添加个代表是否交换的变量,不交换时值为False,如果交换了就置为True。最后每轮结束,如果值为False就说明这轮没有发生交换,没有发生任何交换就直接结束循环,退出程序,因为这个列表已经排序好了;如果值为True就说明这轮发生交换了,需要进入下一轮,但在进入下一轮遍历前,需要先将已经置为True的变量重新设置为False。如果你想明白了,请看代码:

L = [2, 4, 3, 9, 7, 5, 1, 8, 6]

for i in range(len(L)-1):
    swap = False   #每一轮都要重置一次swap的值,所以要写在这边
    for j in range(1, len(L)-i):
        if L[j-1] > L[j]:
            swap = True   #每次交换就改变swap的值
            L[j-1], L[j] = L[j], L[j-1]
    if not swap: #当swap为False时,退出循环
        break

print(L)

第二种:当列表尾部部分元素提前完成顺序时,不再进行比较。

[4, 2, 3, 5, 1, 6, 7, 8, 9] 当待排序列表为这个时,在第1轮排序中,当5和1交换位置后,

[2, 3, 4, 1, 5, 6, 7, 8, 9] 后面的数已经是有序的了,不再需要交换了,最大的数9归位,第二大的数8也已经归位,但程序进入第二轮后,继续逐个比较并确保8在倒数第二个位置上。其实,在第二轮中只需要比到5的位置就不需要再比较了(或者有个直接的办法就是直接跳过第二三四五轮)。我们可以记录每次交换时最后的那个索引,因为自这个索引之后就没有发生交换了,下次比较的时候只到这个索引,再往后就不比较了。具体代码如下:

L = [2, 4, 3, 9, 7, 5, 1, 8, 6]

maxindex = len(L) #索引的初始值应为原先下面len(L)-i的第一个值,即len(L)
for i in range(len(L)-1):
    for j in range(1, maxindex): #将之前的len(L)-i替换为上次最后赋值的索引ordindex
        if L[j-1] > L[j]:
            L[j-1], L[j] = L[j], L[j-1]
            maxindex = j   #记录索引,多次循环过后,ordindex存储的是最后被赋值的索引    

print(L)

第三种:鸡尾酒排序,双向比较交换,无交换时排序结束。

[2, 3, 4, 5, 6, 7, 8, 9, 1] 当待排序列表为这个时,在第1轮排序中,9和1交换位置后,

[2, 3, 4, 5, 6, 7, 8, 1, 9] 发现前面全都排好了,就差个最小值1,如果是按照冒泡算法,第2轮又要经过N-2次比较,最后1和8交换位置,然后又进入第3轮……这时我们发现现在已经在做无用功了,要是经过第1轮之后,第2轮,直接把1这个数一直向前移,一直移到最前面,那样的话,经过两轮顺序就排好了,这时候配合第一种优化,排好之后直接结束排序就好了,那样会省很多比较。我们观察这个列表的特点,普通冒泡法之所以要经过这么多步骤主要是因为一个列表中最小的数字太靠后了,它没有办法尽快满足第一种优化方案而尽早退出,所以你也可以理解成这个第三种优化是为了更好的实现第一种优化。基于这种特别情况,我们可以采取双向排序的方式,我每次排一个最大值,然后再排一个最小值,让小的值尽量提前往前走,这样有可能提前结束排序。那么这种优化算法有一种专有的名称叫“鸡尾酒排序”。注意,这种优化主要是针对小的值比较靠后的列表。具体代码实现如下:

L = [2, 4, 3, 9, 7, 5, 1, 8, 6]

for i in range(len(L)//2):
    swap = False
    for j in range(i+1, len(L)-i): #起始和结尾都在发生变化
        if L[j-1] > L[j]:
            L[j-1], L[j] = L[j], L[j-1] 
    for k in range(len(L)-i-2,i,-1): #倒序来进行小值往前走的操作
        if L[k-1] > L[k]:
            L[k-1], L[k] = L[k], L[k-1]
            swap = True #只需将交换变量放在最后面的循环即可
    if not swap:
        break

print(L)
#以上代码是 第一种优化+双向交换 = 鸡尾酒排序

目前能想到的优化就这么多了,最后把 第二种和第三种(已包括第一种)再融合一下,凑成

最终优化版本

L = [2, 4, 3, 9, 7, 5, 1, 8, 6]

minindex = 0
maxindex = len(L)
for i in range(len(L)//2):
    swap = False
    for j in range(minindex+1, maxindex): 
        if L[j-1] > L[j]:
            L[j-1], L[j] = L[j], L[j-1] 
            maxindex = j
    for k in range(maxindex-1, minindex, -1):
        if L[k-1] > L[k]:
            L[k-1], L[k] = L[k], L[k-1]
            minindex = k  #当前半段提前完成排序时也不用再比较了
            swap = True          
    if not swap:
        break

print(L)

附加说明:上面那三种优化及最终优化版都只是针对特殊情况的,那如果没有特殊情况,其实效率和普通冒泡法基本上是一样的,后面我会做个测试,看看优化后的版本性能到底性能会好多少。冒泡排序的时间复杂度为O(n²),空间复杂度为O(1),这些是不变的,即使优化后也是如此。但是可以再细算一点,就拿最标配代码看,

3个元素的列表,要比较2+1=3次;

4个元素的列表,要比较3+2+1=6次;

5个元素的列表,要比较4+3+2+1=10次;

N个元素的列表,要比较N(N-1)/2次(找规律应该会吧)。

[公式]

所以这个时间复杂度O(n²)是从  这里来的,算复杂度的时候把系数及低项数去掉了,但是如果两个算法时间复杂度都是O(n²),那么他们的系数的不同就会影响到他们的计算时间了。比如有10个元素的列表,一个比较了100(n²)次,一个比较了50(1/2n²)次,他们的速度还是有差别的。然后再说一下稳定性,一般来说冒泡排序是稳定的,因为相同的元素是直接跳过的,具体对应的代码为【if L[j-1]> L[j]:】,这里是前面比后面大才交换,前面和后面相等或者小于是不交换的,这就保证了冒泡排序的稳定性,但是如果我把这行代码改成 【if L[j-1] >= L[j]:】那它就不稳定了,因为相等的时候,你也把它交换了,这时你也可以说它是不稳定的。但是完全没必要这么做,因为这做了无用功,相等你还交换啥。所以一般情况下,冒泡排序是稳定的排序。

优化测试:我们对上面的标配版冒泡排序和优化版冒泡排序做个测试比较,分别选取元素个数为20、200、2000的随机列表。

import timeit #一个时间测试模块,用来计算一段代码执行num次的执行时间
import random


def nom_sort(L): #标配版函数
    for i in range(len(L)-1):
        for j in range(1, len(L)-i):
            if L[j-1] > L[j]:
                L[j-1], L[j] = L[j], L[j-1]
    return L


def opt_sort(L): #优化版函数
    minindex = 0
    maxindex = len(L)
    for i in range(len(L)//2):
        swap = False
        for j in range(minindex+1, maxindex): 
            if L[j-1] > L[j]:
                L[j-1], L[j] = L[j], L[j-1] 
                maxindex = j
        for k in range(maxindex-1, minindex, -1):
            if L[k-1] > L[k]:
                L[k-1], L[k] = L[k], L[k-1]
                minindex = k
                swap = True          
        if not swap:
            break
    return L


res = []
number = 200  # 20, 200, 2000
M = list(range(0,number))
for i in range(1000): #循环次数 10000 1000 100
    random.shuffle(M) #随机打散
    N = M[:]
    res.append([timeit.timeit('nom_sort(M)', number=1, globals=globals()),
                timeit.timeit('opt_sort(N)', number=1, globals=globals())]  )
    #分别执行两个函数,计算执行number次的总时间,为保证稳定性和公平性用for循环测试多次
print('%.8f %.8f' % tuple([sum([i[j] for i in res])/len(res) for j in range(2)]))
    #计算两个函数的各自平均秒数

### output ###
元素数  循环次数    标配版      优化版     提高效率
20       10000    0.00005097  0.00004247    16.7 %
200      1000     0.00404639  0.00338712    16.3 %
2000     100      0.46240990  0.40061400    13.4 %

总结:根据上面测试分析,优化后的代码大概效率会提升15%左右,而且随着元素数的增加,效率应该会逐渐减小。好了,咱们的冒泡排序差不多就讲完了。下一篇:选择排序。

0 条回应