数据结构与算法分析(七)--- 排序算法分析 + 排序优化

一、如何分析一个排序算法

学习排序算法,我们除了学习它的算法原理、代码实现之外,更重要的是要学会如何评价、分析一个排序算法。那分析一个排序算法,要从哪几个方面入手呢?

1.1 排序算法的执行效率

对于排序算法执行效率的分析,我们一般会从这几个方面来衡量:

  • 最好、最坏、平均情况时间复杂度

我们在分析排序算法的时间复杂度时,要分别给出最好情况、最坏情况、平均情况下的时间复杂度。除此之外,你还要说出最好、最坏时间复杂度对应的要排序的原始数据是什么样的。

为什么要区分这三种时间复杂度呢?第一,有些排序算法会区分,为了好对比,所以我们最好都做一下区分。第二,对于要排序的数据,有的接近有序,有的完全无序,有序度不同的数据,对于排序的执行时间肯定是有影响的,我们要知道排序算法在不同数据下的性能表现。

  • 时间复杂度的系数、常数 、低阶

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

  • 比较、交换或移动的次数

前面介绍过的插入排序、希尔排序、归并排序、快速排序等都是基于比较的排序算法。基于比较的排序算法的执行过程,会涉及两种操作,一种是元素比较大小,另一种是元素交换或移动。所以,如果我们在分析排序算法的执行效率的时候,应该把比较次数和交换(或移动)次数也考虑进去。

1.2 排序算法的内存消耗

算法的内存消耗可以通过空间复杂度来衡量,排序算法也不例外。不过,针对排序算法的空间复杂度,我们还引入了一个新的概念,原地排序(Sorted in place)。原地排序算法,就是特指空间复杂度是 O(1) 的排序算法。

1.3 排序算法的稳定性

仅仅用执行效率和内存消耗来衡量排序算法的好坏是不够的。针对排序算法,我们还有一个重要的度量指标,稳定性。对于稳定的排序算法,如果待排序的序列中存在值相等的元素,经过排序之后,相等元素之间原有的先后顺序不变。

针对排序算法的稳定性举例解释一下,比如我们有一组数据 2,9,3,4,8,3,按照大小排序之后就是 2,3,3,4,8,9,这组数据里有两个 3。经过某种排序算法排序之后,如果两个 3 的前后顺序没有改变,那我们就把这种排序算法叫作稳定的排序算法;如果前后顺序发生变化,那对应的排序算法就叫作不稳定的排序算法。

你可能疑惑,两个 3 哪个在前,哪个在后有什么关系啊,稳不稳定又有什么关系呢?为什么要考察排序算法的稳定性呢?

很多数据结构和算法课程,在讲排序的时候,都是用整数来举例,但在真正软件开发中,我们要排序的往往不是单纯的整数,而是一组对象,我们需要按照对象的某个 key 来排序。

比如说,我们现在要给电商交易系统中的“订单”排序。订单有两个属性,一个是下单时间,另一个是订单金额,如果我们现在有 10 万条订单数据,我们希望按照金额从小到大对订单数据排序,对于金额相同的订单,我们希望按照下单时间从早到晚有序。对于这样一个排序需求,我们怎么来实现呢?

最先想到的方法是:我们先按照金额对订单数据进行排序,然后,再遍历排序之后的订单数据,对于每个金额相同的小区间再按照下单时间排序。这种排序思路理解起来不难,但是实现起来会很复杂。而且假如原始订单就是按照下单时间排好序的,没有充分利用这个有效信息,会让计算机多做很多无效工作,降低算法的执行效率。

借助稳定排序算法,这个问题可以非常简洁地解决。解决思路是这样的:我们先按照下单时间给订单排序(如果订单本身是按照下单时间排好序的,此步可以省略)。排序完成之后,我们用稳定排序算法,按照订单金额重新排序。两遍排序之后,我们得到的订单数据就是按照金额从小到大排序,金额相同的订单按照下单时间从早到晚排序的。为什么呢?

稳定排序算法可以保持金额相同的两个对象,在排序之后的前后顺序不变。第一次排序之后,所有的订单按照下单时间从早到晚有序了。在第二次排序中,我们用的是稳定的排序算法,所以经过第二次排序之后,相同金额的订单仍然保持下单时间从早到晚有序。
排序算法对比

二、基础排序算法分析

2.1 插入排序算法分析

插入排序动画
在博客:递推与递归 + 减治排序中已经详细介绍过插入排序算法,这里为了分析方便,把递归函数变换为迭代循环的形式。按照博客:队列、栈及其STL容器末尾介绍的将尾递归函数转换为迭代循环函数的方法,转换后的插入排序算法如下:

// algorithm\sort.c

void insert_sort(int *data, int n)
{
	int k = 1;
    while(k < n)
    {   
    	int i = k-1, temp = data[k];
    	while (i >= 0 && data[i] > temp)
    	{
        	data[i+1] = data[i];
        	i--;
	    }
    	data[i+1] = temp;
		
		k = k + 1;
	}
}

使用前面介绍的分析方法,来分析一下插入排序算法:

  • 插入排序的时间复杂度是多少?

最好情况下,要排序的数据已经是有序的了,我们只需要进行一次遍历操作(数据有序时,上面代码的内循环退化为常数时间复杂度),就可以结束了,所以最好情况时间复杂度是 O(n)。而最坏的情况是,要排序的数据刚好是倒序排列的,内循环每次都要比较到最开头插入数据(在数组中插入元素的最坏情况复杂度是O(n)),所以最坏情况时间复杂度为 O(n2)。

