【排序】排序算法介绍

■  快速排序

  个人感觉快速排序相对还好理解一些。大的框架上来说,快速排序使用的是递归的思想。

  具体描述: 首先获取数组的第0元素作为一个基准(pivot),然后从第1元素开始向右遍历,将所有小于基准的值都尽量往左摆放。具体来说那就是在遍历过程中建立两个游标,一个游标i用来做全遍历,另一个游标d_index用来标明到那个元素为止,前面的所有元素都已经是被判定为小于基准因此被放到左边的边界。比较第i元素和基准大小,对于每比较到一次第i元素小于基准的情况,就交换当前i和d_index两个元素,并且将d_index自增1。遍历完一趟后此时应该交换d_index-1(此时的第d_index元素是属于大于基准的哦),这样基准元素就到了它正确的位置,剩余的事情就是再快速排序一下整个序列中[0:d_index-1]以及[d_index:]这两个子序列即可。

  另外由于用了递归,所以得斟酌下递归返回的条件。可以想象,当递归不断深入,对于右半区的序列而言返回的d_index是越来越大的,总有一天+1后会大于right,同时左半区越来越小,会在某一天等于0。这两者都是跳出递归的条件。总的来说,可以设置一个left<right的条件,控制跳出。

  用代码来说:

def quick_sort(lst,left=0,right=None):
    if right is None:
        right = len(lst) - 1
    if left < right:
        partitionIndex = partition(lst,left,right)
        # 递归处理左半区和右半区(虽说是半区,但并不一定是对半开,根据partitionIndex大小不同可能会有偏颇
        quick_sort(lst,left,partitionIndex-1)
        quick_sort(lst,partitionIndex+1,right)

def partition(lst,left,right):
    '''
    对lst的[left:right+1]序列进行分区,最终返回分区元素的下标d_index-1
    函数返回时数组所处的状态应该是d_index-1左边的都比它小,右边都比它大
    :param lst:
    :param left:
    :param right:
    :return:
    '''
    pivot = left
    i = d_index = pivot + 1
    while i <= right:
        if lst[i] < lst[pivot]:
            lst[d_index],lst[i] = lst[i],lst[d_index]
            d_index += 1
        i += 1
    lst[d_index-1],lst[pivot] = lst[pivot],lst[d_index-1]
    return d_index - 1

 

  (上面的"将小于基准的元素尽量放到左边"的逻辑是从左到右依次循环遍历,碰到小于基准的放到当前未遍历区的最左端。另外还可能有多种实现逻辑,比如两个游标分别从左右开始,右边游标遇到小于基准的值就将其值赋到左边,左边游标则是遇到大雨基准值的放到右边。需要注意要灵活一些。)

  如果要求quick_sort只能接受一个lst参数,那么可以考虑将递归放在partition中进行:

def partition(lst,left,right):
    if left >= right:
        return
    pivot = left
    d = i = left + 1
    while i <= right:
        if lst[pivot] < lst[i]:
            lst[d],lst[i] = lst[i],lst[d]
            d += 1
        i += 1
    lst[d-1],lst[pivot] = lst[pivot],lst[d-1]
    pivot = d - 1    # 别忘了这步,否则pivot是0
    partition(lst,left,pivot-1)
    partition(lst,pivot+1,right)

def quick_sort(lst):
    left = 0
    right = len(lst) - 1
    partition(lst,left,right)
View Code

 

  ●  分析

  快速排序的时间复杂度,关键在于看要进行多少次比较和换位。可以想象,最坏的情况下,每次选出的基准都是当前待排序内容中最大值,这样会导致整个递归过程中的右半区始终是空,所有排序负担都在左半区进行。相当于就是做了一个普通的冒泡排序,所以是O(n^2)的。在理想情况下,每次选择的基准都差不多是待排序区域的中间值,这样的话好比是二叉树的搜索,最终的时间复杂度是O(nlogn)的。总体而言,平均情况下更接近O(nlogn),是众多基于关键字排序算法中比较快的,所以被叫做了快速排序。

  空间上,由于使用了递归,系统自动开辟了一个栈空间用来记录全部出现过的partitionIndex。易知这个栈最坏情况下将是O(n),平均的是O(logn)

 

■  归并排序

  归并的思想其实也不难,其核心想法是将待排序的数组看成前后两个(差不多)等长的数组的组合,只要保证两个数组各自都有序,然后通过一定方法将两个有序的数组再有序地合并成一个大数组就完成了排序。至于两个子数组怎么排序,那么可以使用递归的归并排序即可。这个递归在子数组的长度只有1的时候停止并返回子数组本身用于上一级的归并。

  代码:

  拜Python中方便的数组切片所赐,归并的代码可以写得很清晰

