大话数据结构读书笔记-排序

2020-04-29

1.排序的基本概念与分类

定义:假设含有n个记录的序列为\({r_1, r_2,...,r_n}\),其相应的关键字分别为\({k_1,k_2,...,k_n}\),需要确定1, 2, ..., n的一种序列\(p_1,p_2,...,p_n\),使得其对应的关键字满足\(k_{p1},k_{p2},...k_{pn}\)(非递减或非递增)关系,即使得序列成为一个按关键字有序得序列\({r_{p1},r_{p2},...,r_{pn}}\),这样得操作称为排序。

在排序问题中,通常将数据元素称为记录。输入是一个记录集合,输出也是一个记录集合,所以可以将排序看作是排序表得一种操作。关键字\(k_i\)可以是记录r的主关键字,也可以是次关键字,甚至是若干数据项的组合。

1.1 排序的稳定性

由于待排序记录序列中可能存在两个或两个以上的关键字相等的记录,排序结果可能存在不唯一的情况。

定义:假设\(k_i=k_j(1<=i<=n,1<=j<=n,i!=j)\),且在排序前的序列中\(r_j\)领先于\(r_j\)(i<j)。如果排序后\(r_i\)仍领先于\(r_j\),则称所用的排序方法是稳定的;反之,不稳定。

1.2 内排序与外排序

根据是否在排序过程中待排序的记录是否全部被放置在内存中,排序分为:内排序外排序

内排序是在排序整个过程中,待排序的所有记录全部被放置在内存中。外排序是由于排序的记录个数太多,不能同时放置在内存中,整个排序过程需要在内外存之间多次交换数据才能进行。

对于内排序,排序算法的性能主要受3个方面影响:

  1. 时间性能

    排序算法的时间开销是衡量其好坏的最重要标志。在内排序中,主要进行两种操作:比较和移动。高效的排序算法应该具有尽可能少的关键字比较次数和尽可能少的移动次数。

  2. 辅助空间

    评价排序算法的另一个标准是执行算法所需要的辅助存储空间,即除存放待排序所占用的存储空间之外,执行算法所需要的其他存储空间。

  3. 算法的复杂性

    这里指算法本身的复杂度。

根据排序过程中借助的主要操作,可以把内排序分为:插入排序交换排序选择排序归并排序。本文讲解的七种排序算法,按照算法的复杂度可以分为两大类:冒泡排序、简单选择排序和直接插入排序属于简单算法,希尔排序、堆排序、归并排序和快速排序属于改进算法。

​ 图1. 7种内排序算法总览

2.交换排序

冒泡排序与快速排序属于交换排序

2.1 冒泡排序

冒泡排序(Bubble Sort)是一种交换排序,基本思想为:两两比较相邻记录的关键字,如果反序则交换,直到没有反序的记录为止。

2.1.1 冒泡排序算法
class Sort:  
    # 冒泡排序
    def BubbleSort(self, array):
        length = len(array)
        i = 0
        while i < length-1:
            j = length - 1
            while j > i:
                if array[j-1] > array[j]:
                    array[j-1], array[j] = array[j], array[j-1]
                j -= 1
            i += 1
        return array

假设待排序关键字序列为:{9, 1, 5, 8, 3, 7, 4, 6, 2},当i=0时,变量j由8反向循环到1,逐个比较,将较小值交换到前面,直到最后找到最小值放置在第1个位置。如图2所示

图2. 冒泡排序模拟

当i=1时,变量j由8反向循环到2,逐个比较,在将关键字2交换到第二位置时,也使得关键字4与3有所提升。后面不多做赘述。图中较小的数字如同气泡慢慢浮到上面,因此该算法命名为冒泡算法。

2.1.2 冒泡排序优化

上面的冒泡排序还可优化,试想一下,待排序列为{2, 1, 3, 4, 5, 6, 7, 8, 9},即除了第一第二关键字需要交换外,别的都是正常排序。但算法仍然将i=1到7以及每个循环的j都执行一遍,尽管没有交换但比较仍显多余。当i=2时,我们已经对9与8, 8与7, ..., 3 与2作了比较,没有任何数据交换,这说明数据已经有序,不需要再继续后面的循环判断工作。为实现这一想法,改进一下算法,增加一个标记变量flag来实现这一算法的改进。

    def BubbleSort_2(self, array):
        length = len(array)
        flag = True
        i = 0
        while i < length - 1 and flag:
            flag = False
            j = length - 1
            while j > i:
                if array[j-1] > array[j]:
                    array[j-1], array[j] = array[j], array[j-1]
                    flag = True
                j -= 1
            i += 1
        return array