最好、最坏情况下的时间复杂度很容易分析,那平均情况下的时间复杂是多少呢?我们前面讲过,平均时间复杂度就是加权平均期望时间复杂度,分析的时候要结合概率论的知识。对于包含 n 个数据的数组,这 n 个数据就有 n! 种排列方式。不同的排列方式,插入排序执行的时间肯定是不同的。如果用概率论方法定量分析平均时间复杂度,涉及的数学推理和计算就会很复杂。

针对排序算法,有一种更简单的方法,通过“有序度”和“逆序度”这两个概念来进行分析。有序度是数组中具有有序关系的元素对的个数,有序元素对可以用数学表达式表示如下:

有序元素对:a[i] <= a[j], 如果i < j。

对于一个倒序排列的数组,比如 6,5,4,3,2,1,有序度是 0;对于一个完全有序的数组,比如 1,2,3,4,5,6,有序度就是 n*(n-1)/2 ,也即Cn2,对于上面的数组也就是C62 = 15,我们把这种完全有序的数组的有序度叫作满有序度。

逆序度的定义正好跟有序度相反(默认从小到大为有序),逆序元素对可以用数学表达式表示如下:

逆序元素对:a[i] > a[j], 如果i < j。

关于这三个概念,我们还可以得到一个公式:逆序度 = 满有序度 - 有序度。我们排序的过程就是一种增加有序度,减少逆序度的过程,最后达到满有序度,就说明排序完成了。

插入排序包含两种操作,一种是元素的比较,一种是元素的移动。当我们需要将一个数据 a 插入到已排序区间时,需要拿 a 与已排序区间的元素依次比较大小,找到合适的插入位置。找到插入点之后,我们还需要将插入点之后的元素顺序往后移动一位,这样才能腾出位置给元素 a 插入。对于不同的插入点,元素的比较次数是有区别的。但对于一个给定的初始序列,移动操作的次数总是固定的,就等于逆序度,也就是n*(n-1)/2 – 初始有序度。

比如要排序的数组的初始状态是 4,5,6,3,2,1 ,其中有序元素对有 (4,5) 、(4,6)、(5,6),所以有序度是 3。n=6,所以排序完成之后终态的满有序度为 n*(n-1)/2=15,逆序度就是 15 – 3 = 12,插入排序要进行 12 次移动操作。

对于包含 n 个数据的数组进行插入排序,平均移动次数是多少呢?最坏情况下,初始状态的有序度是 0,所以要进行 n*(n-1)/2 次移动。最好情况下,初始状态的有序度是 n*(n-1)/2,就不需要进行移动。我们可以取个中间值 n*(n-1)/4,来表示初始有序度既不是很高也不是很低的平均情况。换句话说,平均情况下,需要 n*(n-1)/4 次移动操作,比较操作肯定要比移动操作多,而复杂度的上限是 O(n2),所以平均情况下的时间复杂度就是 O(n2)。

这个平均时间复杂度推导过程其实并不严格,但是很多时候很实用,毕竟概率论的定量分析太复杂,不太好用。

  • 插入排序是原地排序算法吗?

从实现过程可以很明显地看出,插入排序算法的运行并不需要额外的存储空间,所以空间复杂度是 O(1),也就是说,这是一个原地排序算法。

  • 插入排序是稳定的排序算法吗?

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

2.2 冒泡排序算法分析

冒泡排序动画
冒泡排序只会操作相邻的两个数据。每次冒泡操作都会对相邻的两个元素进行比较,看是否满足大小关系要求。如果不满足就让它俩互换。一次冒泡会让至少一个元素移动到它应该在的位置,重复 n 次,就完成了 n 个数据的排序工作。当某次冒泡操作已经没有数据交换时,说明已经达到完全有序,不用再继续执行后续的冒泡操作。冒泡排序算法实现代码如下:

// algorithm\sort.c

void bubble_sort(int *data, int n)
{
    int i, j;
    bool flag;

    for(i = 0; i < n; i++)
    {
        flag = true;
        for(j = 1; j < n - i; j++)
        {
            if(data[j] < data[j-1])
            {
                swap_data(&data[j], &data[j-1]);
                flag = false;
            }
        }
        if(flag == true)
            break;
    }
}

使用前面介绍的分析方法,来分析一下冒泡排序算法:

  • 冒泡排序的时间复杂度是多少?

最好情况下,要排序的数据已经是有序的了,我们只需要进行一次冒泡操作,就可以结束了,所以最好情况时间复杂度是 O(n)。而最坏的情况是,要排序的数据刚好是倒序排列的,我们需要进行 n 次冒泡操作,所以最坏情况时间复杂度为 O(n2)。

冒泡排序包含两个操作原子,比较和交换。每交换一次,有序度就加 1。不管算法怎么改进,交换次数总是确定的,即为逆序度,也就是n*(n-1)/2–初始有序度。

对于包含 n 个数据的数组进行冒泡排序,最坏情况下,初始状态的有序度是 0,所以要进行 n*(n-1)/2 次交换。最好情况下,初始状态的有序度是 n*(n-1)/2,就不需要进行交换。平均情况下,需要 n*(n-1)/4 次交换操作,比较操作肯定要比交换操作多,而复杂度的上限是O(n2),所以平均情况下的时间复杂度就是 O(n2)。

  • 冒泡排序是原地排序算法吗?

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

  • 冒泡排序是稳定的排序算法吗?

在冒泡排序中,只有交换才可以改变两个元素的前后顺序。为了保证冒泡排序算法的稳定性,当有相邻的两个元素大小相等的时候,我们不做交换,相同大小的数据在排序前后不会改变顺序,所以冒泡排序是稳定的排序算法。

