十大经典排序算法详解

Posted yunweigo

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了十大经典排序算法详解相关的知识,希望对你有一定的参考价值。

本文转自 《卢明冬的博客》

排序算法是《数据结构和算法》中非常基础的算法,但却占据着十分重要的位置,几乎可以说是我们在日常编程代码中使用最频繁的基础算法。本文对常见的十大经典排序算法进行了详细的知识点梳理,从排序思路、动图演示、代码实现、复杂度分析、算法优化等多个方面分别对不同的排序算法进行讲解,内容详实,一篇文章几乎囊括了排序算法所有必知必会的知识点,夸张点说,算得上是 “史上最全” 排序算法讲解。

排序算法的分析和评价

时间复杂度

  • 最好情况、最坏情况、平均情况时间复杂度
    分析排序算法的时间复杂度时,要分别给出最好情况、最坏情况、平均情况下的时间复杂度。之所以这样区分分析,一是便于排序算法的对比分析,二是待排序数据有的接近有序而有的则完全无序,我们需要知道排序算法在不同数据下的性能表现,从而能够在不同的场景下选择更加适合的排序算法。

  • 时间复杂度的系数、常数 、低阶
    时间复杂度反应的是数据规模 n 很大的时候的一个增长趋势,所以它表示的时候通常会忽略系数、常数、低阶。但是实际的软件开发中,我们排序的可能是 10 个、100 个、1000 个这样规模很小的数据,所以,在对同一阶时间复杂度的排序算法性能对比的时候,我们就要把系数、常数、低阶也考虑进来时间复杂度反应的是数据规模 n 很大的时候的一个增长趋势,所以它表示的时候通常会忽略系数、常数、低阶。但是实际的软件开发中,我们排序的可能是 10 个、100 个、1000 个这样规模很小的数据,所以,在对同一阶时间复杂度的排序算法性能对比的时候,我们就要把系数、常数、低阶也考虑进来

  • 比较次数和交换(或移动)次数
    基于比较的排序算法的执行过程,会涉及两种操作,一种是元素比较大小,另一种是元素交换或移动。所以,如果我们在分析排序算法的执行效率的时候,应该把比较次数和交换(或移动)次数也考虑进去。

空间复杂度
排序算法的空间复杂度引入了一个特别的概念,即原地排序 (Sorted in place),也称内部排序。原地排序算法特指空间复杂度是 𝑂(1) 的排序算法,也就是不借用外部多余的(内存)空间消耗,只占用待排序数据原有的空间。

稳定性
排序算法还有一个重要的度量指标是稳定性。它表示如果待排序的数列中存在值相等的元素,经过排序之后,相等元素之间原有的先后顺序不变。稳定性也是实际业务中必须要考虑的因素,比如交易系统,订单金额可能一样,但订单依然有时间上的前后顺序关系。从稳定性角度来讲,有稳定的排序算法也有不稳定的排序算法。

2.十大排序经典算法总览

2.1 排序算法的分类

为了便于集中分析,我们可以把经典的十大排序算法进行不同方式的分类:

按时间复杂度,可以把排序算法分为平方阶、对数阶、线性阶三类;

按空间复杂度,可以分为原地(In-place)排序算法和非原地(Out-place)排序;

稳定性,可以分为稳定排序算法和不稳定排序算法;

是否基于比较,可以分为比较排序算法和非比较排序算法。

2.2 排序算法的性能