2.1.3 冒泡排序复杂度分析

最好的情况下,即本身有序,需要n-1次比较,无数据交换,时间复杂度为\(O(n)\);最坏的情况下,即待排序序列为逆序,需要比较\(\frac{n(n-1)}{2}\)次,并作等数量级的记录移动.因此,总的时间复杂度为\(O(n^2)\)

2.2 快速排序

快速排序是冒泡排序的升级,它们都属于交换排序类。即它也是通过不断比较和移动交换来实现排序的,只不过它的实现,增大了记录的比较和移动的距离,将关键字较大的记录从前面直接移动到后面,关键字较小的的记录从后面直接移动到前面,从而减少了总的比较次数和移动交换次数。

2.2.1 快速排序

快速排序的基本思想是:通过一趟排序将待排记录分割成独立的两部分,其中一部分记录的关键字均比另一部分记录的关键字小,则可分别对这两部分记录继续进行排序,以达到整个序列有序的目的。

话不多说,直接上代码:

    def QuickSort(self, array):
        length = len(array)
        self.Qsort(array, 0, length-1)
        return array

    def QSort(self, array, low, high):
        if low < high:
            pivot = self.Partition(array, low, high)
            self.QSort(array, low, pivot-1)
            self.QSort(array, pivot+1, high)

    def Partition(self, array, low, high):
        pivotKey = array[low]
        while low < high:
            while low < high and array[high] >= pivotKey:
                high -= 1
            array[low], array[high] = array[high], array[low]
            while low < high and array[low] < pivotKey:
                low += 1
            array[low], array[high] = array[high], array[low]
        return low	

核心代码为函数Partition,即选定一个关键字作为枢轴,将比它小的关键字放到左边,比它大的关键字放到右边。程序开始执行,low=0,high=8, pivotkey=array[low](50),第一轮快排如图3所示:
图3. 快排模拟

2.2.2 快速排序优化

1.优化选取枢轴

如果选取的pivotkey是处于整个序列的中间位置,那么我们可以将整个序列分为小数集合和大数集合。但如果选取的枢轴不是中间数,则可能整个序列在一轮pivot=self.Partition后整个序列并没有实质性的变化,由此pivot=array[low]就变成了一个潜在的性能瓶颈。于是,有了三数取中法,即取三个关键字先进行排序,将中间数作为枢轴,一般取左端、中端和右端三个数

def Partition(self, array, low, high):
    	m = low + (high - low) / 2
        if array[low]>array[high]:
            array[low], array[high] = array[high], array[low]
        if array[m]>array[high]:
            array[m], array[high] = array[high], array[m]
        if array[low] > array[m]:
            array[low], array[m] = array[m], array[low]
        pivotKey = array[low] 
        while low < high:
            while low < high and array[high] >= pivotKey:
                high -= 1
            array[low], array[high] = array[high], array[low]
            while low < high and array[low] < pivotKey:
                low += 1
            array[low], array[high] = array[high], array[low]
        return low	

2.优化不必要的交换

如图3所示,我们发现,50这个关键字,其位置变换是0->8->2->5->4,它的最终目标是4,当中的交换时不需要的。因此可以对Partition函数的代码再次优化:

    def Parition(self, array, low, high):
        m = low + (high - low) // 2
        print(low, high, m)
        if array[low] > array[high]:
            array[low], array[high] = array[high], array[low]
        if array[m] > array[high]:
            array[m], array[high] = array[high], array[m]
        if array[low] > array[m]:
            array[low], array[m] = array[m], array[low]
        pivotKey = array[low]
        tmp = pivotKey
        while low < high:
            while low < high and array[high] >= pivotKey:
                high -= 1
            array[low] = array[high]
            while low < high and array[low] < pivotKey:
                low += 1
            array[high] = array[low]
        array[low] = tmp
        return low

3.优化小数组时的排序方案

