数据结构基本排序算法和Python实现-----未完!!!!!!!!

本文深入探讨数据排序的各种算法,涵盖插入排序、交换排序、选择排序、归并排序、基数排序等,详细分析每种算法的原理、实现及性能比较。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

                                 数据结构之排序及python实现(第九章)


排序是数据处理中经常使用的一种重要运算。排序就是将文件中的记录进行整理,使之按照关键字进行递增或递减的顺序排列起来。排序的定义如下:

假设含有n个记录的序列为

                                         {R1, R2, ..., Rn}

其相应的关键字的顺序为

                                        {K1, K2, ..., Kn}

需确定1,2,...,n的一种排列p1,p2, ...,pn,使得其相应的关键字满足以下的非递增(或非递减)的关系

                                        Kp1 <= Kp2 <= ... <=Kpn

即n个记录的序列成为一个按关键字有序的序列

                                        {Rp1, Rp2, ..., Rpn}

需要注意的是,用来进行排序依据的关键字,可以是数字类型,也可以是字符类型,关键字的选择需要根据问题的具体要求而定。

当待排序记录的关键均不相同时,排序的结果是唯一的,否则排序结果不唯一。在排序过程中,若整个文件都是放在内存中处理,排序时不涉及数据的内、外存交换,则称为内部排序,简称为内排序;反之,若排序过程中要进行数据的内、外存交换,则称为外部排序,简称外排序。

在计算机及其应用系统中,排序上所花费的时间在系统运行时间中所占比例较大;并且排序本身对推动算法分析的发展也起着很大作用。

备注:以下算法的具体代码实现中,注释所描述的子序列即是子有序序列。

 

                                                                 9.1   插 入 排 序

顾名思义,插入排序算法就是将记录插入到适当的位置,当所有记录都插入到适当的位置时,记录就排好序了。根据选择位置的策略不同,插入排序又分为直接插入排序、2路插入排序和希尔排序。

9.1.1 直接插入排序

直接插入排序(straight  insertion  sort)是最简单的排序方法之一,它的基本操作是将一个记录插入到有序表中的恰当位置,从而得到一个记录增 1 的有序表。对于第一个记录,直接放置就可,从第二个记录开始执行其基本操作,直到所有记录都插入到有序表中,则排序结束。

将数组 record 中的记录进行从小到大的顺序排序。对于前两个元素,即 record[0]和record[1]。如果它们的次序颠倒,则交换,得到一个有序表,比如,{record[1], record[0]}。对于record[2],则需要比较 record[2]是否小于record[1],若小于则将 record[2]插入到 record[1]的前面,得到有序表 {record[2], record[1], record[0]},否则需要比较 record[2]是否小于record[0]。对后续记录用同样的方法插入到有序表中,从而完成排序。

例如,已有有序表{1, 3, 7, 13, 16},需要插入的数为9,从左到右寻找第一个比9大的数13,将13以及后面的记录依次后移一位,将9插入到空位;或者从右到左寻找第一个比9小的数7,将后面的数依次后移一位,将9插入到空位,得到有序表{1, 3, 7, 9, 13, 16}。在寻找插入位置时,为了防止数组下标越界,不让数组的第一个位置 record[0]存放有序表的记录,应设置监视哨。一趟直接插入排序的算法如程序清单 9-1 所示。

程序清单 9-1     一趟直接插入排序的算法

"""
一 9.1 一趟直接插入排序算法
lis_old为有序表,i为待插入lis_old的记录,length=len(lis_old) - 1 ,lis_new为长度为len(lis_old) + 1,值为任意元素的列表
"""
def straipass(lis_old, i, lis_new, length):

    lis_new[0] = i  #设置监视哨    备注:完整算法中并没有用到
    #从右到左查找第一个比i小的位置
    while i < lis_old[length]:
        if length==len(lis_old) -1:  #为了严密性,一趟算法才多了if else 的if子句。其实完整的算法无需此if子句
            lis_old.insert(len(lis_old), i)
        else:
            lis_new[length +1] = lis_old[length]
        length-=1
    lis_new[length +1] = i  #将i插入到合适的位置。注释:此时的lis_old的length位置元素 是从右往左比较的 一个比i小
#测试
lis_old =[1, 3, 7, 13, 16]
i = 9
length=len(lis_old) - 1
lis_new=[None for j in range(len(lis_old) + 1)]
straipass(lis_old, i, lis_new, length)
print('lis_new---------:',lis_new)

#结果:
#lis_new---------: [9, None, None, 9, 13, 16]

整个排序过程为 n-1 趟插入,第一个记录可以看作是一个有序表的子序列,所以从第二个记录开始执行插入,直到第n个记录被插入到有序表中。

在进行插入排序时不需要将有序表用新的数组来存放,可以复用原来存放待排序记录的数组。算法如程序清单 9-2 所示。

 

程序清单 9-2   直接插入排序的算法

#一 9.2 直接插入排序算法        备注:因为待排序列表是数值型列表,所以每趟的关键字即是每趟要插入的元素
#把第一个记录先看成是有序子序列,然后从第二个记录开始,逐次与前面子序列中的所有记录比较
def straight_sort(lis):
    counts=len(lis)
    for i in range(1, counts):
        key=lis[i]
        j=i-1
        while key < lis[j]:
            lis[j+1]=lis[j]
            j-=1
            if j < 0:
                break
        lis[j+1]=key

        print('第{i}趟关键字:{key}\t\t{lis}'.format(i=i, key=key ,lis=lis))

#测试
lis=[4, 3, 1, 19, 7, 12, 13, 16]
print('原列表lis---------:',lis)
straight_sort(lis)
print('排序结束lis--------:',lis)

#结果
'''
原列表lis--------------: [4, 3, 1, 19, 7, 12, 13, 16]
第1趟关键字:3		[3, 4, 1, 19, 7, 12, 13, 16]
第2趟关键字:1		[1, 3, 4, 19, 7, 12, 13, 16]
第3趟关键字:19		[1, 3, 4, 19, 7, 12, 13, 16]
第4趟关键字:7		[1, 3, 4, 7, 19, 12, 13, 16]
第5趟关键字:12		[1, 3, 4, 7, 12, 19, 13, 16]
第6趟关键字:13		[1, 3, 4, 7, 12, 13, 19, 16]
第7趟关键字:16		[1, 3, 4, 7, 12, 13, 16, 19]
排序结束lis------------: [1, 3, 4, 7, 12, 13, 16, 19]
'''

或者

直接插入排序算法:

#一趟直接插入排序算法:
def straightpass(lis, key, leng):
    index=leng-1
    while key < lis[index]:
        lis[index + 1]=lis[index]
        index -=1
        if index == -1:
            break
    lis[index + 1]=key

#直接插入排序完整算法
def straight_sort(lis):
    for i in range(1, len(lis)):
        straightpass(lis, lis[i], i)

#测试
lis=[4, 3, 1, 19, 7, 12, 13, 16]
print('原列表lis---------:',lis)
straight_sort(lis)
print('排序结束lis--------:',lis)
#结果
"""
原列表lis---------: [4, 3, 1, 19, 7, 12, 13, 16]
排序结束lis--------: [1, 3, 4, 7, 12, 13, 16, 19]
"""

从图 9.1 可以看到,直接插入排序算法非常简洁,也很容易理解。下面从效率方面对直接插入排序进行分析。

(1)从空间来看,它需要一个记录的辅助空间用于存放监视哨。

(2)从时间来看,排序的基本操作为比较记录的关键字大小和移动记录。下面来看一下一趟插入排序,比较记录关键字的次数取决于当前需要插入的第i个与已经排好序的前 i-1 个元素的关系。最好的情况是第i个记录比前 i-1 个记录都大,则只需要进行依次比较;最坏情况是第i个记录比前 i-1 个记录都小。最好情况是待排序序列按非递减有序排列(即正序)给出,则保证每一趟插入都只是比较一次,无需移动记录,整个过程需要比较 n-1 次, 无需移动记录。最坏情况是待排序序列按非递增有序排列(即逆序),则每一趟插入要比较i次(i-1次和前 i-1 个记录的比较,以及1次和监视哨的比较),移动数据 i-1 次,整个排序过程需要比较\sum_{i=2}^{n}i次,移动数据\sum_{i=2}^{n}(i-1)次。如果待排序序列以完全随机方式给出,可以取两者的平均值,所以直接插入排序算法的时间复杂度为O(n^{2})。

9.1.2  折半插入排序

前面讲到直接插入排序的算法比较简洁,易于实现,当待排序的记录数不大时,这种算法是一种不错的选择,但当待排序的记录数很大时,则不宜再用直接插入排序,需要更快的排序算法。直接插入排序中的两个基本操作,比较记录的关键字和移动记录。折半排序就是针对比较记录的关键字的整个基本操作进行改进。

在直接插入排序算法中查找插入位置时,采用的顺序查找。前面讲到的顺序表的查找方法中,折半查找方法要由于顺序查找方法,所以现在讲到的折半插入排序就是用折半查找方法代替直接插入算法中顺序查找方法。一趟折半插入排序算法如程序清单 9-3所示。

程序清单 9-3      一趟折半插入排序的算法

#一趟折半插入排序的算法
#lis是子序表,key是要插入lis的记录的关键字(这里就是记录本身)
def one_banpass(lis,key):

    leng=len(lis)

    #初始化搜索的边界,即子序表lis的索引范围
    low=0
    high=leng-1

    #查找带插入的位置
    while low <= high:
        middle=(low + high)//2
        if key < lis[middle]:
            high=middle-1
        else:
            low=middle+1
    #循环结束后的low或(high + 1)就是插入位

    #循环结束后,后移lis中插入位置之后(包括插入位置)的所有记录,留出空位。 备注:从最后的记录开始后移
    for j in range(leng-1, low-1,-1): #即索引: leng-1, leng-2, ..., low
        lis[j+1]=lis[j]

    #将记录插入空位
    lis[low]=key
    #或
    #lis[high+1]=key

折半查找插入位置的分析过程

 

移位分析:
循环结束,说明找到了插入位置,随后进行移位:因为要插入的位置是low,所以子序表从low索引开始都要后移一位,并且是从最后一位元素开始后移。

整个排序和直接插入排序基本相同,只是将直接插入排序的一趟排序算法由折半插入排序的算法一趟排序算法来替换。算法入程序清单 9-4 所示:

 程序清单 9-4   折半插入排序的算法