排序算法平均时间复杂度最好情况最坏情况空间复杂度原地排序稳定排序比较排序
冒泡排序𝑂(𝑛2)𝑂(𝑛)𝑂(𝑛2)𝑂(1)
选择排序𝑂(𝑛2)𝑂(𝑛2)𝑂(𝑛2)𝑂(1)×
插入排序𝑂(𝑛2)𝑂(𝑛)𝑂(𝑛2)𝑂(1)
希尔排序𝑂(𝑛log2𝑛)𝑂(𝑛log𝑛)𝑂(𝑛2)𝑂(1)×
归并排序𝑂(𝑛log𝑛)𝑂(𝑛log𝑛)𝑂(𝑛log𝑛)𝑂(𝑛)×
快速排序𝑂(𝑛log𝑛)𝑂(𝑛log𝑛)𝑂(𝑛2)𝑂(log𝑛)×
堆排序𝑂(𝑛log𝑛)𝑂(𝑛log𝑛)𝑂(𝑛log𝑛)𝑂(1)×
计数排序𝑂(𝑛+𝑘)𝑂(𝑛+𝑘)𝑂(𝑛+𝑘)𝑂(𝑛+𝑘)××
桶排序𝑂(𝑛+𝑘)𝑂(𝑛)𝑂(𝑛2)𝑂(𝑛+𝑘)××
基数排序𝑂(𝑛×𝑘)𝑂(𝑛×𝑘)𝑂(𝑛×𝑘)𝑂(𝑛+𝑘)××

*符号说明:𝑛 为数据规模,𝑘 为分桶数量。

2.3 各阶复杂度性能对比

性能对比

2.4 排序算法的初始状态影响

  1. 算法复杂度与初始状态无关的算法:

    选择排序、归并排序、堆排序、基数排序。

  2. 总排序趟数与初始状态无关:

    快速排序的排序次数(递归深度)与分区点选择(初始状态)有关,还有一个优化后的冒泡排序和后序是否有序有关,其他排序算法的总排序次数均只与总长度 n 有关,与初始状态无关2。

  3. 元素总比较次数与初始状态无关:

    基数排序、选择排序。

  4. 元素总移动次数与初始状态无关:

    基数排序、归并排序。

3.十大经典排序算法详解

3.1 冒泡排序

思路
冒泡排序的基本思路是重复地走访要排序的数列,每次比较两个相邻元素,顺序错误则交换位置(大的下沉放后面、小的上浮放前面),重复进行直到没有元素再需要替换。一次冒泡会让至少一个元素移动到它应该在的位置,重复 n 次,就完成了 n 个数据的排序工作。冒泡排序名字由来是因为越小或越大的元素会经由交换慢慢 “浮” 到数列的顶端(升序或降序排列),如同一个个上升的气泡

场景
适用于元素较少和数组基本有序的情况。

步骤

  1. 比较相邻的元素,如果第一个比第二个大,就交换它们两个;
  2. 对每一对相邻元素作同样的工作,从开始第一对到结尾的最后一对。这步做完后,最后的元素会是最大的数。
  3. 针对所有的元素重复以上的步骤,除了最后一个。
  4. 持续每次对越来越少的元素重复上面的步骤,直到没有任何一对数字需要比较3。

动图

主要动作:比较和移动
在这里插入图片描述
代码

def bubbleSort(arr):
    for i in range(1, len(arr)):  # 对L-1个位置进行迭代
        for j in range(0, len(arr)-i):  #  最后的是最大的,对比次数每次都减小1次
            if arr[j] > arr[j+1]:
                arr[j], arr[j+1] = arr[j+1], arr[j]
    return arr

性能

  • 时间复杂度

    • (最好)当输入的数据已经是正序时,不需要进行排序,𝑂(𝑛)。

    • (最坏)当输入的数据是反序时,n 个元素每个元素都要交换 n 次,所以是 𝑂(𝑛2)。

    • (平均)冒泡排序的时间复杂度和逆序度有关系,每交换一次,有序度就加 1。不管算法怎么优化改进,交换次数总是确定的,即为逆序度(这个也是冒泡排序的一大缺点,可优化空间太小),逆序对也就是满有序度 - 初始有序度(相当于排序后的有序度减去开始排序前的有序度)。

有序度是数列中具有有序关系的元素对的个数,逆序度定义相反,完全有序的数列的有序度叫作满有序度,值为 𝑛×(𝑛−1)/2,逆序度 = 满有序度 - 有序度。排序的过程就是一种增加有序度,减少逆序度的过程,直到最后达到满有序度。