当数组非常小的时候,快速排序反而不如直接插入排序来得更好(直接插入排序是简单排序中性能最好的)。原因在于快排用了递归操作,在大量数据排序时,这点性能影响相对于它整体算法优势而言可以忽略,但是如果数组很小,就成了大炮打蚊子的大问题。因此,我们需要改进QSort函数。

 	# 设置一个常数, MAX_LENGTH_INSERT_SORT
    def QSort(self, array, low, high):
        if high-low > MAX_LENGTH_INSERT_SORT:
            pivot = self.Parition(array, low, high)
            self.QSort(array, low, pivot-1)
            self.QSort(array, pivot+1, high)
        else:
            # 小于等于常数时用直接插入排序
            InsertSort(array) 

4.优化递归操作

递归对性能有一定的影响,QSort在尾部有两次递归操作,如果待排序序列化分极端不平衡,递归深度趋近于n,而不是平衡时的logn。栈的大小是有限的,每次递归掉调用都会耗费一定的栈空间,函数的参数越多,每次递归耗费的空间也越多。是故减少递归,可以大大的提高性能。这里,对QSort实施尾递归,

    def QSort_1(self, array, low, high):
        while low < high:
            pivot = self.Parition(array, low, high)
            self.QSort_1(array, low, pivot-1)
            low = pivot + 1

这里,将if改为while,第一次递归后,变量low就没有用处了,故将pivot+1赋值给low,再循环,来一次Partition(array, low, high),效果等同于QSort(array, pivot+1, high),结果相同,但因采用迭代而不是递归的方法可以缩减堆栈深度,从而提高整体性能。

2.2.3 快速排序复杂度分析

在最优的情况下,快速排序算法的时间复杂度为\(O(nlongn)\),在最坏的情况下,集待排序的序列为正序或逆序,每次划分只得到一个比上一次划分少一个记录的子序列,此时需要执行n-1次递归调用,比较次数为\(\frac{n(n-1)}{2}\),最终时间复杂度为\(O(n^2)\),平均情况下为\(O(nlogn)\)。空间复杂度上,为\(O(logn)\),最坏的情况下为\(O(n)\).排序不稳定。

3. 选择排序

交换排序的思想就是在不断的交换,通过交换完成最终的排序;选择排序则是在每一趟\(n-i+i(i=1,2,...,n-1)\)个记录种选取关键字最小的记录作为有序序列的第i个记录。

3.1 简单选择排序

3.1.1 简单选择排序算法

简单选择排序法就是通过n-1次关键字间的比较,从n-i+1个记录中选出关键字最小的记录,并和第i(i<=i<=n)个记录交换之

来看代码:

    def SimpleSelectionSort(self, array):
        length = len(array)
        for i in range(length-1):
            minv = i
            for j in range(i+1, length):
                if array[minv] > array[j]: # 较小的值位置赋给minv
                    minv = j
            if minv != i: # minv 不等于 i,则说明找到最小值,交换
                array[i], array[minv] = array[minv], array[i]
        return array

3.1.2 简单选择排序复杂度分析

时间复杂度为\(O(n^2)\),尽管与冒泡同为\(O(n^2)\),但简单选择排序的性能要略优于冒泡排序

3.2 堆排序

前面讲到简单选择排序时,它在待排序的n个记录中选择一个最小的记录需要比较n-1次,但是第一次查找后没有将每一趟的比较结果保存下来,在后一趟比较中,有许多比较已经做过,但是由于前面一趟未保存这些比较结果,所以后一趟排序时又重复这些比较操作,因而记录的比较次数较多。如果可以做到每次选择到最小记录的同时,并根据结果对其他记录做出相应调整,那样排序的总体效率就就能提高。堆排序是对简单选择排序进行的一种改进,并且改进的效果非常明显。

3.2.1 堆

堆是具有下列性质的完全二叉树:每个结点的值都大于或等于其做右孩子结点的值,称为大顶堆(如图4左图所示);或者每个结点的值都小于或等于其左右孩子结点的值,称为小顶堆(如图4右图所示)。

图4 堆

从堆的定义可知,根结点一定是所有结点最大(小)者,较大(小)的结点靠近根结点(但也不绝对)。如果按照层序遍历的方式给结点从1开始编号,则结点之间满足如下的关系:

\[\begin{cases} k_i \geq k_{2i} \\ k_i \geq k_{2i+1} \end{cases} 或 \begin{cases} k_i \leq k_{2i} \\ k_i \leq k_{2i+1} \end{cases} 1\leq i \leq \lfloor{\frac{n}{2}}\rfloor \]