2.3 选择排序算法分析

选择排序动画
选择排序算法的实现思路有点类似插入排序,也分已排序区间和未排序区间。但是选择排序每次会从未排序区间中找到最小的元素,将其放到已排序区间的末尾。选择排序算法的实现代码如下:

// algorithm\sort.c

void select_sort(int *data, int n)
{
    int i,j, min;
	for(i = 0; i < n; i++)
    {
        min = i;
        for(j = i + 1; j < n; j++)
        {
            if(data[j] < data[min])
                min = j;
        }
        if(min != i)
            swap_data(&data[i], &data[min]);
    }
}

使用前面介绍的分析方法,来分析一下选择排序算法:

  • 选择排序的时间复杂度是多少?

选择排序的最好、最坏、平均时间复杂度都是O(n2),因为无论数据序列是完全有序,还是完全逆序,都需要找出后边的最小值进行交换。

  • 选择排序是原地排序算法吗?

选择排序不需要额外的存储空间,空间复杂度为O(1),所以是原地排序算法。

  • 选择排序是稳定的排序算法吗?

选择排序每次都要找剩余未排序元素中的最小值,并和前面的元素交换位置,这样破坏了稳定性。因此,选择排序是一种不稳定的排序算法。

比如 5,8,5,2,9 这样一组数据,使用选择排序算法来排序的话,第一次找到最小元素 2,与第一个 5 交换位置,那第一个 5 和中间的 5 顺序就变了,所以就不稳定了。正是因此,相对于冒泡排序和插入排序,选择排序就稍微逊色了。

  • 为什么插入排序更受欢迎呢?

上面三种排序算法都是原地排序算法,这一点三方打平。选择排序是一种不稳定的排序算法,这使其逊色于另外两种排序算法。因为选择排序算法即便在最好情况下,时间复杂度仍为O(n2),因此不能有效利用“原序列已经存在的部分顺序信息”,这一点也使其逊色于另外两种排序算法。

冒泡排序不管怎么优化,元素交换的次数是一个固定值,是原始数据的逆序度。插入排序是同样的,不管怎么优化,元素移动的次数也等于原始数据的逆序度。从代码实现上来看,冒泡排序的数据交换要比插入排序的数据移动要复杂,冒泡排序需要 3 个赋值操作,而插入排序只需要 1 个。

我们把执行一个赋值语句的时间粗略地计为单位时间(unit_time),然后分别用冒泡排序和插入排序对同一个逆序度是 K 的数组进行排序。用冒泡排序,需要 K 次交换操作,每次需要 3 个赋值语句,所以交换操作总耗时就是 3*K 单位时间。而插入排序中数据移动操作只需要 K 个单位时间。

所以,虽然冒泡排序和插入排序在时间复杂度上是一样的,都是 O(n2),但是如果我们希望把性能优化做到极致,那肯定首选插入排序。

从这三种基础算法的复杂度分析也可以看出,冒泡排序与插入排序都依赖于相邻元素的交换或移动,相邻元素每交换或移动一次逆序度只减少1,因此借用前面逆序度的分析,可得出一个结论:通过交换或移动相邻元素来完成排序的算法,其平均时间复杂度下界为Ω(n2)。

选择排序比较特殊,找到最小元素与未排序区间第一个元素进行的并非是相邻元素交换,两者假如跨度较大,每次远距离元素交换可能逆序度减少大于1。比如数列5,4,3,2,1,选择排序第一次交换后,数列变为了1,4,3,2,5,也就是说它这一次交换就使得数列的逆序数-7。所以选择排序可以使用更少的交换来完成排序,当然这并不意味着选择排序就会很快,因为选择排序的元素比较次数(实现代码中的内循环部分)的下界为Ω(n2),因此选择排序的最好、最坏、平均时间复杂度均为O(n2),也正是因为远距离元素交换才让选择排序成为不稳定的排序算法。

要想让排序算法的时间复杂度低于O(n2),我们需要借鉴选择排序这种远距离元素交换或移动方法,同时借鉴插入排序这种充分利用“原序列已存在的有序度”的方法,设计更高效的排序算法。

前面博客介绍过的:希尔排序就是第一批融合上面两种算法优点的排序算法,希尔排序通过一个增量序列,将原序列通过某个增量值提取出多组小序列,某个小序列元素间相隔一个增量值,在这些小序列内部实行插入排序时,小序列内部的相邻元素移动就相当于原序列跨度为增量值的远距离元素移动,通过这种方法可以突破O(n2)时间复杂度边界,实现更高效的排序。

希尔排序不占用额外存储空间,它也是一个原地排序算法。但是,希尔排序涉及远距离元素移动,它并不是一个稳定的排序算法。
基础排序算法分析结果

三、高级排序算法分析

3.1 归并排序算法分析

归并排序动画
在博客:分治与减治 + 分治排序中对归并排序的原理与实现有比较详细的介绍,这里就不再赘述,为了方便后面分析,再次给出实现代码:

// algorithm\sort.c

void merge_sort(int *data, int n)
{
    int *temp = malloc(n * sizeof(int));

    recursive_merge(data, temp, 0, n - 1);

    free(temp);
}

void recursive_merge(int *data, int *temp, int left, int right)
{
    if(left >= right)
        return;

    int mid = left + (right - left) / 2;
    recursive_merge(data, temp, left, mid);
    recursive_merge(data, temp, mid + 1, right);

    merge_data(data, temp, left, mid, right);
}