def banpass (lis):

    #一般把第一个记录先看成是有序子序列,然后从第二个记录开始,逐次与前面子序列中的所有记录去比较,折半插入
    for leng in range(1,8): #leng即是记录要插入的子序表的长度,同时也是 key 在 lis 中的索引位置
        key = lis[leng]
        low = 0
        high = leng -1
        #当 low>high,则找到了插入位置,为 low或者 high+1
        while (low <= high) :
            #//循环计算 middle的值
            middle=(low + high)//2
            #待插入记录和 mid进行比较
            if (key < lis[middle]) :
                #改变 high
                high = middle - 1
            else:
                #//改变 low
                low = middle + 1

        #循环结束,说明找到了插入位置,进行移位:
        #  因为要插入的位置是low,所以子序表从low索引开始都要后移一位,并且是从最后一位元素开始移

        for j in range(leng -1, low -1 ,-1):
            lis[j + 1] = lis[j]

        #//插入
        lis[low] = key
        #或
        # list[high + 1] = key

#测试:
lis=[4, 3, 1, 19, 7, 12, 13, 16]
banpass(lis)
print('折半插入排序后的lis:',lis)
"""
结果:
折半插入排序后的lis: [1, 3, 4, 7, 12, 13, 16, 19]
"""



#一趟折半插入排序的算法
#lis是子序表,key是要插入lis的记录的关键字(这里就是记录本身)
def one_banpass(lis,key):

    leng=len(lis)

    #初始化搜索的边界
    low=1
    high=leng

    #查找带插入的位置
    while low <= high:
        middle=(low + high)//2
        if key < lis[middle]:
            high=middle-1
        else:
            low=middle+1
    #循环结束后的low或(high + 1)就是插入位

    #循环结束后,后移lis中插入位置之后(包括插入位置)的所有记录,留出空位。 备注:从最后的记录开始后移
    for j in range(leng-1, low-1,-1): #即索引: leng-1, leng-2, ..., low
        lis[j+1]=lis[j]

    #将记录插入空位
    lis[low]=key
    #或
    #lis[high+1]=key

从 9-4 程序清单  9-4  所示的算法可以看到算法的两个基本操作:比较记录关键字通过利用折半查找算法得到了改善;移动记录的次数和直接插入排序一样,所以折半插入排序的时间复杂度还是O(n^{2})。

或者 

程序清单如下:

#一趟折半插入排序算法:
def one_banpass(lis, key, leng):

    low=0
    high=leng-1

    while (low <= high):
        middle = (low + high) // 2
        if (key < lis[middle]):
            high = middle - 1
        else:
            low = middle + 1

    for j in range(leng - 1, low - 1, -1):
        lis[j + 1] = lis[j]

    lis[low] = key

#折半插入排序完整算法:
def banpass(lis):
    for i in range(1, len(lis)):
        one_banpass(lis, lis[i], i)

#测试:
lis=[4, 3, 1, 19, 7, 12, 13, 16]
banpass(lis)
print('折半插入排序后的lis:',lis)
"""
结果
折半插入排序后的lis: [1, 3, 4, 7, 12, 13, 16, 19]
"""

9.1.3   2路插入排序

前面将的折半插入排序是针对直接插入排序中比较记录关键字这个基本操作进行的优化,而 2路插入排序则是在折半排序的基础上对移动记录这个基本操作进行的优化。

2路插入排序的做法是用待排序的序列的第一个记录record[1]将排序任务分成两部分,第一部分的所有记录都比 record[1]小,第二部分的所有记录都比record[1]大。这样将一个比较大的排序任务,分解成两个较小的排序任务,从而减小了记录的移动次数。例如,待排序的一个序列位逆序排列,序列长度为n,则移动次数为\sum_{i=2}^{n}(i-1);而分解后(假设最好情况下,record[1]为排序后处于中间位置的记录)移动次数变为2*\sum_{i=2}^{n/2}(i-1)

一趟 2路插入排序的算法如程序清单  9-5 所示。

程序清单 9-5  一趟 2路插入排序的算法

#2路插入排序一趟算法。
# 思想:tem中的后面某些位置留给比ref小的记录less用来移位,然后再把less插入空位;
        #前面的位置留给比ref的大的记录great用来移位,然后把great插入空位。
        #相当于记录可以从两端移动位置,即双向移位。相比折半插入减少了记录移动次数
#lis:待排序列表;
#key:待插入记录关键字,在这里就记录本身;

def twopass(lis, key): #!!!注意:这里的参数lis就是原待排序列表,这是与直接插入排序和折半插入排序一趟算法的不同点   
    '''备注:在完整算法中,以下两行变量定义需要注释掉'''
    #2路移动记录。    备注:每趟 tem中first和final之间不会有lis中的元素
    first=len(tem)#每趟排序后最小值在tem中的索引(初始值例外)。初始值置为tem的长度
    final=0##每趟排序后最大值在tem中的索引。初始值置0

    global first, final#在完整算法中需要新增的代码

    ref=lis[0]#参照,即假设lis排序后其处于中间位置
    #插左边
    if key < ref:
        #折半查找插入位置,即在first~(len(tem) -1 )之间找插入位
        low = first
        high = len(tem) - 1
        while low <= high:
            middle = (low + high) // 2
            if key < tem[middle]:
                high = middle - 1
            else:
                low = middle + 1
        #前移记录
        for j in range(first, low):  # 插入位置是low,所以low之前(不包括low!!!)的记录前移
            tem[j - 1] = tem[j]
        first -= 1
        #插入记录
        tem[low-1]=key

    #插右边
    else:
        #折半查找插入位置,即在0~final之间找插入位
        low = 0
        high = final
        while low<= high:
            middle = (low + high) // 2
            if key < tem[middle]:
                high = middle - 1
            else:
                low = middle + 1

        #后移记录
        for j in range(final, low -1, -1):  # 插入位置是low,所以low之后(包括low!!!)的记录后移
            tem[j + 1] = tem[j]
        #插入记录
        tem[low]=key
        final += 1

2路插入完整算法如程序清单 9-6 所示。

#2路插入排序一趟算法。
# 思想:
        #定义一个长度等于待排序lis长度的辅助空间:tem数组
        #把tem看作是循环数组,tem中的后面某些位置留给比ref(参照值,即lis[0])小的记录less用来移位,然后再把less插入空位;
        #前面的位置留给比ref的大的记录great用来移位,然后把great插入空位;
        #并且设置first和final分别记录每趟排序后tem中最小值和最大值的索引。first初始值是len(tem);fianl初始值是0。
        #相当于记录可以从两端移动位置,即双向移位。相比折半插入减少了记录移动次数
#lis:待排序列表;
#key:待插入记录关键字,在这里就记录本身;


#一趟算法
def twopass(lis,key): #!!!注意:这里的参数lis就是原待排序列表。这是与直接插入排序和折半插入排序 的一趟算法不同点

    global first, final

    ref=lis[0]

    #插左边
    if key < ref:
        #折半查找插入位置,即在first~(len(tem) -1 )之间找插入位
        low = first
        high = len(tem) - 1
        while low <= high:
            middle = (low + high) // 2
            if key < tem[middle]:
                high = middle - 1
            else:
                low = middle + 1
        #移动记录
        for j in range(first, low):  # 插入位置是low,所以low之前(不包括!!!)的记录前移
            tem[j - 1] = tem[j]
        first -= 1
        #插入记录
        tem[low-1]=key

    #插右边
    else:
        #折半查找插入位置,即在0~final之间找插入位
        low = 0
        high = final
        while low<= high:
            middle = (low + high) // 2
            if key < tem[middle]:
                high = middle - 1
            else:
                low = middle + 1

        #移动记录
        for j in range(final, low -1, -1):  # 插入位置是low,所以low之后(包括low!!!)的记录后移
            tem[j + 1] = tem[j]
        #插入记录
        tem[low]=key
        final += 1

    print('每趟排序后的first:{};final:{};辅助列表tem:{}'.format(first, final, tem))

#2路插入完整算法

def twosort(lis):

    for i in range(1, len(lis)):
        twopass(lis,lis[i])

    # 用排序后的tem的两个子序列(即:tem[:final+1]和tem[first:]。注意:排序结束后first=final+1)的元素替换原列表lis中的元素
    n = 0
    for i in range(first, len(tem)):
        lis[n] = tem[i]
        n += 1
    m = len(tem) - first
    for i in range(0, final + 1):
        lis[m] = tem[i]
        m += 1
    print('2路插入排序后的lis:', lis)

#测试
lis=[4, 3, 1, 19, 7, 12, 13, 16]
tem=[0 for i in range(len(lis))] #用来存放每趟排序后子序列元素的辅助空间
tem[0]=lis[0]#tem的第一个值也就是参照值

first = len(tem)  # 每趟排序后最小值在tem中的索引(初始值例外)。初始值置为tem的长度
final = 0  #每趟排序后最大值在tem中的索引。初始值置0

print('游标的初始值first:{};final:{};tem的初始值:{}'.format(first, final, tem))

twosort(lis)

#结果
"""
游标的初始值first:8;final:0;tem的初始值:[4, 0, 0, 0, 0, 0, 0, 0]
每趟排序后的first:7;final:0;辅助列表tem:[4, 0, 0, 0, 0, 0, 0, 3]
每趟排序后的first:6;final:0;辅助列表tem:[4, 0, 0, 0, 0, 0, 1, 3]
每趟排序后的first:6;final:1;辅助列表tem:[4, 19, 0, 0, 0, 0, 1, 3]
每趟排序后的first:6;final:2;辅助列表tem:[4, 7, 19, 0, 0, 0, 1, 3]
每趟排序后的first:6;final:3;辅助列表tem:[4, 7, 12, 19, 0, 0, 1, 3]
每趟排序后的first:6;final:4;辅助列表tem:[4, 7, 12, 13, 19, 0, 1, 3]
每趟排序后的first:6;final:5;辅助列表tem:[4, 7, 12, 13, 16, 19, 1, 3]
2路插入排序后的lis: [1, 3, 4, 7, 12, 13, 16, 19]
"""

简洁代码如下:

