传说中线性时间复杂度的排序算法

本文用我自己的理解来介绍3个”超快“的排序算法:计数排序,基数排序,桶排序。

谈到排序该怎么算,直觉上应该都要元素之间进行比较才能排出顺序,比较是不可或缺的,但偏偏有的排序算法可以不用比较,比如传说中的“睡眠排序”(n个线程同时睡觉,按照醒来的顺序排序)。因此排序算法可以分成基于比较的排序和非比较的排序2大类。

基于比较的排序算法有:插入排序、冒泡排序、选择排序、希尔排序、快速排序、堆排序、归并排序。它们都挺节省内存,空间复杂度基本在O(1)左右。

比较排序最大的缺点就是慢,即使是快排。他们的时间复杂度从O(n2)到O(n*log2n)不等。《算法导论》中有一节讲的是“(比较)排序算法时间的下界”:任何比较排序都至少要进行nLog2n次比较才能排完,理论上不存在更快的比较排序算法。虽然多项式时间算法在经典计算机上处理起来很容易,但在线性代数时间算法面前就像乌龟一样慢了。

用散列表来排序

算法的本质是数学,数学的本质是研究时间与空间的关系,所以衡量一个算法好坏就得看它是否妥善的协调好自身消耗的时间与空间。

先做一个思想实验,我们打牌的时候,摸牌的过程就是一种插入排序:把一张牌插入到手中的一推牌当中。但如果我给你10张打乱的0~9数字的牌,让你按顺序从左到右摊开放在桌上,这时候你可能就会采用计数排序(counting sort)来操作了。因为对于这10张牌你大致都知道他们在什么位置,不需要相互比较,你看到‘9’就会下意识的把它摆到桌面的最右边,看到‘5’就会放到中间位置,直到10张牌全部摆放正确,这时候你会惊奇的发现,整个排序过程没有用到比较就飞快的完成了。

那么恭喜你,你已经对“散列”(hashing)的基本原理有了一个初步的认识。至于散列表是什么不在本文的讨论范围,后期会单独拉一篇文章来详谈,题目暂定《散列表:以空间换时间的艺术》。现在只要知道散列表是一种使用起来非常快的数据结构,而快的原因在于它是以牺牲空间来换取时间为代价的。

同理,计数排序也是通过牺牲空间来提升速度,回到之前那0~9的10张牌,假如我先在10张牌中随机抽出3张牌,让剩下的7张牌给你来排序。还按照计数排序的思想,由于你不知道抽走的是哪三张,排序的时候你仍然会在桌面上预留10张牌的空位置,然后将7张牌填入自己的位置之后,桌面上剩下的3个空位就”浪费“了,这就是空间的牺牲。如果10个数的范围再扩大一点,牺牲的空间则更大。

好在我们的世界中空间总共有3个维度,而时间只有1个维度(千万别说时间是第四维空间),如果能换取宝贵的时间,牺牲再多的空间也是可以接受的。计数排序就是在这样的理论基础上诞生的,它的时间复杂度达到惊人的Ο(n+k):其中n是待排序的数组,k是数组元素的大小范围,也就是max-min+1。复杂度也很好理解,Ο(n+k)分成O(n)和O(k),O(n)是遍历一遍待排数组消耗的时间,O(k)则是遍历一遍”预留空间“,也就是之前思想实验中的桌面:你总得把桌上的10张牌收集起来啊。

下面这张动图很好的展示了计数排序需要的2次遍历:

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

但是现在有一个问题,如果k值过大,也就是数组的范围很大的话,计数排序开辟的额外数组就会很大,遍历时间也会增长,如果这样一串整数:1,2,1,3,8,90000000。计数排序在这些场合就不适用了。如果数组范围从1 到 n2,也就是k=n2:计数排序的范围随着数量的激增而呈指数性增长,计数排序的复杂度就变成了O(n+n2),也就是O(n2):这样反而比比较排序还要慢了,自然是不答应。

为避免数组范围过大带来的问题,我们需要对计数排序进行扩展:事实上,计数排序是基数排序的一种特殊情况。基数排序(radix sort)将每个整数拆分成多个比特段来依次进行计数排序,其中每个比特段就是一位:1个bit就是一个2进制位,2个bit就是一个4进制位,以此类推。

基数排序依照按位排序的顺序还分为LSD(从低位到高位)和MSD(从高位到低位)。但LSD才能做到线性的时间复杂度,下图展示了按照十进制进行LSD的动画模拟:

所以计数排序中,所有整数可以看成都是个位数,只是这是一个k进制的个位数,k为整数的范围,所以说计数排序是特殊的基数排序。

由于基数排序由若干次计数排序组成,不难得出基数排序的通用时间复杂度是O(d*(n+b)),其中b是采用的进制,对于上图b=10,d则表示你总共分了多少块,或者说整数的长度,或者说最大值的位数,所以d=⌊(log b  (k)+1)⌋,对于上图来说d=2。

空间与时间的关系

时间复杂度是一个表达式,描述的是随着空间的线性增长,时间的变化规律。其中线性增长的空间指的是待排数组的长度n,表达式的值代表运算过程中原子操作的次数。所以乍一看基数排序并不比计数排序快,是因为时间复杂度描述的是时间增长趋势而不是具体的时间。基数排序适合含有大整数,多位数的数组。typescript代码如下:

// 基数排序伪代码
function radixSort(arr: Array, base: number = 10) { 
    // 找到最大值来获取位数
    let m = getMax(arr); 

    // 每一位都执行计数排序 
    // 注意传递的不是位数而是exp=10^i(i是当前位)
    for (let exp = 1; m/exp > 0; exp *= base) 
        countSort(arr, exp); 
}

无论是计数排序,基数排序还是桶排序,他们3个的本质都是一样的,都属于分配排序(distribution sorts)。如果说计数排序是基数排序的特殊情况,那桶排序(bucket sort)是计数排序的升级版。

计数排序可以看成每个桶中放一个数,桶排序给他扩展到可以放多个数。一个桶代表一个范围。

然后,元素在每个桶中排序(采用任意排序算法,如插入排序):

当输入的数据可以均匀的分配到每一个桶中,桶排序的效率最高。在基数排序(包括计数排序)的基础上,桶排序还可以处理含有小数的数组。

由于桶排序包含分配排序和比较排序2个步骤,桶排序的时间复杂度也分成2个,分配排序部分就是一次遍历:O(n),比较排序那就花费理论下界的时间呗:O(Ni*logNi),其中Ni 为第i个桶的数据量。

(完)

彩蛋

再分享3个石破天惊的排序算法:睡眠排序,面条排序,猴子排序。保证你没见识过,戳下面的链接阅读原文:

https://blog.csdn.net/github_38885296/article/details/84397922

猜你喜欢

转载自blog.csdn.net/github_38885296/article/details/102656369