void merge_data(int *data, int *temp, int left, int mid, int right)
{
    int i = left, j = mid + 1, k = 0;

    while(i <= mid && j <= right)
    {
        if(data[i] <= data[j])
            temp[k++] = data[i++];
        else
            temp[k++] = data[j++];
    }

    while(i <= mid)
        temp[k++] = data[i++];

    while(j <= right)
        temp[k++] = data[j++];

    for(i = left, k = 0; i <= right; i++, k++)
        data[i] = temp[k];
}

使用前面介绍的分析方法,来分析一下归并排序算法:

  • 归并排序的时间复杂度是多少?

归并排序涉及递归,时间复杂度的分析稍微有点复杂。在递归那篇博客中介绍过,递归的适用场景是,一个问题 a 可以分解为多个子问题 b、c,那求解问题 a 就可以分解为求解问题 b、c。问题 b、c 解决之后,我们再把 b、c 的结果合并成 a 的结果。

如果我们定义求解问题 a 的时间是 T(a),求解问题 b、c 的时间分别是 T(b) 和 T( c),那我们就可以得到这样的递推关系式:

T( a ) = T( b ) + T( c ) + K
其中 K 等于将两个子问题 b、c 的结果合并成问题 a 的结果所消耗的时间。

从刚刚的分析,我们可以得到一个结论:不仅递归求解的问题可以写成递推公式,递归代码的时间复杂度也可以写成递推公式。套用这个公式,我们来分析一下归并排序的时间复杂度。

我们假设对 n 个元素进行归并排序需要的时间是 T(n),那分解成两个子数组排序的时间都是 T(n/2)。我们知道,merge() 函数合并两个有序子数组的时间复杂度是 O(n)。所以,套用前面的公式,归并排序的时间复杂度的计算公式就是:

T(1) = C; n=1时,只需要常量级的执行时间,所以表示为C。
T(n) = 2*T(n/2) + n; n>1

通过这个公式,如何来求解 T(n) 呢?我们再进一步分解一下计算过程:

T(n) = 2*T(n/2) + n     
	 = 2*(2*T(n/4) + n/2) + n 
	 = 4*T(n/4) + 2*n     
	 = 4*(2*T(n/8) + n/4) + 2*n 
	 = 8*T(n/8) + 3*n     
	 = 8*(2*T(n/16) + n/8) + 3*n 
	 = 16*T(n/16) + 4*n     
	 ......     
	 = 2^k * T(n/2^k) + k * n     
	 ......

通过这样一步一步分解推导,我们可以得到 T(n) = 2k * T(n / 2k) + k * n。当 T(n/2k)=T(1) 时,也就是 n/2k=1,我们得到 k=log2n 。我们将 k 值代入上面的公式,得到 T(n)=C * n+n * log2n 。如果我们用大 O 标记法来表示的话,T(n) 就等于 O(nlogn),所以归并排序的时间复杂度是 O(nlogn)。

从我们的原理分析和伪代码可以看出,归并排序的执行效率与要排序的原始数组的有序程度无关,所以其时间复杂度是非常稳定的,不管是最好情况、最坏情况,还是平均情况,时间复杂度都是 O(nlogn)。

  • 归并排序的空间复杂度是多少?

从归并排序的合并函数代码可以看出,在合并两个有序数组为一个有序数组时,需要借助额外的存储空间。因此,归并排序不是原地排序算法

那么,归并排序的空间复杂度到底是多少呢?如果我们继续按照分析递归时间复杂度的方法,通过递推公式来求解,那整个归并过程需要的空间复杂度就是 O(nlogn)。但是,递归代码的空间复杂度并不能像时间复杂度那样累加。在任意时刻,CPU 只会有一个函数在执行,也就只会有一个临时的内存空间在使用,临时内存空间最大也不会超过 n 个数据的大小,所以归并排序的空间复杂度是 O(n)

  • 归并排序是稳定的排序算法吗?

从归并排序的实现代码可以看出,归并排序稳不稳定关键要看合并函数,也就是两个有序子数组合并成一个有序数组的那部分代码。

在合并的过程中,如果 data[left…mid] 和 data[mid+1…right] 之间有值相同的元素,那我们可以像上面实现代码中那样,先把 data[left…mid] 中的元素放入 temp 数组,这样就保证了值相同的元素,在合并前后的先后顺序不变。所以,归并排序是一个稳定的排序算法。

3.2 快速排序算法分析

快速排序动画
在博客:分治与减治 + 分治排序中对快速排序的原理与实现有比较详细的介绍,这里就不再赘述,上面的动画没有体现三数取中的过程,只体现了选择最后一个元素作为枢纽值的过程。为了方便后面分析,再次给出实现代码:

// algorithm\sort.c

void quick_sort(int *data, int left, int right)
{
    if(right - left <= 1)
    {
        if(right - left == 1 && data[left] > data[right])
            swap_data(&data[left], &data[right]);
  
        return;
    }

    int divide = partition(data, left, right);
    quick_sort(data, left, divide - 1);
    quick_sort(data, divide + 1, right);
}

int partition(int *data, int left, int right)
{
    /*
    // 随机选择分界线或枢纽值
    int index = left + rand() % (right - left + 1);
    swap_data(&data[index], &data[right]);
    int i = left, j = right - 1, pivot = right;
    */
    // 三数取中法选择分界线或枢纽值
    int mid = left + (right - left) / 2;
    if(data[left] > data[mid])
        swap_data(&data[left], &data[mid]);
    if(data[left] > data[right])
        swap_data(&data[left], &data[right]);
    if(data[mid] > data[right])
        swap_data(&data[mid], &data[right]);

    swap_data(&data[mid], &data[right-1]);
    int i = left + 1, j = right - 2, pivot = right - 1;
    
    while (true)
    {
        while (data[i] < data[pivot])
            i++;

        while (j > left && data[j] >= data[pivot])
            j--;
        
        if(i < j)
            swap_data(&data[i], &data[j]);
        else
            break;
    }
    
    if(i < right)
        swap_data(&data[i], &data[pivot]);

    return i;
}