def twosort(lis):
    ref = lis[0]#参照值
    tem = [0 for i in range(len(lis))]  # 用来存放每趟排序后子序列元素的辅助空间
    tem[0] = lis[0]  # tem的第一个值也就是参照值
    first = len(tem)  # 每趟排序后最小值在tem中的索引(初始值例外)。初始值置为tem的长度
    final = 0  ##每趟排序后最大值在tem中的索引。初始值置0

    for i in range(1, len(lis)):
        key=lis[i]#待插记录

        # 插左边
        if key < ref:
            low = first
            high = len(tem) - 1
            while low <= high:
                middle = (low + high) // 2
                if key < tem[middle]:
                    high = middle - 1
                else:
                    low = middle + 1
            # 前移记录
            for j in range(first, low):
                tem[j - 1] = tem[j]
            first -= 1
            # 插入记录
            tem[low - 1] = key

        # 插右边
        else:
            low = 0
            high = final
            while low <= high:
                middle = (low + high) // 2
                if key < tem[middle]:
                    high = middle - 1
                else:
                    low = middle + 1
            # 后移记录
            for j in range(final, low - 1, -1):
                tem[j + 1] = tem[j]
            # 插入记录
            tem[low] = key
            final += 1

    # 用排序后的tem的两个子序列(即:tem[:final+1]和tem[first:]。注意:排序结束后first=final+1)的元素替换原列表lis中的元素
    n = 0
    for i in range(first, len(tem)):
        lis[n] = tem[i]
        n += 1
    m = len(tem) - first
    for i in range(0, final + 1):
        lis[m] = tem[i]
        m += 1
    print('2路插入排序后的lis:', lis)

#测试
lis=[4, 3, 1, 19, 7, 12, 13, 16]
twosort(lis)

#结果
"""
2路插入排序后的lis: [1, 3, 4, 7, 12, 13, 16, 19]
"""

通过上面的讲解可知,2路插入排序虽然通过增加空间开销的方法(使用n个记录的辅助空间)减少了移动次数,但算法的复杂度仍然是O(n^{2}),并且当第一个记录在排序好的序列中的位置不是中间位置时,会影响2路插入排序的优化效果,最坏情况是第一个记录是最大的或最小的,这时2路插入排序没有任何优势。那么有没有更好的优化办法呢?下一节将讲解表插入排序,即通过使用另一种数据结构来去除记录移动操作。

9.1.4   希尔排序

前面讲到直接插入排序的时间复杂度度为O(n^{2}),但在下面这两种情况下效率还是比较高的:

一是待排序序列基本有序时。极端情况下,当待排序列为正序时算法的时间复杂度为O(n^{})。

二是在n^{}值比较小时。对于n^{}较大,并且待排序列无序时,直接插入排序就不太使用了。

如果能将整个待排序序列划分成小的子序列,利用直接插入排序在子序列上高效地排序,进而得到一个大的基本有序序列,然后在整个基本有序的序列上在使用直接插入排序进行排序,则会得到一个高效的排序算法。下面讲到的希尔排序就是基于这种思路设计出来的排序算法。

希尔排序(Shell's sort)又称为缩小增量排序(diminishing increment sort),这种插入排序与前几种排序方法进行比较,其算法时间复杂度有了较大改进。该算法使用一个增量序列(increment sequence)h_{1},h_{2},...,h_{l},并且当 i<jh_{i}< h_{j}。希尔排序是把记录按下标的一定增量分组,对每组使用直接插入排序算法排序;随着增量逐渐减少,每组包含的关键词越来越多,当增量减至1时,整个文件恰被分成一组,算法便终止。

下面给出一趟希尔排序的算法,如程序清单 9-7 所示。需要说明的是,不像直接插入排序、折半排序和2路插入排序的一趟算法,每一趟以处理一个待排序记录为标志;希尔排序的一趟算法,每一趟不是处理一个待排序记录,而是处理递增序列中的一个元素。同时希尔排序的算法有一个返回值(备注:我自己实现的没有返回值),将一趟排序的结果返回,这是因为希尔排序的每一趟排序要用上一趟排序的结果作为输入。

下面用一个例子来说明希尔排序的过程,如图 9-3 所示,待排序列为 [4, 3, 1, 19, 7, 12, 13, 16],增量序列为[1, 2, 3, 5] ,第一趟排序,选用增量h_{4} = 5,每隔5个元素得到一个子序列,得到 [4, 12][3, 13][1, 16],分别对每个子序列进行排序,得到一趟排序的结果。第二趟排序,以第一趟排序的结果为输入,选用增量h_{3} = 3,进行第二趟排序。第三、四趟排序类似,最后就是排好序的序列[1, 3, 4, 7, 12, 13, 16, 19]

程序清单  9-7    一趟希尔排序的算法

#希尔排序。备注:每个增量结束后的输出序列就是下一个增量要操作的输入序列

#一趟算法:
        #lis是待排序列表,length是lis的长度,h是增量序列hlis中的值。备注:从hlis中倒取增量值/

def shellpass(lis, h, length):

        #每趟最多取h个子待排表
        for i in range(h):
            if i + h > length - 1:
                break

            #获取增量为h的第i个子待排序表,并使用直接插入排序法为之排序
            for j in range(i, length, h):#此子待排序列表从lis中第i个索引位置开始取元素
                #直接插入排序。
                leng=int((j-i)/h)#int( (j-i)/h )为增量为h的第i子待排序表的子序表长度,lis[j]就是要在此长度的子序表中找插入位置
                key=lis[j]#待插入记录
                index_h=leng-1
                while key < lis[i + index_h * h] and index_h>=0:
                    lis[i + (index_h+1)* h]=lis[i + index_h * h]
                    index_h-=1
                lis[i+(index_h+1)*h]=key
            print('增量h:{}\t每个子待排序序列排序后lis:{}'.format(h, lis))
        print('=='*30)

希尔排序算法如程序清单 9-8 所示

程序清单 9-8  希尔排序的算法

#希尔排序算法
def shellsort(lis,hlis):
    hlen=len(hlis)
    length=len(lis)

    for n in range(hlen-1,-1,-1):
        shellpass(lis,hlis[n],length)

    print('希尔排序后的lis:{}'.format(lis))


#测试
hlis=[1,2,3,5]
lis=[4, 3, 1, 19, 7, 12, 13, 16]
shellsort(lis,hlis)

#结果
"""
增量h:5	每个子待排序序列排序后lis:[4, 3, 1, 19, 7, 12, 13, 16]
增量h:5	每个子待排序序列排序后lis:[4, 3, 1, 19, 7, 12, 13, 16]
增量h:5	每个子待排序序列排序后lis:[4, 3, 1, 19, 7, 12, 13, 16]----->备注:下一个增量3的输入
============================================================
增量h:3	每个子待排序序列排序后lis:[4, 3, 1, 13, 7, 12, 19, 16]
增量h:3	每个子待排序序列排序后lis:[4, 3, 1, 13, 7, 12, 19, 16]
增量h:3	每个子待排序序列排序后lis:[4, 3, 1, 13, 7, 12, 19, 16]----->备注:下一个增量2的输入
============================================================
增量h:2	每个子待排序序列排序后lis:[1, 3, 4, 13, 7, 12, 19, 16]
增量h:2	每个子待排序序列排序后lis:[1, 3, 4, 12, 7, 13, 19, 16]----->备注:下一个增量1的输入
============================================================
增量h:1	每个子待排序序列排序后lis:[1, 3, 4, 7, 12, 13, 16, 19]----->备注:最终结果
============================================================
希尔排序后的lis:[1, 3, 4, 7, 12, 13, 16, 19]
"""

从上面可以看到,希尔排序的思路清晰,使用分支策略,将比较长的序列分成较小的的子序列来处理,实现起来也比较简洁。但是需要注意的是,希尔排序有这样一个重要的性质(这里只给出而不证明):即 h_{k} 排序好了也就是说所有相隔 h_{k} 的记录都排序好了,在以后的多趟排序中,h_{k} 的排序性不能被破坏(备注:所以才从最大增量\rightarrow最小增量依次截取排序子序列)。这个性质可以说是希尔排序的基础,如果不是这样,那么前面的排序就会被后面的各趟排序打乱。

从上面的算法可以看出,希尔排序并没有限制递增序列h必须是什么样子,只是说满足 h_{1} = 1 的递增的整数序列就可以,也就说希尔排序可以用不同的递增序列 h 来排序。事实上递增序列 h 在很大程度上决定了希尔排序的算法的性能。

也正因为希尔排序对增量序列的依赖,它的时间复杂度是所选取的增量序列的函数,这又涉及到一些数学上尚未解决的难题,因此目前没有一种最好的增量序列。但在实践中已经取得了一些局部结论,比如,Sedgewick 提出了几种增量序列,其最坏时间复杂度为O\left ( n^{3/4} \right ),其中最好的序列为 \left \{ 1, 5, 9, 41, 109, ... \right \}。在实际应用中把这些之直接放到数组中用就可以了。

希尔排序是在实际应用中经常被选用的算法,即使对于数以万计的 n,它仍能表现出很好的性能。

由于多次插入排序,我们知道一次插入排序是稳定的,不会改变相同元素的相对顺序,但在不同的插入排序过程中,相同的元素可能在各自的插入排序中移动,最后其稳定性就会被打乱,所以shell排序是不稳定的。

 

                                                   9.2  交换排序

顾名思义,交换排序就是两两比较待排序记录的关键字,发现两个记录的次序相反时进行交换,知道没有反序的记录为止。按照排序的不同策略,交换排序可以分为冒泡排序和快速排序两种。

9.2.1 冒泡排序

冒泡排序是交换排序算法中最简单的一种。下面以一个例子来说明这一算法的过程。待排序列序列为 \left \{ 4, 3, 1, 19, 7, 12, 13, 16 \right \},在一趟排序过程中需要两个指针分别指向当前位置和下一个位置,当前记录的关键字小于下一个位置记录的关键字时交换记录(否则不换),同时将两个指针后移一个位置。一趟排序的过程如图 9-4 所示。

每一趟排序都会有一个关键字最大的记录排到最后面,下一趟排序就不包括这个记录了。如果第一趟排序是在序列\left \{ 4, 3, 1, 19, 7, 12, 13, 16 \right \} 中进行的,第二趟排序就不包括记录 19 了,而是在记录 \left \{ 4, 3, 1, 7, 12, 13, 16 \right \} 中进行,整个排序过程如图 9.5所示。需要注意的是第三趟排序,这一趟排序中没有进行交换操作,这就代表序列已经是有序的了,这也是排序结束的标志。

下面给出一趟冒泡排序的算法,如程序清单 9-9 所示。该算法中使用了一个临时变量 isFinish 来标识该趟算法中是否由交换操作,如果没有则 isFinish 的值仍为初始值 True 不变,否则重新设置为 False

程序清单 9-9   一趟冒泡排序的算法

#冒泡排序

#一趟冒泡排序算法
def bubblepass(sublis, sublength):
    #sublis:待排序列lis的子待排序列,即已经排出前m大个记录后的lis序列;sublength:剩余元素的个数
    isFinish=True

    for j in range(sublength -1):
        if sublis[j]>sublis[j+1]:
            sublis[j+1],sublis[j]=sublis[j],sublis[j+1]
            isFinish=False

    print('第{}趟排序后lis:{}'.format( (len(sublis)-sublength + 1), sublis))
    return isFinish

整个冒泡排序算法如程序清单 9-10 所示。注意当 bubblepass 的返回值为 True 时,即该趟排序没有进进行交换操作时,表示排序完成。

程序清单 9-10  冒泡排序的算法

#冒泡排序算法

def bubblesort(lis, length): #lis:待排序列表;length:lis的长度

    for i in range(length, 1, -1):
        if bubblepass(lis, i):
            return

#测试
lis=[4, 3, 1, 19, 7, 12, 13, 16]
print('\t原序列lis:{}'.format(lis))
bubblesort(lis, len(lis))

#结果
"""
	原序列lis:[4, 3, 1, 19, 7, 12, 13, 16]
第1趟排序后lis:[3, 1, 4, 7, 12, 13, 16, 19]
第2趟排序后lis:[1, 3, 4, 7, 12, 13, 16, 19]
第3趟排序后lis:[1, 3, 4, 7, 12, 13, 16, 19]
"""

下面分析冒泡排序的效率。当待排序序列为正时,只进行一趟排序就可以完成排序。排序过程中只需要进行 n-1 次比较,不进行交换操作。当待排序序列为逆序列时,需要进行 n-1 趟排序,排序过程中需要进行  \sum_{i=2}^{n}\left ( i-1 \right )  次比较,并作相同次数的交换操作。所以冒泡排序算法总的时间复杂度度为O\left (n ^{2} \right )

9.2.2  快速排序

快速排序(quick sort)是已知排序算法中速度最快的,平均时间复杂度为 O\left ( n \right \log_{2}{n}) ,最坏复杂度:O\left ( n^{2} \right )。用快速排序算法对序列 S 进行排序可分成以下 四 步

(1)如果 S 中只有 1 个或 0 个元素,则返回。

(2)在 S 中任意取一个元素 v ,称为枢纽元(pivot)。

(3)将  S-{v} 分成两个不相交的集合:S_{1}=\left \{ x\varepsilon S-\left \{ v \right \} | x\leqslant v\right \} 和 S_{2}=\left \{ x\varepsilon S-\left \{ v \right \} | x> v} \right \}