若将图4的大顶堆和小顶堆用从层序遍历存入数组,则一定满足上面的关系表达式。

3.2.2 堆排序算法

堆排序就是利用堆(假设大顶堆)进行排序的方法。基本思想是:将待排序的序列造成一个大顶堆,此时整个序列的最大值就是堆顶的根节点。将它移走(即将其与堆数组的末尾元素交换,此时末尾元素就是最大值),然后将剩余的n-1个序列重新构造一个堆,这样就会得到n个元素中的次小值。如此反复,便能得到一个有序序列

实现堆排序需要解决两个问题:

  1. 如何由一个无序序列构建一个堆?
  2. 在输出堆顶元素后,如何调整剩余元素成为一个新的堆?

先看代码:

    def HeapSort(self, array):
        length = len(array)
        i = length // 2
        while i >= 0:
            self.HeapAdjust(array, i, length-1)
            i -= 1
        i = length-1
        while i > 0:
            array[i], array[0] = array[0], array[i] # 将当前堆顶元素与第i个元素交换,实现
            self.HeapAdjust(array, 0, i-1)          # 将0~i的序列的根节点放到末尾
            i -= 1
        return array

    def HeapAdjust(self, array, s, m):
        if m == 0: # m == 0 时好跳出循环
            return
        temp = array[s]
        j = 2 * s
        while j <= m:
            if j < m and array[j] < array[j+1]: # 调整位置s的元素,将其作为它后面元素的堆根	
                j += 1						    # 结点
            if temp > array[j]:
                break
            array[s] = array[j]
            s = j
            j = j*2
        array[s] = temp

3.2.3 堆排序复杂度分析

堆排序运行的主要时间消耗在初始构建堆和在重建堆时的反复筛选上。在构建堆的过程中,因为是在完全二叉树的最下层最右边的非终端结点开始构建,将它与孩子进行比较和若有必要的交换,对于每个非终端结点来说,最多进行两次比较和交换操作,因此构建整个堆的时间复杂度为\(O(n)\)

在正是排序时,第i次取堆顶记录重建堆需要用\(O(logi)\)的时间并且需要取n-1次堆顶记录,故重建堆的时间复杂度为\(O(nlogn)\)

总体来说,堆排序的时间复杂度为\(O(nlogn)\)由于其对原始记录的排序状态不敏感,因此无论最好、最坏和平均时间复杂度均为\(O(nlogn)\)

空间复杂度上,需用一个用来交换的暂存单元,也非常不错。不过由于举几的比较与交换时跳跃式进行的,故堆排序是一种不稳定的排序方法。另外,由于初始构建堆所需的比较次数较多,因此不适合待排序序列个数较少的情况

4.插入排序

4.1 直接插入排序

4.1.1 直接插入排序算法

直接插入排序的基本操作是将记录插入到已经排好序的有序表中, 从而得到一个新的、记录数增1的有序表

先看直接插入排序的代码:

   def InsertSort(self, array):
        length = len(array)
        for i in range(1, length):
            if array[i] < array[i-1]:
                tmp = array[i]
                j = i-1
                while array[j] > tmp and j >= 0:
                    array[j+1] = array[j] # 
                    j -= 1
                array[j+1] = tmp
        return array

4.1.2 直接插入排序复杂度分析

空间复杂度方面,它只需要一个记录的辅助空间。时间复杂度,在最好的情况,即排序的表本身就是有序的。比如待排序序列为{2, 3, 4, 5, 6},那么比较次数共比较了\(n-1(\sum_{i=2}^{n}1)\)次,并且没有移动的记录,时间复杂度为\(O(n)\)

当最坏的情况,即待排序序列是逆序的情况,比如{6, 5, 4, 3, 2},此时比较\(\sum_{i=2}^{n}{i=2+3+...+n}=\frac{(n+2)(n-1)}{2}\)次,而移动记录的次数也达到最大值\(\sum_{i=2}^{n}{i+1}=\frac{(n+4)(n-1)}{2}\)

若待排序记录是随机的,那么根据概率相同的原则,平均比较和移动次数约为\(\frac{n^2}{4}\)次。因此,直接插入排序算法的时间复杂度为\(O(n^2)\)。虽然同样的\(O(n^2)\)时间复杂度,直接插入排序法比冒泡和简单选择排序的性能要好一些。

4.2 希尔排序