使用前面介绍的分析方法,来分析一下快速排序算法:

  • 快速排序的时间复杂度是多少?

快排也是用递归来实现的,对于递归代码的时间复杂度分析,前面总结的公式,这里也还是适用的。如果每次分区操作,都能正好把数组分成大小接近相等的两个小区间,那快排的时间复杂度递推求解公式跟归并是相同的。所以,快排的时间复杂度也是 O(nlogn)。

T(1) = C; n=1时,只需要常量级的执行时间,所以表示为C。
T(n) = 2*T(n/2) + n; n>1

但是,公式成立的前提是每次分区操作,我们选择的 pivot 都很合适,正好能将大区间对等地一分为二。但实际上这种情况是很难实现的。举一个比较极端的例子,如果每次分区得到的两个区间都是极不均等的(其中一个区间只有1个元素),我们需要进行大约 n 次分区操作,才能完成快排的整个过程。每次分区我们平均要扫描大约 n/2 个元素,这种情况下,快排的时间复杂度就从 O(nlogn) 退化成了 O(n2)。

已经分析了两个极端情况下的时间复杂度,一个是分区极其均衡,一个是分区极其不均衡。它们分别对应快排的最好情况时间复杂度和最坏情况时间复杂度。那快排的平均情况时间复杂度是多少呢?

我们假设每次分区操作都将区间分成大小为 9:1 的两个小区间。我们继续套用递归时间复杂度的递推公式,就会变成这样:

T(1) = C; n=1时,只需要常量级的执行时间,所以表示为C。
T(n) = T(n/10) + T(9*n/10) + n; n>1

这个公式的递推求解的过程比较复杂,就不展开分析了,这里直接给出结论:T(n) 在大部分情况下的时间复杂度都可以做到 O(nlogn),只有在极端情况下,才会退化到 O(n2)。而且,我们也有很多方法将这个概率降到很低,比如上面实现代码中给出的随机选择枢纽值、三数取中法等。

如果想分析快速排序的平均时间复杂度,可以借助递归树来分析,更简单些,想了解使用递归树如何分析快速排序的平均时间复杂度,可以参考博客:递归树与决策树应用

  • 快速排序的空间复杂度是多少?

从快速排序的实现代码可以看出,快速排序并没有使用额外的存储空间,空间复杂度为O(1)。所以,快速排序是一种原地排序算法。

  • 快速排序是稳定的排序算法吗?

从快速排序的分区函数partition()代码可以看出,分区的过程涉及交换操作,如果数组中有两个相同的元素,比如序列 6,8,7,6,3,5,9,4,在经过第一次分区操作之后,两个 6 的相对先后顺序就会改变。所以,快速排序并不是一个稳定的排序算法。

  • 为什么快速排序更受欢迎呢?

归并排序算法是一种在任何情况下时间复杂度都比较稳定的排序算法,但它存在一个致命的缺点,即归并排序不是原地排序算法,空间复杂度比较高,是 O(n)。正因为此,它也没有快速排序应用广泛。

快速排序算法虽然最坏情况下的时间复杂度是 O(n2),但是平均情况下时间复杂度是 O(nlogn)。而且,快速排序算法时间复杂度退化到 O(n2) 的概率非常小,我们可以通过合理地选择 pivot 来避免这种情况。快速排序算法相比归并排序算法减少了递归的回归合并过程和数据元素复制过程,在工程上,快速排序也会比归并排序快两到三倍。

快速排序并不是一个稳定的排序算法,如果要求使用高效稳定的排序算法,还是得使用归并排序。

归并排序、快速排序、包括后面要介绍的堆排序实际上都是基于比较的排序算法,我们可以借助决策树来分析这类基于比较的排序算法的时间复杂度下界是Ω(N*logN),如果想了解使用决策树的分析过程,可以参考博客:递归树与决策树应用

由此可见,要想对N个数据完成排序,基于相邻元素交换或移动的排序算法的时间复杂度下界是Ω(N2),基于元素间比较的排序算法的时间复杂度下界是Ω(N*logN),要想实现更高效的排序算法,需要避免元素间的比较,那怎么在避免元素间比较的情况下使元素有序呢?

如果我们把每个元素值映射到一个原本就有序的结构中,然后再从有序结构中取出,就可以完成排序了吧。前面介绍的顺序表中连续的存储单元编号(也即数组下标)本身就是从小到大编址的有序结构啊,本来数组下标用来表示元素的存储位置,如果我们把元素值映射到数组下标中,而将元素存储的位置放到数组某下标对应的存储单元中,就可以借助该数组将对应的元素放到正确的位置,整个过程并没有涉及比较过程,只是完成了两次元素映射(用空间换时间),可以达到接近线性的时间复杂度,这就是下面要介绍的线性排序算法(可以按上述逻辑重点分析下计数排序,桶式排序可以看作是半线性排序算法)。

四、线性排序算法分析

4.1 桶排序算法分析

