【算法与数据结构】之排序算法

总结


没有绝对最优的排序算法,只有针对具体情况来分析,

  • 如果数据有大量重复元素——三路快排
  • 如果数据近乎有序——插入排序
  • 如果数据的取值范围非常有限(比如学生成绩)——计数排序
  • 是否有稳定性要求——归并排序(选择排序/快速排序/希尔排序/堆排序不稳定)
  • 数据的存储状况——快排非常依赖于数据的随机存取,如果数据是使用链表存储的——归并排序(链表存储的数据,一次只能访问一个元素)
  • 数据量的大小——如果非常大,不足以装载在内存里,需要使用外排序算法

插入排序在数据量少的时候十分快,甚至比O(nlogn)的还快,因此数据量少的时候,使用插入排序,可以达到优化归并排序和快速排序的效果。

排序算法的最优复杂度为O(nlogn),这里按照复杂度来说:

一、O(n^2)复杂度

1. 选择排序O(n^2)

排序思想:在要排序的一组数中,选出最小(或者最大)的一个数与第1个位置的数交换;然后在剩下的数中再找最小(或者最大)的与第2个位置的数交换,以此类推,知道第n-1个元素(倒数第二个数)和第n个元素(最后一个数)比较为止。
复杂度:O(n^2)复杂度,两层循环,复杂度较高,不实用。

def selectionSort(array):
    if not array:
        return
    for i in range(len(array)):
        minIndex = i
        for j in range(minIndex+1, len(array)):
            if array[j] < array[minIndex]:
                minIndex = j
        # 内层循环结束后,minIndex中存放的是最小元素的下标
        array[i], array[minIndex] = array[minIndex], array[i] # 交换位置,将最小元素放到此时i的位置
    return array

2. 冒泡排序O(n^2)

稳定排序,复杂度为O(n^2),没有插入排序效率高,该算法每经过一轮遍历,都要遍历所有所有的元素,而且遍历的轮数和元素数量相当。
当经过第一轮遍历后,最大的元素已经排好序了,第二轮遍历后,次大的元素已经排好序了,依次类推(假定是从小到大排序)。
参考 https://blog.csdn.net/lu_1079776757/article/details/80459370

def BubbleSort(array, n):
    for i in range(n):  # 遍历n轮,才能将所有元素归位
        for j in range(n-1-i): # 每次比较相邻两个位置的元素
            if array[j] > array[j + 1]:
                array[j], array[j + 1] = array[j+1], array[j]
    return array

3. 插入排序(最差情况O(n^2))(重要)

排序思想:从第2个元素开始,和前面所有的元素比较,将其插入到合适的位置。
最差情况下复杂度 θ ( n 2 ) \theta(n^2) θ(n2),对于近乎有序的数组,插入排序排序效率非常高,复杂度为O(n)。(因为只需要比较,不需要交换)

而且,插入排序因为可以提前跳出循环,因此本身效率也要比选择排序更高一点,所以常用。

def InsertSort(array, n):
    for i in range(1, n):
        key = array[i]
        j = i -1
        while j >= 0 and array[j] > key:
            array[j + 1] = array[j]
            j = j - 1
        array[j + 1] = key
    return array

4. 希尔排序O(n^3/2)

讲的非常好 https://blog.csdn.net/qq_39207948/article/details/80006224

希尔排序是插入排序的延申,我们知道,插入排序在小规模数据或者数据基本有序时十分高效。 但是对于大规模无序的数据,插入排序效率很差,改进算法就是——希尔排序。希尔排序是不稳定排序。

【希尔排序的思想】:

  • 首先它把较大的数据集合按一定的增量(gap)分割成若干个小组(逻辑上分组),然后对每一个小组分别进行插入排序,此时,插入排序所作用的数据量比较小(每一个小组),插入的效率比较高。

  • 对每个分组进行插入排序后,各个分组就变成有序的了,我们称为部分有序(整体不一定有序)。

  • 不断缩短增量(二分法),重复第一步操作,直至步长缩减为1,则整个数组被分为一组,此时,整个数组已经接近有序了,插入排序效率高。

  • 希尔排序的代码相比于插入排序,只是在外层多加了一个控制增量gap的循环而已。

【希尔排序复杂度分析】:

