【算法】计数排序、桶排序和基数排序详解

。_饼干妹妹 提交于 2020-03-23 20:37:02

01.计数排序、桶排序与基数排序

并不是所有的排序 都是基于比较的,计数排序和基数排序就不是。基于比较排序的排序方法,其复杂度无法突破\(n\log{n}\) 的下限,但是 计数排序 桶排序 和基数排序是分布排序,他们是可以突破这个下限达到O(n)的的复杂度的

1. 计数排序

概念

计数排序是一种稳定的线性时间排序算法。计数排序使用一个额外的数组C,使用 C[i] 来计算 i 出现的次数。然后根据数C来将原数组A中的元素排到正确的位置。

复杂度

计数排序的最坏时间复杂度、最好时间复杂度、平均时间复杂度、最坏空间复杂度都是O(n+k) 。n为元素个数,k为待排序数的最大值。

优缺点

计数排序不是比较排序,排序的速度优于任何比较排序算法。由于用来计数的数组C的长度取决于待排序数组中数据的范围(等于待排序数组的最大值与最小值的差加1),这使得对于数组中数据范围很大的数组,需要大量的时间和内存。(简言之,不适于大范围数组)

通俗地理解,例如有10个年龄不同的人,统计出有8个人的年龄比A小,那A的年龄就排在第9位,用这个方法可以得到其他每个人的位置,也就排好了序。当然,年龄有重复时需要特殊处理(保证稳定性),这就是为什么最后要反向填充目标数组,以及将每个数字的统计减去1的原因。算法的步骤如下:

  1. 找出待排序数组中的最大元素和最小元素。
  2. 统计数组中值为 i 的元素出现的次数,存入数组C的第 i 项。
  3. 对所有的计数累加(从C中第一个元素开始,每一项和前一项累加)。
  4. 反向填充目标数组:将每个元素 i 放在新数组的第 C[i] 项, 每放一个元素就将 C[i] 减去1.

C语言实现

#include <stdio.h>
#include <stdlib.h>
#include <time.h>

void print_arr(int *arr, int n)
{
    int i;
    printf("%d", arr[0]);
    for (i = 1; i < n; i++)
    printf("%d", arr[i]);
    priintf("\n");
}

void counting_sort(int *ini_arr, int *sortrd_arr, int n)
{
    int *count_arr = (int *)malloc(sizeof(int) * 100);
    int  i, j, k;

    // 初始计数化数组
    for (k = 0; k < 100; k++)
        count_arr[k] = 0;
    
    // 步骤二, 计数
    for (i = 0; i < 100; i++)
        count_arr[i]++;
    
    // 步骤三, 对所有的计数累加(计算每个数的实际顺序)
    for (k = 1; k < 100; k++)
        count_arr[k] += count_arr[k-1];
    
    // 步骤4, 反向填充数组
    for (j = n; j > 0; j--)
    {
        int elem = ini_arr[j - 1];          // 取待排序元素
        int index = count_arr[elem] - 1;    // 取待排序元素在有序数组中的序号
        sortrd_arr[index] = elem;           // 将待排序数组存入结果数组中
        count_arr[elem]--;                  // 修正排序结果,保证sorted_arr数组中元素的稳定性
    }
    /*
     * 上述句子也可以写为: 
     *      sortrd_arr[--count_arr[ini_arr[j -1]]] = ini_arr[j -1];
     *
     */
        free(count_arr);
}

int main(int argc, char **argv) 
{
    int n =10;
    int i;
    int *arr = (int *)malloc(sizeof(int) * n);
    int *sorted_arr = (int *)malloc(sizeof(int) * n);
    srand(time(0));
    for (i = 0; i< n; i++)
    arr[i] = rand()%100;
    printf("Init array:");
    print_arr(arr, n);
    counting_sort(arr, sorted_arr, n);
    printf("Sorted_arr;");
    free(arr);
    free(sorted_arr);
    return 0;
}


2. 桶排序

概念

桶排序(Bucket sort)或所谓的箱排序,其工作原理是将阵列分到有限数量的桶里。每个桶再分别排序。桶排序是分布排序,不是比较排序,因而不受到比较排序 O(\(n\log{n}\))下限的影响。

复杂度

最坏时间复杂度是O(n^2), 平均复杂度是O(n+k),最坏空间复杂度是O(n*k)。

关键步骤

  1. 设置一个定量的阵列当作空桶子。
  2. 寻访序列,并且把项目一个一个放到对应的桶子去。
  3. 对每个不是空的桶进行排序。可以在放入元素的时候进行插入排序,也可以在写回的时候进行快速排序。
  4. 从不是空的桶子里把项目放回到原来的序列中。