桶排序,借鉴了快速排序按分界线划分区间的技巧,核心思想是将要排序的数据通过m个分界线划分为(m + 1)个区间,将这些区间内的元素按顺序分配到(m + 1)个桶里,每个桶里的数据再单独进行排序。桶内排完序之后,再把每个桶里的数据按照顺序依次取出,组成的序列就是有序的了。
桶排序动画
桶排序的实现原理比较简单,桶内排序调用前面介绍的快速排序或归并排序算法即可,在将原数据序列划分放入m个桶内的过程相对复杂些,主要是每个桶内的元素个数不确定,可以采用变长数组作为桶的容器。

使用前面介绍的分析方法,来分析一下桶排序算法:

  • 桶排序的时间复杂度是多少?

如果要排序的数据有 n 个,我们把它们均匀地划分到 m 个桶内,每个桶里就有 k = n/m 个元素。每个桶内部使用快速排序或归并排序(看对排序的稳定性是否有要求),时间复杂度为 O(k * logk)。m 个桶排序的时间复杂度就是 O(m * k * logk),因为 k=n/m,所以整个桶排序的时间复杂度就是 O(n * log(n/m))。当桶的个数 m 接近数据个数 n 时,log(n/m) 就是一个非常小的常量,这个时候桶排序的最好情况时间复杂度接近 O(n)。

桶排序看起来很优秀,那它是不是可以替代我们之前讲的排序算法呢?答案当然是否定的,因为桶排序对要排序数据的要求是非常苛刻的。首先,要排序的数据需要很容易就能划分成 m 个桶,并且桶与桶之间有着天然的大小顺序,这样每个桶内的数据都排序完之后,桶与桶之间的数据不需要再进行排序。其次,数据在各个桶之间的分布是比较均匀的,如果数据经过桶的划分之后,有些桶里的数据非常多,有些非常少,很不平均,那桶内数据排序的时间复杂度就不是常量级了,在极端情况下,如果数据都被划分到一个桶里,那就退化为 O(nlogn) 的排序算法了。

  • 桶排序是原地排序算法吗?

桶排序中的桶需要额外占用内存空间,因此桶排序并不是原地排序算法

  • 桶排序是稳定的排序算法吗?

桶排序在将原数据序列划分区间并放到各个桶中的过程中可以保持相等元素的原有顺序不变。因此,桶排序是否是稳定的排序算法就取决于每个桶内元素的排序使用的是不是稳定排序算法。假如每个桶内使用的是快速排序,此时桶排序就不是稳定的排序算法,假如每个桶内使用的是归并排序,则桶排序就是稳定的排序算法。

  • 桶排序的适用场景有哪些?

桶排序比较适合用在外部排序中。所谓的外部排序就是数据存储在外部磁盘中,数据量比较大,内存有限,无法将数据全部加载到内存中。比如我们有 10GB 的订单数据,我们希望按订单金额(假设金额都是正整数)进行排序,但是我们的内存有限,只有几百 MB,没办法一次性把 10GB 的数据都加载到内存中。这个时候该怎么办呢?这个时候可以借助桶排序的处理思想来解决这个问题。

我们可以先扫描一遍文件,看订单金额所处的数据范围。假设经过扫描之后我们得到,订单金额最小是 1 元,最大是 10 万元。我们将所有订单根据金额划分到 100 个桶里,第一个桶我们存储金额在 1 元到 1000 元之内的订单,第二桶存储金额在 1001 元到 2000 元之内的订单,以此类推。每一个桶对应一个文件,并且按照金额范围的大小顺序编号命名(00,01,02…99)。

理想的情况下,如果订单金额在 1 到 10 万之间均匀分布,那订单会被均匀划分到 100 个文件中,每个小文件中存储大约 100MB 的订单数据,我们就可以将这 100 个小文件依次放到内存中,用快排来排序。等所有文件都排好序之后,我们只需要按照文件编号,从小到大依次读取每个小文件中的订单数据,并将其写入到一个文件中,那这个文件中存储的就是按照金额从小到大排序的订单数据了。

不过,订单按照金额在 1 元到 10 万元之间并不一定是均匀分布的 ,所以 10GB 订单数据是无法均匀地被划分到 100 个文件中的。有可能某个金额区间的数据特别多,划分之后对应的文件就会很大,没法一次性读入内存。这又该怎么办呢?针对这些划分之后还是比较大的文件,我们可以继续划分,比如,订单金额在 1 元到 1000 元之间的比较多,我们就将这个区间继续划分为 10 个小区间,1 元到 100 元,101 元到 200 元,201 元到 300 元…901 元到 1000 元。如果划分之后,101 元到 200 元之间的订单还是太多,无法一次性读入内存,那就继续再划分,直到所有的文件都能读入内存为止。

4.2 计数排序算法分析

计数排序可以看作是桶排序的一种特殊情况。当要排序的 n 个数据,所处的范围并不大的时候,比如最大值是 k,我们就可以把数据划分成 k 个桶。每个桶内的数据值都是相同的,省掉了桶内排序的时间。
计数排序动画
我们都经历过高考,高考查分数系统你还记得吗?我们查分数的时候,系统会显示我们的成绩以及所在省的排名。如果你所在的省有 50 万考生,如何通过成绩快速排序得出名次呢?考生的满分是 750 分,最小是 0 分,这个数据的范围很小,所以我们可以分成 751 个桶,对应分数从 0 分到 750 分。根据考生的成绩,我们将这 50 万考生划分到这 751 个桶里。桶内的数据都是分数相同的考生,所以并不需要再进行排序。我们只需要依次扫描每个桶,将桶内的考生依次输出到一个数组中,就实现了 50 万考生的排序。因为只涉及扫描遍历操作,所以时间复杂度是 O(n)。

计数排序的算法思想就是这么简单,跟桶排序非常类似,只是桶的大小粒度不一样。为什么这个排序算法叫“计数”排序呢?“计数”的含义来自哪里呢?