希尔排序的复杂度分析极其复杂,我们只需要记住这个结论:希尔排序的复杂度和增量序列是相关的

  1. 例如使用{1,2,4,8,…}这种序列并不是很好的增量序列,使用这个增量序列的时间复杂度(最坏情形)是O(n^2)
  2. Hibbard提出了另一个增量序列{1,3,7,...,2^k-1},这种序列的时间复杂度(最坏情形)为O(n^1.5)
  3. Sedgewick提出了几种增量序列,其最坏情形运行时间为O(n^1.3),其中最好的一个序列是{1,5,19,41,109,...}

【希尔排序代码】:

def ShellSort(array):
    n = len(array)
    gap = n // 2
    while gap > 0:
        for i in range(gap, len(array)):
            while i >= gap and array[i] < array[i - gap]:
                array[i], array[i - gap] = array[i - gap], array[i]
                i -= gap
        gap = gap // 2
    return array

 

二、O(nlogn)复杂度

1. 归并排序O(nlogn)

在这里插入图片描述归并排序是将数组分成log(n)的层级,每个层级使用O(n)的复杂度来排序,合并的复杂度也是O(n),因此达到了O(nlogn)的复杂度。
归并排序需要O(n)辅助空间,这个辅助空间用来存放归并的结果。

class MergeSort:
    def MergeSort(self, array):
        if not array or len(array) <= 1:
            return array
        mid = len(array) // 2
        left = self.MergeSort(array[:mid])
        right = self.MergeSort(array[mid:])
        return self.merge(left, right)

    def merge(self, left, right):
        if not left:
            return right
        if not right:
            return left
        res = []
        i,j = 0, 0
        while i <= len(left)-1 or j < len(right):
            if i == len(left):
                res.extend(right[j:])
                return res
            if j == len(right):
                res.extend(left[i:])
                return res
            if left[i] < right[j]:
                res.append(left[i])
                i += 1
            else:
                res.append(right[j])
                j += 1
        return res

排序算法的选择:

  1. 当数组近乎有序的时候,插入排序要比归并排序好,这是因为插入排序在数组近乎有序的时候,复杂度退化到了O(n),
  2. 数据少的时候使用插入排序比归并排序好,这是因为复杂度虽然一个是O(n^2),一个是O(nlogn),但其实前面都忽略了一个常数项,而插入排序的前面这个常数项更小一些,所以数据少的时候,插入排序更快。

归并排序可以自顶向下,使用递归的方法实现,也可以自底向上,不用递归,使用迭代的方法实现。前者更快。但是后者由于没有使用数组的索引,因此可以对链表进行归并排序。
参考这里 https://www.jianshu.com/p/3f27384387c1
https://blog.csdn.net/su_bao/article/details/81053871

 

2. 快速排序O(nlogn)

快速排序,最关键的就是Partition函数的思想:

第一个元素v设为主元,j是区分大于和小于主元的两个子序列的分界线,更准确来说,j是指向<=主元pivot的最后一个元素的下标。i是待比较的数组下标,如果arr[i] > pivot,什么都不用做,继续循环即可(蓝色区域直接往后延伸1),如果arr[i] < pivot,那么将j先加1,即j指向第一个大于pivot的数,然后交换arr[j]和arr[i]的值,即可将这个元素添加到橙色区域的最后,依次类推。在i遍历到数组末尾时,橙色和蓝色部分已经完全分开,这时,只需将pivot的值和arr[j]的值互换,即可将主元pivot换到中间位置了。Partition的结果是:

# 对array[low, high]进行快速排序
def QuickSort(array, low, high):
    if low >= high:
        return
    if low < high:
        r = Partition(array, low, high) # r是切分点的下标,左边的值都小于等于array[r],右边的都大于等于array[r]
        QuickSort(array, low, r) # 不能写成r-1,python和c++不一样,python是前闭后开区间,相当于array[low,r)
        QuickSort(array, r + 1, high)  # array[r+1, high)  array[r]位置存放的是主元,已经排好序了,所以只需对其两端的数组进行递归的快排

def Partition(array, low, high):
    pivot = array[low]
    # array[low+1, j]  < pivot, array[j+1, i] > pivot, i是待比较元素的下标
    j = low
    for i in range(j + 1, high):
        if array[i] <= pivot:
            j += 1
            array[j], array[i] = array[i], array[j]
    array[low], array[j] = array[j], array[low]
    return j