算法实现

假设数据分布在[0, 100]之间,每个桶内部用链表表示,在数据入桶的同时插入排序。然后把各个桶中的数据合并。

#include <iterator>
#include <iostream>
#include <vector>

using namespace std;

const int BUCKET_NUM = 10;

// 数据结构的定义,explicit表示不允许构造函数发生隐式转换
struct ListNode {
    explicit ListNode(int i = 0): mData(i), mNext(NULL){};
    ListNode *mNext;
    int mData;
};

ListNode *insert(ListNode *head, int val)
{
    ListNode dummyNode;                         // 节点指针
    ListNode *newNode = new ListNode(val);      //待插入的新节点
    ListNode *pre , *curr;
    dummyNode.mNext = head;                     // 将指针指向链表头部
    pre = &dummyNode;                           // 设置临时指针。pre是当前检查元素的上一个元素
    curr = head;

    while (NULL != curr && curr->mData <= val)  // 末尾检测
    {
        pre = curr;                             // 不断向前循环,直到末尾或者找到不小于val的元素
        curr = curr -> mNext;       
    }

    newNode->mNext = curr;                      // 改变指针指向
    pre ->mNext =  newNode;
    return dummyNode.mNext;                     // 返回链表头节点
}

ListNode *Merge(ListNode *head1, ListNode *head2)   // 将head2合并到head1
{
    ListNode dummyNode;
    ListNode *dummy = &dummyNode;               // 临时指针
    while(NULL != head1 && NULL != head2)       // 循环直到末尾
    {
        if (head1->mData <= head2 ->mData)      // 类似于归并排序
        {
            dummy->mNext = head1;
            head1 = head1 -> mNext;
        }else{
            dummy->mNext = head2;
            head2 = head2->mNext;
        }
        dummy = dummy->mNext;
    }
    if (NULL != head1) dummy->mNext = head1;
    if(NULL!=head2) dummy->mNext = head2;
    return dummyNode.mNext;
}

void BucketSort(int n,int arr[]){
	vector<ListNode*> buckets(BUCKET_NUM,(ListNode*)(0));

    // 将元素分配到桶
	for(int i=0;i<n;++i){
		int index = arr[i]/BUCKET_NUM;
		ListNode *head = buckets.at(index);
		buckets.at(index) = insert(head,arr[i]);
	}

    // 逐一合并各个桶
	ListNode *head = buckets.at(0);
	for(int i=1;i<BUCKET_NUM;++i){
		head = Merge(head,buckets.at(i));
	}
    //将排好序的元素写回原数组
	for(int i=0;i<n;++i){
		arr[i] = head->mData;
		head = head->mNext;
	}
}

3. 基数排序

定义

基数排序是桶排序的扩充,也是一种分布排序。其原理是将整数按位数切割为不同的数字,然后按每个数分别比较。根据比较的方向,基数排序又可以分为MSD(从左到右)和LSD(从右向左)

LSD原理 将所有带比较数值统一为同样的数位长度,数位较短的前面补0,。然后,从最低位开始,进行一次排序,一直到最高位排序完成以后,数列就变成一个有序数列。其思想就是,将待排序数据中的每组关键字依次进行桶分配。

MSD原理
msd算法从左向右遍历字符。其核心思想是分治,我们采用递归的方法来实现。原理如下:

  • 首先,使用键索引排序的方法对首字母进行排序,此时排好序的数组已经是首字母有序的数组,并且已经按照首字字母分好了组。
  • 按照分好的组,递归的对每个首字母对应的子数组进行排序。
  • 重复步骤二。

复杂度

最坏时间复杂度是O(kN),最坏空间复杂度是O(k+N);

LSD实现

/**
 * 基数排序:C 语言
 *
 * 
 */

#include <stdio.h>

// 数组长度
#define LENGTH(array) ( (sizeof(array)) / (sizeof(array[0])) )

/*
 * 获取数组a中最大值
 *
 * 参数说明:
 *     a -- 数组
 *     n -- 数组长度
 */
int get_max(int a[], int n)
{
    int i, max;

    max = a[0];
    for (i = 1; i < n; i++)
        if (a[i] > max)
            max = a[i];
    return max;
}

/*
 * 对数组按照"某个位数"进行排序(桶排序)
 *
 * 参数说明:
 *     a -- 数组
 *     n -- 数组长度
x*     exp -- 指数。对数组a按照该指数进行排序。
 *
 * 例如,对于数组a={50, 3, 542, 745, 2014, 154, 63, 616};
 *    (01) 当exp=1表示按照"个位"对数组a进行排序
 *    (02) 当exp=10表示按照"十位"对数组a进行排序
 *    (03) 当exp=100表示按照"百位"对数组a进行排序
 *    ...
 */
