我们已经知道很多高效率的排序,归并排序、快速排序、堆排序。它们都是O(nlogn)级别的。事实上还存在更高效率的排序算法,但是它采取了“拿空间换取时间”,这三种排序算法分别是:①计数排序 ②基数排序 ③ 桶排序 下面我们来分析一下他们的原理以及效率。
计数排序
计数排序的基本思想是:对于每一个输入元素x,确定小于x的元素个数。利用这一信息,就可以直接把x放到它在输出数组中的位置上了。例如,如果有17个元素小于x,则x就应该在第18个输出位置上。当有几个元素相同时,这一方案要略作修改。因为不能把它们放在同一个输出位置上。
int ret_max(int arr[], int length)
{
int max_val = arr[0];
for (int i = 0; i < length; i++)
if (arr[i] > max_val)
max_val = arr[i];
return max_val;
}
void CountSort(int arr[], int length)
{
if (!arr || length <= 0)
return;
int max_val = ret_max(arr, length);
int* Brr = new int[length];
int* Crr = new int[max_val+1];
for (int i = 0; i < max_val+1; i++)
Crr[i] = 0;
for (int i = 0; i < length; i++)
Crr[arr[i]]++;
for (int i = 1; i < max_val+1; i++)
Crr[i] = Crr[i] + Crr[i - 1];
for (int i = length - 1; i >=0; i--)
{
Brr[Crr[arr[i]]-1] =arr[i];
Crr[arr[i]]--;
}
delete[] Crr;
delete[] Brr;
//代码为了简洁和防止内存泄漏 结果可以在调试时查看
}
那么我们来分析一下计数排序的效率:首先我们有两段额外的空间,一段大小为n,一段大小为max_val+1,所以算法的空间复杂度为O(max(n,max_val+1)),那么他的时间复杂度就是O(n+max_val),在实际工作中,当max_val=O(n)时,我们一般会采用计数排序,这时的运行时间为O(n)。计数排序的下界优于我们之前所讲的所有O(nlogn)级别的排序,因为它并不是一个比较排序算法。事实上,它的代码中完全没有输入元素之间的比较操作。相反,计数排序是使用输入元素的实际值来确定其在数组中的位置。当我们脱离了比较排序模型的时候,Ω(nlogn)这一下界就不再适用了。
计数排序的一个重要性之就是它是稳定的:具有相同值的元素在输出数组中的相对次序与它们在输入数组中的相对次序相同。也就是说,对两个相同的数来说,在输入数组中先出现的数,在输出数组中也位于前面。通常,这种稳定性只有当进行排序的数据还附带卫星数组时才比较重要。计数排序的稳定性很重要的另一个原因是:计数排序经常会被用作基数排序算法的一个子过程。下面会讲到,为了使基数排序能正常运行,计数排序必须是稳定的。
总结:计数排序只能用在数据范围不大的场景中,如果数据范围k比要排序的数据n大很多,就不适合用计数排序了。而且,计数排序只能给非负整数排序,如果要排序的数据是其他类型的,要在其不改变相对大小的情况下,转化为非负整数。
比如如果考试成绩精确到小数点后一位,我们需要将所有的分数都先乘以10,转化为整数。如果排序的数据有负数,数据范围为[-1000,,1000],那我们就需要先对每个数据都加1000,转化为非负整数。
基数排序
假如我们要对一些手机号码进行排序的话,必须保证在每一位之前都是已排序的,什么意思呢?比如13662342568 与 15216034567这两个手机号码排序时,首先根据首位号码排序,如果首位相同接着排序第二位以此类推。那么有一种排序算法很适合这种问题-基数排序。分别根据每一位来进行排序,例如我们进行普通的排序时,待排序的数字的位数如果相同的且是d。那么我们只要排d次就行了。需要注意的是,每一次排序都应该是稳定的。比如353和452,在排个位的时候,452在353前面。在排十位时,我们保证了尽管此时十位相同但是个位依然是从小到大排列的。(只有稳定排序才能有这样的作用,因为我们个位排好时,在排十位前 此时十位相同的两个数字的个位已经排好序了,又因为稳定排序 排前排后 相同元素的相对顺序不会变,所以此时十位相同但是个位依然是从小到大排列的),后面百位、千位..都一样。
RADIX-SORT(A,d)
for i=1 to d
use a stable sort array on digit i
如果我们使用的稳定排序算法耗时O(n+max_val)。那么它就可以在O(d(n+k))时间内将这些数排好序。
总结:1.利用计数排序作为中间稳定排序的基数排序不是原址排序,而很多O(nlogn)时间的比较排序是原址排序。因此,当主存的容量比较宝贵时,我们可能会更倾向于想快速排序这样的原址排序。
2.基数排序对要排序的数据是有要求的,需要可以分割出独立的“位”来比较,而且位之间有递进的关系,如果a数据的高位比b数据大,那剩下的低位就不用比较了。除此之外,每一位的数据范围不能太大,要可以用线性排序算法来排序,否则,基数排序的时间复杂度就无法做到O(n)。
桶排序
首先,我们来看桶排序。桶排序,顾名思义,会用到“桶”,核心思想是将要排序的数据分到几个有序的桶里,每个桶里的数据再单独进行排序。桶内排完序之后,再把每个桶里的数据按照顺序依次取出,组成的序列就是有序的了。
桶排序的时间复杂度为什么是 O(n) 呢?我们一块儿来分析一下。
如果要排序的数据有 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 元之间的订单还是太多,无法一次性读入内存,那就继续再划分,直到所有的文件都能读入内存为止。
总结:数据在各个桶之间的分布是比较均匀的。如果数据经过桶的划分之后,有些桶里的数据非常多,有些非常少,很不平均,那桶内数据排序的时间复杂度就不是常量级了。在极端情况下,如果数据都被划分到一个桶里,那就退化为 O(nlogn) 的排序算法了。
面试题:
1.排序一堆数据,但是内存放不下怎么办?
答:除了前面讲堆排序的那种解法外,这里可以采用桶排序的思路。将来自外存的数据进行读取,然后建立k个文件(相当于桶),每个文件负责存储一个范围内的数据比如第一个桶存1-1000,第二个桶1001-2000 以此类推。将数据分别放到各个桶里后,然后将各个桶的数据单独放到内存中进行快排,对每个文件的数据都排好后,然后放回文件里。由于每个桶的数据桶间本身就是有序的。所以在各个桶排好序之后,其实所有的数据就已经排好了。
2.对员工年龄排序(1-99)时间复杂度为O(n)
答:需要问清楚面试官公司员工人数,是否可以用额外空间复杂度(其实问了也一样 O(n)的排序算法 是不可能不需要空间复杂度的)。这题直接用计数排序就行了。
3.
有一个整形数组A,请设计一个复杂度为O(n)的算法,算出排序后相邻两数的最大差值。
给定一个int数组A和A的大小n,请返回最大的差值。保证数组元素多于1个。
答:运用桶排序的思路,将各个数据放入桶中,然后从第一个非空桶开始,当前桶内最小值减去前面桶的最大值。用一个res记录起来,这个值不断迭代直到找到最后的最大差值。
测试样例:
[1,2,5,4,6],5
返回:2
class Gap {
public:
int from_Bucket(int val,int len ,int max,int min)
{
return (int) ((val - min) * len / (max - min));
}
int maxGap(vector<int> A, int n) {
// write code here
if(A.empty())
return 0;
int len = n;
int min_val = INT_MAX;
int max_val = INT_MIN;
for (int i = 0; i < len; i++) {
min_val =min(min_val, A[i]);
max_val =max(max_val, A[i]);
}
if (min_val== max_val) {
return 0;
}
int *mins=new int[len+1];
for(int i=0;i<len;i++)
mins[i]=INT_MAX;
int* maxs=new int[len+1];
for(int i=0;i<len;i++)
maxs[i]=INT_MIN;
bool* hasnums=new bool[len+1];
int bucket=0;
for(int i=0;i<len;i++)
{
bucket=from_Bucket(A[i],len,max_val,min_val);
mins[bucket]=hasnums[bucket]?(min(mins[bucket],A[i])):A[i];
maxs[bucket]=hasnums[bucket]?(max(maxs[bucket],A[i])):A[i];
hasnums[bucket]=true;
}
int res=0;
int lastMax=0;
int i=0;
while(i<=len)
{
if(hasnums[i++])
{
lastMax=maxs[i-1];
break;
}
}
while(i<=len)
{
if(hasnums[i])
{
res=max(res,mins[i]-lastMax);
lastMax=maxs[i];
}
i++;
}
return res;
}
};