最坏情况下,初始状态的有序度是 0,所以要进行 𝑛×(𝑛−1)/2 次交换。

最好情况下,初始状态的有序度是 𝑛×(𝑛−1)/2,就不需要进行交换。

我们可以取个中间值 𝑛×(𝑛−1)/4,来表示初始有序度既不是很高也不是很低的平均情况。换句话说,平均情况下,需要 𝑛×(𝑛−1)/4 次交换操作,比较操作肯定要比交换操作多,而复杂度的上限是 O(n),所以平均情况下的时间复杂度就是 O(n)。

  • 空间复杂度
    冒泡排序的过程只涉及相邻数据的交换操作,只需要常量级的临时空间,所以它的空间复杂度为 𝑂(1),是一个原地排序算法。

  • 稳定性
    相邻的两个元素大小相等时不做交换,相同大小的数据在排序前后不会改变顺序,所以冒泡排序是稳定的排序算法。

优化
冒泡排序的优化思路主要是识别出已经有序的部分,避免这些部分被重复遍历比较。

优化一:对于连片有序而整体无序的数据 (例如:1, 2,3 ,4 ,7,6,5),当已经完成有序时,后面的剩余走访都是多余的,因此加入一个标记(代码中的 is_sorted),如果某次遍历没有发生元素交换,说明说明这组数据已经有序,不用再继续下去,直接跳出循环。

优化二:对于前面大部分是无序而后边小半部分有序的数据 (例如:1,2,5,7,4,3,6,8,9,10),我们可以继续优化。可以记下最后一次交换的位置(代码中 last_exchange_index),后边没有交换,必然是有序的,然后下一次排序从第一个比较到上次记录的位置结束即可。

冒泡排序优化代码

def bubbleSort_OP(arr):
    last_exchange_index = 0 # 用于记录最后一次交换的位置
    sort_border = len(arr) - 1 # 无序数列的边界,每次比较的终止点
 
    for i in range(1, len(arr)):
        is_sorted = True # 有序标记
        for j in range(0, sortBorder): # 只遍历到无序数列边界
            if arr[j] > arr[j+1]:
                arr[j], arr[j+1] = arr[j+1], arr[j]
                
                # 有元素交换,所以不是有序,标记变为false
                is_sorted = False
                # 把无序数列的边界更新为最后一次交换元素的位置
                last_exchange_index = j
             
        sort_border = last_exchange_index
        if is_sorted:
            break
    return arr

参考冒泡排序算法优化

特点

  • 适用场景:适用元素较少的情况下和数组基本有序的情况;
  • 优点:实现简单,空间复杂度低,稳定;
  • 缺点:时间复杂度高,效率慢。

3.2.选择排序(Selection Sort)

思路

将待排序数据分为两个区间,已排序区间和未排序区间。选择排序每次会从剩余未排序区间中选择一个最小(大)的元素,将其交换至已排序区间的末尾,直到所有元素排序完毕。

步骤

首先在未排序序列中找到最小(大)元素,交换到排序序列的起始位置;
再从剩余未排序元素中继续寻找最小(大)元素,然后交换到已排序序列的末尾;
重复第二步,直到所有元素均排序完毕。

动图
主要动作:比较和交换

在这里插入图片描述

代码

def selectionSort(arr):
    for i in range(0, len(arr)-1):
        min_index = i
        for j in range(i+1, len(arr)):
            if arr[j] < arr[min_index]:
                min_index = j
        if i != min_index:
            arr[i], arr[min_index] = arr[min_index], arr[i]
    return arr