(4)返回排序结果:\left \{ quicksort\left (S _{1} \right ), v, quicksort \right \}

下面以一个例子来说明快速排序算法的过程,如图 9.6 所示,待排序列表序列为 \left \{ 4, 3, 1, 19, 7, 12, 13, 16 \right \} 。对子集合排序是一个递归调用。

从上面可以看出,快速排序算法是一个递归算法,第(1)步是递归结束的条件,第(4)步是进行递归调用,而第(2)、(3)步时算法的关键步骤,关系到算法的性能,但第(2)、(3)步的处理不是唯一的,可以有不同的取枢纽元的策略,对集合的分割也可以有多种策略。

选取枢纽元时希望得到规模相当的 S_{1} 和 S_{2} ,就像保持二叉搜索树平衡一样。如果选区的枢纽元是所有记录中的最小的或最大的,从而把数据都划入到 S_{1}S_{2} 中,则快速排序算法就会退化为冒泡排序算法,这是不愿意看到的结果,所以对枢纽元的选择策略直接影响快速排序的性能。

有一种最简单的选择枢纽元的方法是选择待排序序列的第一个或最后一个记录,这种方法在序列完全随机的情况下是可以的,但在实际应用中待排序列是经过预排序的(或者大段的数据是预排序的),此时盲目选择第一个记录作为枢纽元会使得划分的子集的规模严重失衡,甚至这种情况会发生在所有的递归调用中使得快速排序的时间花费是2次幂的。

另一种比较直接的做法是使用随机数发生器来随即地选取枢纽元,这种做法比较稳定,随机选取地枢纽元不可能连续不断的产生很差的划分,但生成随机数是一种昂贵的操作,过多地使用不利于减少平均运行时间。

第三种是实际中比较常用的三数中值分割法(median-of-three partitioning)。枢纽元的最佳选择是序列的中值,但这会明显降低排序的性能,所以用三个数值的中值作为中值的估量值。在实际应用中随机性没有太大的作用,并且获取随机数的操作比较费时,所以取待排序列的第一个记录、位置位于中间的记录和最后一个记录的中值。例如,待排序列\left \{ 4, 3, 1, 19, 7, 12, 13, 16 \right \} ,取 record[0]=4record[3]=19record[-1]=16 的中值 16 为枢纽元,这样就可以处理输入数据为预排序的情况。实践证明,使用三数中值分割法使得快速排序的性能提高了大约 5% 。

下面给出了使用三数中值分割法获取枢纽元的算法,如程序清单 9-11 所示。该算法除了获取枢纽元外,还会将枢纽元放在待排序记录的最后。

#三数中值分割法获取枢纽元的算法
def getPovit(lis, left, right): #lis:待排序列;left:lis第一个元素的索引位;right:lis最后一个元素的索引位
    center=(right + left)//2 #lis中值的索引位

    #找lis[left]、lis[center]和lis[right],并把它移动到lis的最后位置。
    if lis[left] >= lis[center]:
        if lis[center] >= lis[right]:#中值是lis[center]
            lis[center], lis[right] = lis[right], lis[center] #center 和 right 换位
        else:
            if lis[left] >=lis[right]:#中值是lis[right]
                pass #无须换位
            else:#中值是lis[left]
                lis[left], lis[right] = lis[right], lis[left] #left 和 right 换位

    else:#即lis[center] > lis[left]
        if lis[left] >= lis[right]:#中值是lis[left]
            lis[left], lis[right] = lis[right], lis[left]  # left 和 right 换位
        else:
            if lis[center] >= lis[right]:#中值是lis[right]
                pass #无须换位
            else:#中值是lis[center]
                lis[center], lis[right] = lis[right], lis[center]  # center 和 right 换位

    #将pivot放在倒数第一的位置,除了可以得到povit的右值,还能得到其左值
    return lis[right]

#测试
lis=[4, 3, 1, 19, 7, 12, 13, 16]
print('枢纽元:',getPovit(lis, 0, len(lis)-1))
print('枢纽元与最后一个记录交换后的lis:',lis)

#结果:
"""
枢纽元: 16
枢纽元与最后一个记录交换后的lis: [4, 3, 1, 19, 7, 12, 13, 16]
"""

对集合分割采用下面的策略:

(1)将枢纽元与最后一个记录交换,这样做的目的是让枢纽元 povit 离开需要分割的数据集合。

(2)需要两个指针 first last ,first 指向第一个元素record[0]last 指向倒数第二个元素 record[-2]

(3)first 向后移动第一个比枢纽元 pivot 大的元素,last 向前移动到第一个比枢纽元 pivot 小的元素。交换 firstlast 指针所指的记录。

(4) 重复步骤(3),直到 first last 相互错开,即 first 位于 last 的后面。将 first 所指记录 与 最后一个记录(即枢纽元 pivot)交换。

下面以一个例子来说明快速排序算法中集合分割策略操作的过程,如图 9.7 所示。处理序列为 \left \{ 8, 4, 3, 1, 19, 7, 12, 13, 16 \right \},枢纽元素为8。(备注:此枢纽元素不是通过三数值中值法来获取)

下面给出快速排序的算法,如程序清单 9-12 所示。

程序清单 9-12  快速排序算法

def straight_sort(lis):
    counts=len(lis)
    for m in range(1, counts):
        key=lis[m]
        n=m-1
        while key < lis[n]:
            lis[n+1]=lis[n]
            n-=1
            if n < 0:
                break
        lis[n+1] = key

#快速排序算法
def quicksort(sublis, left, right): #sublis:待排序集合,初始值为原列表lis;
                                    # left:待排序集合sublis的第一个元素在待排序原列表lis中的索引位;
                                    # right:待排序集合sublis的最后一个元素在待排序原列表lis中的索引位;
    if right-left >=3:
        pivot= getPovit(sublis, left,  right)
        i, j = left, right-1
        while True:
            while sublis[i] <= pivot: #从前向后找第一个比枢纽值大的元素
                i+=1
            while sublis[j] > pivot: #从后向前找第一个不大于枢纽值的元素
                j-=1

            if i < j:
                sublis[i], sublis[j] = sublis[j], sublis[i]
            else:
                break

        lis[i], lis[right] = lis[right], lis[i]
        quicksort(sublis, left, i - 1)
        quicksort(sublis, i + 1, right)

    else:#当子待排序列的长度小于一定长度时,使用直接插入排序
        straight_sort(sublis)
#测试
lis=[4, 3, 1, 19, 7, 12, 13, 16]
quicksort(lis,0, len(lis)-1)
print('快速排序后的lis:{}'.format(lis))

#结果
"""
快速排序后的lis:[1, 3, 4, 7, 12, 13, 16, 19]
"""

最后对快速排序的平均时间复杂度进行分析,该算法是一个递归算法。首先通过对算法的分析,得到递推式:

                                                                  T(n)=T(i)+T(n-i-1)+cn

其中,n 为算法输入的规模;i 为分割得到的一个子集的规则;cn 为线性开销。

在完全随机的情况下,T(i) 和  \bg_white \fn_cm T(n-i-1) 的平均时间为 \left (1/n\right )\sum_{j=0}^{n-1}T\left (j \right )  ,从而公式可以变为

                                                                T(n)=\left (2/n\right )\sum_{j=0}^{n-1}T\left (j \right )+cn

假设 \bg_white \fn_cm T(0)\bg_white \fn_cm T(1)\leq bb为常量),则可以证明:当 k=2(b+c) 和 n\geq 2 时,有

                                                               T(n)\leq kn\log_{2}n    

以上结果可以采用归纳法证明你得到。

从上面的分析可以看出,从算法消耗的时间来看,快速排序算法的平均性能优于上面所介绍的排序算法。从所需的辅助空间来看,除了 2路插入排序外,都是只需要一个记录的附加空间即可,但快速排序算法需要一个栈空间来实现递归。

                                                9.3  选择排序

选择排序(selection sort) 是一种简单的、直观的排序算法。它的基本思想:首先在未排序列列表中找到最小元素,存放到排序序列(即子排好序的序列)的起始位置,然后再从剩余未排序元素中继续寻找最小元素,并放到排序序列末尾。依此类推,直到所有元素都排序完毕。选择排序分为:直接选择排序、树形选择排序和堆排序。

9.3.1  直接选择排序

直接选择排序算法的思路是:从序列中选择最小的记录放在最前面,然后从剩下的记录中再选择最小的记录,排在第二的位置,依次进行,直到将所有的记录排序好。