nums = [6,10,13,5,8,3,2,11]
QuickSort(nums, 0, len(nums))
print(nums)

将主函数和Partation函数融合成一个函数,快速排序代码为:

def quicksort(data, start, end):
    if start >= end:
        return
    left, right = start, end
    base = data[left]

    while left  < right:
        while left < right and data[right] >= base:
            right -= 1
        data[left] = data[right]
        while left < right and data[left] < base:
            left += 1
        data[right] = data[left]
    data[left] = base
    quicksort(data, start, left - 1)
    quicksort(data, left + 1, end)

3. 快速排序的两个改进(随机快排,Partition2)

  1. 快速排序的优点:
    在数据完全随机的情况下,快速排序比归并排序要快30%左右

  2. 快速排序的缺点
    如果数据完全有序或近乎有序的时候,快速排序十分十分慢,快速排序复杂度退化为O(n^2)

  3. 归并排序每次都是将数组一分为二的,但是快速排序是根据主元的大小对数组分成两份,也就是说容易出现一大一小的情况,两边不平衡,它的子数组高度不一定是logn,很有可能比logn大,最坏情况下,递归树的高度为n,每层处理的时候使用O(n)的复杂度来处理,因此复杂度退化为O(n^2)。
    – 改进方法:随机选择主元,快速排序复杂度的期望为O(nlogn)

  4. 当数组中含有大量重复元素时,快速排序比 归并排序慢的多。快排又退化成了O(n^2),因为大量的重复键值会导致递归树的两边不平衡。(等于主元的重复元素会被分到其中一边)
    – 改进方法:重写Partition函数 ,命名为Partition2,:将等于pivot的元素,分散到左右两边,就能保证平衡。

    i从左向右遍历,如果arr[i]小于pivot,不做处理,直到走到arr[i]大于等于pivot的第一个位置停止,j同理,从右向左走到第一个小于等于pivot的位置停止,此时,如果坐标i < j,则交换arr[i]和arr[j]的值,如果i>j,则说明已经遍历完成,直接跳出循环。当遍历完成后,将pivot的值和arr[j]的值互换。(为什么要跟arr[j]的值互换?因为i指向数组中第一个大于等于pivot的位置,j指向数组中最后一个小于等于pivot的位置,只有跟j互换,才能使得主元正好在中间,将两边分开。 )

4. 三路快速排序

三路快速排序用来处理具有重复键值的数组效果非常好。主要是因为它将等于键值的部分放在了中间,递归的时候,值递归两边,减少了数据量。
思想:将数组分成大于,小于,等于主元三个部分,递归处理左右两边大于和小于的部分,中间部分不处理(节省了时间)

i是遍历的下标,根据arr[i]的大小将其放到不同的位置,如果arr[i]<pivot,交换arr[i]和arr[lt+1](arr[lt+1]是等于pivot部分的第一个元素),并且将lt指针往后一位,如果arr[i]>pivot,交换arr[i]和arr[gt-1],然后将gt索引往前一位,如果arr[i]=pivot,不做处理,继续遍历即可。这里只是说了大致思想,边界条件需要特别注意。当i和gt指针重合时,一轮遍历完成,这时需要将pivot的值和lt位置的元素交换即可完成一轮循环:

### array[low,high)排序
class QuickSort3Ways:
    def quickSort3Ways(self, array, low, high):
        if low >= high:
            return
        if low < high:
            lt, gt = self.Partition(array, low, high) # lt是等于pivot的第一个元素,gt是大于pivot的第一个元素
            self.quickSort3Ways(array, low, lt)
            self.quickSort3Ways(array, gt, high)
        return array[low:high - 1] # 返回排好序的数组

    def Partition(self, array, low, high):
        if low >= high:
            return
        # lt指向小于区间内的最后一个位置,gt是大于区间内的前一个位置
        lt, gt = low, high  # 初始化
        i = lt + 1
        pivot = array[low]
        while i < gt:
            if array[i] < pivot:
                array[lt + 1], array[i] = array[i], array[lt + 1]
                lt += 1
                i += 1
            elif array[i] > pivot:
                array[gt - 1], array[i] = array[i], array[gt - 1]
                gt -= 1
            else:  # array[i] == pivot的情况
                i += 1
        array[low], array[lt] = array[lt], array[low] # 交换lr和pivot位置的元素,这是lr指向的是等于区间内的第一个元素,gt仍指向大于区间内的第一个位置
        return lt, gt