性能

  • 时间复杂度

    最好、最坏、平均时间复杂度均为 𝑂(𝑛2),因为选择排序的时间复杂度与数据原本有序度没有关系,它需要的遍历次数是固定的,不会受到数据原本的有序度的影响。

    虽然选择排序和冒泡排序的时间复杂度一样,但实际上,选择排序进行的交换操作很少,最多会发生 𝑁−1 次交换。而冒泡排序最坏的情况下要发生 𝑁22 次交换操作。从这个意义上讲,选择排序的性能略优于冒泡排序。而且,选择排序比冒泡排序的思想更加直观。

  • 空间复杂度

选择排序只涉及最小(大)元素和已排序的末尾元素的交换,只需要常量级的临时空间,不需要额外空间来进行排序,所以它的空间复杂度为 𝑂(1),是一个原地排序算法。

  • 稳定性

    选择排序是不稳定排序算法,因为每次都要找剩余未排序元素中的最小值,并和前面的元素交换位置,等值的元素随时可能会被置换到后面,发生相对位置改变,这样破坏了稳定性。

优化

选择排序优化思路之一是 “双路优化”,每次遍历剩余元素的时候,一次确定两个元素的位置,找出其中最小值和最大值,比如升序排序,每次将最小值放在起始位置,最大值放在末尾位置。这样遍历的次数会减少一半。时间复杂度是 𝑂(𝑁2×𝑁2),虽然还是平方级别的,但是运行时间有了相应的减少。

def selectionSort_OP(arr):
    length = len(arr)
    for i in range(length - 1):
        print(arr) # 打印每一次选择后的结果
        min_index = i # 最小值下标 
        max_index = length - i - 1 # 最大值下标
        for j in range(i + 1, length - i -1):
            if arr[min_index] > arr[j]:
                min_index = j
            if arr[max_index] < arr[j]:
                max_index = j
        # 当最小元素的位置+1 = 最大元素的位置时,表明数据已经全部有序,直接退出。
        if min_index + 1 == max_index:
            break
        #前面的数据与最小值交换
        if min_index != i: # 加判断避免自己和自己交换
            arr[i], arr[min_index] = arr[min_index], arr[i]
        #后面的数据与最大值交换
        if max_index != length - i -1: # 避免自己和自己交换
            arr[length - i - 1], arr[max_index] = arr[max_index], arr[length - i - 1]
    return arr

特点

适用场景:适用元素较少的情况下和数组基本有序的情况;
优点:交换次数少,移动次数确定 n 次;
缺点:效率慢,不稳定。

3.3.插入排序(Insertion Sort)

思路

将待排序数据分为两个区间,已排序区间和未排序区间。初始已排序区间只有一个元素,就是数组中的第一个元素。插入算法的核心思想是取未排序区间中的元素,在已排序区间中找到合适的插入位置将其插入,并保证已排序区间数据一直有序。重复这个过程,直到未排序区间中元素为空,算法结束。

步骤

将第一待排序序列第一个元素看做一个有序序列,把第二个元素到最后一个元素当成是未排序序列。
从头到尾依次扫描未排序序列,将扫描到的每个元素插入有序序列的适当位置。(如果待插入的元素与有序序列中的某个元素相等,则将待插入元素插入到相等元素的后面。)
动图

主要动作:比较和移动
在这里插入图片描述
代码

def insertionSort(arr):
    for i in range(len(arr)):
        pre_index = i - 1
        current = arr[i]
        while pre_index >= 0 and arr[pre_index] > current:
            arr[pre_index+1] = arr[pre_index]
            pre_index -= 1
        arr[pre_index+1] = current
    return arr

性能

  • 时间复杂度

    (最好)如果要排序的数据已经是有序的,并不需要搬移任何数据。只是从头到尾遍历了一遍有序数据进行比较,所以这种情况下,最好是时间复杂度为 𝑂(𝑛)。
    (最坏)如果数组是完全倒序的,每次插入都相当于在数组的第一个位置插入新的数据,需要移动大 量的数据,所以最坏情况时间复杂度为 𝑂(𝑛2)。
    (平均)数组中插入一个数据的平均时间复杂度是 𝑂(𝑛)。所以,对于插入排序来说,每次插入操作都相当于在数组中插入一个数据,循环执行 n 次插入操作,所以平均时间复杂度为 𝑂(𝑛2)。
    空间复杂度

    插入排序算法的运行并不需要额外空间来进行排序,所以它的空间复杂度为 𝑂(1),是一个原地排序算法。

  • 稳定性