4.2.1 希尔排序算法

希尔排序算法代码如下:

    def ShellSort(self, array):
        length = len(array)
        increment = length
        while increment > 1:
            increment = increment // 3 + 1
            for i in range(increment, length): # 相隔 increment进行直接插入排序
                if array[i] < array[i-increment]:
                    temp = array[i]
                    j = i - increment
                    while j >= 0 and temp < array[j]:
                        array[j+increment] = array[j]
                        j -= increment
                    array[j+increment] = temp
        return array


希尔排序是直接插入排序的升级版本,直接插入排序在记录数量少或者记录基本有序的情况下效率很高,但是这两种情况都比较特殊。所以条件不存在时,效率就会变低。“铁人”王进喜有句话说的好,“有条件要上,没有条件创造条件也要上。”那如何让待排序记录较少呢?很容易想到的是分组,将待排序序列分割成若干子序列,此时每个子序列的待排序记录就较少了,然后在这些子序列内分别直接插入排序,整个序列都基本有序时,再对全体记录进行一次直接插入排序。

需要注意,基本有序指的是:小的关键字基本在前面,大的基本在后面,不大不小的基本在中间。比如序列{2, 1, 3, 6, 7, 5, 8, 9}就是基本有序,而序列{1, 5, 9, 3 ,7, 8, 2, 4 ,6}这样9在第三位,2在倒数第三位就不是基本有序。

为达到基本有序,希尔排序采取使用一个增量将整个序列依次间隔增量划分子序列,这样每个子序列排好序后,整个序列便能够达到基本有序。

4.2.2 希尔排序复杂度分析

由于采用“增量”实现跳跃移动,使得排序效率提高,希尔排序算法是不稳定的排序算法。“增量”的选取十分关键,应该选取什么样的增量才是最好,目前还是一个数学难题。但是大量的研究表明,当增量序列为\(dlta[k]=2^{t-k}-1(0\leq k \leq t \leq \lfloor{log_2 (n+1)}\rfloor)\)时,可以获得不错的效果,时间复杂度为\(O(n^{3/2})\),要好于直接排序的\(O(n^2)\)。需要注意,增量序列的最后一个增量必须等于1

5.归并排序

5.1 归并排序算法

归并排序就是利用归并的思想实现的排序方法。原理是假设初始序列含有n个记录,则可以看成是n个有序的子序列,每个子序列的长度为1,然后两两归并,得到\(\lceil{n/2}\rceil\)个长度为2或为1的有序子序列;再两两归并,......,如此重复,直至得到一个长度为n的有序序列为止,这种排序方法称为2路归并排序。

代码:

 def MergeSort(self, array):
        self.MSort(array, 0, len(array))
        return array

    def MSort(self, array, s, t):
        if s == t:
            return
        else:
            m = (s+t) // 2
            self.MSort(array, s, m)
            self.MSort(array, m+1, t)
            self.Merge(array, s, m+1, t)

    def Merge(self, SR, s, m, n):

        left = []
        right = []
        for i in range(s, m):
            left.append(SR[i])
        for i in range(m, n):
            right.append(SR[i])
        left_size = m - s
        right_size = n - m
        i = 0
        j = 0
        k = s
        while i<left_size and j < right_size:
            if left[i] < right[j]:
                SR[k]=left[i]
                i+=1
            else:
                SR[k]=right[j]
                j+=1
            k+=1
        while i<left_size:
            SR[k]=left[i]
            k+=1
            i+=1
        while j<right_size:
            SR[k]=right[j]
            k+=1
            j+=1

5.2 归并排序复杂度分析

一趟归并需要将SR[0]~SR[n-1]中相邻的长度为h的有序序列进行两两归并,并将结果放回,这需要将待排序序列中的所有记录扫描一遍,因此耗费\(O(n)\)时间,而由完全二叉树的深度可知,整个归并排序需要进行\(\lceil log_2 n \rceil\)次,因此,总的时间复杂度为\(O(nlogn)\),这是归并排序算法中最好、最坏、平均的时间性能

归并排序在归并过程中需要与原始记录序列同样数量的存储空间存放归并结果及递归深度为\(log_2 n\)的栈空间,因此空间复杂度为\(O(n+long_2 n)\)。同时归并排序是一种稳定的算法,总的来说,归并排序是一种比较占用内存,但效率高且稳定的算法。