5. 归并排序和快速排序的衍生问题

二者都用了分治算法(分而治之)。现在来看几个实际的例子。

求逆序对的个数

逆序对:可以用来衡量数组的有序程度。
暴力解法,两重循环,复杂度O(n^2)
归并排序来解决该问题,复杂度O(nlogn)

取数组中第n大的元素

特殊情况:取数组中的最大值,最小值
遍历:复杂度O(n)

对于一般性问题:取数组中的第n大的元素,两种思路:

  • 排序,再找索引n,算法复杂度O(nlogn)
  • 快速排序,但是每轮结束之后,只需处理n所在的那一半序列,另一半都是不符合要求的,复杂度O(n)
    (算法复杂度 = n + n/2 + n/4 + … + 1=O(2n))

6. 堆排序

堆排序也是一种O(nlogn)级别的排序算法,具体的看这里

7. 排序算法总结

平均时间复杂度最差时间复杂度原地排序额外空间稳定排序
插入排序O(n^2)O(n^2)yesO(1)yes
归并排序O(nlogn)O(nlogn)no(必须开辟额外空间)O(n)yes
快速排序O(nlogn)O(n^2)yesO(logn)no
堆排序O(nlogn)O(n^2)yesO(1)no

【注意】:

  1. 指的是平均时间复杂度,如果数组已经有序,插入排序退化到O(n),对于极其特殊的情况,快速排序可能会退化到O(n^2),因此常用随机快排
  2. 总体而言,快速排序是最快的,一般系统级别的排序都是用快排,对于有大量重复键值的数组,一般使用三路快排。
  3. 插入排序和堆排序可以直接在原地完成,因此额外空间为O(1)
  4. 快排虽然也可以在原地完成,但是在递归的过程中,需要使用额外的O(logn)的栈空间来存储每层的节点,以便递归。

-----2019.9.10更新-----
在这里插入图片描述

排序算法的稳定性

稳定排序:对于相等的元素,在排序后,原来靠前的元素依然靠前,相等元素的相对位置没有发生改变。

如图所示,排序之前,三个3的相对位置为“红绿蓝”,排序之后的相对位置依旧为“红绿蓝”,则称为稳定排序。

【插入排序是稳定排序】
因为对于一个待插入的元素,从后往前判断,只有当 待插入元素小于该元素时,才插到该元素前面,因此保持了稳定性。

【归并排序是稳定排序】
归并的时候,只有序列2中的元素小于序列1中的元素时,才归并进去,因此保持了稳定性。

  • 排序算法的稳定性与算法的实现的过程有关,如果实现的不好,可能会将插入排序和归并排序变成不稳定的。
  • 可以通过自定义比较函数,让排序算法不存在稳定性的问题(对于相等的元素,自定义一个比较函数,将这些相等的元素也排序)

三、线性时间排序算法

前面的所有排序算法都是基于比较的排序算法,下面说的这几种不是基于比较的排序算法。 【参考】

1. 计数排序

【参考】
计数排序不是基于比较的排序算法,其核心在于将输入的数据转化为键存储在额外开辟的数组空间中。作为一种线性时间复杂度的排序,计数排序要求输入的数据必须是有确定范围的整数。

计数排序是一个稳定的排序算法。当输入的元素是 n 个 0到 k 之间的整数时,时间复杂度是O(n+k),空间复杂度也是O(n+k),其排序速度快于任何比较排序算法。当k不是很大并且序列比较集中时,计数排序是一个很有效的排序算法。

计数排序特别适用于数字的范围十分有限的情况,leetcode 75. Sort Colors就是一个计数排序的典型例子。只需统计数字0,数字1,数字2的出现次数即可。

2. 桶排序

桶排序是计数排序的升级版。 它利用了函数的映射关系,高效与否的关键就在于这个映射函数的确定。 【参考】

3. 基数排序

【参考1】【参考2】

基数排序是对桶排序的一种改进,这种改进是让“桶排序”适合于更大的元素值集合的情况,而不是提高性能。一我们平时常见的基数排序是按照低位先排序,然后收集,然后再按照高位排序,然后收集;依次类推。直到最高位。

基数排序的时间复杂度为O (n log( r ) m),其中r为所采取的基数,而m为堆数。基数排序是稳定排序。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值