等值元素可以选择将后面出现的元素,插入到前面出现元素的后面,这样就可以保持原有的前后顺序不变,所以插入排序是稳定的排序算法。

  • 优化

上面提到的插入排序算法其实是直接插入排序(straight insertion sort),它还有很多优化算法,如:

  • 折半插入排序(binary insertion sort)

思路:直接插入排序在插入到已排序的数据时采用的是顺序查找的方式,因为已排序区域已经是有序数据,所以可以考虑使用折半查找(二分查找)的方法来进行插入,所以称为折半插入排序。

优缺点:折半插入排序算法相比较于直接插入排序算法,只是减少了比较次数,而移动次数没有进行优化,所以该算法的时间复杂度仍是 𝑂(𝑛2)。

  • 二路插入排序(two-way insertion sort)

思路:

直接插入排序是一个原地排序算法,因为基础数据结构是数组,内存空间固定,将后面的元素插入到前面必然需要先将其他元素往后移动,以此来保持相对有序。当前位置与正确顺序位置的距离越远,那么需要移动次数就越多。二路插入排序算法是对折半插入排序的进一步改进,主要目的是减少其在排序过程中移动元素的次数从而提高效率。

为了减少移动次数,二路插入排序借助了一个辅助数组 A,其大小与原数组一样,这个数组需要设置成环状数组(代码中通常是在基本数组结构中对数组索引进行一个巧妙取余运算来实现的,所以仅仅是一个逻辑环状数组),这样便可以进行双端插入,这也是二路插入排序名称的由来。大致过程是将(原数组)无序表中第一个记录添加进 A[0] 的位置上,然后从无序表中第二个记录开始,同 A[0] 作比较:如果该值比 A[0] 大,则添加到其右侧;反之添加到其左侧。当所有元素分配好后,其实数组已经变成两个有序区,整体也是一个有序序列。

详细步骤说明

  1. 设定一个辅助数组 A,大小是原来数组相同的大小,将原数组第一个元素赋值给 A[0],作为标志元素;
  2. 通过设置 first 和 final 指向整个有序序列的最小值和最大值,即为序列的尾部和头部,并且将其设置位一个循环数组,这样就可以进行双端插入;
  3. 按顺序依次插入剩下的原数组的元素;
  4. 将待插入元素与 A[0] 比较,偌大于 A[0],则插入 A[0] 前面的有序序列,否则插入后面的有序序列,具体定位可用折半查找。
  5. 查找到插入位置后进行记录的移动,分别往 first 方向前移和往 final 方向移动
  6. 插入记录将排序好的 A 数组的数据从 first 到 final,按次序赋值回原数组。
    二路插入排序 Python 代码(含折半插入排序代码):
def two_insertionSort(arr):
    n = len(arr)
    A = [0] * n
    A[0] = arr[0] 
    first, final = 0, 0 
    for i in range(1, n):
        cur = arr[i]
        if cur >= A[final]:  # 待插入元素比最大的元素大,插入右边
            final += 1
            A[final] = cur 
        elif cur <= A[first]: # 待插入元素比最小的元素小,插入左边
            first = (first - 1 + n) % n  # 取余运算,模拟环状数组,已实现前端插入
            A[first] = cur 
        else: # 插入元素比最小大,比最大小,这里使用折半插入(二分查找)法插入
            if cur < A[0]:
                low, high = first, n - 1
            else:
                low, high = 0, final
            while low <= high:
                m = low + (high - low) // 2 
                if cur < A[m]:
                    high = m - 1
                else:
                    low = m + 1
            # 用二分查找查到的要插入的位置是high+1,需要先移动原来的有序元素,腾出位置来。
            j = final
            while j != high:  
                # 考虑环形数组,全部用取余的方式实现分别往first方向前移和往final方向移动。
                A[(j + 1) % n] = A[j]
                j = (j - 1 + n) % n
            # 插入新元素
            A[(high + 1) % n] = cur 
            final += 1
    # 将排序好的A数组的数据从first到final,按次序赋值回原数组。
    j = first
    for i in range(n):
        arr[i] = A[j]
        j = (j + 1) % n
    return arr