想弄明白这个问题,我们就要来看计数排序算法的实现方法。还拿考生那个例子来解释,为了方便说明对数据规模做了简化。假设只有 8 个考生,分数在 0 到 5 分之间。这 8 个考生的成绩我们放在一个数组 A[8] 中,它们分别是:2,5,3,0,2,3,0,3。考生的成绩从 0 到 5 分,我们使用大小为 6 的数组 C[6] 表示桶,其中下标对应分数。不过,C[6] 内存储的并不是考生,而是对应的考生个数。像我刚刚举的那个例子,我们只需要遍历一遍考生分数,就可以得到 C[6] 的值。
C6数组
对于只包含一个整数值的元素序列,且对相等元素的先后顺序没有要求(也即对排序算法的稳定性没有要求),我们可以依次输出C[6]数组中相应分数(下标)的考生个数(数组元素值),对于上面的例子,输出0, 0, 2, 2, 3, 3, 3, 5。

但在工程中,一个元素常包含比较多的维度信息,我们按照某个维度信息值进行排序,就不能采用上面的方法了。特别当要求使用稳定的排序算法时,我们应该如何实现呢?

从图中可以看出,分数为 3 分的考生有 3 个,小于 3 分的考生有 4 个,所以,成绩为 3 分的考生在排序之后的有序数组 R[8] 中,会保存下标 4,5,6 的位置。
R8数组
那我们如何快速计算出,每个分数的考生在有序数组中对应的存储位置呢?这个处理方法非常巧妙,思路是这样的:我们对 C[6] 数组顺序求和,C[6] 存储的数据就变成了下面这样子。C[k] 里存储小于等于分数 k 的考生个数。
C6顺序求和后的数组
有了前面的数据准备之后,现在我就要讲计数排序中最复杂、最难理解的一部分了。我们从后到前依次扫描数组 A,比如当扫描到 3 时,我们可以从数组 C 中取出下标为 3 的值 7,到目前为止,包括自己在内,分数小于等于 3 的考生有 7 个,也就是说 3 是数组 R 中的第 7 个元素(也就是数组 R 中下标为 6 的位置)。当 3 放入到数组 R 中后,小于等于 3 的元素就只剩下了 6 个了,所以相应的 C[3] 要减 1,变成 6。以此类推,当我们扫描到第 2 个分数为 3 的考生的时候,就会把它放入数组 R 中的第 6 个元素的位置(也就是下标为 5 的位置)。当我们扫描完整个数组 A 后,数组 R 内的数据就是按照分数从小到大有序排列的了。
计数排序图示
上面的过程略复杂,给出计数排序的实现代码供对比理解:

// algorithm\sort.c

void count_sort(int *data, int n)
{
    // Find the maximum and minimum values
    int i, min = 0, max = 0;
    for (i = 0; i < n; i++)
    {
        if(data[i] > max)
            max = data[i];
        if(data[i] < min)
            min = data[i];
    }
    // Allocate space for count array
    int *temp = malloc((max - min + 1) * sizeof(int));
    if(temp == NULL)
        return;
    memset(temp, 0, ((max - min + 1) * sizeof(int)));
    // Count the number of times each element appears
    for(i = 0; i < n; i++)
        temp[data[i] - min]++;
    // Sum of count array accumulation
    for (i = 1; i < (max - min + 1); i++)
        temp[i] += temp[i - 1];
    // Allocate space for sorted elements of storage
    int *res = malloc(n * sizeof(int));
    if(res == NULL)
        return;
    // Put the element in the right place
    for(i = n - 1 ; i >= 0; i--)
    {
        int count = temp[data[i] - min];
        if(count > 0)
        {
        	res[count - 1 + min] = data[i];
        	temp[data[i] - min]--;
        }
    }
    // Copy an ordered array to the original
    for (i = 0; i < n; i++)
        data[i] = res[i];
    // Free temporarily allocated space
    free(res);
    free(temp);
}

这种利用另外一个数组来计数的实现方式是不是很巧妙呢?这也是为什么这种排序算法叫计数排序的原因。前面对计数排序的实现技巧既能快速找到各个元素应该在的位置,还能保持相等元素的先后顺序不变。因此,计数排序是一种稳定的排序算法,但需要占用额外的计数数组,其并不是一个原地排序算法

总结一下,计数排序只能用在数据范围不大的场景中,如果数据范围 k 比要排序的数据 n 大很多,就不适合用计数排序了。而且,计数排序只能给非负整数排序,如果要排序的数据是其他类型的,要将其在不改变相对大小的情况下,转化为非负整数。

比如,还是拿考生这个例子。如果考生成绩精确到小数后一位,我们就需要将所有的分数都先乘以 10,转化成整数,然后再放到 9010 个桶内。再比如,如果要排序的数据中有负数,数据的范围是 [-1000, 1000],那我们就需要先对每个数据都加 1000,转化成非负整数。

4.3 基数排序算法分析

我们再来看这样一个排序问题。假设我们有 10 万个手机号码,希望将这 10 万个手机号码从小到大排序,你有什么比较快速的排序方法呢?我们之前讲的快排,时间复杂度可以做到 O(nlogn),还有更高效的排序算法吗?桶排序、计数排序能派上用场吗?手机号码有 11 位,范围太大,显然不适合用这两种排序算法。针对这个排序问题,有没有时间复杂度是 O(n) 的算法呢?下面介绍一种新的排序算法,基数排序。