void count_sort(int a[], int n, int exp)
{
    // 存储"被排序数据"的临时数组
    int *output = (int *)malloc(sizeof(int)*n);            
    int i, buckets[10] = {0};

    // 将数据出现的次数存储在buckets[]中
    for (i = 0; i < n; i++)
        buckets[ (a[i]/exp)%10 ]++;

    // 更改buckets[i]。目的是让更改后的buckets[i]的值,是该数据在output[]中的位置。
    for (i = 1; i < 10; i++)
        buckets[i] += buckets[i - 1];

    // 将数据存储到临时数组output[]中
    for (i = n - 1; i >= 0; i--)
    {
        output[buckets[ (a[i]/exp)%10 ] - 1] = a[i];
        buckets[ (a[i]/exp)%10 ]--;
    }

    // 将排序好的数据赋值给a[]
    for (i = 0; i < n; i++)
        a[i] = output[i];
}

/*
 * 基数排序 
 *
 * 参数说明:
 *     a -- 数组
 *     n -- 数组长度
 */
void radix_sort(int a[], int n)
{
    int exp;    // 指数。当对数组按各位进行排序时,exp=1;按十位进行排序时,exp=10;...
    int max = get_max(a, n);    // 数组a中的最大值

    // 从个位开始,对数组a按"指数"进行排序
    for (exp = 1; max/exp > 0; exp *= 10)
        count_sort(a, n, exp);
}

void main()
{
    int i;
    int a[] = {53, 3, 542, 748, 14, 214, 154, 63, 616};
    int ilen = LENGTH(a);

    printf("before sort:");
    for (i=0; i<ilen; i++)
        printf("%d ", a[i]);
    printf("\n");

    radix_sort(a, ilen);

    printf("after  sort:");
    for (i=0; i<ilen; i++)
        printf("%d ", a[i]);
    printf("\n");
}

msd实现

  /*
  MSD(Most Significant Digit First) 高位优先的字符串排序
 
  该算法基于键索引计数法的思想,进行了扩展,使得该算法可以
  处理不等长的字符串排序,其中涉及两个关键点。
  1、采用分治法,从高位向低位的方向,依次选取关键字做为排序
     的键字进行排序,每一轮排序后,将字符串组进行拆分,对拆
     分后的每个子组分别进行排序,这里子组拆分的依据就是本轮
     键索引计数法排序之后的分类组。
  
     如下示例,最初只有一个字符串组,其中组成员数为5个字符串
     首先,选择第0列做为排序的链字进行键索引计数法排序,排序
     完成后,按分类组划分,此时分为了两组(见第一次后的情况),
     这时候对这两组分别进行链索引计数法排序,注意这时每组第
     0列已经为相同字符,所以此时选择第1做为排序的链字进行键
     索引计数法排序,在第二次排序后,此时已经分为了4组lp字符串
     组,依次类推,直到所有子组仅含有一个成员,所有子组排序
     处理完后,即整个字符串排序算法完成。
  
     原始:    第一次后:    第二次后:
     abcd      abcd          abcd
     ddba      acca              
     daca                    acca 
     acca      ddba              
     daab      daca          daca
               daab          daab
                                 
                             ddba
  
  2、刚才提到了该算法可以处理不等长的字符串排序,该算法采用一
     种比较巧妙的方法,将短字符串长度已经满足不了排序的处理也
     做为键值比较处理了,同时如果短字符串长度满足不了排序处理
     时,该键值优先级最高,所以就会出现排在最上方。
  
     如下示例,当第2列(字符c的位置)处理完后,开始进行第3列比较
     处理,此时第一个条目abcd的第3列键值为d、第二个条目abc的第
     3列键值已经不存在,长度已经满足不了排序,但此时键值优先级
     为最高,第三个条目abcde的第3列键值为d,所以本轮最终将第二
     个条目abc排在了最上面,相同原理,abcd条目就会比abcde条目的
     优先级高。
  
     原始:    排序后:
     abcd      abc
     abc       abcd
     abcde     abcde
  */
 

#include <IOSTREAM>
#include <FSTREAM>
#include <STRING>
#include <VECTOR>


const int R = 256;  // 基数
const int M = 15;   // 小数组使用插入排序的阈值
using namespace std;

int charAt(const string& str, int d)
{
    if ( d < str.size() )
        return str[d];
    else 
        return -1;
}