优缺点:

二路插入排序在折半插入排序减少比较次数的基础上,进一步减少了移动次数,其平均移动次数约为 18𝑛2,但也只是减少了移动次数,并没有从根本上避免,所以其时间复杂度仍为 𝑂(𝑛2)。而且二路插入排序为了减少移动次数借助了外部空间,其空间复杂度变为 O(n),不再是原地排序算法。

  • 表插入排序(list insertion sort)

思路:

前面所介绍到的三种插入排序算法,其基本数据结构都采用数组的形式进行存储,因而无法避免排序过程中产生的数据移动的问题。如果想要从根本上解决只能改变数据的存储结构,比如可以使用链表存储。

表插入排序,即使用链表的存储结构对数据进行插入排序。在对记录按照其关键字进行排序的过程中,不需要移动元素的存储位置,只需要更改结点间指针的指向。

优缺点:

与直接插入排序相比只是避免了插入时元素的移动,而插入过程中比较次数并没有改变,所以表插入排序算法的时间复杂度仍是 𝑂(𝑛2)。

  • 希尔排序(Shell’s Sort)希尔排序是直接插入排序的一种更高效的改进版本,它从根本上降低了时间复杂度,也被列入常见的十大排序算法之一,具体内容可参考下一小节。

特点

  • 适用场景:数据少并且数组大部分有序时;
  • 优点:稳定,相对于冒泡和选择更快;
  • 缺点:比较次数不一定,比较次数越少,插入点后的数据移动越多,特别是当数据总量庞大的时候,但用链表可以解决这个问题。

3.4.希尔排序(Shell’s Sort)

希尔排序是直接插入排序算法的一种更高效的改进版本,又称 “缩小增量排序”(Diminishing Increment Sort)。

思路

希尔排序对直接插入排序的两个改进点是:越有序直接插入排序效率越高,直接插入排序每次只能移动一位。

希尔排序利用分组粗调的方式使得整个序列先变得 “基本有序”,这样再用插入排序可以极大地减少工作量。希尔排序的整个排序过程按照不同步长(增量)对元素进行插入排序,当刚开始元素很无序的时候,步长最大,所以插入排序的元素个数很少,速度很快;当元素基本有序了,步长很小,插入排序对于有序的序列效率很高。具体做法是先设定一个缩小增量的规则,以某个增量选取数组中元素进行分组,对每个分组进行直接插入排序,然后缩小增量再次分组排序,依次类推,直到增量缩小到 1,程序结束。

增量也被称为间隔(gap)或步长,用途就是按照一个增量跨越选取进行多次分组,增量的选择可以有很多种,比较常用的是逐步折半的增量方法,如 8 个元素所使用的分组跨度为(4,2,1),这个逐步折半法是 Donald Shell 在发明希尔排序时提出的一种朴素方法,被称为希尔增量。

希尔排序的核心在于增量序列的设定。代码中既可以提前设定好增量序列,也可以动态的定义增量序列。动态定义增量序列的算法是《算法(第 4 版)》的合著者 Robert Sedgewick 提出的。

步骤
选择一个增量序列 𝑡1,𝑡2,……,𝑡𝑘,其中 𝑡𝑖>𝑡𝑗, 𝑡𝑘=1;
按增量序列个数 k,对序列进行 k 趟排序;
每趟排序,根据对应的增量 𝑡𝑖,将待排序列分成若干长度为 m 的组,分别对各组元素进行直接插入排序。仅增量因子为 1 时,整个序列作为一个表来处理,表长度即为整个序列的长度。