下面以一个例子来说明一趟直接选择排序算法的过程。如图 9.8 所示,待处理序列为 \left \{ 8, 4, 3, 1, 19, 7, 12, 13, 16 \right \}。两个指针:min 用来指示当前扫描过的记录中的最小记录,curr 用来指示当前处理的记录,curr每次增 1, 当 curr 所指向的记录小于 min 所指向的记录时,更新 min 为 curr。一趟排序结束后 min 所指向的记录即为最小的记录。

一趟直接选择排序的算法如程序清单 9-13 所示。

程序清单 9-13   一趟直接选择排序的算法

#直接选择排序

#一趟直接选择排序算法
def selectpass(lis, left, right, sublis="这里写出来是为了方便理解;但算法中用不到此参数"):
                                    #lis:待排序列表
                                    #sublis:待排序子列表,初始值为待续列表lis;
                                    # left:sublis第一个元素在lis中的索引位
                                    #right:sublis最后一个元素在lis中的索引位
    min=left
    curr=left + 1

    for i in range(curr, right+1):
        if lis[min] > lis[i]:
            min = i
    return min

还是以  \left \{8, 4, 3, 1, 19, 7, 12, 13, 16 \right \} 为输入序列,图 9.9  给出了整个选择排序的过程。第一趟在 \left \{8, 4, 3, 1, 19, 7, 12, 13, 16 \right \} 中找到最小的元素 1,与第一个元素 8 进行交换。第二趟在 \left \{4, 3, 1, 19, 7, 12, 13, 16 \right \} 中找到最小元素3,与第二个元素 4 交换。以后各趟使用相同的方法处理,最后得到排好序的序列 \left \{1, 3, 4, 7, 8, 12, 13, 16,19\right \}

 直接选择排序的算法如程序清单 9-14 所示。

程序清单 9-14   直接选择排序的算法

直接选择排序算法
def selectsort(lis):

    length=len(lis)
    for j in range(length -1): #j就是sublis的第一个元素在lis中的索引
        min=selectpass(lis, j, length -1)
        lis[min], lis[j] = lis[j], lis[min]

#测试
lis=[8,4,3,1,19,7,12,13,16]
selectsort(lis)
print('直接选择排序后的lis:{}'.format(lis))

#结果
"""
直接选择排序后的lis:[1, 3, 4, 7, 8, 12, 13, 16, 19]
"""

简单选择排序算法:

liss=[8,4,3,1,19,7,12,13,16]

for k in range(len(liss)-1):
    min=k
    _curr=min+1

    for curr in range(_curr, len(liss)):
        if liss[min]>liss[curr]:
            min=curr
    liss[k],liss[min]=liss[min],liss[k]
print('排序后liss:{}'.format(liss))
#排序后liss:[1, 3, 4, 7, 8, 12, 13, 16, 19]

通过分析可以看出,采用直接选择排序,记录移动的次数比较少。当代处理序列为正序时,不需要移动记录;当处理序列为逆序时,需要 n-1 次操作,而每次交换操作需要移动 3 次数据,所以需要 3(n-1) 次数据移动操作。但数据比较的次数比较多,无论带处理序列如何排列,都需要  \sum_{i=n}^{1}=n*(n-1)/2 次比较。所以直接选择排序总的时间复杂度为 O(n^{2})

9.3.2  树形选择排序

直接选择排序在选择最小记录时比较费时,本小节介绍的选择排序就是针对这一操作进行的优化。在直接选择排序中,第一趟从 n 个记录中查找最小记录,需要 n-1 个记录中寻找最小元素,需要 n-2 次比较操作。注意,第二趟能利用第一趟的信息,则可以减少比较次数。实际上体育的锦标赛就是一种选择排序,本小节将要讲的树形选择排序(tree selection sort)就是利用了锦标赛的原理,所以又叫作锦标赛排序(tournament sort)。

体外话:锦标赛。根据锦标赛传递关系。亚军只能从被冠军击败的人中选出。

树形选择排序:为了减少简单选择排序,我们利用前n-1次比较信息,减少下次选择。树形选择排序的第一趟对待排序序列中的记录进行两两比较,取较小者,得到一个长度为  \left \lceil math.ceil(n/2) \right \rceil  的序列;第二趟在 \left \lceil math.ceil(n/2) \right \rceil 个较小的记录中再进行两两比较;以此类推,直到得到最小的记录。这样一个过程可以看成一棵树的构建过程,如图 9.10 所示,待处理序列为 \left \{8, 4, 3, 1, 19, 7, 12, 13, 16 \right \} ,两两比较(当序列记录数为奇数则在末尾补\infty),得到序列 \left \{4, 1, 7, 12, 16, \infty \right \}。第二轮(趟)以第一轮(趟)的结果为输入,依次进行比较,最后得到最小记录 1 。

从上面可以看出,第一趟树形选择排序同样需要比较所有的记录,得到最小元素,比直接选择排序没有什么优势,但得到了一颗排序树(该树中非叶子节点为其左右孩子节点中较小的记录),这就为后续的选择操作提供了便利,可以利用这样一棵树更快的选择记录。下面来看看如何利用这样一颗树来减少后续选择排序的比较次数,如图 9.11 所示。

从图 9.11 可以看到,后续每趟寻找最小元素的操作,首先将叶子节点中最小记录修改为“最大值”(math.inf) , 然后将此叶子节点与其兄弟节点比较,将它们的父节点更新为较小者;然后处理此父节点,如此自低向上直到更新到跟节点,从而保证了非叶子节点均为其左右孩子节点的较小者的特性,最后得到的树的根节点即为所要寻找的最小元素。

下面分析下树形选择排序的性能。因为完全二叉树的深度为 \left \lceil\log_{2}(n+1) \right \rceil ,所以除了第一趟选择最小记录外,以后每趟均需要比较 \left \lceil\log_{2}(n+1) \right \rceil次,因此它的时间复杂度为 O(n\log _{2}n) ,但其需要更多的辅助空间,并且在选择最小值的过程中要进行许多与“最大值”的比较。所以树形选择排序还有需要改进的地方,下面将要讲的堆排序将会对这些方面进行改进。

9.3.3  堆排序

堆又称为优先队列,它是至少提供这样两组操作的数据结构:插入(insert) 和删除最小者(DeleteMin)。

堆排序就是利用堆这样的数据结构来排序,将待排序记录构造成堆结构,用DeleteMin操作获的最小记录,然后将剩余记录再调整成堆。下一趟获得次小记录,如此进行,直到所有的记录排序好。所以堆排序的操作步骤如下:

(1)将待排序记录构造成堆结构。

(2)利用堆的 DeleteMin 的操作获取最小元素,从堆结构中将其删除,并将剩余部分调整成堆结构。

(3)重复第(2)步,直到将所有记录排序好。

堆排序中比较耗时的是第(1)步构造堆和第(3)步将剩余部分调整成堆结构。堆的实现中最常用的是二叉堆。二叉堆的应用非常普遍,在无特殊说明的情况下使用堆这个名词时往往指的就是二叉堆,所以提供的堆排序算法就是使用这种实现。

二叉树有以下性质,首先它是一个完全二叉树,其次这个完全二叉树的所有非叶子节点都不大于其左右孩子节点。

完全二叉树是一种很有规律的数据结构,可以用数组表示而不需要指针。图 9.12 给出了一颗完全二叉树和该完全二叉树的数组实现。

对于数组中元素 Array[i] 的左孩子为 \bg_white \fn_cm Array[2i]  ,右孩子为 \bg_white \fn_cm Array[2i+1] ,父节点为 \bg_white \fn_cm Array[i/2] 。下面给出的算法就是使用的这种数据结构。   【备注:完全二叉树的数组实现,如果此数组下标从0开始,则数组中元素 Array[i] 的左孩子为 \bg_white \fn_cm Array[2i+1]  ,右孩子为 \bg_white \fn_cm Array[2i+2] ,父节点为 \bg_white \fn_cm Array[(i-1)/2] 。】

注释下图:

      非叶子节点数:1+(end-1)//2 或 length//2 ;索引:start \sim (end-1)//2 或 \bg_green start\sim length//2 -1

      叶子节点数  :end - (end-1)//2 或 \bg_green length - length//2 ;索引:1+(end-1)//2 \sim end 或 \bg_green length//2\sim length-1

下面对堆排序的两个最重要的操作----构造堆和 DeleteMin后调整为堆  进行分析。为了分析方便,先介绍 DeleteMin后调整为堆 的操作。

在 DeleteMin 后,将最末尾的叶子节点放到根结点处,这样得到的完全二叉树的左、右子树分别还是堆结构。比较根节点和左、右孩子节点,如果根节点不是最小的,则将根节点和较小的孩子节点进行交换。这时被交换的孩子节点所在的子树可能不能保持堆的结构,还需要与其左、右孩子节点进行比较。如此进行调整,知道叶子节点。这种操作叫做下滤(percolate down)。

下面给出一个 DeleteMin 操作的例子。如图 9.13 所示,删除根节点上的 13后,将末尾的叶子节点31放入根节点,比较31和其左右子树根节点14和16的大小,14最小,所以和31交换。因左子树的堆结构被破坏,需要接着将左子树的根节点31和它的两个左、右子树根节点19和21进行比较,19最小,所以将31和19进行交换。同理,需要继续将31和其左右子树根节点65和26进行比较,26最小,所以将31和26进行交换。这时31已经位于叶子节点,调整结束。

DeleteMin 操作的算法如程序清单 9-15 所示。

#DeleteMin操作。备注:包括DeleteMin后调整堆/。实际上是:交换堆顶与堆末元素,并重构堆(不包括堆末元素--即交换前的堆顶元素)
def deleteMin(lis, end):
    if end <= 0:#lis 是空列表或单元素列表
        return

    lis[0],lis[end]=lis[end],lis[0]
    end-=1
    if end <= 0: #参与重构堆的元素数为2
        if lis[0] < lis[1]:
            lis[0], lis[1] = lis[1], lis[0]
        return

    #删除堆根节点后,即lis[0],lis[end]=lis[end],lis[0]操作缩小尺寸后,
    #     # 如果参与重构堆的完全二叉树最后一个非叶子节点只有一个叶子节点,则重构堆(即调整堆)时此非叶子节点不再参与比较。即
    #     #在调整堆中参与比较的非叶子节点数为:end//2,索引从0开始
    i=0 #i是非叶子节点索引
    while i <= end//2 - 1:
        lessChildPos=(2*i+1) if lis[2*i+1]<lis[2*i+2] else (2*i+2)

        if lis[i]>lis[lessChildPos]:
            lis[i], lis[lessChildPos] =lis[lessChildPos],lis[i]
            i=lessChildPos
        else:#否则当前的节点lis[i]和它的两个孩子节点相比是 最小的,所以它的孩子节点不用再比较(因为上次已经比较过了,即其两左右已经是堆)
            break
    print("本次deleteMin操作找到的最小值:",lis[end+1], end='\t\t') #打印每次deleteMin操作重构堆之前的堆顶元素。对于大顶堆来说就是本堆的最大值;对于小顶堆来说就是本堆的最小值