刚刚这个问题里有这样的规律:假设要比较两个手机号码 a,b 的大小,如果在前面几位中,a 手机号码已经比 b 手机号码大了,那后面的几位就不用看了。借助稳定排序算法,这里有一个巧妙的实现思路,先按照最后一位来排序手机号码,然后再按照倒数第二位重新排序。以此类推,最后按照第一位重新排序,经过 11 次排序之后,手机号码就都有序了。

手机号码稍微有点长,这里使用几个数字以动画形式展示基数排序过程:
基数排序动画
注意,这里按照每位来排序的排序算法要是稳定的,否则这个实现思路就是不正确的。因为如果是非稳定排序算法,那最后一次排序只会考虑最高位的大小顺序,完全不管其他位的大小关系,那么低位的排序就完全没有意义了。既然基数排序的每一位都使用了稳定的排序算法,基数排序本身也是一个稳定的排序算法。由于需要额外占用k位个桶的内存空间,基数排序并不是原地排序算法

根据每一位来排序,我们可以用刚讲过的桶排序或者计数排序,它们的时间复杂度可以做到 O(n)。如果要排序的数据有 k 位,那我们就需要 k 次桶排序或者计数排序,总的时间复杂度是 O(k*n)。当 k 不大的时候,比如手机号码排序的例子,k 最大就是 11,所以基数排序的时间复杂度就近似于 O(n)。

有时候要排序的数据并不都是等长的,比如我们排序牛津字典中的 20 万个英文单词,最短的只有 1 个字母,最长的有 45 个字母,对于这种不等长的数据,基数排序还适用吗?

我们可以把所有的单词补齐到相同长度,位数不够的可以在后面补“0”,因为根据ASCII 值,所有字母都大于“0”,所以补“0”不会影响到原有的大小顺序,这样就可以继续用基数排序了。

总结一下,基数排序对要排序的数据是有要求的,需要可以分割出独立的“位”来比较,而且位之间有递进的关系,如果 a 数据的高位比 b 数据大,那剩下的低位就不用比较了。除此之外,每一位的数据范围不能太大,要可以用线性排序算法来排序,否则,基数排序的时间复杂度就无法做到 O(n) 了。

五、如何实现一个通用高效的排序算法

如果要实现一个通用的、高效率的排序函数,我们应该选择哪种排序算法?我们先回顾一下前面讲过的几种排序算法:
排序算法对比
线性排序算法的时间复杂度比较低,适用场景比较特殊。所以,如果要写一个通用的排序函数,不能选择线性排序算法。

如果对小规模数据进行排序,可以选择时间复杂度是 O(n2) 的算法;如果对大规模数据进行排序,时间复杂度是 O(nlogn) 的算法更加高效。所以,为了兼顾任意规模数据的排序,一般都会首选时间复杂度是 O(nlogn) 的排序算法来实现排序函数。

时间复杂度是 O(nlogn) 的排序算法不止一个,我们已经介绍过的有归并排序、快速排序,后面介绍堆的时候我们还会讲到堆排序。堆排序和快速排序都有比较多的应用,比如 Java 语言采用堆排序实现排序函数,C 语言使用快速排序实现排序函数。因为归并排序并不是原地排序算法,空间复杂度是 O(n),故使用归并排序的情况并不多。

快速排序和归并排序是用递归实现的,递归需要警惕栈溢出。为了避免递归过深而栈过小,导致栈溢出,我们可以通过在堆上模拟实现一个函数调用栈,手动模拟递归压栈、出栈的过程,这样就没有系统栈大小的限制了。

为了让你对如何实现一个排序函数有一个更直观的感受,我拿 Glibc 中的 qsort() 函数举例说明一下。虽说 qsort() 从名字上看,很像是基于快速排序算法实现的,实际上它并不仅仅用了快排这一种算法。

如果你去看源码,你就会发现,qsort() 会优先使用归并排序来排序输入数据,因为归并排序的空间复杂度是 O(n),所以对于小数据量的排序,比如 1KB、2KB 等,归并排序额外需要 1KB、2KB 的内存空间,这个问题不大,现在计算机的内存都挺大的,我们很多时候追求的是速度。归并排序是一个稳定的排序算法,通过空间换取排序的稳定性在某些情况下是值得的。

但如果数据量太大,比如排序 100MB 的数据,这个时候我们再用归并排序就不合适了。所以,要排序的数据量比较大的时候,qsort() 会改为用快速排序算法来排序。那 qsort() 是如何选择快速排序算法的分区点的呢?如果去看源码,你就会发现,qsort() 选择分区点的方法就是“三数取中法”

还有我们前面提到的递归太深会导致堆栈溢出的问题,qsort() 是通过自己实现一个堆上的栈,手动模拟递归来解决的

实际上,qsort() 并不仅仅用到了归并排序和快速排序,它还用到了插入排序。在快速排序的过程中,当要排序的区间中,元素的个数小于等于 4 时,qsort() 就退化为插入排序,不再继续用递归来做快速排序,因为在小规模数据面前,O(n2) 时间复杂度的算法并不一定比 O(nlogn) 的算法执行时间长,此时选择比较简单、不需要递归的插入排序算法更有优势。

好了,C 语言的 qsort() 已经分析完了,基本上用到了前面介绍过的大部分排序算法。由此可见,在工程中要实现一个通用且高效的排序函数,常常需要将多种排序算法的优点综合起来,尽可能将性能优化到极致。

本章算法实现源码下载地址:https://github.com/StreamAI/ADT-and-Algorithm-in-C/tree/master/algorithm

更多文章:

发布了65 篇原创文章 · 获赞 35 · 访问量 2万+

猜你喜欢

转载自blog.csdn.net/m0_37621078/article/details/103675586