动图
主要动作:分组、比较、移动
在这里插入图片描述

代码

使用希尔增量序列,最坏复杂度 𝑂(𝑛2):

def shellSort(arr):
    n = len(arr)
    gap = n // 2
    while gap > 0:
        for i in range(gap, n):
            temp = arr[i]
            j = i - gap
            while j >= 0 and arr[j] > temp:
                arr[j + gap] = arr[j]
                j = j - gap
            arr[j + gap] = temp
        gap = gap // 2
    return arr

性能

时间复杂度

希尔排序在直接插入排序的基础上,增加了一个新的特性,从根本上提高了效率。希尔排序的排序效率取决于分组使用的增量序列,除了大小关系,还有序列间的数学性质,比如它们的公因子等。但关键词比较次数、记录移动次数和增量序列选择之间的关系,至今没有一个统一的公式可以归纳,是数学上的一个难题,所以分析起来情况比较复杂,所以这里我们只简单总结一下,不再进行详细分析。

(最好)因为希尔排序的性能和增量序列的设计有关,对于大部分增量序列的设定方法,最好的情况可以达到 𝑂(𝑛log𝑛);对于比较差的增量序列设定方法,最好的时间复杂度是 𝑂(𝑛log2𝑛)。

注:𝑂(log2𝑛)=𝑂(log𝑛)2=𝑂(log𝑛×log𝑛)
因此,复杂度 𝑂(log2𝑛)>𝑂(log𝑛)。

目前已知最好增量序列是由 Sedgewick 提出的 (1,5,19,41,109,209,505,929,2161…),该序列的项来自 9×4𝑖−9×2𝑖+1 以及 2𝑖+2×(2𝑖+2−3)+1 这两个算式。

关于希尔排序最好情况的时间复杂度,其实有些争议,我目前看到有三种不同的说法:𝑂(𝑛)、𝑂(𝑛log𝑛)、𝑂(𝑛log2𝑛)。

经过上面的分析,𝑂(𝑛log2𝑛) 应该是不太可能的,因此争议主要集中在 𝑂(𝑛) 和 𝑂(𝑛log𝑛) 这两种,对于大部分增量序列设定,在待排序列已经完全有序情况下,最内层的循环实际上基本不会发生,因此每个间隔(或增量)的比较总数等于数组的大小。以希尔增量序列为例,其时间复杂度为:

𝑂((𝑛−𝑛/2)+(𝑛−𝑛/4)+(𝑛−𝑛/8)++(𝑛−1))𝑂(𝑛log𝑛−𝑛)𝑂(𝑛log𝑛)

可能确实有某些希尔排序的变体可以实现最好情况的时间复杂度是 O(n),不过对于大部分增量序列的设定方法,最好情况的时间复杂度是 𝑂(𝑛log𝑛),在英文维基百科中,也是这样标注的,所以本文将 𝑂(𝑛log𝑛) 作为希尔排序的最好情况时间复杂度。

(最坏)对于目前已知最好的增量序列设定方法在最坏情况下,复杂度是 𝑂(𝑛log2𝑛);对于比较差的的增量序列设定方法(比如常用希尔增量序列),最坏的时间复杂度是 𝑂(𝑛2)。

(平均)希尔排序的平均时间复杂度与增量序列有关,很多资料讲希尔排序平均时间复杂度是 𝑂(𝑛log𝑛),但实际上不一定能达到,因为希尔算法在最坏的情况下和平均情况下执行效率相差不是很多(百度百科),个人觉得可以概括性的认为大部分的较好的增量序列方法平均时间复杂度是 𝑂(𝑛log2𝑛),是介于 𝑂(𝑛log𝑛) 和 𝑂(𝑛2) 之间的复杂度(也有些地方提到复杂度大概是 𝑂(𝑛(1.3))~𝑂(𝑛2))。