def merge_sort(lst):
    leng = len(lst)
    if leng <= 1:
        return lst  # 这里记得要返回子数组,如果返回None那么这个子数组的信息就丢失了!
    lc = lst[:leng//2]
    rc = lst[leng//2:]
    return list(merge(merge_sort(lc),merge_sort(rc)))

def merge(left,right):
    # res = []  借用了Python的生成器机制,没有单独开一个数组来保存结果
    i,j = 0,0
    m,n = len(left),len(right)
    while i < m and j < n:
        if left[i] <= right[j]:
            # res.append(left[i])
            yield left[i]
            i += 1
        else:
            # res.append(right[j])
            yield right[j]
            j += 1

    # 一个遍历完了另一个还没完时的补处理
    while i < m:
        # res.append(left[i])
        yield left[i]
        i += 1
    while j < n:
        # res.append(right[j])
        yield right[j]
        j += 1

    # return res

   归并排序是一种稳定的排序方法,当多个元素同值时原先就放在左边的元素最终排序出来之后也放在左边。时间复杂度上来说归并排序是O(nlogn)的,并且不论原数组是否处于比较理想的状态都差不多是这个。空间上来说,因为需要额外维护一个数组用来进行子数组的合并(虽然Python用了yield,空间上可能复杂度更小一些),这个数组最大的时候也就是n个元素,因此是O(n)的空间复杂度。

■  希尔排序

  希尔排序可以看做是一个插入排序的改进型。相比于插入排序不断地遍历数组中的所有位置,希尔排序通过一定间隔将数组分成若干组,优先排列距离较远的元素,这样万一较远的元素是可能需要交互位置的元素时可以比较快的完成操作。希尔排序又叫减小增量排序。

  希尔排序的想法是这样的,首先指定一个gap值,通常可以指定为数组长度//2。将数组中所有0,0+gap,0+2*gap...这些下标的元素作为一个组进行简单的插入排序,保证位于这些隔着gap格的位置上的各个元素处于有序的状态。处理完这一组后继续处理1,1+gap...这一组,以此类推。将所有组都处理完成后,将gap减小1,重复上述操作。最终当gap==1的时候,在进行逐个元素之间的微调即可,最终获得有序序列。

  代码:

def shell_sort(lst):
    gap = len(lst) // 2  # 初始指定gap是长度的一半
    while gap > 0:  # 不断缩小gap
        i = gap
        while i < len(lst):  # 经过这个循环可以保证以当前gap为间隔形成的各个组都有序了
            j = i
            while j-gap >= 0:  # 每趟遍历保证最小的放在最左边
                if lst[j-gap] > lst[j]:
                    lst[j-gap],lst[j] = lst[j],lst[j-gap]
                j -= gap
            i += 1

        gap /= 2

  希尔排序的复杂度取决于增量gap的具体设置。像上面这个例子中使用了gap = len(lst) // 2开始计算,这样整个排序的时间复杂度可能在O(n^2)左右。一般希尔排序最低的时间复杂度可以降低到O(nlog2n),比直接插入排序要快一点,但是还是慢于快速排序的O(nlogn)。

  至于空间复杂度,由于不涉及任何递归以及额外的数据结构,所以是O(1)的。

  ●  一般模式说明

  一般说到希尔排序,算法结构就是如上面代码那样的。但是在自己实现的时候,经常会把第二层循环写成for(i=0; i<gap; i++)。然后把第三层循环写成for(j=i; j+gap<len(lst); j+=gap)。这样写其实从功能实现的角度来说没什么毛病,无非是将最里层的循环不变式改成了最大的放在最右边。

  但是需要注意的是公认的希尔排序,还是以上面代码中的模式为准。为了考试面试等,还是需要把自己的思维扭转过来。

 

■  堆排序

  在写二叉树和堆的数据结构的时候已经提到过了堆排序如何构建以及使用。就不再写了。

  不过世面上面常用的堆排序的“模板”在细节上和我看的那本书里描述的堆排序还有些不同(比如约定俗成的函数名等等),下面特地写一下符合世间一般标准的堆排序代码:

def buildMaxHeap(lst):
    for i in range((len(lst)-1)//2,-1,-1):
        heapify(lst,i)

def heapify(lst,i):    # heapify相当于那边书上的shiftdown,不过参数要少一些
    lc = 2 * i + 1
    rc = 2 * i + 2
    while rc < len(lst) or lc < len(lst):
        if rc < len(lst) and lst[rc] > lst[lc]:
            t = rc
        else:
            t = lc
        if lst[t] > lst[i]:
            lst[i],lst[t] = lst[t],lst[i]
        i = t
        lc,rc = 2*i+1,2*i+2

def heapSort(lst):
    buildMaxHeap(lst)
    i = len(lst)-1
    res = []    # 其实如果通过堆本身结构来存储已排序的部分,可以不用这个额外的数据结构,只是heapify中没有end参数,导致Python中很难指定某一次向下筛选的下边界
    while lst:  # 条件也可以写是while i >= 0之类的
        lst[0],lst[i] = lst[i],lst[0]
        res.insert(0,lst.pop())
        heapify(lst,0)
        i -= 1
    return res

 

  无非就是把向下筛选的shiftdown叫做了heapify,并且是通过大顶堆来排序的。

   ●  分析

  正如书上所说,构造堆的过程是一个O(n)的过程(详细证明目前我还做不出来…,凭感觉看应该是O(nlogn)的,但并不是),排序时每个元素都会去进行一次向下筛选,所以总的时间复杂度是O(nlogn)。一般来说如果充分利用堆结构本身的空间那么可以不用外部数据结构记录结果,所以空间复杂度可以做到O(1)。

 

■  计数排序

  * 基数排序和计数排序是两个不同的东西…

  上述所有排序算法,一般情况下时间复杂度是O(nlgn)的,对于某些算法如快速排序,如果情况很糟糕的话反而会更差。

  另一方面,从整体的角度出发看待排序操作,我们必然是要访问数组中每个元素的值的,不然没办法做出完整正确的排序。因此排序算法一般复杂度的下界就是O(n)了。那么有没有O(n)的排序算法实现呢?答案是肯定的。而且为了让时间复杂度降低到O(n),我们必然要采取的策略就是以空间换时间,计数排序就是这样一种线性时间复杂度,但是空间消耗可能比较大的排序算法。

 

  ●  描述与实现

  对于数组中的某个数,排序完成(假设是升序排序)之后这个数在有序数组中的位置如何确定?针对这个问题,显然,答案是其下标是i-1,i是数组中所有小于等于它值的数的个数(由于是小于等于,包括其自身所以要减去1,暂时不考虑有相同值的元素)。

  基于这个显而易见的事实,计数排序的基本想法就是,针对待排序数组lst,额外维护一个数组B。B的长度是max(lst)+1,而B[i]元素的值是lst中所有小于等于i的元素的个数(注意是i,下标值)。

  示例: 如果lst是[2,1,5,3,2,3],那么维护的这个B是[0, 1, 3, 5, 5, 6]。其中B中值的意义就是,lst中小于等于0的值是0个,小于等于1的值的个数是1个,小于等于2的值的个数是3个……

  得出B之后,接下来要做的是逆序遍历原数组lst,对于元素lst[j],访问B[lst[j]],获得到的数字 - 1(减去1是为了调和下标和个数之间相差1的问题)就是这个lst[j]值该在排序后数组安排的下标的位置。安排完成后,别忘了B[lst[j]] -= 1,表明如果后面又遇到和lst[j]等值的元素,那么这些元素应该往前一格安排。由于排序直接针对下标进行赋值,我们不能直接用原lst作为结果的容器,因此可以另新开一个和lst等长的数组C,用C作为结果容器来记录排序结果。

  计数排序的一个特点就是它是稳定的,而稳定的依据在于我们遍历原数组的时候使用的是逆序遍历。由于B中的值是逐渐递减的,所以等值元素安排入新数组的顺序是从后往前的,为了保证稳定性,遍历的时候也从后往前才行。

  实现代码也不难:

def count_sort(lst):
    leng = len(lst)
    B = [0] * (max(lst)+1)    # 这里也务必要+1,若不加1的话max(lst)无法在B中得到维护
    C = [None] * leng

    for i in lst:    # 首先计数每种元素的出现次数
        B[i] += 1
    tmp = 0
    for i,count in enumerate(B):    # 将B[i]重新赋值成sum(B[:i])
        B[i] = count + tmp
        tmp += count

    for j in range(leng-1, -1, -1):    # 逆序遍历
        val = lst[j]
        C[B[val]-1] = val    # B[val] - 1的减去1别忘了
        B[val] -= 1
    return C

 

  该说的上面基本都说了,略微值得一提的是求B的过程,分成两步走。第一步是统计每个值在lst中出现的次数,第二步是将某个下标为i的值B[i]重新赋值成B[0]到B[i-1]所有值的和,如此递归到B最后一个值。这样得到的B就是符合要求的,“小于等于我的元素有多少个”的列表了。

  ●  分析

  回到算法整体上来,上面说了计数算法是个稳定的排序算法。再来看看时间复杂度,组成程序主体的是三个循环,第一个和第三个循环分别是O(n)的,而第二个循环是O(k)的。n,k分别表示原数组lst长度和数组中最大数值。所以整个算法的复杂度是O(n + k)。当k << n的时候,这个算法的排序基本上属于O(n)的。而空间复杂度,我们创建了一个长度为n的结果数组和一个长度为k的辅助数组,因此总空间复杂度也是O(n+k)。

  技术排序的弱点也很明显,如果n不大但是k很大的话,其复杂度还是比较令人恐惧的。

  另外上述具体代码适用的具体条件还有一个就是默认lst中元素都含有正整数的接口供排序使用。如果元素的数值类型不是正整数,或者没有这个接口的话,恐怕要另寻他路。我一开始尝试想使用HashMap来记录元素出现次数,但是发现为了要统计“小于等于我的元素个数”,HashMap本身又要做一次排序…

  

posted @ 2018-08-28 15:14  K.Takanashi  阅读(258)  评论(0编辑  收藏  举报