数据处理时一个主要需求就是排序,目前主要的内存排序(处理的数据量百万级以下)主要基于关键字大小,具体可分一下几种:
1. 插入排序:直接插入排序(稳定)和希尔排序(升级版,不稳定);
直接插入排序,关键是在以排序好的序列基础上再将加入一个新元素并完成排序,即
数组a[0]-a[i-1]是已经按照从小到大的顺序排序好的序列,而a[i]-a[n-1]是待排序序列,取a[i]进入a[0]-a[i-1]重新排序并完成后a[0]-a[i-1] a[i]又是一个排序好的序列,每次从未排序中选择元素的增量d=1
void my_compare::insert_direct(int a[],int n){
int i,j;
int tmp;
for(i=1;i<n;i++)
{
tmp=a[i];
j=i-1;
while(j>=0&&a[j]>tmp){a[j+1]=a[j];j--;}
a[j+1]=tmp;
}
}
可分析知道,当原始数组就是从小到大排列时执行效率最高O(N)反之当数据基本反序时执行效率最差O(N^2),平均耗时O(N×N),稳定。
希尔排序,根据人名翻译,其实质是缩小增量版的升级插入排序,核心是选择一个增量d(一般是以原始数组数目一般半为起始),剩下的类似直接插入排序,只不过每次从未排序中选择元素的增量d>1,完成一次排序后缩小增量d,直至d=1。
void my_compare::insert_shell(int a[],int n){
int i,j;
int tmp;
int d=n/2;
while(d>0){
//对相距gap距离的所有元素进行插入排序
for(i=d;i<n;i++)
{tmp=a[i];j=i-d;
while(j>=0&&a[j]>tmp){a[j+d]=a[j];j=j-d;}
a[j+d]=tmp;
}
d=d/2;
}
}
这种平均耗时O(N log N),不稳定。
2. 选择排序:直接选择排序(不稳定)和堆排序(以完全二叉树为基础,不稳定);
直接选择排序主要是数组a[0]-a[i-1]是已排序序列,a[i]-a[n-1]序列是未排序序列,从这未排序里选择最小值作为a[i](不同于直接插入是直接选择a[i]),放在数组a[0]-a[i-1]最后位置。其时间平均效率O(N^2),不稳定。
void my_compare::select_direct(int a[],int n){
int i,j;
int tmp;
for(i=0;i<n-1;i++)
{
int k=i;
for(j=i+1;j<n;j++)
if(a[j]<a[k])k=j;
tmp=a[i];
a[i]=a[k];
a[k]=tmp;
}
}
直接选择排序与原始序列是否有序无关,,时间复杂度都是O(N^2)。
堆排序,借鉴了优先队列里堆序的特点即每个树的根是最值,类似堆Deletemin操作每次选择根值(最值)进行n-1操作即完成排序。
void my_compare::sift(int a[],int low,int high){
int i=low,j=2*i;
int tmp=a[i];
while(j<=high){
if(j<high&&(j+1)<=high&&a[j]<a[j+1])j++;
if(tmp<a[j]){a[i]=a[j];i=j;j=2*i;}
else break;//不满足直接退出
}
a[i]=tmp;
}
void my_compare::select_heap(int a[],int n){
int i;
int tmp;n--;
//先建立堆,从最后非叶子开始
for(i=n/2;i>=1;i--)
sift(a,i,n);
for(i=n;i>=2;i--)
{
tmp=a[1];a[1]=a[i];a[i]=tmp;sift(a,1,i-1);
}
}
时间效率(N log N),不稳定。
3. 交换排序:冒泡排序(稳定)和快速排序(升级版,选择基准,不稳定);
不同于直接插入和直接选择区分排序区和未排序区,冒泡排序算法思想是直接对整个无序的原始数组进行处理,每趟对相邻关键字进行比较和位置置换,一趟完成使得最值如气泡一般漂浮到最后位置,接着对剩下的数组进行类似处理。
void my_compare::swap_bubble(int a[],int n){
int i,j;
int tmp;
for(i=0;i<n-1;i++)//n-1趟比较即可
{
for(j=0;j<n-i-1;j++)
if(a[j]>a[j+1]){tmp=a[j];a[j]=a[j+1];a[j+1]=tmp;}
}
}
类似直接插入排序当原始数组就是从小到大排列时执行效率最高O(N)反之当数据基本反序时执行效率最差O(N^2),平均耗时O(Nlog N),稳定。
快速排序相比较之前排序有点复杂, 快速排序由于排序效率在同为O(N*logN)的几种排序方法中效率较高,因此经常被采用,再加上快速排序思想----分治法也确实实用,因此很多软件公司的笔试面试,包括像腾讯,微软等知名IT公司都喜欢考这个,还有大大小的程序方面的考试如软考,考研中也常常出现快速排序的身影。故重点介绍:
快速排序是基于交换的冒泡排序的改进,基本思想是在n个关键字中取一个记录作为基准枢纽元,然后对剩下的n-1个元素进行分类,所有比基元小的放置在前子区间A,比基元大的都放在后子区间B,完成了一趟快排即:
前子区间A 基元 后子区间B-----------------------------------A与B数目尽量相同,否则为劣质分割
此时基元上的关键字已完成最终位置的排序,剩下的就是分别对两个子区间也采用这种思路,直到每个子区间只有一个关键字为止,总而言之是分而治之。
虽然上述算法无论选择哪个元素作为基准都能完成排序,但是不同基准选择效果不同:
1 最容易想到的选择第一个元素作为基元
void my_compare::quicksort(int a[],int low,int high){
int i=low,j=high;//指向无序区的第一个和最后一个以便从两端向中间
int tmp;
if(low<high){
tmp=a[low];//选择第一个作为基准
while(i<j){
while(j>i&&a[j]>tmp)j--;
if(j>i){a[i]=a[j];i++;}
while(j>i&&a[i]<tmp)i++;
if(j>i){a[j]=a[i];j--;}
}
a[i]=tmp;
//完成一趟快排,之后对两个子区间递归
quicksort(a,low,i-1);
quicksort(a,i+1,high);
}
}
void my_compare::swap_quick(int a[],int n){
quicksort(a,0,n-1);
}
这种情况一般针对与输入的关键字都是随机的,此时时间效率能达到O(Nlog N),不稳定。但是一旦输入是预排序或者反序,这样的基准将产生非常恶劣影响:剩下的n-1个元素不是都划入A就是被划入B。例如数组是预排序的,若采用第一个元素作为基元,则根本没有“一分为二”,时间效率O(N^2),时间增加了,但是实际上是浪费了(因为花了如此多时间还只是对已经排好的序列再进行一次排列而已)。
2 最安全的选择随机元素作为基元
一般选择采用随机选择基元是安全的,另外也可以采用叁数中值分隔法,即选择序列左端/右端/及中心位置的这三个元素中值作为基元例如8,1,4,9,6,3,5,2,7,0最左端8,最右端0中间位置(left_right)/2上是6,这三个数中值为6故基元为6。且在选择基元后,可把这三个数值进行排序,把最小者放在最左位置(这也正是它在A中的应有位置)把最大放在最右端(这也正是它在B中的应有位置)以减少分割时的操作。
具体的分割策略如下:
所有元素互异:当i在j左边时,i右移,移过那些小于基元的元素,同时j左移,移过那些大于基元的元素,当i和j停止时,当i小于j前提下,i指向一个大于基元而j指向一个小于基元,交换i和j所指元素,直到i和j已经交错,分割的最后一步是将基元与i最后到达位置进行交换以便让基元回到最终位置。
有元素等于基元:无非三种情况,要么i和j遇见与基元相等的直接跳过;要么停下进行交换;要么一个停下一个跳过,这种不对称做法容易导致分割两部分不均故直接舍弃。为了分析可以假设所有元素都相同。
对于直接跳过情景,由于基元与i最后到达位置进行交换,故将导致产生两个非常不均衡的子区间,类似与预排序数组与第一个元素作为基准的情景此时时间O(N^2),故舍弃;
对于都停下交换情景:虽然没有实际意义,但是效果在于i和j将在中间交替,而不像直接跳过情景i直接跳到了序列最后位置。因此将基元与i最后到达位置进行交换以便让基元回到最终位置后,分割两部分基本均衡,这种完美均衡效果就是运行时间O(N log N)。故采用这种。
// 6快速排序。基本思想是n个关键字中选取一个作为pivot,然后对剩下n-1个元素进行分类,所有小的放在前面区间A,
// 大的放在后面区间B,此时pivot其实已经完成最终位置的排序。接下来就是对A和B区间也采用类似思路,直到每个区间长为1.
// 选择pivot算法:
// 1 选第一个元素,若输入是顺序了,则达不到1分2效果,A区间长的0,B区间长度N,效能差,pass
// 2 随机选择,这里特别的设计了3数中值法,
// 2.1 选择pivot。选择序列头尾和中间位置的3个元素的中值作为pivot,且在选择后,把这3个位置数值进行排序,避免后续再排序
// 2.2 基于该pivot分割得A和B。i为序列开始,j为序列结束:
// i不断右移动,跳过小于pivot元素,同时j左移,跳过大于pivot元素
// 当i<j时,i指向大于pivot而j指向小于pivot元素,交换。
// 继续上述操作,直到i>=j
// 将pivot与i最后到达的位置进行交换,这个位置也就是pivot最终排序的位置
// 2.3 对剩下的A和B区间递归调用
// 在进行pivot与i最后到达的位置交换时很可能就破坏了相等元素顺序性故不稳定
int getPivot(vector<int>&nums,int low,int high){
// 3数中值法
int mid=(low+high)/2;
int tmp;
// 3个元素的中值作为pivot,且在选择后,把这3个位置数值进行排
if(nums[low]>nums[mid]){
// low<mid
tmp=nums[low];
nums[low]=nums[mid];
nums[mid]=tmp;
}
if(nums[low]>nums[high]){
// low<high
tmp=nums[low];
nums[low]=nums[high];
nums[high]=tmp;
}
if(nums[mid]>nums[high]){
// mid<high
tmp=nums[mid];
nums[mid]=nums[high];
nums[high]=tmp;
}
//将选择中值此时也即中间位置值作为基元并交换到序列最后第二位置方便后续排序操作
tmp=nums[mid];
nums[mid]=nums[high-1];
nums[high-1]=tmp;
return nums[high-1];
}
void part(vector<int>&nums,int low,int high){
int i,j;
int pivot;
if(low<high){
// 选择pivot
pivot=getPivot(nums,low,high);
// 基于该pivot分割得A和B。i为序列开始,j为序列结束
i=low;//low位置已经在getPivot提前设为较小值故从low+1开始
j=high-1;//high已经在getPivot提前设为较大值,high-1位置已经是pivot故从high-2开始
while(1){
while(nums[++i]<pivot){
}
while(j>low&&nums[--j]>pivot){
}
if(i<j){
//当i<j时,说明i指向大于pivot而j指向小于pivot元素,交换
int tmp=nums[i];
nums[i]=nums[j];
nums[j]=tmp;
}else{
break;
}
}
//将pivot与i最后到达的位置进行交换,这个位置也就是pivot最终排序的位置
if(i<high){
int tmp=nums[i];
nums[i]=nums[high-1];
nums[high-1]=tmp;
}
// 对剩下的A和B区间递归调用
part(nums,low,i-1);
part(nums,i+1,high);
}
}
void quickSort(vector<int>&nums){
part(nums,0,nums.size()-1);
}
快排一般也会问时间复杂度:
在理想情况即A和B两子区间分得均匀:
对于一长度n的序列,先扫描找到基准,然后两个子区间分别递归:
第一次时:T(n)=2*T(n/2)+n
第二次时:T(n)=2*T(n/2)+n=T(n)=2*(2*T(n/4)+n/2)+n=4T(n/4)+2n
第三次时:T(n)=2*T(n/2)+n=8T(n/8)+3n
第k次时:T(n)=2*T(n/2)+n=2^kT(n/2^k)+kn
已知T(1)=0,则k=log2(n),T(n)=n*log2(n)
在恶劣条件下:
当待排序的序列为正序或逆序排列时,且每次划分只得到一个比上一次划分少一个记录的子序列,注意另一个为空。需要进行n-1次比较,每次比较只少一个元素。
4. 归并排序(从小到大,稳定);
其核心思想是把a[0]-a[n-1]看成n个长度为1的有序表,将相邻的有序表成对归并得到n/2个长度为2的有序表,然后继续按照此思路归并知道最后得到1个长度为n的有序表。设计思路:
a 先完成相邻两个有序表a[low]-a[mid]与a[mid+1]-a[high]的合并
b 完成给定长度lenghth下原始数组的合并(注意对于最后一个子表长度小于Length的处理,这是经常遇见的情形)
c 完成lenght=1,=2,=4...n的循环
void my_compare::merge_two(int a[],int low,int mid,int high){
int len=high-low+1;
int *pt=(int *)malloc(sizeof(int)*len);
int i=low,j=mid+1;//两个有序区a[low]-a[mid] a[mid+1]-a[high]开始位置
int k=0;
while(i<=mid&&j<=high){
if(a[i]<a[j]){pt[k]=a[i];i++;k++;}
else {pt[k]=a[j];j++;k++;}
}
while(i<=mid){pt[k]=a[i];i++;k++;}
while(j<=high){pt[k]=a[j];j++;k++;}
for(k=0,i=low;k<len;i++,k++)
a[i]=pt[k];//将排序好的还原到a数组
}
void my_compare::merge_1(int a[],int length,int n)//某length对应每趟下子表长
{
int i;
for(i=0;i+2*length-1<n;i=i+2*length)
merge_two(a,i,i+length-1,i+2*length-1);//归并length长两相邻子表,非最后
if(i+length-1<n)
merge_two(a,i,i+length-1,n-1);//处理最后两个子表
}
void my_compare::merge_(int a[],int n){
int l;
for(l=1;l<n;l=2*l)
merge_1(a,l,n);
}
归并排序不同于插入之希尔排序/选择之堆排序/交换之快速排序从长到短的分而治之,他是从短到长一一破解。时间效率也是O(N log N),稳定。
5 番外:基数排序(稳定)
核心是不同于插入/交换/选择排序比较关键字比较,基数排序直接比较数值的每一位数字,对数组n个元素进行若干趟分配与和收集。要求每个元素是d位的十进制正整数元素。对于n个元素每一位数字无非从0-9,建立这样的数组alist[10],且每个数组元素alist[j]指向一个 单链表,每条单链表上元素均是某趟下数字为j的元素。下一趟处理是建立在上一趟分配收集完基础上。
int my_compare::getres(int a,int d,int i){
if(i<1||i>d)return -1;
int j=i;
int res;
do{res=a%10;
a=a/10;
j++;
}while(j<=d);
return res;
}// 得到指定位数字
void my_compare::radix(int a[],int n,int d){
typedef struct ele{
int key;
ele *next;
}newtype;
newtype *tp[10],*tail[10],*p=NULL;
int i,j,k;
//从低位到高位做d趟排序
for(i=d-1;i>=0;i--){
for(j=0;j<10;j++){tp[j]=tail[j]=NULL;}
for(k=0;k<n;k++)
{int res=getres(a[k],d,i+1);// 获得该未数字
newtype *s=(newtype*)malloc(sizeof(newtype));
s->key=a[k];s->next=NULL;
if(tp[res]==NULL){tp[res]=s;tail[res]=s;}
else {tail[res]->next=s;tail[res]=s;}
}
//完成一趟排序后,收集
k=0;
for(j=0;j<10;j++)
if(tp[j]){p=tp[j];while(p){a[k++]=p->key;p=p->next;}}
}
}
void my_compare::radix_(int a[],int n){
radix(a,n,3);
}
这种情况时间复杂度O(d*(n+10)),空间复杂度O(n+10),稳定。