//参数分别表示字符串容器,排序字符串起始位置,排序字符串结束位置,键的位数,辅助存储容器
void MSD_sort(vector<string>& sVec, int lo, int hi, int d, vector<string>& aux)
{
    int i,r;

    /*
     * 这里存在一个优化:当数组数量较少的时候,我们可以使用插入排序优化算法
     *  
     * 故而下列return语句可以改写为:
     *      if (hi <= low + M)
     *              {
     *                  insert_sort(svec, low, hi, d, aux );
     *                  return;                 
     *              }
     */
    if (hi <= lo)
        return;

    /*
     * R+2 的原因是:
     *      在charAt函数中,但索引值大于字符串本身的大小时,我们返回了-1,在下面,我么们将会看到,
     *      我们在所有的返回值上都加了1,然后将它作为count数组的索引,这意味着对于每个字符,都有
     *      可能是R+1种分组结果,因为键索引排序的方法本身就需要多一个额外的位置,故而是R+2.
     * 
     * 返回-1的原因:
     *      返回-1是为了处理具有相同首字母的字符串中长度稍短的那个字符。举例来说,
     *      对于三个顺序字符abcfg,abc,abcds来说,abc是三者中最短的,我们很容易知道,
     *      abc应该排在最前面,那么我如何让计算机知道这件事呢。我们是这样处理的,在charAt
     *      方法中可以看到,对于索引超出长度的字符串,我们返回的是-1.在count中我们对返回值加了1,
     *      从而让count的索引都是非负整数。这样,我们就将所有字符都被检查过的字符串所在的子数组
     *      排在其他子数组的前面,这样就不需要递归的将该子数组排序。
     *  
     */
    int count[R+2]={0};
    
    //计算频率, 注意此处的 R+2
    for (i=lo; i<=hi; i++)
        count[charAt(sVec[i], d) + 2]++;
    
    //频率转化为索引
    for (r=0; r<R+1; r++)
        count[r+1] += count[r];
    
    //分类
    for (i=lo; i<=hi; i++)
        aux[count[charAt(sVec[i], d) + 1]++] = sVec[i];
    
    //回写
    for (i=lo; i<=hi; i++)
        sVec[i] = aux[i-lo];//注意aux下标

    //以从左到右的每个字符为键进行排序
    for (r=0; r<R; r++)//count[R+1]为0,不对应任何字符
        MSD_sort(sVec, lo+count[r], lo+count[r+1]-1, d+1, aux);
}

int main(int argc, char* argv[])
{
    string str;
    vector<string> sVec;
    ifstream infile("data.txt");
    cout<<"------Before sort:"<<endl;
    while (infile>>str)
    {
        cout<<str<<endl;
        sVec.push_back(str);
    }

    int n = sVec.size();
    vector<string> aux(n);
    MSD_sort(sVec, 0, n-1, 0, aux);

    cout<<"------After sort:"<<endl;
    for (int i=0; i<n; i++)
        cout<<sVec[i]<<endl;

    return 0;
}

msd算法的性能及其改进

我们从三个方面衡量算法的性能:需要检查的字符数量,统计字符出现频率时所需的时间和空间,将频率转化为索引所需的时间和空间。

msd算法的性能取决于数据,因为他并非是比较排序,键的顺序并不重要,所需关注的只是键所对应的值:

  • 对于随机输入,msd只检查足以区分字符串的(字符),因为其运行时间是亚线性的。

  • 对于非随机输入,他的运行时间仍然可能是亚线性的。特别是当存在大量等值键的情况下,msd会检查比随机输入更多的键,因而可能耗费更多的时间,其时间接近线性。

  • 最坏情况下,即为所有字符都相同并且所有字符长度都一致,那么其运行时间是线性的。

用msd算法对基于大型字母表的字符串排序时,msd算法可能消耗大量的时间和空间,特别是在有大量重复字符串的情况下。

msd算法可以注意的地方:

  • 小数组时可以使用改进后的插入排序。
  • 对于含有大量等值键的子数组排序会比较慢。msd的最坏情况就是所有的键均相同。
  • 额外空间的占用。为了进行切分,msd算法使用了两个辅助数组,一个是aux, 一个是count。本质上,使用aux只是为了保证稳定性。aux当然可以舍弃,但是这样就不会有稳定性了。

4.参考链接

  1. https://segmentfault.com/a/1190000012923917
  2. https://zh.wikipedia.org/wiki/%E5%9F%BA%E6%95%B0%E6%8E%92%E5%BA%8F
  3. https://zh.wikipedia.org/wiki/%E8%AE%A1%E6%95%B0%E6%8E%92%E5%BA%8F
  4. https://blog.csdn.net/liujianfeng1984/article/details/48488597
  5. https://blog.csdn.net/xuelabizp/article/details/50781616
易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!