#测试
li=[1,2,3,4,5,6,7,8,9,10,11,12]
for index in range(len(li)-1, 0, -1): #n就是待排序序列的末尾索引。当n=2时,
    # 调用deleteMin后堆顶与此待排序序列的最后一个元素交换,此时还剩两个元素,其实已经排好了序。
    deleteMin(li, 0, index)
    print('之后待排序序列最大索引n:{}; \t调整堆后的堆li:{}'.format(index -1, li))

#结果
"""
本次deleteMin操作找到的最小值: 1		之后待排序序列最大索引n:11; 	调整堆后的堆li:[2, 4, 3, 8, 5, 6, 7, 13, 9, 10, 11, 12, 1]
本次deleteMin操作找到的最小值: 2		之后待排序序列最大索引n:10; 	调整堆后的堆li:[3, 4, 6, 8, 5, 12, 7, 13, 9, 10, 11, 2, 1]
本次deleteMin操作找到的最小值: 3		之后待排序序列最大索引n: 9; 	调整堆后的堆li:[4, 5, 6, 8, 11, 12, 7, 13, 9, 10, 3, 2, 1]
本次deleteMin操作找到的最小值: 4		之后待排序序列最大索引n: 8; 	调整堆后的堆li:[5, 8, 6, 9, 11, 12, 7, 13, 10, 4, 3, 2, 1]
本次deleteMin操作找到的最小值: 5		之后待排序序列最大索引n: 7; 	调整堆后的堆li:[6, 8, 7, 9, 11, 12, 10, 13, 5, 4, 3, 2, 1]
本次deleteMin操作找到的最小值: 6		之后待排序序列最大索引n: 6; 	调整堆后的堆li:[7, 8, 10, 9, 11, 12, 13, 6, 5, 4, 3, 2, 1]
本次deleteMin操作找到的最小值: 7		之后待排序序列最大索引n: 5; 	调整堆后的堆li:[8, 9, 10, 13, 11, 12, 7, 6, 5, 4, 3, 2, 1]
本次deleteMin操作找到的最小值: 8		之后待排序序列最大索引n: 4; 	调整堆后的堆li:[9, 11, 10, 13, 12, 8, 7, 6, 5, 4, 3, 2, 1]
本次deleteMin操作找到的最小值: 9		之后待排序序列最大索引n: 3; 	调整堆后的堆li:[10, 11, 12, 13, 9, 8, 7, 6, 5, 4, 3, 2, 1]
本次deleteMin操作找到的最小值: 10		之后待排序序列最大索引n: 2; 	调整堆后的堆li:[11, 13, 12, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1]
本次deleteMin操作找到的最小值: 11		之后待排序序列最大索引n: 1; 	调整堆后的堆li:[12, 13, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1]
之后待排序序列最大索引n: 0; 	调整堆后的堆li:[13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1]"""

下面介绍堆排序的另一种重要操作-----从无序序列构造堆。之前介绍的 DeleteMin 是从根节点进行下滤操作来完成对堆的调整;而构造堆从最后一个非叶子节点一直到根节点,都需要进行上滤操作。如图 9.14所示,找到最后一个非叶子节点14与其左右孩子比较并调整(如图 9.14中的阴影部分);接着调整非叶子节点 26、68;当处理非叶子节点21和13时交换21和13,破环了右子树的堆结构,需要继续处理其右子树,直至叶子节点。处理根节点同样需要下滤至叶子节点。

