数据结构与算法篇 桶排序,计数排序,基数排序

今天我们来讲三种线性排序,时间复杂度O(n)的排序算法:桶排序,计数排序,基数排序

假如我们要对100万的用户排序,那么我们该怎么做呢?归并排序?快速排序,最好的时间复杂度也要O(nlogn)

首先来看一下桶排序的核心思想:将要排序的数据分到几个有序的桶里,每个桶里的数据再单独进行排序,然后它们组成的序列就有序了。桶排序的时间复杂度为什么为O(n),首先我们有n个数据,我们把它们均匀的分到m个桶里,每个桶就有k=n/m个元素

所以桶排序的时间复杂度O(n*log(n/m)),当桶的个数m接近数据个数n的时候那时间复杂度接近O(n),在做了一大堆假设后,桶排序的时间复杂度终于到了O(n),也就是说桶排序对数据的要求是非常严格的。

首先排序的数据需要很容易就能被划分成m个桶,桶与桶之间有着天然的大小的顺序,每个桶排序完以后,桶与桶之间的数据不需要再进行排序;其次各个桶之间的数据分布是比较均匀的。

最后桶排序是比较适合用在外部排序中,所谓的外部排序就是数据存储在外部磁盘中,数据量比较大,内存有限,无法将全部数据加载到内存中,比如我们要排序10GB的数据(订单金额),但是我们的内存只有几百MB,那该怎么办呢?

首先我们先扫描一遍整个文件,查看金额的范围,最小为1,最大为10万,那么我们将订单分成100个桶,第一个桶存储的金额1-1000,第二个是1001-2000,以此类推,然后再对每一个桶进行快速排序。当其中一个桶的数据还是过大我们还可以继续进行分桶,快排,分桶快排。。。。。。

/*
 * 桶排序
 *
 * 参数说明:
 *     a -- 待排序数组
 *     n -- 数组a的长度
 *     max -- 数组a中最大值的范围
 */
void bucketSort(int a[], int n, int max)
{
    int i,j;
    int buckets[max];

    // 将buckets中的所有数据都初始化为0。
    memset(buckets, 0, max*sizeof(int));

    // 1. 计数
    for(i = 0; i < n; i++) 
        buckets[a[i]]++; 

    // 2. 排序
    for (i = 0, j = 0; i < max; i++) 
    {
        while( (buckets[i]--) >0 )
            a[j++] = i;
    }
}

计数排序

技术排序可以看做是桶排序的一种特殊情况,计数排序的思想也是非常的简单的,假如我们高考查分数系统进行排序,分数是0-900分,那么我们就分成901个桶,相同分数的学生都在同一个桶内,整个过程只需要设计到扫描,输出到一个数组里,所以它的时间复杂度也是O(n)。

假设有8个考生,分数在0-5之间,这8个考生的成绩我们放在一个数组里A[8],它们分别是2,5,3,0,2,3,0,3

0-5我们就分成了6个桶也就是C[6],其中下标也就是对应着分数,其中数组的值对应考生的个数

从图中就可以看到,分数为3分的考生有三个,小于三分的考生有4个

那我们是如何快速的算出,每个分数的考生在有序数组中对应存储位置,我们对C[6]进行顺序求和,那么C[6]存储的数据就是小于等于的考生个数

有了前面的数据准备之后,现在我就要讲计数排序中最复杂、最难理解的一部分了,请集中精力跟着我的思路!

我们从后到前依次扫描数组 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 内的数有序了

// 计数排序,a 是数组,n 是数组大小。假设数组中存储的都是非负整数。
void countingSort(int a[], int n) {
  if (n <= 1) return;
 
  // 查找数组中数据的范围
  int max = a[0];
  for (int i = 1; i < n; ++i) {
    if (max < a[i]) {
      max = a[i];
    }
  }
 
  int *c = (int *)malloc(max*sizeof(int));// 申请一个计数数组 c,下标大小 [0,max]
  for (int i = 0; i <= max; ++i) {
    c[i] = 0;
  }
 
  // 计算每个元素的个数,放入 c 中
  for (int i = 0; i < n; ++i) {
    c[a[i]]++;
  }
 
  // 依次累加
  for (int i = 1; i <= max; ++i) {
    c[i] = c[i-1] + c[i];
  }
 
  // 临时数组 r,存储排序之后的结果
  int *r = (int *)malloc(n*sizeof(int));
  // 计算排序的关键步骤,有点难理解
  for (int i = n - 1; i >= 0; --i) {
    int index = c[a[i]]-1;
    r[index] = a[i];
    c[a[i]]--;
  }
 
  // 将结果拷贝给 a 数组
  for (int i = 0; i < n; ++i) {
    a[i] = r[i];
  }
}

基数排序

我们再来看一个问题就是,假如我们有10万个手机号,从小到大排序,使用快排的话时间复杂度O(nlogn)

假设要比较两个手机号码的大小,如果前面几位中,a已经比b大,后面的几位就不需要看了。

还记得我们在前面讲过借助稳定排序算法,这里有一个巧妙的实现思路。先按照最后一位来排序手机号码,然后,再按照倒数第二位重新排序,以此类推,最后按照第一位重新排序。经过 11 次排序之后,手机号就有序了。

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

实际上,有时候要排序的数据并不都是等长的,比如我们排序牛津字典中的 20 万个英文单词,最短的只有 1 个字母,最长的我特意去查了下,有 45 个字母,中文翻译是尘肺病。对于这种不等长的数据,基数排序还适用吗?

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

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

猜你喜欢

转载自blog.csdn.net/weixin_38452632/article/details/83374086