这样我们很容易作对比,希尔排序没有快速排序算法快(𝑂(𝑛log𝑛)),因此中等大小规模表现良好,对规模非常大的数据排序不是最优选择,但是比直接插入排序等 𝑂(𝑛2) 复杂度的算法快得多。一个应用经验是,几乎任何排序工作在开始时都可以用希尔排序,若在实际使用中证明它不够快,再改成快速排序这样更高级的排序算法。

空间复杂度

希尔排序只需要在原数组内部完成逻辑分组和交换,并不需要额外空间来进行排序,所以它的空间复杂度为 𝑂(1),是一个原地排序算法。

稳定性

一次插入排序是稳定的,不会改变相同元素的相对顺序,但希尔排序进行了多次分组插入排序,在不同的插入排序过程中,相同的元素可能在各自的插入排序中移动,最后其稳定性就会被打乱,所以希尔排序是不稳定的。

希尔排序的优势是实现简单,空间复杂度和时间复杂度都不是很高,劣势是希尔排序是非稳定排序算法,另外希尔排序的时间复杂度分析比较困难。

优化

希尔排序的复杂度和增量序列直接相关,可以使用更加复杂的增量序列达到优化目的。 比如使用 Knuth 增量序列,最坏复杂度 𝑂(𝑛32):

def shellSort_OP(arr):
    gap = 1
    while gap < len(arr) // 3:
        gap = gap * 3 + 1   # 动态定义间隔序列,[1 , 4 , 13 , 40 , 121 , 364 , 1093...]
    while gap > 0:
        for i in range(gap, len(arr)):
            temp = arr[i]
            j = i - gap
            while j >= 0 and arr[j] > temp:
                arr[j+gap] = arr[j]
                j -= gap
            arr[j+gap] = temp
        gap = gap // 3  
    return arr

特点

  • 适用场景:可以用于大型数组,比插入排序和选择排序快;
  • 优点:原地排序,空间复杂度 O(1)。改进的插入排序,相对高效的基于交换元素的排序算法;
  • 缺点:不稳定,时间复杂度依赖于增量序列函数。

3.5.归并排序(Merge Sort)

思路

归并排序的核心思想就是把要排序的序列从中间分成前后两部分,然后对前后两部分分别排序,这个分裂过程可以进行多次,直到最小单元非常容易完成排序,最后再将排好序的两部分依次合并在一起,这样整个序列就都有序了。通常我们提到的归并排序大部分情况是将两个有序序列合并成一个有序序列,称为二路归并(2 路归并),当然也有多路的归并排序。

归并排序使用的是分治思想(Divide and Conquer),也称分治法,顾名思义,就是分而治之,将一个大问题分解成小的子问题来解决。小的子问题解决了,大问题也就解决了。

分治思想通常用递归来实现,分治是一种解决问题的算法思想,递归是一种编程技巧,二者并不冲突。因为递归都可以用迭代重写(只是有些实现起来逻辑非常复杂),所以归并也可以用迭代来实现,不同的是,递归是自上而下的,而迭代是自下而上的。

步骤

分裂部分

  1. 把长度为 n 的输入序列分成两个长度为 n/2 的子序列;
  2. 对这两个子序列分别采用归并排序;
  3. 将两个排序好的子序列合并成一个最终的排序序列;

合并部分

  1. 申请空间,使其大小为两个已经排序序列之和,该空间用来存放合并后的序列;
  2. 设定两个指针,最初位置分别为两个已经排序序列的起始位置;
  3. 比较两个指

    以上是关于十大经典排序算法详解的主要内容,如果未能解决你的问题,请参考以下文章

    动画详解十大经典排序算法Java版实现(下)

    动画详解十大经典排序算法Java版实现(下)

    动画详解十大经典排序算法Java版实现(上)

    动画详解十大经典排序算法Java版实现(上)

    十大经典排序算法总结(基数排序)

    十大经典排序算法总结(归并排序)