01.计数排序与基数排序
并不是所有的排序 都是基于比较的,计数排序和基数排序就不是。基于比较排序的排序方法,其复杂度无法突破 的下限,但是 计数排序 桶排序 和基数排序是分布排序,他们是可以突破这个下限达到O(n)的的复杂度的。
1. 计数排序
概念
计数排序是一种稳定的线性时间排序算法。计数排序使用一个额外的数组C
,使用 C[i]
来计算 i
出现的次数。然后根据数C
来将原数组A中的元素排到正确的位置。
复杂度
计数排序的最坏时间复杂度、最好时间复杂度、平均时间复杂度、最坏空间复杂度都是O(n+k)
。n为元素个数,k为待排序数的最大值。
优缺点
计数排序不是比较排序,排序的速度优于任何比较排序算法。由于用来计数的数组C的长度取决于待排序数组中数据的范围(等于待排序数组的最大值与最小值的差加1),这使得对于数组中数据范围很大的数组,需要大量的时间和内存。(简言之,不适于大范围数组)
通俗地理解,例如有10个年龄不同的人,统计出有8个人的年龄比A小,那A的年龄就排在第9位,用这个方法可以得到其他每个人的位置,也就排好了序。当然,年龄有重复时需要特殊处理(保证稳定性),这就是为什么最后要反向填充目标数组,以及将每个数字的统计减去1的原因。算法的步骤如下:
- 找出待排序数组中的最大元素和最小元素。
- 统计数组中值为 i 的元素出现的次数,存入数组C的第 i 项。
- 对所有的计数累加(从C中第一个元素开始,每一项和前一项累加)。
- 反向填充目标数组:将每个元素 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( )下限的影响。
![](/qrcode.jpg)
复杂度
最坏时间复杂度是O(n^2), 平均复杂度是O(n+k),最坏空间复杂度是O(n*k)。
关键步骤
- 设置一个定量的阵列当作空桶子。
- 寻访序列,并且把项目一个一个放到对应的桶子去。
- 对每个不是空的桶进行排序。可以在放入元素的时候进行插入排序,也可以在写回的时候进行快速排序。
- 从不是空的桶子里把项目放回到原来的序列中。
算法实现
假设数据分布在[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当然可以舍弃,但是这样就不会有稳定性了。