构造堆的算法如程序清单 9-16 所示。需要说明下,因为最后一个叶子节点record(n)的父节点为最后一个非叶子节点,所以最后一个非叶子节点为 record[n//2] ,其中 n 为记录的个数。【备注:完全二叉树的数组实现,如果此数组下标从0开始,则最后一个叶子节点为 record(n-1),所以最后一个非叶子节点为 \bg_white \fn_cm record[(n-1)//2 - 1]  ,其中 n为记录的个数。

程序清单  9-16   构造堆的算法

#构造初始堆
def buildHeap(lis):
    length = len(lis)
    while True:
        isFinish = True
        for i in range(length//2):
            if i == length//2 - 1 and length%2 == 0:#lis元素个数为偶数,且i等于要构造的堆的最后一个非叶子节点时
                lessChildPos = 2*i + 1
            else:
                lessChildPos=(2*i+1) if lis[2*i+1]<lis[2*i+2] else (2*i+2)
            if lis[i]>lis[lessChildPos]:
                lis[i],lis[lessChildPos] = lis[lessChildPos], lis[i]
                isFinish = False
        if isFinish:
            break
#测试
lis=[8,20,13,21,3,2,5,6,8,15,14,1,19,7,12,13,16]
buildHeap(lis)
print('lis_B:',lis)
#结果
"""
lis_B: [1, 3, 2, 6, 14, 8, 5, 13, 8, 15, 20, 13, 19, 7, 12, 21, 16]
"""

lis_B的堆如下图:

最后给出堆排序的算法,如程序清单 9-17所示。输入为一个无序的序列,算法过程构造一个最小堆(即小顶堆),并使用 DeleteMin操作,最后得到一个从大到小的序列。如果需要升序,则需要构造一最大堆(即个大顶堆)。

程序清单  9-17  堆排序算法

#堆排序算法(逆序)
def heapsort(lis):
    buildHeap(lis)
    for index in range(len(lis) -1, 0, -1):
        deleteMin(lis, index)

#测试
lis=[8,20,13,21,3,2,5,6,8,15,14,1,19,7,12,13,16]
heapsort(lis)
print(lis)

#结果
"""
[21, 20, 19, 16, 15, 14, 13, 13, 12, 8, 8, 7, 6, 5, 3, 2, 1]
"""

堆排序完整算法清单如下:

def buildHeap(lis):
    length = len(lis)
    while True:
        isFinish = True
        for i in range(length//2):
            if i == length//2 - 1 and length%2 == 0:                 
                lessChildPos = 2*i + 1
            else:
                lessChildPos=(2*i+1) if lis[2*i+1]<lis[2*i+2] else (2*i+2)
            if lis[i]>lis[lessChildPos]:
                lis[i],lis[lessChildPos] = lis[lessChildPos], lis[i]
                isFinish = False
        if isFinish:
            break

def deleteMin(lis,end):
    if end <= 0:
        return

    lis[0],lis[end]=lis[end],lis[0]
    end-=1
    if end <= 0:
        if lis[0] < lis[1]:
            lis[0], lis[1] = lis[1], lis[0]
        return

    i=0
    while i<= end//2 - 1:
        lessChildPos=(2*i+1) if lis[2*i+1]<lis[2*i+2] else (2*i+2)
        if lis[i]>lis[lessChildPos]:
            lis[i], lis[lessChildPos] =lis[lessChildPos],lis[i]
            i=lessChildPos
        else:
            break

def heapsort(lis):
    buildHeap(lis)
    for index in range(len(lis) -1, 0, -1):
        deleteMin(lis, index)

另外一种方法:

def heapify(lis, length, i):#length是lis的长度,i是非叶子节点在lis中的索引位置
    largest = i
    left = 2*i + 1
    right = 2*i + 2
    if left <= length -1 and lis[i] < lis[left]:
        largest = left
    if right <= length -1 and lis[largest] < lis[right]:
        largest = right
    if largest != i:
        lis[i], lis[largest] = lis[largest], lis[i]
        heapify(lis, length, largest)
def heapSort(lis):
    length = len(lis)

    #构建初始大顶堆
    for i in range(length//2 - 1, -1, -1):
        heapify(lis, length, i)

    #一个个交换元素
    for j in range(length -1, 0, -1):
        lis[j], lis[0] = lis[0], lis[j]
        heapify(lis, j, 0)
#测试:            
liss=[8, 20, 13, 21, 3, 2, 5, 6, 8, 15, 14, 1, 19, 7, 12, 13, 16]
heapSort(liss)
print(liss)
#结果:
[1, 2, 3, 5, 6, 7, 8, 8, 12, 13, 13, 14, 15, 16, 19, 20, 21]

        第三种方法(百度百科)

def big_endian(arr, start, end):
    root = start
    child = root * 2 + 1  # 左孩子
    while child <= end:# 左孩子索引比最后一个节点索引还大,也就意味着最后一个叶子节点了,就得跳出去一次循环,已经调整完毕
        if child + 1 <= end and arr[child] < arr[child + 1]:
            # 为了始终让其跟子元素的较大值比较,如果右边大就左换右,左边大的话就默认
            child += 1
        if arr[root] < arr[child]:
            # 父节点小于子节点直接交换位置,同时坐标也得换,这样下次循环可以准确判断:是否为最底层,是不是调整完毕
            arr[root], arr[child] = arr[child], arr[root]
            root = child
            child = root * 2 + 1
        else:
            break


def heap_sort(arr):  # 无序区大根堆排序
    first = len(arr) // 2 - 1
    for start in range(first, -1, -1):
        # 构建初始大顶堆:从下到上,从左到右对每个节点进行调整,循环得到非叶子节点
        big_endian(arr, start, len(arr) - 1)  # 去调整所有的节点
    for end in range(len(arr) - 1, 0, -1):
        arr[0], arr[end] = arr[end], arr[0]  # 顶部尾部互换位置
        big_endian(arr, 0, end - 1)  # 重新调整子节点的顺序,从顶开始调整
    return arr


def main():
    l = [1, 3, 2, 6, 14, 8, 5, 13, 8, 15, 20, 13, 19, 7, 12, 21, 16]
    print(heap_sort(l))


if __name__ == "__main__":
    main()


#运行结果:
#[1, 2, 3, 5, 6, 7, 8, 8, 12, 13, 13, 14, 15, 16, 19, 20, 21]

上面说到树插入排序的两个缺点:一是辅助空间比较大;需要进行一些不必要的和“最大值”的比较操作。而堆排序这两个方面则表现良好,辅助空间只需要一个用来完成交换的记录空间,在堆排序中没有和“最大值”的比较,每次排序都是在待排序记录之间进行的。

堆排序耗时的两个操作是构造堆和删除最小之后调整堆。对高度为k的堆进行下滤操作最多进行2(k-1)次比较。

  初始化建堆的时间复杂度为O(n),排序重建堆的时间复杂度为nlog(n),所以总的时间复杂度为O(n+nlogn)=O(nlogn)。另外堆排序的比较次数和序列的初始状态有关,但只是在序列初始状态为堆的情况下比较次数显著减少,在序列有序或逆序的情况下比较次数不会发生明显变化。(备注:堆排序时间复杂度计算摘自:https://blog.youkuaiyun.com/qq_34228570/article/details/80024306)                                                 

                                                      9.4  归并排序

从算法的名字可以看出,该算法的内容就是使用归并的方法来完成排序,其中归并操作也是该算法最重要的操作。

归并操作就是将两个排好序的序列合并成一个有序的序列。下面一个例子来看归并操作的过程,如图9.15所示,两个输入序列为 A B,一个输出序列 C,操作过程需要3个指针 AptrBprtCptr 。在初始状态下,3个指针都指向对应序列的开始位置,比较AptrBprt 指向的记录的大小,将较小者存入 Cptr 所指向的位置,相关的指针后移一位。当AptrBprt  中的任何一个到达对应序列的末尾时,将另一个指针到对应序列末尾的所有元素复制到  C 中。

归并操作的算法如程序清单 9-18 所示。需要说明的是,该算法将两个输入序列用一个大的数组存放,同时为了是归并排序算法调用方便,还要将合并好的数据存入原来存放输入序列的位置。

程序清单  9-18  归并操作的算法


def merge(a, b):
    c = []
    h = j = 0
    while j < len(a) and h < len(b):
        if a[j] < b[h]:
            c.append(a[j])
            j += 1
        else:
            c.append(b[h])
            h += 1

    if j == len(a):
        for i in b[h:]:
            c.append(i)
    else:
        for i in a[j:]:
            c.append(i)

    return c
#测试
a=[1,3,5,7,9]
b=[2,4,6,8,10,12,14]
c=merge(a,b)
print(c)
#结果
"""
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 12, 14]
"""

def merge(a, b):
    c = []
    h = j = 0
    while j < len(a) and h < len(b):
        if a[j] < b[h]:
            c.append(a[j])
            j += 1
        else:
            c.append(b[h])
            h += 1

    c+=a[j:]
    c+=b[h:]

    return c

#测试
a=[1,3,5,7,9]
b=[2,4,6,8,10,12,14]
c=merge(a,b)
print(c)
#结果
"""
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 12, 14]
"""

归并排序要处理的序列是无序的,而归并操作要求两个输入序列是有序的,这就需要采用分治法,即将无序序列分为两个更小规模的序列,直至分到两个输入序列都只有一个记录,归并操作就可以进行了。同时归并操作的输出为一个有序序列,可以进行下一轮的归并。这个过程需要处理整个输入只有一个记录的特殊情况,如果使用递归算法,这也是递归的终止条件。

归并操作的递归算法如程序清单 9-19 所示。

def merge_sort(lists):
    if len(lists) <= 1:
        return lists
    middle = len(lists)//2
    left = merge_sort(lists[:middle])
    right = merge_sort(lists[middle:])
    return merge(left, right)


if __name__ == '__main__':
    lis = [4, 7, 8, 3, 5, 9,10,1,2,3,43,12,10,21,9,5,31,1,2,34,3]
    c=merge_sort(lis)
    print(c)
    #结果
    '''
    [1, 1, 2, 2, 3, 3, 3, 4, 5, 5, 7, 8, 9, 9, 10, 10, 12, 21, 31, 34, 43]
    '''

递归实现的归并排序算法的时间复杂度为 n\log_{2}{n} 。但在整个算法中需要将数据复制到临时数组,还要再从临时数组复制过来,这严重影响了排序速度,再加上递归需要较多的辅助空间,使得递归实现的归并算法在实际应用中的性能比较差。对于数组的复制问题可以经过仔细的设计,通过改变临时数组 temArray 和 A 的角色来避免;还可以将算法非递归化来优化算法的性能。即使是这样,在一些关键的排序应用中,人们还是倾向于使用快速排序。但和快速排序相比,归并排序也有它的优势,它比快速排序要稳定。归并排序的重要性还体现在它是大多数外部排序算法的基础。

                                                           基数排序

基数排序(radix sorting)和前面所讲的排序算法不同,它针对的是多关键字排序。它是一种非比较型整数排序算法,其原理是将整数按位数切割成不同的数字,然后按每个位数分别比较。由于整数也可以表达字符串(比如名字或日期)和特定格式的浮点数,所以基数排序也不是只能使用于整数。

对于序列 \left \{ R_{1},R_{2},...R_{n} \right \} 。每一个记录 R_{i} 含有 d 个关键字 \left ( k_{i}^{0}, k_{i}^{1},...,k_{i}^{d-1} \right ) ,如果序列关于 \left ( k_{i}^{0}, k_{i}^{1},...,k_{i}^{d-1} \right ) 是有序的,则对任意 \bg_white \fn_cm R_{p} 和 \bg_white \fn_cm R_{q}(1\leq p\leq q\leq n) ,满足关系式:

                                                                \bg_white \fn_cm \left ( k_{p}^{0}, k_{p}^{1},...,k_{p}^{d-1} \right )< \left ( k_{q}^{0}, k_{q}^{1},...,k_{q}^{d-1} \right )

其中,k^{0} 为最高位关键字;k^{d-1} 为最低位关键字。例如,要对扑克牌进行排序,其中最高位关键字 k^{0} 为花色,次高位关键字为面值 (2<3<4<...<10<J<Q<K<A)。所以将这幅扑克牌排好序后应该是这样:

第一种排序方法是先按关键字花色 k^{0} 进行排序,会得到 4组扑克,每一组有相同的花色,然后对每一组的内部按关键字面值 k^{1} 进行排序,将得到的 4 组排好序的扑克牌叠放到一起。这种排序方式称为 MSD(Most Significant Digit,最高有效位数)排序。

第二种排序方法是先按关键字面值 k^{1} 进行排序,会得到 13 组扑克牌,这时就将这 13 组扑克牌按面值 k^{1} 从小到大叠放到一起。然后对这一大组扑克牌按花色 k^{0} 进行排序,但排序时要保证排序稳定,否则会打乱第一趟排序的结果。这种排序方法叫作LSD(Least Significant Digit , 最低有效位数)排序。

下面以一个例子来说明第二种排序的操作过程。如图9.16 所示对于整数序列  [133, 950, 129, 038, 766, 542, 400, 137, 377, 796] ,其中k^{0}为百位上的数字,k^{1}为十位上的数字,k^{2} 为个位上的数字。

在图 9.16 中,图 9.16(a)所示为初始序列,首先以个位数 k^{2}  进行排序,得到结果如图 9.16(b) 所示,这一步从结果上来看就像是把记录分成了10份,所以该操作称为“分配”。需要注意的是,在分配时同一组内要保持相对的位置与”分配“相一致,比如图9.16(b)中第0组950在400之前,是因为在图9.16(a)中的结果950就在400之前。从操作上来看实质上是一趟桶排序。接着将图9.16(b)中的结果依次叠放在一起,这个操作就像是把零散的东西收集起来,所以该操作称为”收集“,得到的结果如图9.16(c)所示,可以看到图9.16(c)中的个位数已经是排好序的了。然后以图9.16(c)所示为输入进行一趟”分配“,得到的结果如图9.16(d)所示,对图9.16(d)进行”收集“得到图9.16(e)所示结果,这时可以发现十位和个位都已经是排好序的了。最后在进行一轮”分配“和”收集“,则百位、十位和个位都排好序了,排序操作完成。看一下排序结果,从多关键字角度来看该序列是有序的,若将记录看成一个整体,从单关键字角度来看序列也是有序的,所以单关键字排序也可以转化为多关键字排序来处理。在这个例子中,是通过将单关键字对”基“(radix)10取余的方式分解为多关键字。同理,可以取”基数“为 8 ,得到次高位关键字 k^{1}{0,1,2,3,4,5,6,7},用LSD方法进行排序时”分配“组数从原来的 10 组变成了 8组。

LSD算法的实现如程序清单 9-20 所示。

程序清单  9-20    基数排序的算法

#基数排序  (桶排序)
from random import randint

# 利用列表的下标从小到大实现了排序
def RadixSort(lis, d):
    for k in range(d):  # d轮排序。备注:d时lis中最大值的长度

        s = [[] for k in range(10)]  # 因为每一位数字都是0~9,故建立10个桶

        for i in lis:

            m=i//(10**k) %10 #或者: m = int(i / (10 ** k) % 10)

            s[m].append(i)  # 977/(10**0)%10=7,s[7].append(977) ;977/10=97(小数舍去),87/100=0

        lis = [j for i in s for j in i]  # 利用了for i in s,执行时会按s中下标从0开始

    return lis

#测试
if __name__ == '__main__':
    a = [randint(1, 999) for i in range(10)]  # 最多是三位数,因此d=3
    print('排序前a:{}'.format(a))

    a = RadixSort(a, 3)  # 将排好序的数组再赋给a!!!!
    print('排序后a:{}'.format(a))
#结果:
"""
排序前a:[290, 162, 664, 181, 889, 854, 253, 368, 512, 281]
排序后a:[162, 181, 253, 281, 290, 368, 512, 664, 854, 889]
"""

该算法需要对记录进行 MAX_DIGIT(即最大记录的长度) 遍扫描,每一遍要完成两步操作,一是“分配”操作,该操作实质是一趟桶排序需要对 n 个记录遍历一遍;二是“收集”操作,该操作只需要将 RADIX_SIZE 个组首尾相连即可。所以每一遍的处理耗时为 O(RADIX_SIZE + n) ,因此总的算法时间复杂度为 O( MAX_DIGIT*(RADIX_SIZE +n) )。基数的选择在很大程度上影响了算法的运行效率,一般情况下是根据 n(即记录数) 和记录的最大关键字来确定基数。

自测题:已知序列["how", "are", "you", "why", "car", "box", "bus", "ant", "buy"],请给出采用基数排序法对序列升序排序时的每一趟的结果。

test_liss=["how", "are", "you", "why", "car", "box", "buy", "ant", "bus"]
def test_radixsort(liss,d):
    for m in range(d)[::-1]:

        #构造桶字典:每个键分别为26个小写字母,初始值都是空列表。
        s={}
        s.update([(chr(i),[]) for i in range(ord('a'),ord('z')+1)])

        for e in liss:
            s[e[m]].append(e)

        liss.clear()
        for v in s.values():
            if v:
                liss.extend(v)

        print('每趟基数排序结果liss:{}'.format(liss))

    return liss
print("\t\t\t 排序前:{}".format(test_liss))
liss=test_radixsort(test_liss,3)
print("\t\t\t 排序后:{}".format(liss))
#结果
"""
	    排序前:['how', 'are', 'you', 'why', 'car', 'box', 'buy', 'ant', 'bus']
每趟基数排序结果liss:['are', 'car', 'bus', 'ant', 'you', 'how', 'box', 'why', 'buy']
每趟基数排序结果liss:['car', 'why', 'ant', 'you', 'how', 'box', 'are', 'bus', 'buy']
每趟基数排序结果liss:['ant', 'are', 'box', 'bus', 'buy', 'car', 'how', 'why', 'you']
	    排序后:['ant', 'are', 'box', 'bus', 'buy', 'car', 'how', 'why', 'you']
"""

                                                    额外:   桶排序

 

桶排序也叫计数排序,简单来说,就是将数据集里面所有元素按顺序列举出来,然后统计元素出现的次数。最后按顺序输出数据集里面的元素。

但是桶排序非常浪费空间, 比如需要排序的范围在0~2000之间, 需要排序的数是[3,9,4,2000], 同样需要2001个空间。

注意: 桶排序不能排序小数

算法实现如下图:

#桶排序
def bucketSort(nums):
  # 选择一个最大的数
  max_num = max(nums)
  # 创建一个元素全是0的列表, 当做桶
  bucket = [0]*(max_num+1)
  # 把所有元素放入桶中, 即把对应元素个数加一
  for i in nums:
    bucket[i] += 1
  # 存储排序好的元素
  sort_nums = []
  # 取出桶中的元素
  for j in range(len(bucket)):
    if bucket[j] != 0:
        sort_nums.extend([j]*(bucket[j]))
  return sort_nums
nums = [5,6,3,2,1,65,2,0,8,0]

#测试:
sort_nums=bucketSort(nums)
print(sort_nums)
#结果:
"""
[0, 0, 1, 2, 2, 3, 5, 6, 8, 65]
"""

                                 

                              9.6   各种内部排序算法比较

时间复杂度和稳定性的总结https://www.cnblogs.com/vinozly/p/5606132.html   

上面介绍的多种排序算法之间没有绝对的优劣之分,每一种算法都有其优缺点,所以在实际应用中要根据具体情况,选择合适的算法。根据算法的时间复杂度可以将算法分类如下:

  • 时间复杂度为 O(n^{2}) 的算法有:直接插入排序、折半插入排序、2路插入排序、直接选择排序和冒泡排序,这些算法的实现比较简答。
  • 时间复杂度为 O(n\log_{2}n) 的算法有:希尔排序、快速排序、树形选择排序、堆排序和归并排序。这些算法的实现都比较复杂,其中希尔排序中增量序列的选择比较繁琐,快速排序的关键字(即中枢元素)的选择也是难点,堆排序则需要维持一个堆,归并排序的辅助空间要求比较多。
  • 时间复杂度为 O(n) 的排序算法有桶排序和基数排序。这两个排序算法的特点是对排序对象的要求比较高,比如桶排序要求排序对象的关键字要在固定的范围内,并且要求相对集中,如果关键字的分布比较稀疏则要求的辅助空间过多;基数排序则要将多关键字或单关键字的值通过基数分解,分解过程中基数的选择也是需要慎重考虑的事情。

在选择算法时除了要考虑时间复杂度外,还要考虑算法的稳定性。排序算法的稳定性:当排序对象中有多个记录的关键字相同时,如果使用排序算法将排序对象排好序后这些记录的相对位置没有改变,则称该算法是稳定的,否则是不稳定的。

在选择排序算法时稳定性是一个非常重要的参考标准,在有些应用中必须要求排序算法是稳定的,比如基数排序的 LSD 算法中每一趟的 “分配” 和 “收集” 操作都必须是稳定的,不然就会出错。例如,对 \left \{ 32, 31 \right \} 进行排序,先按个位排得到 \left \{ 31, 32 \right \} ;再按十位排,因为十位都是 3, 如果排序算法不稳定,有可能 31 和32 交换,从而得到错误的排序结果 \left \{ 32, 31 \right \}

(1)稳定的排序算法:冒泡排序、直接插入排序、归并排序、基数排序和桶排序。

(2)不稳定的排序算法:折半插入排序、2路插入排序、希尔排序、快速排序、直接选择排序、树形选择排序和堆排序。

算法的空间复杂度(即,额外空间)也是衡量算法优劣的一个方面,在选择使用哪一种排序算法时也是需要考虑的因素。当空间复杂度过高,并且排序规模也比较大,需要的空间超出了内存的大小时,就需要频繁地从硬盘或其他外设读、写数据,算法的运行时间将大大增加,所以算法的空间复杂度也是选择算法时一个不可忽视的因素。

还有一个因素在选择算法时也会考虑到,那就是算法的复杂度。如果算法的实现比较复杂,则在实现过程中出错的概率就比较大,这会影响到整个软件的健壮性。从另一个方面来说,在问题规模 n 比较小时,复杂的算法实现在表现上并没有什么优势,甚至比那些简单算法更差。

根据算法的复杂性,将排序算法归为两类:

(1)简单的排序算法:直接插入排序、折半插入排序、2路插入排序、冒泡排序、直接选择排序和桶排序。

(2)较复杂的排序算法:希尔排序、快速排序、树形选择排序、堆排序、归并排序和基数排序。

表9.1 对各种排序算法的各项性能参数进行了比较,在对算法的优缺点进行了解后,还要考虑所处理问题的特点,从而能更好地选择合适地算法。

一般为情况下需要综合考虑以下几个因素来选择合适的排序算法:

(1)对稳定性的要求

(2)问题的规模,即待排序记录的个数

(3)关键字的初始状态

(4)对时间和辅助空间的要求

(5)每一记录的大小

在选择排序算法时遵循以下原则:

(1)考虑对稳定性的要求。如果要求稳定。则只能从稳定的排序算法中选取,否则可以在所有的排序算法中选取。

(2)考虑问题的规模。如果规模较大,则选择平均算法复杂度较好的算法,否则选择实现简单的算法。

(3)考虑到初始序列是否为基本有序。如果基本有序,则考虑在基本有序情况下表新较好的算法,如直接插入排序、冒泡排序或随机的快速排序。

(4)考虑对时间以及辅助空间的要求。如果对辅助空间的要求比较高,则不能选择空间复杂度较高的算法。

(5)考虑每个记录的大小,如果记录所占空间比较大,则在选择排序时尽量选择移动次数较少的排序算法,比如直接选择排序就比直接插入排序更合适。

总之,没有哪一种算法是最好的,每一种算法都有其优缺点。在实际应用中要根据具体情况选择合适的算法,才能得到较好的效果。

                                                   9.7  外部排序

https://cloud.tencent.com/developer/news/269143

前面讨论的内部排序算法都需要将输入的待排序字符输入内存。然而,当解决一些实际问题时,经常会出现由于装入的数据量太大而无法全部装入内存的情况。外部排序算法(external sorting),可以用来解决这些输入量很大的问题。

9.7.1  选择外排序的理由

大部分内部排序算法都是在内存可以直接寻址的前提下执行的。所谓直接寻址就是直接给出操作数的地址。这样,希尔排序就可以在一个时间单位下比较元素 A[i]A[i-h_{k}] ,快速排序可以在一个常数个时间单位下比较元素 A[left]A[center] ,A[right] 。但是如果数据不是存储在像内存这样可以直接寻址的介质上,希尔排序、快速排序等算法的效率就没有了。例如,如数据存储在磁带上,而磁带上的元素只能进行顺序访问,即使希尔排序算法中的 A[i] 和 A[i-h_{k}] 在同一磁盘上,寻找 A[i] 和 A[i-h_{k}] 也需要转动磁盘和磁头,这样就会影响实际的效率。

为了检查外部访问究竟有多慢,读者可以自己建立一个大的随机文件,但不要大到装不进内存,将该文件读入并选择一种有效内部排序算法对其进行排序,可以看出将这些数据排序所花费的时间与将其读入内存所花费的时间相比时微乎其微的。尽管采用的排序算法可以达到 O(n\log_{2}{n}) 的复杂度,但读入所花费的时间是 O(N)

通过上面的分析可知,外部排序算法要比内部排序算法更依赖于存储设备。为了更好地说明问题,采用最受限制的存储介质-----磁带来讨论外部排序算法。所需要说明的是,假设至少有3个磁带驱动器进行排序工作,其中两个用于排序,第三个用于简化工作。如果只用一个磁带进行排序,则任何排序算法访问磁带的时间复杂度都为 O(N^{2})

9.7.2  简单外部排序算法

最简单的外部排序算法可以使用归并算法的思想来完成。假设有 4 个磁带,分别为 T_{a1}T_{a2}T_{b1}T_{b2} 。一开始,数据储存在磁带 T_{a1} 上,并且内存可以一次容纳和排序 M 个记录,则对于有 N 个记录的磁带,简单排序算法的步骤如下:

(1)从磁带 T_{a1} 上一次性读取 M 个数据。

(2)在内存中将这 M 个数据进行排序,然后再把这些数据交替写到 T_{b1} 上。然后重复第(1)步,再将得到的 M 个已排好序的数据写到 T_{b2} 上。就这样交替地写,直到将磁带 T_{a1} 上数据全部输出到 T_{b1}  和 T_{b2} 上。这里称每组排序过的记录为顺串(ruuno)。然后将所有磁带的磁头移动到磁带的开始位置。

(3)从  T_{b1}  和 T_{b2} 各取 下一个顺串,并将二者合并,形成一个 2 倍长的顺串,将结果写到磁带 T_{a1} 上。然后再从T_{b1}  和 T_{b2} 各取   下一个顺串,合并后写到磁带 T_{a2} 上。

(4)重复步骤(3),交替地将新生成地顺串写到磁带  T_{a1}  和 T_{a2} 上,直到   T_{b1}  和 T_{b2} 为空或者还剩下一个顺串。对于后者,把剩下的顺串复制到适当的顺串上。

(5)将 4 个磁带的磁头移动到开始位置,并重复步骤(2)~(4),直到得到一个长度为 N 的顺串。

假设需要排序的数据集 D=\left \{ 80,93,10,95,11,35,16,98,29,58,42,75,15 \right \} 存储在磁带上,内存容量为3,对数据集 D  

 

 

 

 

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值